From 390188f0ce0b6e24ee7882524df6fce05029248f Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 29 Jul 2026 21:26:02 +0200 Subject: [PATCH] fix(portals): stop two backup paths from deleting pins they never read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two data-loss paths, both P1, both mine. A web export wrote `sourcePins: []`. Pins are Electron-only, so out there we cannot read them — which is not the same as knowing there are none, and restore treats the collection as authoritative. A backup made in the browser was therefore an instruction to delete every pin the moment it was imported on the desktop. There are three answers here, not two: pins exist, there are none, and "could not look". The last omits the field, exactly as an archive written before pins existed does. The same rule now covers Electron with the bridge method missing. I had written a test asserting the unreachable-store case resolves to an empty list "because a backup made there is complete". That reasoning was wrong: empty was true of what the runtime could see, never of the playlist. Restore also cleared the playlist's pins and then wrote the archive's one by one. A write failing partway left the previous pins already gone and only a prefix applied — a state belonging to neither, reported as a failure the user could not undo. `DB_REPLACE_VOD_SOURCE_PINS` does the clear and every write in one transaction, so the playlist ends up as the archive describes it or exactly as it was. Co-Authored-By: Claude Opus 5 --- .../src/app/api/main.preload.spec-data.ts | 8 ++ .../src/app/api/main.preload.ts | 2 + .../vod-source-pin.operations.spec.ts | 57 ++++++++++ .../operations/vod-source-pin.operations.ts | 63 +++++++++++ .../app/events/database/vod-sources.events.ts | 5 + .../database/worker-ipc-contract.spec-data.ts | 5 + .../src/app/workers/database-worker.types.ts | 1 + .../src/app/workers/database.worker.ts | 13 +++ .../lib/playlist-backup.service.pins.spec.ts | 97 +++++++++-------- .../src/lib/playlist-backup.service.ts | 103 +++++++++++------- .../src/lib/vod-source-pin.service.ts | 31 ++++++ .../src/lib/electron-api.interface.ts | 5 + 12 files changed, 306 insertions(+), 84 deletions(-) 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;