diff --git a/.changes/xtream-catchup-server-timezone.md b/.changes/xtream-catchup-server-timezone.md new file mode 100644 index 000000000..405f92f4d --- /dev/null +++ b/.changes/xtream-catchup-server-timezone.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index f4b87bd72..ba3fcb7ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: diff --git a/CLAUDE.md b/CLAUDE.md index 87ebe2d94..4f0800b2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts b/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts new file mode 100644 index 000000000..a90d8be04 --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts @@ -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 { + 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 { + 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[0], + credentials: { username: string; password: string } +): Promise { + 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 { + 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); + } +}); diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index de0f5816b..a3e895386 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -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], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 09098763d..d11393f60 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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[]) => diff --git a/apps/electron-backend/src/app/database/operations/playlist-server-timezone.operations.ts b/apps/electron-backend/src/app/database/operations/playlist-server-timezone.operations.ts new file mode 100644 index 000000000..7d4f3cc02 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playlist-server-timezone.operations.ts @@ -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 +): 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).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; +} diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts index c204da07c..3370d633e 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts @@ -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; + rows: Record; + }; + + 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 } + >; + + 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([ { diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index b15c0988f..96e9b1bc0 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -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 => row !== null); + .map((playlist) => ({ playlist, row: buildPlaylistRow(playlist) })) + .filter( + ( + entry + ): entry is { + playlist: Record; + row: NonNullable; + } => 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 }; diff --git a/apps/electron-backend/src/app/events/database/playlist.events.ts b/apps/electron-backend/src/app/events/database/playlist.events.ts index eb9683c75..208e7c2b5 100644 --- a/apps/electron-backend/src/app/events/database/playlist.events.ts +++ b/apps/electron-backend/src/app/events/database/playlist.events.ts @@ -16,7 +16,10 @@ handleWorkerRequest( 'DB_MIGRATE_APP_PLAYLISTS', (playlists: Record[]) => ({ playlists }) ); -handleWorkerRequest('DB_CREATE_PLAYLIST', (playlist: Record) => playlist); +handleWorkerRequest( + 'DB_CREATE_PLAYLIST', + (playlist: Record) => playlist +); handleWorkerRequest( 'DB_UPSERT_APP_PLAYLIST', (playlist: Record) => 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; diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts index 49a51080e..5487705de 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts @@ -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'], diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 0eae7ea29..36db8a5d4 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -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', diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 790604981..042c11744 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -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; diff --git a/apps/xtream-mock-server/README.md b/apps/xtream-mock-server/README.md index e9ef764a5..3f53466c3 100644 --- a/apps/xtream-mock-server/README.md +++ b/apps/xtream-mock-server/README.md @@ -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. diff --git a/apps/xtream-mock-server/src/app/handlers/get-account-info.handler.ts b/apps/xtream-mock-server/src/app/handlers/get-account-info.handler.ts index 681668b4d..60c28b0b2 100644 --- a/apps/xtream-mock-server/src/app/handlers/get-account-info.handler.ts +++ b/apps/xtream-mock-server/src/app/handlers/get-account-info.handler.ts @@ -5,6 +5,19 @@ export function handleGetAccountInfo(req: Request, res: Response): void { const { username = '', password = '' } = req.query as Record; 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, }, }); } diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index e2dfa1dd8..33530d3d4 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -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 = { 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: diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 8d9105db5..fbd2bc353 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -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 | diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 3dfc85bec..25d6b02ef 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -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`. diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.catchup.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.catchup.spec.ts new file mode 100644 index 000000000..7f224686e --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.catchup.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 7acf46d79..184b62675 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -1122,6 +1122,7 @@ export class StreamResolverService { serverUrl: string; username: string; password: string; + serverTimezone?: string; }, streamId: number, limit: number, diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts index 983a85c21..b00aa0d39 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts @@ -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( diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts index 09b1f7938..f29d0dbf8 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts @@ -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: [], diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index ea8c8bf37..37d7898cb 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -107,6 +107,25 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { }); } + async rememberServerTimezone( + playlistId: string, + credentials: XtreamCredentials, + serverTimezone: string + ): Promise { + // 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 { 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 diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts index 2ee488a7c..3b56bd804 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -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, diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 67a5f2f4c..d469dd8be 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -128,6 +128,34 @@ export class PwaXtreamDataSource implements IXtreamDataSource { } } + async rememberServerTimezone( + playlistId: string, + credentials: XtreamCredentials, + serverTimezone: string + ): Promise { + 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 { 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 diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index e5c9cf12b..383e108a3 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -164,6 +164,23 @@ export interface IXtreamDataSource { updates: Partial ): Promise; + /** + * 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; + /** * Delete a playlist and all its data */ diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts index b2f0679f4..7cc305a49 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.spec.ts @@ -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 diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts index 2d46830b9..55ea8fa58 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-api.service.ts @@ -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() diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts index 6dbdf3197..e150e13a2 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.spec.ts @@ -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'); + }); }); }); diff --git a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts index 9a60f698c..76d399088 100644 --- a/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/xtream-url.service.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); } } diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts index 2bb834d4a..024f7b32e 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-epg.feature.ts @@ -129,6 +129,7 @@ export function withEpg() { serverUrl: playlist.serverUrl, username: playlist.username, password: playlist.password, + serverTimezone: playlist.serverTimezone, }; }; diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts index c15138a7b..56db7caae 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts @@ -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 | 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); + }); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts index e196c89d0..3f2737c79 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts @@ -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 => { + 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(); } }, diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 36393956a..a18305d3b 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -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(channels.map((ch) => ch.xtream_id)); diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts index 63247e585..274496307 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts @@ -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)); diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 5510a0b01..daac7f235 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -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 { + 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 */ diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 4c424f473..d57b510cc 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -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', diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index d667f2129..07d05908e 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -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, diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 7be304d71..c4c7a9e6a 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 5edea1aef..8f248fdd2 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -896,6 +896,16 @@ export interface ElectronBridgeApi { dbGetPlaylist: ( playlistId: string ) => Promise; + /** + * 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, /** diff --git a/libs/shared/interfaces/src/lib/playlist-meta.type.ts b/libs/shared/interfaces/src/lib/playlist-meta.type.ts index ade1f5fdc..ac8101087 100644 --- a/libs/shared/interfaces/src/lib/playlist-meta.type.ts +++ b/libs/shared/interfaces/src/lib/playlist-meta.type.ts @@ -28,6 +28,7 @@ export type PlaylistMeta = Pick< | 'hiddenGroupTitles' | 'portalUrl' | 'recentlyViewed' + | 'serverTimezone' | 'isFullStalkerPortal' | 'stalkerSerialNumber' | 'stalkerDeviceId1' diff --git a/libs/shared/interfaces/src/lib/playlist.interface.ts b/libs/shared/interfaces/src/lib/playlist.interface.ts index fecdaf60a..73d5de6f1 100644 --- a/libs/shared/interfaces/src/lib/playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist.interface.ts @@ -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; diff --git a/libs/shared/interfaces/src/lib/xtream-server-clock.util.ts b/libs/shared/interfaces/src/lib/xtream-server-clock.util.ts new file mode 100644 index 000000000..d257eb395 --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-server-clock.util.ts @@ -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(); + +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(), + }; +} diff --git a/libs/shared/interfaces/src/lib/xtream-server-timezone.util.spec.ts b/libs/shared/interfaces/src/lib/xtream-server-timezone.util.spec.ts new file mode 100644 index 000000000..590ad484e --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-server-timezone.util.spec.ts @@ -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(); + }); + }); +}); diff --git a/libs/shared/interfaces/src/lib/xtream-server-timezone.util.ts b/libs/shared/interfaces/src/lib/xtream-server-timezone.util.ts new file mode 100644 index 000000000..28a0b81c6 --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-server-timezone.util.ts @@ -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); +} diff --git a/tools/release/capture-navigation-download-actions.ts b/tools/release/capture-navigation-download-actions.ts new file mode 100644 index 000000000..acf33c389 --- /dev/null +++ b/tools/release/capture-navigation-download-actions.ts @@ -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 { + 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 { + 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 { + 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 { + await prepareGuideDownloads(page); + await openDownloadsPage(page); +} + +async function openDownloadsOfflineMovie(page: Page): Promise { + 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> = { + 'open-downloads-manager': openDownloadsManager, + 'open-downloads-offline-movie': openDownloadsOfflineMovie, +}; diff --git a/tools/release/capture-navigation-helpers.ts b/tools/release/capture-navigation-helpers.ts new file mode 100644 index 000000000..feca13db8 --- /dev/null +++ b/tools/release/capture-navigation-helpers.ts @@ -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; + +/** Route segment of each seeded source, as it appears in `/workspace//`. */ +export type PlaylistProvider = + | 'playlists' + | 'xtreams' + | 'xtreams-secondary' + | 'stalker'; + +const playlistIds = new Map(); + +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 { + 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, + label: RegExp +): Promise { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/tools/release/capture-navigation-portal-actions.ts b/tools/release/capture-navigation-portal-actions.ts new file mode 100644 index 000000000..2f35704d6 --- /dev/null +++ b/tools/release/capture-navigation-portal-actions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + await openLiveCategory(page, 'xtreams', 'live', param); +} + +async function openStalkerLive( + page: Page, + param: string | null +): Promise { + await openLiveCategory(page, 'stalker', 'itv', param); +} + +async function openXtreamLiveChannel( + page: Page, + param: string | null +): Promise { + // 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 { + 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 { + 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> = { + '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, +}; diff --git a/tools/release/capture-navigation-setup-actions.ts b/tools/release/capture-navigation-setup-actions.ts new file mode 100644 index 000000000..b8b8bc69a --- /dev/null +++ b/tools/release/capture-navigation-setup-actions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> = { + '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, +}; diff --git a/tools/release/capture-navigation.ts b/tools/release/capture-navigation.ts index 9766c87a5..6238fb738 100644 --- a/tools/release/capture-navigation.ts +++ b/tools/release/capture-navigation.ts @@ -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//`. */ -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(); - -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> = { + ...SETUP_ACTIONS, + ...PORTAL_ACTIONS, + ...DOWNLOAD_ACTIONS, +}; /* ------------------------------------------------------------------ */ /* Theme */ @@ -92,674 +80,17 @@ export async function runAction( action: string, param: string | null ): Promise { + 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 { - 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, - label: RegExp -): Promise { - // 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - // 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 { - 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 { - 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); -} - - diff --git a/tools/release/project.json b/tools/release/project.json index 28a2f2fc8..7d2f7e24f 100644 --- a/tools/release/project.json +++ b/tools/release/project.json @@ -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": [