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 70a8a864a..73a72dc30 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 @@ -433,6 +433,14 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ // separate calls that can half-apply. forwardedArgs: [vodSourcePin, ['title:dune:'], ['title:dune:2021']], }, + { + method: 'dbReplaceVodSourcePins', + args: ['playlist-1', [vodSourcePin]], + channel: 'DB_REPLACE_VOD_SOURCE_PINS', + // One statement for the whole playlist: restore cannot afford a clear + // and a write that can half-apply. + forwardedArgs: ['playlist-1', [vodSourcePin]], + }, { method: 'dbClearVodSourcePin', args: [vodSourceMatchKeys], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 8e04661f0..28927ef91 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -916,6 +916,8 @@ const electronApi: ElectronBridgeApi = { retireKeys ?? [], aliasKeys ?? [] ), + dbReplaceVodSourcePins: (playlistId: string, pins: VodSourcePin[]) => + ipcRenderer.invoke('DB_REPLACE_VOD_SOURCE_PINS', playlistId, pins), dbClearVodSourcePin: (matchKeys: string[]) => ipcRenderer.invoke('DB_CLEAR_VOD_SOURCE_PIN', matchKeys), // Playback Positions diff --git a/apps/electron-backend/src/app/database/operations/vod-source-pin.operations.spec.ts b/apps/electron-backend/src/app/database/operations/vod-source-pin.operations.spec.ts index 94579263b..62f2fb23a 100644 --- a/apps/electron-backend/src/app/database/operations/vod-source-pin.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/vod-source-pin.operations.spec.ts @@ -14,6 +14,7 @@ import { getVodSourcePin, setVodSourcePin, clearVodSourcePinsForPlaylist, + replaceVodSourcePinsForPlaylist, } from './vod-source-pin.operations'; const tmdbRow = { @@ -299,6 +300,62 @@ describe('vod-source-pin.operations', () => { }); }); + describe('replaceVodSourcePinsForPlaylist', () => { + const pin: VodSourcePin = { + matchKey: 'tmdb:603', + playlistId: 'playlist-1', + contentId: 501, + portalType: 'xtream', + }; + + it('clears and rewrites inside ONE transaction', async () => { + const { db, transaction, deleteRun, insertRun } = + createUpsertDbMock(); + + await expect( + replaceVodSourcePinsForPlaylist(db, 'playlist-1', [pin, pin]) + ).resolves.toEqual({ success: true }); + + // Split into a clear and per-pin writes, a write failing partway + // leaves the old pins gone and the archive half-applied — a state + // belonging to neither, which the user cannot undo. + expect(transaction).toHaveBeenCalledTimes(1); + expect(deleteRun).toHaveBeenCalledTimes(1); + expect(insertRun).toHaveBeenCalledTimes(2); + }); + + it('clears without writing for an empty set', async () => { + const { db, deleteRun, insertRun } = createUpsertDbMock(); + + // Present-but-empty means the user had none; the clear still runs. + await replaceVodSourcePinsForPlaylist(db, 'playlist-1', []); + + expect(deleteRun).toHaveBeenCalledTimes(1); + expect(insertRun).not.toHaveBeenCalled(); + }); + + it('refuses a blank playlist id rather than clearing everything', async () => { + const { db, transaction } = createUpsertDbMock(); + + await expect( + replaceVodSourcePinsForPlaylist(db, '', [pin]) + ).resolves.toEqual({ success: false }); + expect(transaction).not.toHaveBeenCalled(); + }); + + it('drops rows that name no film', async () => { + const { db, insertRun } = createUpsertDbMock(); + + await replaceVodSourcePinsForPlaylist(db, 'playlist-1', [ + { ...pin, matchKey: '' }, + { ...pin, contentId: undefined as unknown as number }, + pin, + ]); + + expect(insertRun).toHaveBeenCalledTimes(1); + }); + }); + describe('clearVodSourcePinsForPlaylist', () => { it('deletes by playlist, with no key cap to truncate', async () => { const { db, deleteFn, deleteWhere } = createDbMock(); diff --git a/apps/electron-backend/src/app/database/operations/vod-source-pin.operations.ts b/apps/electron-backend/src/app/database/operations/vod-source-pin.operations.ts index 406eaa7a9..25695313e 100644 --- a/apps/electron-backend/src/app/database/operations/vod-source-pin.operations.ts +++ b/apps/electron-backend/src/app/database/operations/vod-source-pin.operations.ts @@ -198,6 +198,69 @@ export async function clearVodSourcePinsForPlaylist( return { success: true }; } +/** + * Make this playlist's pins exactly `pins`, in ONE transaction. + * + * Restore cannot do this as a clear followed by writes. If a write fails + * partway the user's previous pins are already gone and only a prefix of the + * archive has landed — the import reports failure, and the state it leaves + * behind belongs to neither the backup nor what was there before. One + * statement group means the playlist either ends up as the archive describes + * it or exactly as it was. + */ +export async function replaceVodSourcePinsForPlaylist( + db: AppDatabase, + playlistId: string, + pins: VodSourcePin[] +): Promise<{ success: boolean }> { + if (typeof playlistId !== 'string' || playlistId === '') { + return { success: false }; + } + + const updatedAt = new Date().toISOString(); + const rows = (pins ?? []) + .filter( + (pin) => + typeof pin?.matchKey === 'string' && + pin.matchKey !== '' && + Number.isInteger(pin?.contentId) + ) + .map((pin) => ({ + matchKey: pin.matchKey, + playlistId, + contentId: pin.contentId, + portalType: pin.portalType, + updatedAt: pin.updatedAt ?? updatedAt, + })); + + const inserts = rows.map((row) => + db + .insert(schema.vodSourcePins) + .values(row) + .onConflictDoUpdate({ + target: schema.vodSourcePins.matchKey, + set: { + playlistId: row.playlistId, + contentId: row.contentId, + portalType: row.portalType, + updatedAt: row.updatedAt, + }, + }) + ); + + await db.transaction(() => { + db.delete(schema.vodSourcePins) + .where(eq(schema.vodSourcePins.playlistId, playlistId)) + .run(); + + for (const insert of inserts) { + insert.run(); + } + }); + + return { success: true }; +} + function toPin(row: schema.VodSourcePinRow): VodSourcePin { return { matchKey: row.matchKey, diff --git a/apps/electron-backend/src/app/events/database/vod-sources.events.ts b/apps/electron-backend/src/app/events/database/vod-sources.events.ts index a19c85337..90b512f07 100644 --- a/apps/electron-backend/src/app/events/database/vod-sources.events.ts +++ b/apps/electron-backend/src/app/events/database/vod-sources.events.ts @@ -41,3 +41,8 @@ handleWorkerRequest( handleWorkerRequest('DB_CLEAR_VOD_SOURCE_PIN', (matchKeys: string[]) => ({ matchKeys, })); + +handleWorkerRequest( + 'DB_REPLACE_VOD_SOURCE_PINS', + (playlistId: string, pins: VodSourcePin[] = []) => ({ playlistId, pins }) +); 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 7b6ca8dca..7980c2840 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 @@ -379,6 +379,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ aliasKeys: ['title:dune:2021'], }, }, + { + operation: 'DB_REPLACE_VOD_SOURCE_PINS', + args: ['playlist-1', [vodSourcePin]], + payload: { playlistId: 'playlist-1', pins: [vodSourcePin] }, + }, { operation: 'DB_CLEAR_VOD_SOURCE_PIN', args: [vodSourceMatchKeys], 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 6f5ee2aef..7b4c07c60 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -62,6 +62,7 @@ export const DB_WORKER_OPERATIONS = [ 'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST', 'DB_SET_VOD_SOURCE_PIN', 'DB_CLEAR_VOD_SOURCE_PIN', + 'DB_REPLACE_VOD_SOURCE_PINS', ] as const; export type DbWorkerOperation = (typeof DB_WORKER_OPERATIONS)[number]; diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 1ef09279b..00f5f185f 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -95,6 +95,7 @@ import { clearVodSourcePin, getVodSourcePin, clearVodSourcePinsForPlaylist, + replaceVodSourcePinsForPlaylist, listVodSourcePinsForPlaylist, setVodSourcePin, } from '../database/operations/vod-source-pin.operations'; @@ -830,6 +831,18 @@ async function executeRequest( ); } + case 'DB_REPLACE_VOD_SOURCE_PINS': { + const payload = message.payload as { + playlistId: string; + pins: VodSourcePin[]; + }; + return replaceVodSourcePinsForPlaylist( + db, + payload.playlistId, + payload.pins ?? [] + ); + } + case 'DB_CLEAR_VOD_SOURCE_PIN': { const payload = message.payload as { matchKeys: string[] }; return clearVodSourcePin(db, payload.matchKeys); diff --git a/libs/services/src/lib/playlist-backup.service.pins.spec.ts b/libs/services/src/lib/playlist-backup.service.pins.spec.ts index 30178f71b..98eea4d2c 100644 --- a/libs/services/src/lib/playlist-backup.service.pins.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.pins.spec.ts @@ -31,6 +31,7 @@ describe('PlaylistBackupService Xtream source pins', () => { playlistsService: collaborators.playlistsService, databaseService: collaborators.databaseService, vodSourcePinService: { + isAvailable: true, listForPlaylistOrThrow: jest .fn() .mockRejectedValue(new Error('database is locked')), @@ -52,6 +53,7 @@ describe('PlaylistBackupService Xtream source pins', () => { playlistsService: collaborators.playlistsService, databaseService: collaborators.databaseService, vodSourcePinService: { + isAvailable: true, listForPlaylistOrThrow: jest.fn().mockResolvedValue([ { matchKey: 'tmdb:603', @@ -82,14 +84,13 @@ describe('PlaylistBackupService Xtream source pins', () => { it('restores pins against the imported playlist, not the exported one', async () => { const collaborators = createRestoreCollaborators(); - const setPin = jest.fn().mockResolvedValue(true); + const replacePins = jest.fn().mockResolvedValue(true); const service = createPlaylistBackupService({ ...collaborators, vodSourcePinService: { + isAvailable: true, listForPlaylistOrThrow: jest.fn().mockResolvedValue([]), - set: setPin, - clear: jest.fn().mockResolvedValue(true), - clearForPlaylist: jest.fn().mockResolvedValue(true), + replaceForPlaylist: replacePins, }, }); @@ -100,26 +101,32 @@ describe('PlaylistBackupService Xtream source pins', () => { await service.importBackup(JSON.stringify(manifest)); - // The match key identifies the film and carries over as-is; the - // playlist id is this installation's, not the archive's. - expect(setPin).toHaveBeenCalledWith({ - matchKey: 'tmdb:603', - playlistId: 'xtream-1', - contentId: 501, - portalType: 'xtream', - }); + // One call, not a clear plus writes: a write failing partway would + // leave the old pins gone and the archive half-applied. The match key + // identifies the film and carries over as-is; the playlist id is this + // installation's, not the archive's. + expect(replacePins).toHaveBeenCalledTimes(1); + expect(replacePins).toHaveBeenCalledWith('xtream-1', [ + { + matchKey: 'tmdb:603', + playlistId: 'xtream-1', + contentId: 501, + portalType: 'xtream', + }, + ]); }); - it('reports a pin that could not be written', async () => { + it('reports pins that could not be written', async () => { const collaborators = createRestoreCollaborators(); const service = createPlaylistBackupService({ ...collaborators, vodSourcePinService: { + isAvailable: true, listForPlaylistOrThrow: jest.fn().mockResolvedValue([]), - // `set` reports failure rather than throwing, so ignoring the - // result would drop the preference while the summary claims - // the import succeeded. - set: jest.fn().mockResolvedValue(false), + // Reported rather than thrown, so ignoring the result would + // drop every preference while the summary claims the import + // succeeded. + replaceForPlaylist: jest.fn().mockResolvedValue(false), }, }); @@ -141,6 +148,7 @@ describe('PlaylistBackupService Xtream source pins', () => { const service = createPlaylistBackupService({ ...collaborators, vodSourcePinService: { + isAvailable: true, listForPlaylistOrThrow: jest.fn().mockResolvedValue([ { matchKey: 'tmdb:999', @@ -149,43 +157,41 @@ describe('PlaylistBackupService Xtream source pins', () => { portalType: 'xtream', }, ]), - set: jest.fn().mockResolvedValue(true), - clear: jest.fn().mockResolvedValue(true), - clearForPlaylist: clearPins, + replaceForPlaylist: clearPins, }, }); // Present-but-empty is an answer, like the playback positions cleared // beside it: leaving the current pins would resurrect preferences the // archive deliberately does not contain. - await service.importBackup(JSON.stringify(createXtreamManifest([], []))); - - // By playlist, not by key list: the keyed clear caps its input and - // would have left the surplus behind while reporting success. - expect(clearPins).toHaveBeenCalledWith('xtream-1'); - }); - - it('fails the entry when the existing pins cannot be cleared', async () => { - const collaborators = createRestoreCollaborators(); - const service = createPlaylistBackupService({ - ...collaborators, - vodSourcePinService: { - listForPlaylistOrThrow: jest.fn().mockResolvedValue([]), - set: jest.fn().mockResolvedValue(true), - clear: jest.fn().mockResolvedValue(true), - clearForPlaylist: jest.fn().mockResolvedValue(false), - }, - }); - - const summary = await service.importBackup( + await service.importBackup( JSON.stringify(createXtreamManifest([], [])) ); - // Writing the archive's pins over pins that are still there produces a - // union of both, which is neither state the user asked for. - expect(summary).toEqual( - expect.objectContaining({ merged: 0, failed: 1 }) - ); + // By playlist and in one statement, so the surplus cannot survive and + // a partial application cannot be left behind. + expect(clearPins).toHaveBeenCalledWith('xtream-1', []); + }); + + it('omits the collection entirely when it cannot read pins', async () => { + const collaborators = createRestoreCollaborators(); + const service = createPlaylistBackupService({ + playlistsService: collaborators.playlistsService, + databaseService: collaborators.databaseService, + vodSourcePinService: { + // The PWA, where pins do not exist at all. + isAvailable: false, + listForPlaylistOrThrow: jest.fn().mockResolvedValue([]), + }, + }); + + const backup = await service.exportBackup(); + + const entry = backup.manifest.playlists[0] as XtreamPlaylistBackupEntry; + // NOT `[]`. "Could not look" is a third answer, and writing it as + // "there are none" turns a web-made archive into an instruction that + // deletes every pin when imported on the desktop. + expect(entry.userState.sourcePins).toBeUndefined(); }); it('leaves pins alone for an archive that has no opinion', async () => { @@ -223,6 +229,7 @@ describe('PlaylistBackupService Xtream source pins', () => { const service = createPlaylistBackupService({ ...collaborators, vodSourcePinService: { + isAvailable: true, listForPlaylistOrThrow: jest.fn().mockResolvedValue([]), set: setPin, clear: jest.fn().mockResolvedValue(true), diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index 32a1663f2..881041f5f 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -22,6 +22,7 @@ import { XtreamBackupCategoryType, XtreamBackupContentType, XtreamPlaylistBackupEntry, + XtreamBackupSourcePin, XtreamPendingRestoreState, createRandomId, } from '@iptvnator/shared/interfaces'; @@ -254,7 +255,14 @@ export class PlaylistBackupService { favorites: [], recentlyViewed: [], playbackPositions: [], - sourcePins: [], + // NOT `[]`. Pins are Electron-only, so out here we cannot + // read them — which is not the same as knowing there are + // none. Restore treats the collection as authoritative and + // clears the playlist's pins before applying it, so an + // empty array exported from the web would wipe them the + // moment the archive was imported on the desktop. Omitted, + // the archive says "no opinion", exactly as one written + // before pins existed does. }, }; } @@ -277,8 +285,9 @@ export class PlaylistBackupService { // Throws rather than reading a failure as "no pins": restore // treats this collection as authoritative and clears the // playlist's pins before applying it, so an empty list born of a - // failed read would wipe them. - this.vodSourcePinService.listForPlaylistOrThrow(playlist._id), + // failed read would wipe them. `null` where the store is not + // reachable at all, which is a different answer again — see below. + this.readSourcePinsForExport(playlist._id), ]); return { @@ -317,15 +326,43 @@ export class PlaylistBackupService { // Carried under the playlist they point AT, so a restore that // does not include that portal cannot resurrect a preference // for something the archive never had. - sourcePins: sourcePins.map((pin) => ({ - matchKey: pin.matchKey, - contentId: pin.contentId, - ...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}), - })), + // + // Present ONLY when this runtime can actually read them. The + // collection is authoritative on restore, so stating `[]` + // where the answer is "could not look" turns the archive into + // an instruction to delete. + ...(sourcePins ? { sourcePins } : {}), }, }; } + /** + * The playlist's pins for the archive, or `null` when this runtime cannot + * read them at all. + * + * Three outcomes, and conflating any two of them loses data: pins exist, + * there are none, and "could not look". Only the first two belong in an + * archive — the third has to leave `sourcePins` out entirely, so restore + * treats it as no opinion rather than as an instruction to delete. A read + * that FAILS is neither: it throws, and the export fails with it. + */ + private async readSourcePinsForExport( + playlistId: string + ): Promise { + if (!this.vodSourcePinService.isAvailable) { + return null; + } + + const pins = + await this.vodSourcePinService.listForPlaylistOrThrow(playlistId); + + return pins.map((pin) => ({ + matchKey: pin.matchKey, + contentId: pin.contentId, + ...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}), + })); + } + private buildStalkerEntry(playlist: Playlist): StalkerPlaylistBackupEntry { return { portalType: 'stalker', @@ -778,21 +815,6 @@ export class PlaylistBackupService { }); } - /** - * Drops every pin currently pointing at this playlist. - * - * One statement rather than list-then-clear-by-key: the keyed clear caps - * its input, so a playlist with more pinned movies than the cap kept the - * surplus while reporting success — and a failed list read silently - * became "there was nothing to clear". - */ - private async clearPinsForPlaylist(playlistId: string): Promise { - if (!(await this.vodSourcePinService.clearForPlaylist(playlistId))) { - throw new PlaylistBackupError( - `Clearing the existing pinned sources for "${playlistId}" failed.` - ); - } - } private async restoreXtreamEntry( playlistId: string, @@ -889,29 +911,32 @@ export class PlaylistBackupService { // so leaving the current ones would resurrect preferences the archive // deliberately does not contain. Absent (an older archive) means "no // opinion", and those are left alone. - if (state.sourcePins) { - await this.clearPinsForPlaylist(playlistId); + if (!state.sourcePins) { + return; } - // The match key identifies the film and survives untouched; only the - // playlist has a new id in this installation. - for (const pin of state.sourcePins ?? []) { - const written = await this.vodSourcePinService.set({ + // ONE call, not a clear followed by writes. Split up, a write that + // fails partway leaves the previous pins already deleted and only part + // of the archive applied — a state belonging to neither, reported as a + // failure the user has no way to undo. The match key identifies the + // film and survives untouched; only the playlist has a new id here. + const replaced = await this.vodSourcePinService.replaceForPlaylist( + playlistId, + state.sourcePins.map((pin) => ({ matchKey: pin.matchKey, playlistId, contentId: pin.contentId, - portalType: 'xtream', + portalType: 'xtream' as const, ...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}), - }); + })) + ); - // `set` reports a failed write rather than throwing, so ignoring - // it would drop the preference while the summary claims the - // import succeeded. - if (!written) { - throw new PlaylistBackupError( - `Restoring the pinned source for "${pin.matchKey}" failed.` - ); - } + // Reported rather than thrown, so ignoring it would drop every + // preference while the summary claims the import succeeded. + if (!replaced) { + throw new PlaylistBackupError( + `Restoring the pinned sources for "${playlistId}" failed.` + ); } } diff --git a/libs/services/src/lib/vod-source-pin.service.ts b/libs/services/src/lib/vod-source-pin.service.ts index 8de5b054a..b18c285f9 100644 --- a/libs/services/src/lib/vod-source-pin.service.ts +++ b/libs/services/src/lib/vod-source-pin.service.ts @@ -139,6 +139,37 @@ export class VodSourcePinService { } } + /** + * Make this playlist's pins exactly `pins`, in one transaction. + * + * Restore needs the clear and the writes to be indivisible. Done in steps, + * a write that fails partway leaves the user's previous pins already gone + * and only part of the archive applied — a state belonging to neither, and + * reported as a failure the user cannot act on. + */ + async replaceForPlaylist( + playlistId: string, + pins: VodSourcePin[] + ): Promise { + if (!this.isAvailable || !playlistId) { + return false; + } + + try { + const result = await window.electron.dbReplaceVodSourcePins( + playlistId, + pins + ); + return result?.success === true; + } catch (error) { + console.warn( + 'Replacing pinned VOD sources failed:', + redactSensitiveData(error) + ); + return false; + } + } + /** Clears the pin under every alias, so unpinning cannot be undone by a * stale row stored under the other key form. */ async clear(matchKeys: string[]): Promise { diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 97ebcf1a1..e8905a06e 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -923,6 +923,11 @@ export interface ElectronBridgeApi { retireKeys?: string[], aliasKeys?: string[] ) => Promise; + /** Makes the playlist's pins exactly `pins`, in one transaction. */ + dbReplaceVodSourcePins: ( + playlistId: string, + pins: VodSourcePin[] + ) => Promise; dbClearVodSourcePin: ( matchKeys: string[] ) => Promise;