diff --git a/AGENTS.md b/AGENTS.md index 3c33dbe1c..ba3fcb7ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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"). diff --git a/CLAUDE.md b/CLAUDE.md index d2b905021..4f0800b2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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"). diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index de0f5816b..a3e895386 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -130,6 +130,20 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ channel: 'DB_UPDATE_PLAYLIST', forwardedArgs: [playlistId, playlistUpdates], }, + { + method: 'dbSetPlaylistServerTimezone', + args: [ + playlistId, + { serverUrl: 'http://panel.example', username: 'u', password: 'p' }, + 'Europe/London', + ], + channel: 'DB_SET_PLAYLIST_SERVER_TIMEZONE', + forwardedArgs: [ + playlistId, + { serverUrl: 'http://panel.example', username: 'u', password: 'p' }, + 'Europe/London', + ], + }, { method: 'dbDeletePlaylist', args: [playlistId, operationId], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 09098763d..d11393f60 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -749,6 +749,17 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist), dbGetPlaylist: (playlistId: string) => ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId), + dbSetPlaylistServerTimezone: ( + playlistId: string, + connection: { serverUrl: string; username: string; password: string }, + serverTimezone: string + ) => + ipcRenderer.invoke( + 'DB_SET_PLAYLIST_SERVER_TIMEZONE', + playlistId, + connection, + serverTimezone + ), dbUpsertAppPlaylist: (playlist: Playlist, _operationId?: string) => ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist), dbMigrateAppPlaylists: (playlists: Playlist[]) => diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts index 746e00a33..ee28ef542 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts @@ -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; + rows: Record; + }; + + expect(result.results).toEqual({ + fresh: { updated: true }, + same: { updated: false }, + moved: { updated: false }, + legacy: { updated: true }, + broken: { updated: false }, + missing: { updated: false }, + }); + expect(result.rows).toEqual({ + fresh: JSON.stringify({ + _id: 'fresh', + favorites: ['1'], + serverTimezone: 'Europe/London', + }), + same: JSON.stringify({ + _id: 'same', + serverTimezone: 'Europe/London', + }), + moved: JSON.stringify({ _id: 'moved' }), + legacy: JSON.stringify({ serverTimezone: 'UTC+03:00' }), + broken: 'not json', + }); + }); + it('loads app playlist metadata without selecting the large payload column', async () => { const from = jest.fn().mockResolvedValue([ { diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index 280974d4c..6e71d91c4 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -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. */ diff --git a/apps/electron-backend/src/app/events/database/playlist.events.ts b/apps/electron-backend/src/app/events/database/playlist.events.ts index eb9683c75..208e7c2b5 100644 --- a/apps/electron-backend/src/app/events/database/playlist.events.ts +++ b/apps/electron-backend/src/app/events/database/playlist.events.ts @@ -16,7 +16,10 @@ handleWorkerRequest( 'DB_MIGRATE_APP_PLAYLISTS', (playlists: Record[]) => ({ playlists }) ); -handleWorkerRequest('DB_CREATE_PLAYLIST', (playlist: Record) => playlist); +handleWorkerRequest( + 'DB_CREATE_PLAYLIST', + (playlist: Record) => playlist +); handleWorkerRequest( 'DB_UPSERT_APP_PLAYLIST', (playlist: Record) => playlist @@ -27,11 +30,26 @@ handleWorkerRequest( ); handleWorkerRequest('DB_GET_APP_PLAYLISTS', () => ({})); handleWorkerRequest('DB_GET_APP_PLAYLIST_METAS', () => ({})); -handleWorkerRequest('DB_GET_APP_PLAYLIST', (playlistId: string) => ({ playlistId })); -handleWorkerRequest('DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS', (playlistId: string) => ({ +handleWorkerRequest('DB_GET_APP_PLAYLIST', (playlistId: string) => ({ playlistId, })); -handleWorkerRequest('DB_GET_PLAYLIST', (playlistId: string) => ({ playlistId })); +handleWorkerRequest( + 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS', + (playlistId: string) => ({ + playlistId, + }) +); +handleWorkerRequest('DB_GET_PLAYLIST', (playlistId: string) => ({ + playlistId, +})); +handleWorkerRequest( + 'DB_SET_PLAYLIST_SERVER_TIMEZONE', + ( + playlistId: string, + connection: { serverUrl: string; username: string; password: string }, + serverTimezone: string + ) => ({ playlistId, connection, serverTimezone }) +); handleWorkerRequest( 'DB_UPDATE_PLAYLIST', ( @@ -56,20 +74,12 @@ handleWorkerRequest('DB_SET_APP_STATE', (key: string, value: string) => ({ ipcMain.handle( 'DB_DELETE_PLAYLIST', - async ( - event, - playlistId: string, - operationId?: string - ) => { + async (event, playlistId: string, operationId?: string) => { try { - return await requestWorkerWithEvents( - event, - 'DB_DELETE_PLAYLIST', - { - playlistId, - operationId, - } - ); + return await requestWorkerWithEvents(event, 'DB_DELETE_PLAYLIST', { + playlistId, + operationId, + }); } catch (error) { console.error('Error handling DB_DELETE_PLAYLIST:', error); throw error; diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts index 49a51080e..5487705de 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts @@ -41,7 +41,9 @@ const playbackBatchItems = [ durationSeconds: 1200, }, ]; -const playbackClearBatchItems = [{ contentXtreamId: 42, contentType: 'episode' }]; +const playbackClearBatchItems = [ + { contentXtreamId: 42, contentType: 'episode' }, +]; const tmdbCacheEntry = { mediaType: 'movie', lookupKey: 'id:603', @@ -113,6 +115,23 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ args: [playlistId, playlistUpdates], payload: { playlistId, updates: playlistUpdates }, }, + { + operation: 'DB_SET_PLAYLIST_SERVER_TIMEZONE', + args: [ + playlistId, + { serverUrl: 'http://panel.example', username: 'u', password: 'p' }, + 'Europe/London', + ], + payload: { + playlistId, + connection: { + serverUrl: 'http://panel.example', + username: 'u', + password: 'p', + }, + serverTimezone: 'Europe/London', + }, + }, { operation: 'DB_GET_APP_STATE', args: ['workspace:last-route'], diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 0eae7ea29..36db8a5d4 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -25,6 +25,7 @@ export const DB_WORKER_OPERATIONS = [ 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS', 'DB_GET_PLAYLIST', 'DB_UPDATE_PLAYLIST', + 'DB_SET_PLAYLIST_SERVER_TIMEZONE', 'DB_DELETE_PLAYLIST', 'DB_GET_APP_STATE', 'DB_SET_APP_STATE', diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 790604981..2e68b6d5a 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -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; diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 7f48a9e5f..b24b8599b 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -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`. diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts index 983a85c21..b00aa0d39 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.delegation.spec.ts @@ -70,6 +70,31 @@ describe('ElectronXtreamDataSource (delegation)', () => { }); }); + it('persists the panel timezone through the conditional worker UPDATE with the request connection (issue #1562)', async () => { + await harness.dataSource.rememberServerTimezone( + playlistId, + { + serverUrl: 'http://portal.example', + username: 'demo', + password: 'secret', + allowedOutputFormats: ['ts'], + }, + 'Europe/London' + ); + + expect( + harness.dbService.setXtreamPlaylistServerTimezone + ).toHaveBeenCalledWith( + playlistId, + { + serverUrl: 'http://portal.example', + username: 'demo', + password: 'secret', + }, + 'Europe/London' + ); + }); + it('deletes playlists via the DB and propagates failures', async () => { await harness.dataSource.deletePlaylist(playlistId); expect(harness.dbService.deletePlaylist).toHaveBeenCalledWith( diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts index 09b1f7938..f29d0dbf8 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts @@ -48,6 +48,7 @@ export function createDbServiceMock() { clearPlaylistRecentItems: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn().mockResolvedValue(null), setContentMetadataIfMissing: jest.fn().mockResolvedValue(undefined), + setXtreamPlaylistServerTimezone: jest.fn().mockResolvedValue(true), deleteXtreamPlaylistContent: jest.fn().mockResolvedValue({ hiddenCategories: [], favorites: [], diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index ea8c8bf37..37d7898cb 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -107,6 +107,25 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { }); } + async rememberServerTimezone( + playlistId: string, + credentials: XtreamCredentials, + serverTimezone: string + ): Promise { + // One conditional UPDATE in the worker (`DB_SET_PLAYLIST_SERVER_TIMEZONE`): + // the row-level connection match and the no-op-when-equal check + // happen inside the statement, never as a read here. + await this.dbService.setXtreamPlaylistServerTimezone( + playlistId, + { + serverUrl: credentials.serverUrl, + username: credentials.username, + password: credentials.password, + }, + serverTimezone + ); + } + async deletePlaylist(playlistId: string): Promise { await this.dbService.deletePlaylist(playlistId); } @@ -336,9 +355,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { await this.dbService.saveXtreamContent( playlistId, remoteData as - | XtreamLiveStream[] - | XtreamVodStream[] - | XtreamSerieItem[], + XtreamLiveStream[] | XtreamVodStream[] | XtreamSerieItem[], type, onProgress, options diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts index ad4103d74..3b56bd804 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -15,6 +15,7 @@ describe('PwaXtreamDataSource', () => { }; let playlistsService: { getPlaylistById: jest.Mock; + transformPlaylistMeta: jest.Mock; }; const credentials: XtreamCredentials = { @@ -31,6 +32,7 @@ describe('PwaXtreamDataSource', () => { }; playlistsService = { getPlaylistById: jest.fn(() => of(undefined)), + transformPlaylistMeta: jest.fn(() => of(null)), }; TestBed.configureTestingModule({ @@ -110,6 +112,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({ diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index a1f0b65db..d469dd8be 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -128,6 +128,34 @@ export class PwaXtreamDataSource implements IXtreamDataSource { } } + async rememberServerTimezone( + playlistId: string, + credentials: XtreamCredentials, + serverTimezone: string + ): Promise { + try { + // `transformPlaylistMeta` runs the read and the write inside + // one IndexedDB readwrite cursor transaction, so the + // connection check below cannot be overtaken by an edit. + const playlistsService = this.injector.get(PlaylistsService); + const written = await firstValueFrom( + playlistsService.transformPlaylistMeta(playlistId, (row) => + row.serverUrl !== credentials.serverUrl || + row.username !== credentials.username || + row.password !== credentials.password || + row.serverTimezone === serverTimezone + ? null + : { ...row, serverTimezone } + ) + ); + if (written) { + await this.updatePlaylist(playlistId, { serverTimezone }); + } + } catch (error) { + this.logger.error('Failed to persist the portal timezone', error); + } + } + async deletePlaylist(playlistId: string): Promise { const playlists = this.getPlaylistsFromStorage(); const filtered = playlists.filter((p) => p.id !== playlistId); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index e5c9cf12b..383e108a3 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -164,6 +164,23 @@ export interface IXtreamDataSource { updates: Partial ): Promise; + /** + * Persist the panel clock a successful account-info check learned + * (`resolveXtreamServerTimezone`) onto the stored playlist row, which + * the Favorites / Recent catch-up resolver reads instead of the store + * (issue #1562). Each runtime applies it atomically against the row's + * current connection: the write lands only while the row still points + * at `credentials`, so an edit that moved the source meanwhile keeps + * the clock the edit flow dropped, and a row already carrying the value + * is left untouched. Never rejects — a failed write is retried by the + * next check. + */ + rememberServerTimezone( + playlistId: string, + credentials: XtreamCredentials, + serverTimezone: string + ): Promise; + /** * Delete a playlist and all its data */ diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts index da2d39242..56db7caae 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.spec.ts @@ -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); }); }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts index fcb3f16bd..3f2737c79 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-portal.feature.ts @@ -15,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 => { 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( diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 5bab038c1..daac7f235 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -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 { + if (!window.electron?.dbSetPlaylistServerTimezone) { + return false; + } + try { + await window.electron.dbSetPlaylistServerTimezone( + playlistId, + connection, + serverTimezone + ); + return true; + } catch (error) { + console.error('Error persisting the portal timezone:', error); + return false; + } + } + /** * Check if categories exist */ diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 5edea1aef..8f248fdd2 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -896,6 +896,16 @@ export interface ElectronBridgeApi { dbGetPlaylist: ( playlistId: string ) => Promise; + /** + * Records the panel clock learned from account info as one conditional + * UPDATE on the row payload; a no-op unless the row still points at the + * given connection (issue #1562). + */ + dbSetPlaylistServerTimezone: ( + playlistId: string, + connection: { serverUrl: string; username: string; password: string }, + serverTimezone: string + ) => Promise<{ updated: boolean }>; dbUpsertAppPlaylist: ( playlist: Playlist, /**