mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(xtream): render catch-up start times in the panel timezone (#1563)
* fix(xtream): render catch-up start times in the panel timezone
The `{Y-m-d:H-M}` segment of an Xtream timeshift URL is read by the panel
with `strtotime()` in ITS timezone (`server_info.timezone`), never the
viewer's. The timezone was learned in memory only, by the store's
`checkPortalStatus()`, so the Favorites / Recent catch-up resolver — which
reads the STORED playlist row — always fell back to the viewer's local
clock and asked the panel for the wrong programme (#1562).
- Normalize the panel's clock once (`resolveXtreamServerTimezone`): an
ICU-resolvable name is kept, otherwise a `UTC±HH:MM` offset is derived
from the `time_now` / `timestamp_now` clock pair, so spellings such as
`UTC+3` no longer silently mean "local time".
- Persist it on the playlist row through `transformPlaylistMeta` (no-op
when unchanged) and project it back from the payload in
`DB_GET_PLAYLIST`, so both catch-up entry points and a restart see it.
- Format with `hourCycle: 'h23'` (server midnight is `00`, never `24`) and
read timestamp-less EPG `start`/`end` strings in the panel's clock.
- Mock: `tzoffset:tzoffset` scenario with an unusable timezone name and a
+03:00 clock pair; Electron e2e covers Live TV, Favorites, a restart into
Global favorites, and the clock-pair derivation at a UTC-3 viewer.
Closes #1562
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): guard the account-info answer by playlist identity and reject rolled-over dates
Review follow-ups (Greptile):
- A source switch while `get_account_info` is in flight no longer hands
playlist A's status or clock to playlist B: the store is patched only
while the asking playlist is still selected, the timezone is persisted
under the asking playlist's id regardless, and a late failure cannot mark
the newly selected playlist unavailable.
- `parseNaiveUtcMs` reads the constructed date back, so out-of-range panel
strings (`2026-13-01 25:00:00`) are rejected instead of silently rolling
over into a real instant.
- Document that a clock-derived fixed offset is a DST-less snapshot, refreshed
by every account-info check and only ever used for non-standard servers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): drop a panel clock that no longer belongs to the source
Review follow-ups (Codex + Greptile):
- A metadata update or DB_UPDATE_PLAYLIST that points the source at another
server drops the persisted `serverTimezone` (payload-only) until the next
account-info check, so Favorites / Recent cannot keep rendering the OLD
panel's clock; an update that supplies a clock keeps it.
- A late account-info answer is persisted only onto a row that still points
at the panel it came from — an edit that moved the source during the
request keeps the clock the edit flow dropped.
- The PWA data source and the route-session converter carry the persisted
timezone into the store playlist, so a later response without a usable
clock has a previous value to preserve.
- Mirror the catch-up timezone contract into AGENTS.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): drop the stale panel clock inside the UPDATE statement
Review follow-up (Codex): the database worker interleaves requests, so a
read-modify-write of the playlist payload could hand a concurrent upsert's
newer payload back to the past. The `serverTimezone` removal on a server
URL change is now one `CASE … json_remove(payload, '$.serverTimezone')`
expression inside the same UPDATE, guarded by `json_valid`; the spec runs
the real statement against Electron's SQLite on the actual `playlists`
table (moved, renamed, clock-less, malformed-payload and NULL-URL rows).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(xtream): split the server-clock primitives out of the timezone util
Review follow-up (Greptile): `xtream-server-timezone.util.ts` had grown past
the 300-line file guideline. The zone-agnostic wall-clock primitives (stored
forms, Intl parts, naive parsing) now live in `xtream-server-clock.util.ts`;
the timezone util keeps the Xtream policy and re-exports the public helpers,
so every import and the spec are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): offer the learned panel clock to storage on every check
Review follow-up (Codex): a transient storage failure left the clock in the
store but not on the row, and the next check compared the answer with the
in-memory value and never retried. The resolved timezone is now always
handed to `transformPlaylistMeta`, whose row-level equality check keeps the
common case a read without a write; a failed write is retried by the next
account-info check.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): apply an account-info answer only to the panel it came from
Review follow-up (Codex): an in-place edit keeps the playlist id while
moving the source, so an answer already on the wire for the OLD panel
passed the id-only guard and patched the new panel's status and clock into
the store. One `answersFor(candidate, credentials)` predicate now gates the
store patch, the error path and the persisted-row transform alike.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): never report another panel's status for the selected playlist
Review follow-up (Greptile): callers gate content initialization on the
value `checkPortalStatus()` returns for whatever is selected NOW. When the
answer no longer describes the selected playlist (source switch or in-place
edit during the request), the store's own verdict about the current
selection is returned instead of the old panel's status — on success and on
failure alike.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): persist the panel clock with one conditional UPDATE
Review follow-up (Codex): `transformPlaylistMeta` reads the row and then
upserts it whole, while the Xtream edit dialog saves through
`DB_UPDATE_PLAYLIST` outside `PlaylistsService`'s queue and the database
worker interleaves requests — an edit landing between that read and the
upsert was silently undone.
Persistence now goes through `IXtreamDataSource.rememberServerTimezone`:
- Electron: new `DB_SET_PLAYLIST_SERVER_TIMEZONE` worker op — one UPDATE
that `json_set`s the payload only while the row still points at the
request's connection and does not already carry the value; a malformed
payload is never rewritten (CASE, not AND, so json_extract cannot run
before json_valid). Wired through the worker types, main handler,
preload, bridge interface, both IPC contract tables and
`DatabaseService.setXtreamPlaylistServerTimezone`.
- PWA: `transformPlaylistMeta`, whose read and write share one IndexedDB
readwrite cursor transaction, plus the localStorage copy.
The store no longer injects `PlaylistsService`; it offers the resolved clock
to the data source and keeps only its in-memory guards. Real-SQLite coverage
for the op (fresh / same / moved / NULL / malformed / missing rows),
delegation specs for both data sources, docs updated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): keep the stored panel clock across clockless full upserts
Review follow-up (Codex): a `PlaylistsService` mutation that read the row
before `DB_SET_PLAYLIST_SERVER_TIMEZONE` landed and upserted afterwards
replaced the payload with its clockless snapshot. `DB_UPSERT_APP_PLAYLIST(S)`
now carry the STORED clock into a snapshot that has none while the row still
points at the same connection (`playlistConflictUpdate`, nested CASE so the
json_* readers never run on a malformed payload); a snapshot with its own
clock, or one that moves the source, wins as is. Real-SQLite coverage for
kept / moved / own-clock / batch rows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(release): split capture-navigation under the max-lines cap
`tools/release/capture-navigation.ts` had grown to 567 counted lines, past
the 400-line rule, which failed `release-tools:lint` and — because the file
was not in the baseline — the max-lines baseline test on master and on
every PR branched from it. The 19 named setup actions are now grouped by
subject over one leaf module of shared page helpers:
- `capture-navigation-helpers.ts`: playlist-id registry, dialog handling,
navigation moves, `settleUi`
- `capture-navigation-setup-actions.ts`: add-playlist dialogs, settings
sections, remote control
- `capture-navigation-portal-actions.ts`: portal catalogs, live lists,
alternative sources (the two identical live-category flows share one
helper)
- `capture-navigation-download-actions.ts`: the download manager shots
- `capture-navigation.ts`: the `runAction` dispatcher, theme switching and
the re-exported API the seeding driver and the capture script import
Actions call their siblings directly instead of recursing through
`runAction`, so no module depends on the dispatcher. The action vocabulary
is unchanged (same 19 names, same waits and timeouts); every file is under
300 lines and the new modules are listed in the `release-tools` lint target.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(electron): move the panel-clock SQL into its own operations module
Review follow-up (Greptile): the timezone persistence, invalidation,
upsert-preservation and row projection had landed in
`playlist.operations.ts`, a baselined 1,000-line file. They now live in
`playlist-server-timezone.operations.ts` (155 lines) — the three SQL
shapes plus the payload projection — and the playlist operations compose
them; the baselined file shrinks by 107 lines. Behaviour and the
real-SQLite coverage are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
98da686cea
commit
97b0264dee
51 files changed
+3259
-844
No files matched your search
@@ -0,0 +1,241 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
channelItemByTitle,
|
||||
clickCategoryByNameExact,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
LaunchedElectronApp,
|
||||
openGlobalFavorites,
|
||||
openPlaylistFavorites,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
restartElectronApp,
|
||||
switchUnifiedCollectionScope,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import { fetchXtreamEpgFixture } from './portal-mock-fixtures';
|
||||
|
||||
/**
|
||||
* Issue #1562: the `{Y-m-d:H-M}` segment of an Xtream timeshift URL is read
|
||||
* by the panel in ITS timezone (`server_info.timezone`), never the viewer's.
|
||||
* The viewer here sits at UTC-3 while the mock panel runs at UTC (or, in the
|
||||
* clock-pair scenario, at an unusable `UTC+3` name with a +03:00 clock), so
|
||||
* a URL rendered in the viewer's clock is unambiguously wrong.
|
||||
*/
|
||||
const VIEWER_TIMEZONE = 'America/Sao_Paulo';
|
||||
const CHANNEL = 'Timezone News';
|
||||
const PAST_PROGRAM = 'Earlier Bulletin';
|
||||
const TIMESHIFT_SEGMENT =
|
||||
/\/timeshift\/[^/]+\/[^/]+\/\d+\/(\d{4}-\d{2}-\d{2}:\d{2}-\d{2})\//;
|
||||
|
||||
function formatWallClock(epochSeconds: number, offsetMinutes: number): string {
|
||||
const date = new Date((epochSeconds + offsetMinutes * 60) * 1000);
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}:${pad(date.getUTCHours())}-${pad(date.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
function formatInZone(epochSeconds: number, timeZone: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(new Date(epochSeconds * 1000));
|
||||
const read = (type: Intl.DateTimeFormatPartTypes) =>
|
||||
parts.find((part) => part.type === type)?.value ?? '??';
|
||||
return `${read('year')}-${read('month')}-${read('day')}:${read('hour')}-${read('minute')}`;
|
||||
}
|
||||
|
||||
function captureTimeshiftRequests(page: Page): string[] {
|
||||
const captured: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/timeshift/')) {
|
||||
captured.push(request.url());
|
||||
}
|
||||
});
|
||||
return captured;
|
||||
}
|
||||
|
||||
async function activatePastProgram(
|
||||
page: Page,
|
||||
captured: string[]
|
||||
): Promise<string> {
|
||||
captured.length = 0;
|
||||
await expect(page.locator('app-epg-timeline')).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
const block = page
|
||||
.locator('app-epg-timeline .epg-timeline__block')
|
||||
.filter({ hasText: PAST_PROGRAM })
|
||||
.first();
|
||||
await expect(block).toBeVisible({ timeout: 20000 });
|
||||
await block.click();
|
||||
await expect
|
||||
.poll(() => captured.length, { timeout: 30000 })
|
||||
.toBeGreaterThan(0);
|
||||
const segment = TIMESHIFT_SEGMENT.exec(captured[0] ?? '')?.[1];
|
||||
if (!segment) {
|
||||
throw new Error(`Unexpected timeshift URL shape: ${captured[0]}`);
|
||||
}
|
||||
return segment;
|
||||
}
|
||||
|
||||
type PastProgramWindow = { startTimestamp: number };
|
||||
|
||||
/**
|
||||
* The fixture rounds "now" to 15 minutes per request, so the app's own
|
||||
* schedule may straddle a boundary the test's snapshot did not. Both
|
||||
* snapshots are accepted as the server rendering; the viewer rendering of
|
||||
* either is the regression.
|
||||
*/
|
||||
async function expectServerClock(
|
||||
segment: string,
|
||||
windows: PastProgramWindow[],
|
||||
serverOffsetMinutes: number
|
||||
): Promise<void> {
|
||||
const serverRenderings = windows.map((window) =>
|
||||
formatWallClock(window.startTimestamp, serverOffsetMinutes)
|
||||
);
|
||||
const viewerRenderings = windows.map((window) =>
|
||||
formatInZone(window.startTimestamp, VIEWER_TIMEZONE)
|
||||
);
|
||||
expect(viewerRenderings).not.toContain(segment);
|
||||
expect(serverRenderings).toContain(segment);
|
||||
}
|
||||
|
||||
async function pastProgramWindows(
|
||||
request: Parameters<typeof fetchXtreamEpgFixture>[0],
|
||||
credentials: { username: string; password: string }
|
||||
): Promise<PastProgramWindow> {
|
||||
const fixture = await fetchXtreamEpgFixture(request, credentials);
|
||||
const past = fixture.fullEpg.find(
|
||||
(listing) => listing.title === PAST_PROGRAM
|
||||
);
|
||||
if (!past) {
|
||||
throw new Error(
|
||||
'The EPG fixture has no past programme to catch up on.'
|
||||
);
|
||||
}
|
||||
return { startTimestamp: past.startTimestamp };
|
||||
}
|
||||
|
||||
async function openTimezoneNewsInLiveTv(
|
||||
page: Page,
|
||||
categoryName: string
|
||||
): Promise<void> {
|
||||
await openWorkspaceSection(page, 'Live TV');
|
||||
await clickCategoryByNameExact(page, categoryName);
|
||||
const row = channelItemByTitle(page, CHANNEL).first();
|
||||
await expect(row).toBeVisible({ timeout: 20000 });
|
||||
await row.click();
|
||||
}
|
||||
|
||||
test('@epg @xtream @electron renders catch-up start times in the panel timezone from Live TV, Favorites, and after a restart', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(240000);
|
||||
const credentials = { username: 'epg', password: 'epg' };
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const fixture = await fetchXtreamEpgFixture(request, credentials);
|
||||
const before = await pastProgramWindows(request, credentials);
|
||||
|
||||
let app: LaunchedElectronApp = await launchElectronApp(dataDir, {
|
||||
env: { TZ: VIEWER_TIMEZONE },
|
||||
});
|
||||
let captured = captureTimeshiftRequests(app.mainWindow);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Catch-up timezone',
|
||||
...credentials,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
|
||||
// Live TV layout: the store learned the timezone from account info.
|
||||
await openTimezoneNewsInLiveTv(app.mainWindow, fixture.categoryName);
|
||||
const liveSegment = await activatePastProgram(app.mainWindow, captured);
|
||||
await expectServerClock(liveSegment, [before], 0);
|
||||
|
||||
// Favorites: the resolver reads the persisted row instead.
|
||||
const row = channelItemByTitle(app.mainWindow, CHANNEL).first();
|
||||
await row.hover();
|
||||
await row.locator('.favorite-button').first().click();
|
||||
await expect(
|
||||
row.locator('.favorite-button mat-icon').first()
|
||||
).toHaveText(/star/);
|
||||
await openPlaylistFavorites(app.mainWindow);
|
||||
const favoriteRow = channelItemByTitle(app.mainWindow, CHANNEL).first();
|
||||
await expect(favoriteRow).toBeVisible({ timeout: 20000 });
|
||||
await favoriteRow.click();
|
||||
const favoritesSegment = await activatePastProgram(
|
||||
app.mainWindow,
|
||||
captured
|
||||
);
|
||||
const afterFavorites = await pastProgramWindows(request, credentials);
|
||||
await expectServerClock(favoritesSegment, [before, afterFavorites], 0);
|
||||
|
||||
// Restart and go straight to the global collection: no portal route
|
||||
// bootstraps, so only the persisted timezone can be right.
|
||||
app = await restartElectronApp(app, dataDir, {
|
||||
env: { TZ: VIEWER_TIMEZONE },
|
||||
});
|
||||
captured = captureTimeshiftRequests(app.mainWindow);
|
||||
await openGlobalFavorites(app.mainWindow);
|
||||
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
||||
const restoredRow = channelItemByTitle(app.mainWindow, CHANNEL).first();
|
||||
await expect(restoredRow).toBeVisible({ timeout: 20000 });
|
||||
await restoredRow.click();
|
||||
const restartSegment = await activatePastProgram(
|
||||
app.mainWindow,
|
||||
captured
|
||||
);
|
||||
const afterRestart = await pastProgramWindows(request, credentials);
|
||||
await expectServerClock(
|
||||
restartSegment,
|
||||
[before, afterFavorites, afterRestart],
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @xtream @electron derives the panel offset from its clock pair when the timezone name is unusable', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
const credentials = { username: 'tzoffset', password: 'tzoffset' };
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const fixture = await fetchXtreamEpgFixture(request, credentials);
|
||||
const before = await pastProgramWindows(request, credentials);
|
||||
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: { TZ: VIEWER_TIMEZONE },
|
||||
});
|
||||
const captured = captureTimeshiftRequests(app.mainWindow);
|
||||
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Catch-up offset clock',
|
||||
...credentials,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openTimezoneNewsInLiveTv(app.mainWindow, fixture.categoryName);
|
||||
const segment = await activatePastProgram(app.mainWindow, captured);
|
||||
const after = await pastProgramWindows(request, credentials);
|
||||
|
||||
// `server_info.timezone` is `UTC+3` (no ICU knows it); the clock pair
|
||||
// says +03:00, so the URL must be three hours ahead of UTC.
|
||||
await expectServerClock(segment, [before, after], 180);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -130,6 +130,20 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
|
||||
channel: 'DB_UPDATE_PLAYLIST',
|
||||
forwardedArgs: [playlistId, playlistUpdates],
|
||||
},
|
||||
{
|
||||
method: 'dbSetPlaylistServerTimezone',
|
||||
args: [
|
||||
playlistId,
|
||||
{ serverUrl: 'http://panel.example', username: 'u', password: 'p' },
|
||||
'Europe/London',
|
||||
],
|
||||
channel: 'DB_SET_PLAYLIST_SERVER_TIMEZONE',
|
||||
forwardedArgs: [
|
||||
playlistId,
|
||||
{ serverUrl: 'http://panel.example', username: 'u', password: 'p' },
|
||||
'Europe/London',
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'dbDeletePlaylist',
|
||||
args: [playlistId, operationId],
|
||||
|
||||
@@ -749,6 +749,17 @@ const electronApi: ElectronBridgeApi = {
|
||||
ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist),
|
||||
dbGetPlaylist: (playlistId: string) =>
|
||||
ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId),
|
||||
dbSetPlaylistServerTimezone: (
|
||||
playlistId: string,
|
||||
connection: { serverUrl: string; username: string; password: string },
|
||||
serverTimezone: string
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_SET_PLAYLIST_SERVER_TIMEZONE',
|
||||
playlistId,
|
||||
connection,
|
||||
serverTimezone
|
||||
),
|
||||
dbUpsertAppPlaylist: (playlist: Playlist, _operationId?: string) =>
|
||||
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist),
|
||||
dbMigrateAppPlaylists: (playlists: Playlist[]) =>
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* The panel clock an Xtream account-info check learns (`serverTimezone`)
|
||||
* lives in the playlist row's JSON payload — it has no column — and every
|
||||
* write that touches it must stay atomic against the row's CURRENT
|
||||
* connection, because the database worker interleaves requests and the
|
||||
* Xtream edit dialog saves through `DB_UPDATE_PLAYLIST` outside
|
||||
* `PlaylistsService`'s queue (issue #1562). This module owns the three SQL
|
||||
* shapes and the one projection the playlist operations compose:
|
||||
*
|
||||
* - `setPlaylistServerTimezone`: record a learned clock, conditionally.
|
||||
* - `serverTimezoneInvalidation`: drop it when an update moves the source.
|
||||
* - `playlistConflictUpdate`: keep it across a clockless full upsert.
|
||||
* - `readPayloadServerTimezone`: surface it on a raw row read.
|
||||
*/
|
||||
|
||||
import { sql, type SQL } from 'drizzle-orm';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
|
||||
export interface PlaylistConnectionIdentity {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type PlaylistConflictUpdate = {
|
||||
[K in keyof schema.NewPlaylist]?: schema.NewPlaylist[K] | SQL;
|
||||
};
|
||||
|
||||
const stored = schema.playlists;
|
||||
|
||||
/**
|
||||
* Records the panel clock a successful account-info check learned as ONE
|
||||
* conditional UPDATE: the row must still point at the panel the answer
|
||||
* came from (`DB_UPDATE_PLAYLIST` may have moved it meanwhile), a payload
|
||||
* already carrying the value is left untouched, and a malformed payload is
|
||||
* never rewritten. No read precedes the write, so it can neither hand a
|
||||
* concurrent upsert's newer payload back to the past nor undo an edit that
|
||||
* landed in between.
|
||||
*/
|
||||
export async function setPlaylistServerTimezone(
|
||||
db: AppDatabase,
|
||||
playlistId: string,
|
||||
connection: PlaylistConnectionIdentity,
|
||||
serverTimezone: string
|
||||
): Promise<{ updated: boolean }> {
|
||||
const result = await db
|
||||
.update(stored)
|
||||
.set({
|
||||
payload: sql`CASE
|
||||
WHEN ${stored.payload} IS NULL
|
||||
THEN json_object('serverTimezone', ${serverTimezone})
|
||||
ELSE json_set(${stored.payload}, '$.serverTimezone', ${serverTimezone})
|
||||
END`,
|
||||
})
|
||||
.where(
|
||||
// CASE, not AND: SQLite may reorder AND terms, and json_extract
|
||||
// raises on a malformed payload unless json_valid ran first.
|
||||
sql`${stored.id} = ${playlistId}
|
||||
AND ${stored.serverUrl} IS ${connection.serverUrl}
|
||||
AND ${stored.username} IS ${connection.username}
|
||||
AND ${stored.password} IS ${connection.password}
|
||||
AND CASE
|
||||
WHEN ${stored.payload} IS NULL THEN 1
|
||||
WHEN json_valid(${stored.payload})
|
||||
THEN json_extract(${stored.payload}, '$.serverTimezone') IS NOT ${serverTimezone}
|
||||
ELSE 0
|
||||
END`
|
||||
)
|
||||
.run();
|
||||
|
||||
return { updated: result.changes > 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload expression for a `DB_UPDATE_PLAYLIST` that sets `serverUrl`:
|
||||
* a learned clock belongs to the panel it was learned from, so pointing the
|
||||
* row at another server drops it until the next account-info check, and
|
||||
* Favorites / Recent catch-up cannot keep rendering the OLD panel's clock.
|
||||
*/
|
||||
export function serverTimezoneInvalidation(nextServerUrl: string): SQL {
|
||||
return sql`CASE
|
||||
WHEN ${stored.serverUrl} IS NOT ${nextServerUrl}
|
||||
AND json_valid(${stored.payload})
|
||||
THEN json_remove(${stored.payload}, '$.serverTimezone')
|
||||
ELSE ${stored.payload}
|
||||
END`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `DO UPDATE` half of an app-playlist upsert. A full upsert is built
|
||||
* from a snapshot the caller read earlier; when that snapshot carries no
|
||||
* panel clock while the stored row does — `setPlaylistServerTimezone`
|
||||
* landed in between — the stored clock is carried over as long as the row
|
||||
* still points at the same panel, so a favorites, recent-items or metadata
|
||||
* write cannot hand a clockless payload back to the past. A snapshot that
|
||||
* carries its own clock, or moves the source, wins as is. Nested CASE, not
|
||||
* AND: SQLite may reorder AND terms, and the json_* readers raise on a
|
||||
* malformed payload unless json_valid ran first.
|
||||
*/
|
||||
export function playlistConflictUpdate(
|
||||
row: schema.NewPlaylist,
|
||||
playlist: Record<string, unknown>
|
||||
): PlaylistConflictUpdate {
|
||||
if (readString(playlist.serverTimezone)) {
|
||||
return row;
|
||||
}
|
||||
const excludedPayload = sql.raw(`excluded.${stored.payload.name}`);
|
||||
const payload = sql`CASE
|
||||
WHEN json_valid(${stored.payload})
|
||||
THEN CASE
|
||||
WHEN json_type(${stored.payload}, '$.serverTimezone') = 'text'
|
||||
AND ${stored.serverUrl} IS excluded.${sql.raw(stored.serverUrl.name)}
|
||||
AND ${stored.username} IS excluded.${sql.raw(stored.username.name)}
|
||||
AND ${stored.password} IS excluded.${sql.raw(stored.password.name)}
|
||||
THEN json_set(
|
||||
${excludedPayload},
|
||||
'$.serverTimezone',
|
||||
json_extract(${stored.payload}, '$.serverTimezone')
|
||||
)
|
||||
ELSE ${excludedPayload}
|
||||
END
|
||||
ELSE ${excludedPayload}
|
||||
END`;
|
||||
return { ...row, payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* The clock stored in a raw row's payload, for `DB_GET_PLAYLIST`: the
|
||||
* Xtream store seeds `currentPlaylist` from that read before (or without)
|
||||
* the account-info check that learns the value.
|
||||
*/
|
||||
export function readPayloadServerTimezone(
|
||||
payload: string | null | undefined
|
||||
): string | undefined {
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(payload);
|
||||
return parsed && typeof parsed === 'object'
|
||||
? readString((parsed as Record<string, unknown>).serverTimezone)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import {
|
||||
getAppPlaylistFavoriteChannels,
|
||||
getAppPlaylistMetas,
|
||||
getPlaylist,
|
||||
parseAppPlaylist,
|
||||
} from './playlist.operations';
|
||||
import type { AppDatabase } from '../database.types';
|
||||
@@ -22,6 +27,60 @@ function createPlaylistFavoriteChannelsDbMock(row: unknown | null) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `updatePlaylist` against Electron's native SQLite on the real
|
||||
* `playlists` table (create + column migrations), the way the database
|
||||
* worker executes it; the scenario prints one JSON document.
|
||||
*/
|
||||
function runPlaylistUpdateScenario(scenario: string): unknown {
|
||||
const operationsUrl = pathToFileURL(
|
||||
resolve(__dirname, 'playlist.operations.ts')
|
||||
).href;
|
||||
const timezoneOperationsUrl = pathToFileURL(
|
||||
resolve(__dirname, 'playlist-server-timezone.operations.ts')
|
||||
).href;
|
||||
const connectionUrl = pathToFileURL(
|
||||
resolve(process.cwd(), 'libs/shared/database/src/lib/connection.ts')
|
||||
).href;
|
||||
const script = `
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
const { drizzle } = await import('drizzle-orm/better-sqlite3');
|
||||
const schema = await import('@iptvnator/shared/database/schema');
|
||||
const { updatePlaylist, upsertAppPlaylist, upsertAppPlaylists } = await import(${JSON.stringify(operationsUrl)});
|
||||
const { setPlaylistServerTimezone } = await import(${JSON.stringify(timezoneOperationsUrl)});
|
||||
const { __databaseConnectionTestHooks } = await import(${JSON.stringify(connectionUrl)});
|
||||
const sqlite = new Database(':memory:');
|
||||
const statements = [
|
||||
...__databaseConnectionTestHooks.createTableStatements,
|
||||
...__databaseConnectionTestHooks.columnMigrationStatements,
|
||||
].filter((statement) => /TABLE IF NOT EXISTS playlists\\b|ALTER TABLE playlists\\b/.test(statement));
|
||||
for (const statement of statements) {
|
||||
try { sqlite.exec(statement); } catch { /* column already in the base table */ }
|
||||
}
|
||||
const db = drizzle(sqlite, { schema });
|
||||
${scenario}
|
||||
sqlite.close();
|
||||
`;
|
||||
return JSON.parse(
|
||||
execFileSync(
|
||||
createRequire(__filename)('electron'),
|
||||
['--import', 'tsx', '--eval', script],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
TSX_TSCONFIG_PATH: resolve(
|
||||
process.cwd(),
|
||||
'tsconfig.base.json'
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
describe('playlist.operations', () => {
|
||||
it('hydrates updateDate from lastUpdated when payload is stale', async () => {
|
||||
const parsed = parseAppPlaylist({
|
||||
@@ -56,6 +115,208 @@ describe('playlist.operations', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('projects the persisted server timezone from the payload onto DB_GET_PLAYLIST rows', async () => {
|
||||
const row = {
|
||||
id: 'playlist-1',
|
||||
name: 'Xtream Source',
|
||||
serverUrl: 'http://localhost:8080',
|
||||
username: 'demo',
|
||||
password: 'secret',
|
||||
type: 'xtream',
|
||||
payload: JSON.stringify({
|
||||
_id: 'playlist-1',
|
||||
title: 'Xtream Source',
|
||||
serverTimezone: 'Europe/London',
|
||||
}),
|
||||
};
|
||||
const { db } = createPlaylistFavoriteChannelsDbMock(row);
|
||||
|
||||
await expect(getPlaylist(db, 'playlist-1')).resolves.toEqual({
|
||||
...row,
|
||||
serverTimezone: 'Europe/London',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns DB_GET_PLAYLIST rows untouched when the payload carries no server timezone', async () => {
|
||||
const row = {
|
||||
id: 'playlist-1',
|
||||
name: 'Xtream Source',
|
||||
type: 'xtream',
|
||||
payload: JSON.stringify({ _id: 'playlist-1', serverTimezone: ' ' }),
|
||||
};
|
||||
const { db } = createPlaylistFavoriteChannelsDbMock(row);
|
||||
|
||||
await expect(getPlaylist(db, 'playlist-1')).resolves.toBe(row);
|
||||
await expect(
|
||||
getPlaylist(createPlaylistFavoriteChannelsDbMock(null).db, 'gone')
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('drops the persisted server timezone atomically when DB_UPDATE_PLAYLIST moves the source to another server', () => {
|
||||
// Runs the real UPDATE against Electron's SQLite: the invalidation is
|
||||
// one CASE/json_remove expression inside the statement, never a
|
||||
// read-modify-write that could hand a concurrent upsert's newer
|
||||
// payload back to the past.
|
||||
const rows = runPlaylistUpdateScenario(`
|
||||
const seed = (id, serverUrl, payload) => sqlite.prepare(
|
||||
'INSERT INTO playlists (id, name, serverUrl, type, payload) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(id, id, serverUrl, 'xtream', payload);
|
||||
seed('moved', 'http://old.example', JSON.stringify({ _id: 'moved', serverTimezone: 'Europe/London', favorites: ['1'] }));
|
||||
seed('renamed', 'http://old.example', JSON.stringify({ _id: 'renamed', serverTimezone: 'Europe/London' }));
|
||||
seed('no-clock', 'http://old.example', JSON.stringify({ _id: 'no-clock' }));
|
||||
seed('broken', 'http://old.example', 'not json');
|
||||
seed('null-url', null, JSON.stringify({ _id: 'null-url', serverTimezone: 'UTC' }));
|
||||
seed('untouched', 'http://old.example', JSON.stringify({ _id: 'untouched', serverTimezone: 'UTC' }));
|
||||
await updatePlaylist(db, 'moved', { name: 'Moved', serverUrl: 'http://new.example' });
|
||||
await updatePlaylist(db, 'renamed', { name: 'Renamed', serverUrl: 'http://old.example' });
|
||||
await updatePlaylist(db, 'no-clock', { serverUrl: 'http://new.example' });
|
||||
await updatePlaylist(db, 'broken', { serverUrl: 'http://new.example' });
|
||||
await updatePlaylist(db, 'null-url', { serverUrl: 'http://new.example' });
|
||||
await updatePlaylist(db, 'untouched', { name: 'Only renamed' });
|
||||
const rows = Object.fromEntries(
|
||||
sqlite.prepare('SELECT id, name, serverUrl, payload FROM playlists').all().map((row) => [row.id, row])
|
||||
);
|
||||
process.stdout.write(JSON.stringify(rows));
|
||||
`);
|
||||
|
||||
expect(rows).toEqual({
|
||||
moved: {
|
||||
id: 'moved',
|
||||
name: 'Moved',
|
||||
serverUrl: 'http://new.example',
|
||||
payload: JSON.stringify({ _id: 'moved', favorites: ['1'] }),
|
||||
},
|
||||
renamed: {
|
||||
id: 'renamed',
|
||||
name: 'Renamed',
|
||||
serverUrl: 'http://old.example',
|
||||
payload: JSON.stringify({
|
||||
_id: 'renamed',
|
||||
serverTimezone: 'Europe/London',
|
||||
}),
|
||||
},
|
||||
'no-clock': {
|
||||
id: 'no-clock',
|
||||
name: 'no-clock',
|
||||
serverUrl: 'http://new.example',
|
||||
payload: JSON.stringify({ _id: 'no-clock' }),
|
||||
},
|
||||
broken: {
|
||||
id: 'broken',
|
||||
name: 'broken',
|
||||
serverUrl: 'http://new.example',
|
||||
payload: 'not json',
|
||||
},
|
||||
'null-url': {
|
||||
id: 'null-url',
|
||||
name: 'null-url',
|
||||
serverUrl: 'http://new.example',
|
||||
payload: JSON.stringify({ _id: 'null-url' }),
|
||||
},
|
||||
untouched: {
|
||||
id: 'untouched',
|
||||
name: 'Only renamed',
|
||||
serverUrl: 'http://old.example',
|
||||
payload: JSON.stringify({
|
||||
_id: 'untouched',
|
||||
serverTimezone: 'UTC',
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('records the learned panel timezone with one conditional UPDATE guarded by the row connection (issue #1562)', () => {
|
||||
const result = runPlaylistUpdateScenario(`
|
||||
const conn = { serverUrl: 'http://panel.example', username: 'u', password: 'p' };
|
||||
const seed = (id, serverUrl, payload) => sqlite.prepare(
|
||||
'INSERT INTO playlists (id, name, serverUrl, username, password, type, payload) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(id, id, serverUrl, 'u', 'p', 'xtream', payload);
|
||||
seed('fresh', conn.serverUrl, JSON.stringify({ _id: 'fresh', favorites: ['1'] }));
|
||||
seed('same', conn.serverUrl, JSON.stringify({ _id: 'same', serverTimezone: 'Europe/London' }));
|
||||
seed('moved', 'http://other.example', JSON.stringify({ _id: 'moved' }));
|
||||
seed('legacy', conn.serverUrl, null);
|
||||
seed('broken', conn.serverUrl, 'not json');
|
||||
const results = {
|
||||
fresh: await setPlaylistServerTimezone(db, 'fresh', conn, 'Europe/London'),
|
||||
same: await setPlaylistServerTimezone(db, 'same', conn, 'Europe/London'),
|
||||
moved: await setPlaylistServerTimezone(db, 'moved', conn, 'Europe/London'),
|
||||
legacy: await setPlaylistServerTimezone(db, 'legacy', conn, 'UTC+03:00'),
|
||||
broken: await setPlaylistServerTimezone(db, 'broken', conn, 'Europe/London'),
|
||||
missing: await setPlaylistServerTimezone(db, 'missing', conn, 'Europe/London'),
|
||||
};
|
||||
const rows = Object.fromEntries(
|
||||
sqlite.prepare('SELECT id, payload FROM playlists').all().map((row) => [row.id, row.payload])
|
||||
);
|
||||
process.stdout.write(JSON.stringify({ results, rows }));
|
||||
`) as {
|
||||
results: Record<string, { updated: boolean }>;
|
||||
rows: Record<string, string | null>;
|
||||
};
|
||||
|
||||
expect(result.results).toEqual({
|
||||
fresh: { updated: true },
|
||||
same: { updated: false },
|
||||
moved: { updated: false },
|
||||
legacy: { updated: true },
|
||||
broken: { updated: false },
|
||||
missing: { updated: false },
|
||||
});
|
||||
expect(result.rows).toEqual({
|
||||
fresh: JSON.stringify({
|
||||
_id: 'fresh',
|
||||
favorites: ['1'],
|
||||
serverTimezone: 'Europe/London',
|
||||
}),
|
||||
same: JSON.stringify({
|
||||
_id: 'same',
|
||||
serverTimezone: 'Europe/London',
|
||||
}),
|
||||
moved: JSON.stringify({ _id: 'moved' }),
|
||||
legacy: JSON.stringify({ serverTimezone: 'UTC+03:00' }),
|
||||
broken: 'not json',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the stored panel timezone when a clockless full upsert lands after it on the same connection (issue #1562)', () => {
|
||||
const result = runPlaylistUpdateScenario(`
|
||||
const conn = { serverUrl: 'http://panel.example', username: 'u', password: 'p' };
|
||||
const stale = (id, extra = {}) => ({ _id: id, title: id, ...conn, type: 'xtream', favorites: ['1'], ...extra });
|
||||
for (const id of ['kept', 'moved', 'own-clock', 'batch']) {
|
||||
await upsertAppPlaylist(db, stale(id));
|
||||
await setPlaylistServerTimezone(db, id, conn, 'Europe/London');
|
||||
}
|
||||
// Snapshots read BEFORE the clock landed, written after it.
|
||||
await upsertAppPlaylist(db, stale('kept', { favorites: ['1', '2'] }));
|
||||
await upsertAppPlaylist(db, stale('moved', { serverUrl: 'http://other.example' }));
|
||||
await upsertAppPlaylist(db, stale('own-clock', { serverTimezone: 'UTC+03:00' }));
|
||||
await upsertAppPlaylists(db, [stale('batch', { favorites: ['9'] })]);
|
||||
const rows = Object.fromEntries(
|
||||
sqlite.prepare('SELECT id, serverUrl, payload FROM playlists').all()
|
||||
.map((row) => [row.id, { serverUrl: row.serverUrl, payload: JSON.parse(row.payload) }])
|
||||
);
|
||||
process.stdout.write(JSON.stringify(rows));
|
||||
`) as Record<
|
||||
string,
|
||||
{ serverUrl: string; payload: Record<string, unknown> }
|
||||
>;
|
||||
|
||||
expect(result['kept'].payload).toEqual(
|
||||
expect.objectContaining({
|
||||
favorites: ['1', '2'],
|
||||
serverTimezone: 'Europe/London',
|
||||
})
|
||||
);
|
||||
expect(result['batch'].payload).toEqual(
|
||||
expect.objectContaining({
|
||||
favorites: ['9'],
|
||||
serverTimezone: 'Europe/London',
|
||||
})
|
||||
);
|
||||
expect(result['own-clock'].payload.serverTimezone).toBe('UTC+03:00');
|
||||
expect(result['moved'].serverUrl).toBe('http://other.example');
|
||||
expect(result['moved'].payload).not.toHaveProperty('serverTimezone');
|
||||
});
|
||||
|
||||
it('loads app playlist metadata without selecting the large payload column', async () => {
|
||||
const from = jest.fn().mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
reportOperationProgress,
|
||||
} from './operation-control';
|
||||
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
|
||||
import {
|
||||
playlistConflictUpdate,
|
||||
readPayloadServerTimezone,
|
||||
serverTimezoneInvalidation,
|
||||
} from './playlist-server-timezone.operations';
|
||||
|
||||
const PLAYLIST_TYPES = {
|
||||
XTREAM: 'xtream',
|
||||
@@ -371,10 +376,13 @@ export async function upsertAppPlaylist(
|
||||
}
|
||||
|
||||
const write = async () => {
|
||||
await db.insert(schema.playlists).values(row).onConflictDoUpdate({
|
||||
target: schema.playlists.id,
|
||||
set: row,
|
||||
});
|
||||
await db
|
||||
.insert(schema.playlists)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.playlists.id,
|
||||
set: playlistConflictUpdate(row, playlist),
|
||||
});
|
||||
};
|
||||
if (capturePhase) {
|
||||
await capturePhase.captureAsync(
|
||||
@@ -400,20 +408,27 @@ export async function upsertAppPlaylists(
|
||||
}
|
||||
|
||||
const rows = playlists
|
||||
.map((playlist) => buildPlaylistRow(playlist))
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null);
|
||||
.map((playlist) => ({ playlist, row: buildPlaylistRow(playlist) }))
|
||||
.filter(
|
||||
(
|
||||
entry
|
||||
): entry is {
|
||||
playlist: Record<string, unknown>;
|
||||
row: NonNullable<typeof entry.row>;
|
||||
} => entry.row !== null
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { success: true, count: 0 };
|
||||
}
|
||||
|
||||
await db.transaction((tx) => {
|
||||
for (const row of rows) {
|
||||
for (const { playlist, row } of rows) {
|
||||
tx.insert(schema.playlists)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({
|
||||
target: schema.playlists.id,
|
||||
set: row,
|
||||
set: playlistConflictUpdate(row, playlist),
|
||||
})
|
||||
.run();
|
||||
}
|
||||
@@ -563,14 +578,25 @@ export async function getAppPlaylistFavoriteChannels(
|
||||
return resolved.sort((a, b) => a.favoriteIndex - b.favoriteIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw row plus the fields the Xtream store needs from the JSON payload:
|
||||
* `serverTimezone` has no column, and the store seeds `currentPlaylist`
|
||||
* from this read before (or without) the account-info check that learns
|
||||
* it (issue #1562).
|
||||
*/
|
||||
export async function getPlaylist(db: AppDatabase, playlistId: string) {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(schema.playlists)
|
||||
.where(eq(schema.playlists.id, playlistId))
|
||||
.limit(1);
|
||||
const row = result[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result[0] || null;
|
||||
const serverTimezone = readPayloadServerTimezone(row.payload);
|
||||
return serverTimezone ? { ...row, serverTimezone } : row;
|
||||
}
|
||||
|
||||
export async function updatePlaylist(
|
||||
@@ -586,7 +612,12 @@ export async function updatePlaylist(
|
||||
): Promise<{ success: boolean }> {
|
||||
await db
|
||||
.update(schema.playlists)
|
||||
.set(updates)
|
||||
.set({
|
||||
...updates,
|
||||
...(updates.serverUrl === undefined
|
||||
? {}
|
||||
: { payload: serverTimezoneInvalidation(updates.serverUrl) }),
|
||||
})
|
||||
.where(eq(schema.playlists.id, playlistId));
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -16,7 +16,10 @@ handleWorkerRequest(
|
||||
'DB_MIGRATE_APP_PLAYLISTS',
|
||||
(playlists: Record<string, unknown>[]) => ({ playlists })
|
||||
);
|
||||
handleWorkerRequest('DB_CREATE_PLAYLIST', (playlist: Record<string, unknown>) => playlist);
|
||||
handleWorkerRequest(
|
||||
'DB_CREATE_PLAYLIST',
|
||||
(playlist: Record<string, unknown>) => playlist
|
||||
);
|
||||
handleWorkerRequest(
|
||||
'DB_UPSERT_APP_PLAYLIST',
|
||||
(playlist: Record<string, unknown>) => playlist
|
||||
@@ -27,11 +30,26 @@ handleWorkerRequest(
|
||||
);
|
||||
handleWorkerRequest('DB_GET_APP_PLAYLISTS', () => ({}));
|
||||
handleWorkerRequest('DB_GET_APP_PLAYLIST_METAS', () => ({}));
|
||||
handleWorkerRequest('DB_GET_APP_PLAYLIST', (playlistId: string) => ({ playlistId }));
|
||||
handleWorkerRequest('DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS', (playlistId: string) => ({
|
||||
handleWorkerRequest('DB_GET_APP_PLAYLIST', (playlistId: string) => ({
|
||||
playlistId,
|
||||
}));
|
||||
handleWorkerRequest('DB_GET_PLAYLIST', (playlistId: string) => ({ playlistId }));
|
||||
handleWorkerRequest(
|
||||
'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS',
|
||||
(playlistId: string) => ({
|
||||
playlistId,
|
||||
})
|
||||
);
|
||||
handleWorkerRequest('DB_GET_PLAYLIST', (playlistId: string) => ({
|
||||
playlistId,
|
||||
}));
|
||||
handleWorkerRequest(
|
||||
'DB_SET_PLAYLIST_SERVER_TIMEZONE',
|
||||
(
|
||||
playlistId: string,
|
||||
connection: { serverUrl: string; username: string; password: string },
|
||||
serverTimezone: string
|
||||
) => ({ playlistId, connection, serverTimezone })
|
||||
);
|
||||
handleWorkerRequest(
|
||||
'DB_UPDATE_PLAYLIST',
|
||||
(
|
||||
@@ -56,20 +74,12 @@ handleWorkerRequest('DB_SET_APP_STATE', (key: string, value: string) => ({
|
||||
|
||||
ipcMain.handle(
|
||||
'DB_DELETE_PLAYLIST',
|
||||
async (
|
||||
event,
|
||||
playlistId: string,
|
||||
operationId?: string
|
||||
) => {
|
||||
async (event, playlistId: string, operationId?: string) => {
|
||||
try {
|
||||
return await requestWorkerWithEvents(
|
||||
event,
|
||||
'DB_DELETE_PLAYLIST',
|
||||
{
|
||||
playlistId,
|
||||
operationId,
|
||||
}
|
||||
);
|
||||
return await requestWorkerWithEvents(event, 'DB_DELETE_PLAYLIST', {
|
||||
playlistId,
|
||||
operationId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error handling DB_DELETE_PLAYLIST:', error);
|
||||
throw error;
|
||||
|
||||
@@ -41,7 +41,9 @@ const playbackBatchItems = [
|
||||
durationSeconds: 1200,
|
||||
},
|
||||
];
|
||||
const playbackClearBatchItems = [{ contentXtreamId: 42, contentType: 'episode' }];
|
||||
const playbackClearBatchItems = [
|
||||
{ contentXtreamId: 42, contentType: 'episode' },
|
||||
];
|
||||
const tmdbCacheEntry = {
|
||||
mediaType: 'movie',
|
||||
lookupKey: 'id:603',
|
||||
@@ -113,6 +115,23 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
|
||||
args: [playlistId, playlistUpdates],
|
||||
payload: { playlistId, updates: playlistUpdates },
|
||||
},
|
||||
{
|
||||
operation: 'DB_SET_PLAYLIST_SERVER_TIMEZONE',
|
||||
args: [
|
||||
playlistId,
|
||||
{ serverUrl: 'http://panel.example', username: 'u', password: 'p' },
|
||||
'Europe/London',
|
||||
],
|
||||
payload: {
|
||||
playlistId,
|
||||
connection: {
|
||||
serverUrl: 'http://panel.example',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
},
|
||||
serverTimezone: 'Europe/London',
|
||||
},
|
||||
},
|
||||
{
|
||||
operation: 'DB_GET_APP_STATE',
|
||||
args: ['workspace:last-route'],
|
||||
|
||||
@@ -25,6 +25,7 @@ export const DB_WORKER_OPERATIONS = [
|
||||
'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS',
|
||||
'DB_GET_PLAYLIST',
|
||||
'DB_UPDATE_PLAYLIST',
|
||||
'DB_SET_PLAYLIST_SERVER_TIMEZONE',
|
||||
'DB_DELETE_PLAYLIST',
|
||||
'DB_GET_APP_STATE',
|
||||
'DB_SET_APP_STATE',
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
upsertAppPlaylist,
|
||||
upsertAppPlaylists,
|
||||
} from '../database/operations/playlist.operations';
|
||||
import { setPlaylistServerTimezone } from '../database/operations/playlist-server-timezone.operations';
|
||||
import {
|
||||
addRecentItem,
|
||||
clearPlaylistRecentItems,
|
||||
@@ -735,6 +736,24 @@ async function executeRequest(
|
||||
return getPlaylist(db, payload.playlistId);
|
||||
}
|
||||
|
||||
case 'DB_SET_PLAYLIST_SERVER_TIMEZONE': {
|
||||
const payload = message.payload as {
|
||||
playlistId: string;
|
||||
connection: {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
serverTimezone: string;
|
||||
};
|
||||
return setPlaylistServerTimezone(
|
||||
db,
|
||||
payload.playlistId,
|
||||
payload.connection,
|
||||
payload.serverTimezone
|
||||
);
|
||||
}
|
||||
|
||||
case 'DB_UPDATE_PLAYLIST': {
|
||||
const payload = message.payload as {
|
||||
playlistId: string;
|
||||
|
||||
@@ -104,22 +104,23 @@ media. Barriers and delays are coordination tools, not timing inputs.
|
||||
|
||||
## Available Scenarios (credential pairs)
|
||||
|
||||
| Username | Password | Scenario | Live cats | VOD cats | Series cats | Items/cat | Status |
|
||||
| ------------- | ------------- | ---------------------- | --------- | -------- | ----------- | --------- | -------- |
|
||||
| `user1` | `pass1` | default | 8 | 8 | 8 | 40 | active |
|
||||
| `large` | `large` | large catalog | 20 | 20 | 20 | 200 | active |
|
||||
| `stress` | `stress` | stress catalog | 16 | 16 | 16 | 120 | active |
|
||||
| `performance` | `performance` | performance-100k | 60 | 20 | 20 | 1,000 | active |
|
||||
| `series` | `series` | series-heavy | 3 | 4 | 15 | 30 | active |
|
||||
| `minimal` | `minimal` | minimal (edge cases) | 2 | 2 | 2 | 5 | active |
|
||||
| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active |
|
||||
| `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active |
|
||||
| `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active |
|
||||
| `marketing2` | `marketing2` | same catalog, 2nd copy | 4 | 4 | 4 | curated | active |
|
||||
| `multisrc1` | `multisrc1` | multi-source portal A | 1 | 2 | 1 | 5 | active |
|
||||
| `multisrc2` | `multisrc2` | multi-source portal B | 1 | 2 | 1 | 5 | active |
|
||||
| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired |
|
||||
| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled |
|
||||
| Username | Password | Scenario | Live cats | VOD cats | Series cats | Items/cat | Status |
|
||||
| ------------- | ------------- | -------------------------- | --------- | -------- | ----------- | --------- | -------- |
|
||||
| `user1` | `pass1` | default | 8 | 8 | 8 | 40 | active |
|
||||
| `large` | `large` | large catalog | 20 | 20 | 20 | 200 | active |
|
||||
| `stress` | `stress` | stress catalog | 16 | 16 | 16 | 120 | active |
|
||||
| `performance` | `performance` | performance-100k | 60 | 20 | 20 | 1,000 | active |
|
||||
| `series` | `series` | series-heavy | 3 | 4 | 15 | 30 | active |
|
||||
| `minimal` | `minimal` | minimal (edge cases) | 2 | 2 | 2 | 5 | active |
|
||||
| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active |
|
||||
| `tzoffset` | `tzoffset` | EPG fixture, `UTC+3` clock | 2 | 1 | 1 | 3 | active |
|
||||
| `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active |
|
||||
| `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active |
|
||||
| `marketing2` | `marketing2` | same catalog, 2nd copy | 4 | 4 | 4 | curated | active |
|
||||
| `multisrc1` | `multisrc1` | multi-source portal A | 1 | 2 | 1 | 5 | active |
|
||||
| `multisrc2` | `multisrc2` | multi-source portal B | 1 | 2 | 1 | 5 | active |
|
||||
| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired |
|
||||
| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled |
|
||||
|
||||
Any other credential pair is auto-generated using a hash of `username:password` as the faker seed (6 categories, 30 items each, active account).
|
||||
|
||||
@@ -230,6 +231,7 @@ application code.
|
||||
- **Cached per session**: Data generated once on first request, reused until `/reset`
|
||||
- **EPG**: Titles and descriptions are base64-encoded (matches real Xtream API)
|
||||
- **Dedicated EPG fixture**: `epg:epg` returns stable live channels plus deterministic `get_short_epg` and `get_simple_data_table` payloads for timezone-focused tests
|
||||
- **Unusable timezone name**: `tzoffset:tzoffset` serves the same EPG fixture behind a panel whose `server_info.timezone` is the ICU-unknown spelling `UTC+3`, while its `time_now` / `timestamp_now` clock pair reveals a +03:00 offset — the catch-up URL must be derived from the clock pair (issue #1562)
|
||||
- **Release screenshot fixture**: `marketing:marketing` returns fictional live, VOD, and series data with local generated artwork under `apps/xtream-mock-server/public/marketing`
|
||||
- **Alternative-source fixture**: `marketing2:marketing2` returns the identical marketing catalog under a second credential pair, so a movie added from both looks like the same film in two playlists (the premise of the VOD multi-source chip); guide screenshots seed it as a "backup subscription"
|
||||
- **Local download media**: `marketing:marketing` also serves `/movie/...`, `/series/...` and `/live/...` stream URLs from generated bytes (`downloadStreamFixture: 'local-media'`; movies finish in under a second, episodes trickle for about 20 s) so release and guide screenshots of the download manager complete without any request leaving the machine. Other scenarios keep redirecting streams to the public HLS stub.
|
||||
|
||||
@@ -5,6 +5,19 @@ export function handleGetAccountInfo(req: Request, res: Response): void {
|
||||
const { username = '', password = '' } = req.query as Record<string, string>;
|
||||
const data = getPortalData(username, password);
|
||||
const { scenario } = data;
|
||||
const serverClock = scenario.serverClock ?? {
|
||||
timezone: 'UTC',
|
||||
utcOffsetMinutes: 0,
|
||||
};
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
// A real panel formats `time_now` with `date()` in its own timezone:
|
||||
// the same instant as `timestamp_now`, shifted by the panel's offset.
|
||||
const timeNow = new Date(
|
||||
(nowSeconds + serverClock.utcOffsetMinutes * 60) * 1000
|
||||
)
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace('.000Z', '');
|
||||
|
||||
res.json({
|
||||
user_info: {
|
||||
@@ -26,9 +39,9 @@ export function handleGetAccountInfo(req: Request, res: Response): void {
|
||||
https_port: '',
|
||||
server_protocol: 'http',
|
||||
rtmp_port: '',
|
||||
timezone: 'UTC',
|
||||
timestamp_now: Math.floor(Date.now() / 1000),
|
||||
time_now: new Date().toISOString().replace('T', ' ').replace('.000Z', ''),
|
||||
timezone: serverClock.timezone,
|
||||
timestamp_now: nowSeconds,
|
||||
time_now: timeNow,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,14 @@ export interface ScenarioConfig {
|
||||
expiryDate: string;
|
||||
/** Optional deterministic EPG fixture profile for scenario-specific tests. */
|
||||
epgFixture?: 'timezone-focus';
|
||||
/**
|
||||
* Optional `server_info` clock override. `timezone` is reported
|
||||
* verbatim (real panels sometimes send spellings such as `UTC+3` that
|
||||
* no ICU knows), while `time_now` is rendered at `utcOffsetMinutes`
|
||||
* from the epoch so a client can derive the offset from the clock pair
|
||||
* the way it must for such a panel (issue #1562). Default: `UTC`.
|
||||
*/
|
||||
serverClock?: { timezone: string; utcOffsetMinutes: number };
|
||||
/** Optional deterministic VOD detail profile for metadata fallback tests. */
|
||||
vodDetailsFixture?: 'empty-metadata';
|
||||
/** Optional fictional release-marketing dataset with local demo artwork. */
|
||||
@@ -144,6 +152,20 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
|
||||
expiryDate: '2099-12-31',
|
||||
epgFixture: 'timezone-focus',
|
||||
},
|
||||
'tzoffset:tzoffset': {
|
||||
name: 'epg-fixture-offset-clock',
|
||||
description:
|
||||
'The EPG fixture behind a panel whose timezone name is unusable (`UTC+3`) but whose clock pair reveals a +03:00 offset',
|
||||
seed: 6006,
|
||||
categoryCount: { live: 2, vod: 1, series: 1 },
|
||||
itemsPerCategory: 3,
|
||||
seasonsPerSeries: 1,
|
||||
episodesPerSeason: 3,
|
||||
accountStatus: 'Active',
|
||||
expiryDate: '2099-12-31',
|
||||
epgFixture: 'timezone-focus',
|
||||
serverClock: { timezone: 'UTC+3', utcOffsetMinutes: 180 },
|
||||
},
|
||||
'emptyvod:emptyvod': {
|
||||
name: 'empty-vod-metadata',
|
||||
description:
|
||||
|
||||
Reference in new issue
Block a user