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 ee28ef542..599195f20 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, setPlaylistServerTimezone } = await import(${JSON.stringify(operationsUrl)}); + const { updatePlaylist, setPlaylistServerTimezone, upsertAppPlaylist, upsertAppPlaylists } = await import(${JSON.stringify(operationsUrl)}); const { __databaseConnectionTestHooks } = await import(${JSON.stringify(connectionUrl)}); const sqlite = new Database(':memory:'); const statements = [ @@ -273,6 +273,46 @@ describe('playlist.operations', () => { }); }); + it('keeps the stored panel timezone when a clockless full upsert lands after it on the same connection (issue #1562)', () => { + const result = runPlaylistUpdateScenario(` + const conn = { serverUrl: 'http://panel.example', username: 'u', password: 'p' }; + const stale = (id, extra = {}) => ({ _id: id, title: id, ...conn, type: 'xtream', favorites: ['1'], ...extra }); + for (const id of ['kept', 'moved', 'own-clock', 'batch']) { + await upsertAppPlaylist(db, stale(id)); + await setPlaylistServerTimezone(db, id, conn, 'Europe/London'); + } + // Snapshots read BEFORE the clock landed, written after it. + await upsertAppPlaylist(db, stale('kept', { favorites: ['1', '2'] })); + await upsertAppPlaylist(db, stale('moved', { serverUrl: 'http://other.example' })); + await upsertAppPlaylist(db, stale('own-clock', { serverTimezone: 'UTC+03:00' })); + await upsertAppPlaylists(db, [stale('batch', { favorites: ['9'] })]); + const rows = Object.fromEntries( + sqlite.prepare('SELECT id, serverUrl, payload FROM playlists').all() + .map((row) => [row.id, { serverUrl: row.serverUrl, payload: JSON.parse(row.payload) }]) + ); + process.stdout.write(JSON.stringify(rows)); + `) as Record< + string, + { serverUrl: string; payload: Record } + >; + + expect(result['kept'].payload).toEqual( + expect.objectContaining({ + favorites: ['1', '2'], + serverTimezone: 'Europe/London', + }) + ); + expect(result['batch'].payload).toEqual( + expect.objectContaining({ + favorites: ['9'], + serverTimezone: 'Europe/London', + }) + ); + expect(result['own-clock'].payload.serverTimezone).toBe('UTC+03:00'); + expect(result['moved'].serverUrl).toBe('http://other.example'); + expect(result['moved'].payload).not.toHaveProperty('serverTimezone'); + }); + it('loads app playlist metadata without selecting the large payload column', async () => { const from = jest.fn().mockResolvedValue([ { diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index 6e71d91c4..037660327 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -1,4 +1,4 @@ -import { eq, sql } from 'drizzle-orm'; +import { eq, sql, type SQL } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces'; import { @@ -235,6 +235,48 @@ export function buildPlaylistRow( }; } +/** + * The `DO UPDATE` half of an app-playlist upsert. A full upsert is built + * from a snapshot the caller read earlier; when that snapshot carries no + * panel clock while the stored row does — `setPlaylistServerTimezone` + * landed in between — the stored clock is carried over as long as the row + * still points at the same panel, so a favorites, recent-items or metadata + * write cannot hand a clockless payload back to the past (issue #1562). + * A snapshot that carries its own clock, or moves the source, wins as is. + * Nested CASE, not AND: SQLite may reorder AND terms, and the json_* + * readers raise on a malformed payload unless json_valid ran first. + */ +type PlaylistConflictUpdate = { + [K in keyof schema.NewPlaylist]?: schema.NewPlaylist[K] | SQL; +}; + +function playlistConflictUpdate( + row: schema.NewPlaylist, + playlist: Record +): PlaylistConflictUpdate { + if (getStringValue(playlist.serverTimezone)) { + return row; + } + const stored = schema.playlists; + const payload = sql`CASE + WHEN json_valid(${stored.payload}) + THEN CASE + WHEN json_type(${stored.payload}, '$.serverTimezone') = 'text' + AND ${stored.serverUrl} IS excluded.${sql.raw(stored.serverUrl.name)} + AND ${stored.username} IS excluded.${sql.raw(stored.username.name)} + AND ${stored.password} IS excluded.${sql.raw(stored.password.name)} + THEN json_set( + excluded.${sql.raw(stored.payload.name)}, + '$.serverTimezone', + json_extract(${stored.payload}, '$.serverTimezone') + ) + ELSE excluded.${sql.raw(stored.payload.name)} + END + ELSE excluded.${sql.raw(stored.payload.name)} + END`; + return { ...row, payload }; +} + function getPlaylistItemCount( playlist: Record ): number | undefined { @@ -371,10 +413,13 @@ export async function upsertAppPlaylist( } const write = async () => { - await db.insert(schema.playlists).values(row).onConflictDoUpdate({ - target: schema.playlists.id, - set: row, - }); + await db + .insert(schema.playlists) + .values(row) + .onConflictDoUpdate({ + target: schema.playlists.id, + set: playlistConflictUpdate(row, playlist), + }); }; if (capturePhase) { await capturePhase.captureAsync( @@ -400,20 +445,27 @@ export async function upsertAppPlaylists( } const rows = playlists - .map((playlist) => buildPlaylistRow(playlist)) - .filter((row): row is NonNullable => row !== null); + .map((playlist) => ({ playlist, row: buildPlaylistRow(playlist) })) + .filter( + ( + entry + ): entry is { + playlist: Record; + row: NonNullable; + } => entry.row !== null + ); if (rows.length === 0) { return { success: true, count: 0 }; } await db.transaction((tx) => { - for (const row of rows) { + for (const { playlist, row } of rows) { tx.insert(schema.playlists) .values(row) .onConflictDoUpdate({ target: schema.playlists.id, - set: row, + set: playlistConflictUpdate(row, playlist), }) .run(); } diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index b24b8599b..25d6b02ef 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -293,7 +293,12 @@ 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 +past or undo an edit that landed in between. The reverse ordering is covered +on the upsert side: `DB_UPSERT_APP_PLAYLIST(S)` (`playlistConflictUpdate`) +carries the STORED clock into a snapshot that has none while the row still +points at the same connection, so a favorites, recent-items or metadata write +built from a pre-clock snapshot cannot strip it; a snapshot carrying its own +clock, or moving the source, wins as is. The store offers the resolved value on every check (a transient write failure is retried by the next one), patches its own state only while the selected playlist is still the panel the answer came from (`answersFor`: id + connection), and returns the store's