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>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-07 00:17:10 +02:00
1 parent 5953e60d9a
commit 212d6ed785
21 files changed
+467 -170

No files matched your search

+8 -4
View File
@@ -331,10 +331,14 @@ timezone (`server_info.timezone`), never the viewer's (issue #1562).
`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
`PlaylistsService.transformPlaylistMeta`, because the Favorites / Recent
resolver reads the STORED row, not the store; `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
`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").
+8 -4
View File
@@ -813,10 +813,14 @@ timezone (`server_info.timezone`), never the viewer's (issue #1562).
`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
`PlaylistsService.transformPlaylistMeta`, because the Favorites / Recent
resolver reads the STORED row, not the store; `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
`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").
@@ -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[]) =>
@@ -43,7 +43,7 @@ function runPlaylistUpdateScenario(scenario: string): unknown {
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 } = await import(${JSON.stringify(operationsUrl)});
const { updatePlaylist, setPlaylistServerTimezone } = await import(${JSON.stringify(operationsUrl)});
const { __databaseConnectionTestHooks } = await import(${JSON.stringify(connectionUrl)});
const sqlite = new Database(':memory:');
const statements = [
@@ -221,6 +221,58 @@ describe('playlist.operations', () => {
});
});
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('loads app playlist metadata without selecting the large payload column', async () => {
const from = jest.fn().mockResolvedValue([
{
@@ -629,6 +629,55 @@ function serverTimezoneInvalidation(nextServerUrl: string) {
END`;
}
export interface PlaylistConnectionIdentity {
serverUrl: string;
username: string;
password: string;
}
/**
* Records the panel clock a successful account-info check learned
* (`serverTimezone`, payload-only) 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 (issue #1562).
*/
export async function setPlaylistServerTimezone(
db: AppDatabase,
playlistId: string,
connection: PlaylistConnectionIdentity,
serverTimezone: string
): Promise<{ updated: boolean }> {
const result = await db
.update(schema.playlists)
.set({
payload: sql`CASE
WHEN ${schema.playlists.payload} IS NULL
THEN json_object('serverTimezone', ${serverTimezone})
ELSE json_set(${schema.playlists.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`${schema.playlists.id} = ${playlistId}
AND ${schema.playlists.serverUrl} IS ${connection.serverUrl}
AND ${schema.playlists.username} IS ${connection.username}
AND ${schema.playlists.password} IS ${connection.password}
AND CASE
WHEN ${schema.playlists.payload} IS NULL THEN 1
WHEN json_valid(${schema.playlists.payload})
THEN json_extract(${schema.playlists.payload}, '$.serverTimezone') IS NOT ${serverTimezone}
ELSE 0
END`
)
.run();
return { updated: result.changes > 0 };
}
interface PlaylistDeletionCollection {
readonly categoryIds: number[];
/** Content rows per category, the unit the delete is batched by. */
@@ -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',
@@ -69,6 +69,7 @@ import {
getAppState,
getPlaylist,
setAppState,
setPlaylistServerTimezone,
type AppPlaylistGetPhaseCapture,
type AppPlaylistUpsertPhaseCapture,
updatePlaylist,
@@ -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;
@@ -277,16 +277,32 @@ timezone is learned by `withPortal.checkPortalStatus()` and normalized by
- 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`, via
`PlaylistsService.transformPlaylistMeta` — a no-op write when the row already
carries it) 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. 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`.
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 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`.
@@ -70,6 +70,31 @@ describe('ElectronXtreamDataSource (delegation)', () => {
});
});
it('persists the panel timezone through the conditional worker UPDATE with the request connection (issue #1562)', async () => {
await harness.dataSource.rememberServerTimezone(
playlistId,
{
serverUrl: 'http://portal.example',
username: 'demo',
password: 'secret',
allowedOutputFormats: ['ts'],
},
'Europe/London'
);
expect(
harness.dbService.setXtreamPlaylistServerTimezone
).toHaveBeenCalledWith(
playlistId,
{
serverUrl: 'http://portal.example',
username: 'demo',
password: 'secret',
},
'Europe/London'
);
});
it('deletes playlists via the DB and propagates failures', async () => {
await harness.dataSource.deletePlaylist(playlistId);
expect(harness.dbService.deletePlaylist).toHaveBeenCalledWith(
@@ -48,6 +48,7 @@ export function createDbServiceMock() {
clearPlaylistRecentItems: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
setContentMetadataIfMissing: jest.fn().mockResolvedValue(undefined),
setXtreamPlaylistServerTimezone: jest.fn().mockResolvedValue(true),
deleteXtreamPlaylistContent: jest.fn().mockResolvedValue({
hiddenCategories: [],
favorites: [],
@@ -107,6 +107,25 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
});
}
async rememberServerTimezone(
playlistId: string,
credentials: XtreamCredentials,
serverTimezone: string
): Promise<void> {
// One conditional UPDATE in the worker (`DB_SET_PLAYLIST_SERVER_TIMEZONE`):
// the row-level connection match and the no-op-when-equal check
// happen inside the statement, never as a read here.
await this.dbService.setXtreamPlaylistServerTimezone(
playlistId,
{
serverUrl: credentials.serverUrl,
username: credentials.username,
password: credentials.password,
},
serverTimezone
);
}
async deletePlaylist(playlistId: string): Promise<void> {
await this.dbService.deletePlaylist(playlistId);
}
@@ -336,9 +355,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
await this.dbService.saveXtreamContent(
playlistId,
remoteData as
| XtreamLiveStream[]
| XtreamVodStream[]
| XtreamSerieItem[],
XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[],
type,
onProgress,
options
@@ -15,6 +15,7 @@ describe('PwaXtreamDataSource', () => {
};
let playlistsService: {
getPlaylistById: jest.Mock;
transformPlaylistMeta: jest.Mock;
};
const credentials: XtreamCredentials = {
@@ -31,6 +32,7 @@ describe('PwaXtreamDataSource', () => {
};
playlistsService = {
getPlaylistById: jest.fn(() => of(undefined)),
transformPlaylistMeta: jest.fn(() => of(null)),
};
TestBed.configureTestingModule({
@@ -110,6 +112,49 @@ 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({
@@ -128,6 +128,34 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
}
}
async rememberServerTimezone(
playlistId: string,
credentials: XtreamCredentials,
serverTimezone: string
): Promise<void> {
try {
// `transformPlaylistMeta` runs the read and the write inside
// one IndexedDB readwrite cursor transaction, so the
// connection check below cannot be overtaken by an edit.
const playlistsService = this.injector.get(PlaylistsService);
const written = await firstValueFrom(
playlistsService.transformPlaylistMeta(playlistId, (row) =>
row.serverUrl !== credentials.serverUrl ||
row.username !== credentials.username ||
row.password !== credentials.password ||
row.serverTimezone === serverTimezone
? null
: { ...row, serverTimezone }
)
);
if (written) {
await this.updatePlaylist(playlistId, { serverTimezone });
}
} catch (error) {
this.logger.error('Failed to persist the portal timezone', error);
}
}
async deletePlaylist(playlistId: string): Promise<void> {
const playlists = this.getPlaylistsFromStorage();
const filtered = playlists.filter((p) => p.id !== playlistId);
@@ -164,6 +164,23 @@ export interface IXtreamDataSource {
updates: Partial<XtreamPlaylistData>
): Promise<void>;
/**
* Persist the panel clock a successful account-info check learned
* (`resolveXtreamServerTimezone`) onto the stored playlist row, which
* the Favorites / Recent catch-up resolver reads instead of the store
* (issue #1562). Each runtime applies it atomically against the row's
* current connection: the write lands only while the row still points
* at `credentials`, so an edit that moved the source meanwhile keeps
* the clock the edit flow dropped, and a row already carrying the value
* is left untouched. Never rejects — a failed write is retried by the
* next check.
*/
rememberServerTimezone(
playlistId: string,
credentials: XtreamCredentials,
serverTimezone: string
): Promise<void>;
/**
* Delete a playlist and all its data
*/
@@ -1,8 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { PlaylistsService } from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { signalStore } from '@ngrx/signals';
import { of } from 'rxjs';
import {
XTREAM_DATA_SOURCE,
XtreamPlaylistData,
@@ -25,6 +22,12 @@ 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;
@@ -35,34 +38,13 @@ describe('withPortal', () => {
let apiService: {
getAccountInfo: jest.Mock;
};
let storedPlaylist: Playlist;
let transformPlaylistMeta: jest.Mock;
let rememberServerTimezone: jest.Mock;
beforeEach(() => {
apiService = {
getAccountInfo: jest.fn(),
};
storedPlaylist = {
_id: PLAYLIST.id,
title: PLAYLIST.name,
serverUrl: PLAYLIST.serverUrl,
username: PLAYLIST.username,
password: PLAYLIST.password,
} as Playlist;
// Mirrors the real service: the transform receives the stored row
// and `null` means "nothing to write".
transformPlaylistMeta = jest.fn(
(
_playlistId: string,
transform: (current: Playlist) => Playlist | null
) => {
const next = transform(storedPlaylist);
if (next) {
storedPlaylist = next;
}
return of(next);
}
);
rememberServerTimezone = jest.fn().mockResolvedValue(undefined);
TestBed.configureTestingModule({
providers: [
@@ -75,12 +57,9 @@ describe('withPortal', () => {
provide: XTREAM_DATA_SOURCE,
useValue: {
getPlaylist: jest.fn(),
rememberServerTimezone,
},
},
{
provide: PlaylistsService,
useValue: { transformPlaylistMeta },
},
],
});
@@ -146,7 +125,7 @@ describe('withPortal', () => {
});
describe('server timezone (issue #1562)', () => {
it('keeps the learned IANA timezone in the store AND on the stored row', async () => {
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();
@@ -154,11 +133,11 @@ describe('withPortal', () => {
expect(store.currentPlaylist()?.serverTimezone).toBe(
'Europe/London'
);
expect(transformPlaylistMeta).toHaveBeenCalledWith(
expect(rememberServerTimezone).toHaveBeenCalledWith(
PLAYLIST.id,
expect.any(Function)
PLAYLIST_CONNECTION,
'Europe/London'
);
expect(storedPlaylist.serverTimezone).toBe('Europe/London');
});
it('derives a fixed offset from the panel clock when the timezone name is unusable', async () => {
@@ -171,39 +150,26 @@ describe('withPortal', () => {
await store.checkPortalStatus();
expect(store.currentPlaylist()?.serverTimezone).toBe('UTC+03:00');
expect(storedPlaylist.serverTimezone).toBe('UTC+03:00');
expect(rememberServerTimezone).toHaveBeenCalledWith(
PLAYLIST.id,
PLAYLIST_CONNECTION,
'UTC+03:00'
);
});
it('offers the value to storage every time but writes nothing when the row already carries it', async () => {
storedPlaylist = { ...storedPlaylist, serverTimezone: 'UTC' };
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(transformPlaylistMeta).toHaveBeenCalledTimes(1);
const [, transform] = transformPlaylistMeta.mock.calls[0];
expect(transform(storedPlaylist)).toBeNull();
});
it('retries the write on the next check after a transient storage failure', async () => {
transformPlaylistMeta.mockImplementationOnce(() => {
throw new Error('storage unavailable');
});
respondWith({ timezone: 'Europe/London' });
await store.checkPortalStatus();
expect(store.currentPlaylist()?.serverTimezone).toBe(
'Europe/London'
expect(rememberServerTimezone).toHaveBeenCalledWith(
PLAYLIST.id,
PLAYLIST_CONNECTION,
'UTC'
);
expect(storedPlaylist.serverTimezone).toBeUndefined();
// The store already carries the clock, so only the row-level
// check may decide that the row still lacks it.
await store.checkPortalStatus();
expect(transformPlaylistMeta).toHaveBeenCalledTimes(2);
expect(storedPlaylist.serverTimezone).toBe('Europe/London');
});
it('keeps the previously known timezone when the response carries no usable clock', async () => {
@@ -218,10 +184,10 @@ describe('withPortal', () => {
expect(store.currentPlaylist()?.serverTimezone).toBe(
'Europe/London'
);
expect(transformPlaylistMeta).not.toHaveBeenCalled();
expect(rememberServerTimezone).not.toHaveBeenCalled();
});
it('persists a late answer under the playlist that asked, never onto the one selected meanwhile', async () => {
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',
@@ -247,11 +213,11 @@ describe('withPortal', () => {
await expect(pending).resolves.toBe('unavailable');
expect(store.currentPlaylist()).toEqual(other);
expect(store.portalStatus()).toBe('unavailable');
expect(transformPlaylistMeta).toHaveBeenCalledWith(
expect(rememberServerTimezone).toHaveBeenCalledWith(
PLAYLIST.id,
expect.any(Function)
PLAYLIST_CONNECTION,
'Europe/London'
);
expect(storedPlaylist.serverTimezone).toBe('Europe/London');
});
it('does not patch the store with a late answer when the playlist was edited in place meanwhile', async () => {
@@ -269,11 +235,6 @@ describe('withPortal', () => {
serverUrl: 'https://moved.example.com',
};
store.setCurrentPlaylist(moved);
storedPlaylist = {
...storedPlaylist,
serverUrl: moved.serverUrl,
serverTimezone: undefined,
};
answer({
user_info: { auth: 1, exp_date: '0', status: 'Active' },
server_info: { timezone: 'Europe/London' },
@@ -282,33 +243,13 @@ describe('withPortal', () => {
await expect(pending).resolves.toBe('unavailable');
expect(store.currentPlaylist()).toEqual(moved);
expect(store.portalStatus()).toBe('unavailable');
expect(storedPlaylist.serverTimezone).toBeUndefined();
});
it('does not persist a late answer onto a row whose connection was edited meanwhile', async () => {
let answer!: (value: unknown) => void;
apiService.getAccountInfo.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
})
// 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'
);
const pending = store.checkPortalStatus();
// The edit flow moved the source (and dropped the old clock)
// while the old panel's answer was still on the wire.
storedPlaylist = {
...storedPlaylist,
serverUrl: 'https://moved.example.com',
serverTimezone: undefined,
};
answer({
user_info: { auth: 1, exp_date: '0', status: 'Active' },
server_info: { timezone: 'Europe/London' },
});
await expect(pending).resolves.toBe('active');
expect(transformPlaylistMeta).toHaveBeenCalledTimes(1);
expect(storedPlaylist.serverTimezone).toBeUndefined();
});
it('does not mark a playlist selected meanwhile unavailable for the earlier playlist’s failure', async () => {
@@ -333,16 +274,20 @@ describe('withPortal', () => {
expect(store.portalStatus()).toBe('active');
});
it('still reports the portal status when persisting the timezone fails', async () => {
transformPlaylistMeta.mockImplementation(() => {
throw new Error('storage unavailable');
});
it('still reports the portal status and retries on the next check when the data source rejects', async () => {
rememberServerTimezone.mockRejectedValueOnce(
new Error('storage unavailable')
);
respondWith({ timezone: 'Europe/London' });
await expect(store.checkPortalStatus()).resolves.toBe('active');
expect(store.currentPlaylist()?.serverTimezone).toBe(
'Europe/London'
);
await store.checkPortalStatus();
expect(rememberServerTimezone).toHaveBeenCalledTimes(2);
});
});
});
@@ -15,12 +15,10 @@ import {
} from '../../services/xtream-api.service';
import { PortalStatusType } from '../../xtream-state';
import { createLogger } from '@iptvnator/portal/shared/util';
import { PlaylistsService } from '@iptvnator/services';
import {
resolveXtreamPortalStatus,
resolveXtreamServerTimezone,
} from '@iptvnator/shared/interfaces';
import { firstValueFrom } from 'rxjs';
/**
* Portal state for managing playlist and portal status
@@ -56,7 +54,6 @@ export function withPortal() {
withMethods((store) => {
const apiService = inject(XtreamApiService);
const dataSource = inject(XTREAM_DATA_SOURCE);
const playlistsService = inject(PlaylistsService);
/**
* Whether a playlist (the store's current one, or the stored
@@ -82,12 +79,10 @@ export function withPortal() {
* 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 write
* lands only on a row that still points at the panel the answer
* came from — an edit that moved the source during the request
* keeps the clock the edit flow dropped — is skipped when the
* row already carries the value, and never fails the status
* check that learned it.
* 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,
@@ -95,15 +90,10 @@ export function withPortal() {
serverTimezone: string
): Promise<void> => {
try {
await firstValueFrom(
playlistsService.transformPlaylistMeta(
playlistId,
(playlist) =>
!answersFor(playlist, credentials) ||
playlist.serverTimezone === serverTimezone
? null
: { ...playlist, serverTimezone }
)
await dataSource.rememberServerTimezone(
playlistId,
credentials,
serverTimezone
);
} catch (error) {
logger.error(
@@ -122,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';
@@ -135,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;
@@ -381,6 +373,34 @@ export class DatabaseService {
}
}
/**
* Records the panel clock a successful account-info check learned, as
* one conditional UPDATE on the row payload (issue #1562). A no-op when
* the row no longer points at the given connection or already carries
* the value; never reads before writing, so it cannot undo a concurrent
* edit or upsert. Resolves `true` when the row now carries the clock.
*/
async setXtreamPlaylistServerTimezone(
playlistId: string,
connection: { serverUrl: string; username: string; password: string },
serverTimezone: string
): Promise<boolean> {
if (!window.electron?.dbSetPlaylistServerTimezone) {
return false;
}
try {
await window.electron.dbSetPlaylistServerTimezone(
playlistId,
connection,
serverTimezone
);
return true;
} catch (error) {
console.error('Error persisting the portal timezone:', error);
return false;
}
}
/**
* Check if categories exist
*/
@@ -896,6 +896,16 @@ export interface ElectronBridgeApi {
dbGetPlaylist: (
playlistId: string
) => Promise<ElectronBridgePlaylistRow | null>;
/**
* Records the panel clock learned from account info as one conditional
* UPDATE on the row payload; a no-op unless the row still points at the
* given connection (issue #1562).
*/
dbSetPlaylistServerTimezone: (
playlistId: string,
connection: { serverUrl: string; username: string; password: string },
serverTimezone: string
) => Promise<{ updated: boolean }>;
dbUpsertAppPlaylist: (
playlist: Playlist,
/**