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
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>
265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
/**
|
||
* Setup actions that stage a dialog or a settings page for a shot: the
|
||
* add-playlist forms, the settings sections, the remote-control switch.
|
||
* Actions here call each other directly rather than through `runAction`,
|
||
* which keeps this module free of a dependency on the dispatcher.
|
||
*/
|
||
|
||
import type { Page } from '@playwright/test';
|
||
|
||
import {
|
||
AUTO_DETECT_FIXTURE_MESSAGE,
|
||
CAPTURE_REMOTE_CONTROL_PORT,
|
||
CAPTURE_REMOTE_CONTROL_URL,
|
||
EPG_FIXTURE_URL,
|
||
M3U_FIXTURE_PLAYLIST_TITLE,
|
||
M3U_FIXTURE_PLAYLIST_URL,
|
||
STALKER_FIXTURE_MAC,
|
||
STALKER_FIXTURE_PORTAL_URL,
|
||
STALKER_FIXTURE_TITLE,
|
||
XTREAM_FIXTURE_CREDENTIALS,
|
||
XTREAM_FIXTURE_TITLE,
|
||
XTREAM_MOCK_ORIGIN,
|
||
} from './capture-fixtures';
|
||
import {
|
||
type CaptureAction,
|
||
clickDialogOption,
|
||
goHome,
|
||
openAddPlaylistDialog,
|
||
settleUi,
|
||
} from './capture-navigation-helpers';
|
||
|
||
export async function openSettings(page: Page): Promise<void> {
|
||
await page.locator('a[href$="/workspace/settings"]').first().click();
|
||
await page.waitForURL(/\/workspace\/settings/, { timeout: 15_000 });
|
||
await page
|
||
.locator('[data-test-id="settings-container"]')
|
||
.waitFor({ state: 'visible', timeout: 15_000 });
|
||
}
|
||
|
||
async function openDashboard(page: Page): Promise<void> {
|
||
await page.locator('a.brand[href$="/workspace/dashboard"]').first().click();
|
||
await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 });
|
||
await page
|
||
.locator('[data-test-id="dashboard-hero"]')
|
||
.waitFor({ state: 'visible', timeout: 30_000 });
|
||
await settleUi(page);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Add-playlist dialog */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
async function openAddPlaylistXtream(page: Page): Promise<void> {
|
||
await goHome(page);
|
||
await openAddPlaylistDialog(page);
|
||
const dialog = page.locator('mat-dialog-container').last();
|
||
|
||
await clickDialogOption(dialog, /xtream credentials/i);
|
||
await dialog.locator('#title').fill(XTREAM_FIXTURE_TITLE);
|
||
await dialog.locator('#serverUrl').fill(XTREAM_MOCK_ORIGIN);
|
||
await dialog.locator('#username').fill(XTREAM_FIXTURE_CREDENTIALS.username);
|
||
await dialog.locator('#password').fill(XTREAM_FIXTURE_CREDENTIALS.password);
|
||
// The status probe only talks to the local mock, so the frame can
|
||
// show the successful "portal is active" verdict the guide explains.
|
||
await dialog
|
||
.getByRole('button', { name: /test connection/i })
|
||
.first()
|
||
.click();
|
||
const status = dialog.locator('.connection-status');
|
||
await status.waitFor({ state: 'visible', timeout: 30_000 });
|
||
// The dialog body scrolls; bring the verdict the guide explains
|
||
// into frame together with the credential fields above it.
|
||
await status.scrollIntoViewIfNeeded();
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
async function openAddPlaylistAuto(page: Page): Promise<void> {
|
||
await goHome(page);
|
||
await openAddPlaylistDialog(page);
|
||
const dialog = page.locator('mat-dialog-container').last();
|
||
|
||
await clickDialogOption(dialog, /auto-detect/i);
|
||
await dialog
|
||
.locator('[data-test-id="auto-detect-textarea"]')
|
||
.fill(AUTO_DETECT_FIXTURE_MESSAGE);
|
||
const candidate = dialog
|
||
.locator('[data-test-id="auto-detect-candidate"]')
|
||
.first();
|
||
await candidate.waitFor({ state: 'visible', timeout: 15_000 });
|
||
await candidate.scrollIntoViewIfNeeded();
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
async function openAddPlaylistStalker(page: Page): Promise<void> {
|
||
await goHome(page);
|
||
await openAddPlaylistDialog(page);
|
||
const dialog = page.locator('mat-dialog-container').last();
|
||
|
||
await clickDialogOption(dialog, /stalker portal/i);
|
||
await dialog.locator('#title').fill(STALKER_FIXTURE_TITLE);
|
||
await dialog.locator('#portalUrl').fill(STALKER_FIXTURE_PORTAL_URL);
|
||
await dialog.locator('#macAddress').fill(STALKER_FIXTURE_MAC);
|
||
// Blur runs the MAC normalization the guide describes.
|
||
await dialog.locator('#serialNumber').focus();
|
||
// The form is long; frame the identity fields and the derive
|
||
// toggle rather than the signature fields at the bottom.
|
||
await dialog.locator('.derive-device-ids').scrollIntoViewIfNeeded();
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
async function openAddPlaylistM3uUrl(page: Page): Promise<void> {
|
||
await goHome(page);
|
||
await openAddPlaylistDialog(page);
|
||
const dialog = page.locator('mat-dialog-container').last();
|
||
|
||
await clickDialogOption(dialog, /m3u url/i);
|
||
// Typed only: the dialog fetches nothing until Add is clicked,
|
||
// and the address points at the local mock anyway.
|
||
await dialog
|
||
.locator('input[formcontrolname="playlistUrl"]')
|
||
.fill(M3U_FIXTURE_PLAYLIST_URL);
|
||
await dialog
|
||
.locator('input[formcontrolname="playlistName"]')
|
||
.fill(M3U_FIXTURE_PLAYLIST_TITLE);
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Settings sections */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
async function openSettingsEpg(page: Page): Promise<void> {
|
||
await openSettings(page);
|
||
const sectionLink = page
|
||
.locator('[data-test-id="settings-section-epg"]')
|
||
.first();
|
||
|
||
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
|
||
await sectionLink.click({ timeout: 10_000 });
|
||
await page.waitForURL(/\/workspace\/settings\/epg/, { timeout: 15_000 });
|
||
|
||
const section = page.locator('#epg');
|
||
await section.waitFor({ state: 'visible', timeout: 15_000 });
|
||
// Show a filled source row instead of the empty state. The value
|
||
// is staged in the form only; nothing is saved or fetched. The
|
||
// dirty form is discarded by `discardUnsavedSettings` before the
|
||
// next action or the app teardown — the settings close guard
|
||
// would otherwise hold `app.close()` open forever.
|
||
await section
|
||
.getByRole('button', { name: /add epg source/i })
|
||
.click({ timeout: 10_000 });
|
||
const field = section.locator('input[type="url"]').last();
|
||
await field.waitFor({ state: 'visible', timeout: 10_000 });
|
||
await field.fill(EPG_FIXTURE_URL, { timeout: 10_000 });
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Remote control (guide shots) */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/**
|
||
* Opens Settings › Remote control with the feature switched on and the
|
||
* capture port in the field. The form is left dirty unless a later
|
||
* `enable-remote-control` step saves it; `discardUnsavedSettings` clears it
|
||
* before the next action.
|
||
*/
|
||
async function openRemoteControlSettings(page: Page): Promise<void> {
|
||
await openSettings(page);
|
||
const sectionLink = page
|
||
.locator('[data-test-id="settings-section-remote-control"]')
|
||
.first();
|
||
|
||
await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
|
||
await sectionLink.click({ timeout: 10_000 });
|
||
await page.waitForURL(/\/workspace\/settings\/remote-control/, {
|
||
timeout: 15_000,
|
||
});
|
||
|
||
const section = page.locator('#remote-control');
|
||
await section.waitFor({ state: 'visible', timeout: 15_000 });
|
||
|
||
const toggle = section.locator(
|
||
'[data-test-id="remote-control-enabled"] input[type="checkbox"]'
|
||
);
|
||
|
||
if (!(await toggle.isChecked())) {
|
||
await section.locator('[data-test-id="remote-control-enabled"]').click();
|
||
}
|
||
|
||
const port = section.locator('[data-test-id="remote-control-port"]');
|
||
await port.waitFor({ state: 'visible', timeout: 10_000 });
|
||
|
||
if ((await port.inputValue()) !== String(CAPTURE_REMOTE_CONTROL_PORT)) {
|
||
await port.fill(String(CAPTURE_REMOTE_CONTROL_PORT));
|
||
}
|
||
|
||
await section
|
||
.locator('.remote-control-url')
|
||
.first()
|
||
.waitFor({ state: 'visible', timeout: 15_000 });
|
||
}
|
||
|
||
async function openSettingsRemoteControl(page: Page): Promise<void> {
|
||
await openRemoteControlSettings(page);
|
||
const section = page.locator('#remote-control');
|
||
const qrButton = section.locator('.url-row button').first();
|
||
|
||
await qrButton.waitFor({ state: 'visible', timeout: 15_000 });
|
||
await qrButton.click();
|
||
await section
|
||
.locator('qrcode canvas, qrcode img')
|
||
.first()
|
||
.waitFor({ state: 'visible', timeout: 15_000 });
|
||
await page.waitForTimeout(500);
|
||
}
|
||
|
||
async function enableRemoteControl(page: Page): Promise<void> {
|
||
await openRemoteControlSettings(page);
|
||
const save = page.locator('[data-test-id="save-settings"]').first();
|
||
|
||
if (await save.isEnabled().catch(() => false)) {
|
||
await save.click();
|
||
await settleUi(page);
|
||
}
|
||
|
||
await waitForRemoteControlServer();
|
||
}
|
||
|
||
/** Polls the status endpoint the phone view reads until the app's server answers. */
|
||
async function waitForRemoteControlServer(): Promise<void> {
|
||
const statusUrl = `${CAPTURE_REMOTE_CONTROL_URL}api/remote-control/status`;
|
||
const deadline = Date.now() + 15_000;
|
||
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
const response = await fetch(statusUrl);
|
||
|
||
if (response.ok) {
|
||
return;
|
||
}
|
||
} catch {
|
||
// not up yet
|
||
}
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||
}
|
||
|
||
throw new Error(
|
||
`Remote control server did not answer at ${statusUrl} — is port ${CAPTURE_REMOTE_CONTROL_PORT} held by another IPTVnator instance?`
|
||
);
|
||
}
|
||
|
||
export const SETUP_ACTIONS: Readonly<Record<string, CaptureAction>> = {
|
||
'open-settings': openSettings,
|
||
'open-dashboard': openDashboard,
|
||
'open-add-playlist-xtream': openAddPlaylistXtream,
|
||
'open-add-playlist-auto': openAddPlaylistAuto,
|
||
'open-add-playlist-stalker': openAddPlaylistStalker,
|
||
'open-add-playlist-m3u-url': openAddPlaylistM3uUrl,
|
||
'open-settings-epg': openSettingsEpg,
|
||
'open-settings-remote-control': openSettingsRemoteControl,
|
||
'enable-remote-control': enableRemoteControl,
|
||
};
|