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 44c2c0d68..ed82a77ef 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 @@ -418,6 +418,12 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ channel: 'DB_LIST_VOD_SOURCE_PINS', forwardedArgs: ['playlist-1'], }, + { + method: 'dbClearVodSourcePinsForPlaylist', + args: ['playlist-1'], + channel: 'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST', + forwardedArgs: ['playlist-1'], + }, { method: 'dbSetVodSourcePin', args: [vodSourcePin, ['title:dune:']], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 75f256c63..e85a65768 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -903,6 +903,8 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('DB_GET_VOD_SOURCE_PIN', matchKeys), dbListVodSourcePins: (playlistId: string) => ipcRenderer.invoke('DB_LIST_VOD_SOURCE_PINS', playlistId), + dbClearVodSourcePinsForPlaylist: (playlistId: string) => + ipcRenderer.invoke('DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST', playlistId), dbSetVodSourcePin: (pin: VodSourcePin, retireKeys?: string[]) => ipcRenderer.invoke('DB_SET_VOD_SOURCE_PIN', pin, retireKeys ?? []), dbClearVodSourcePin: (matchKeys: string[]) => 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 7b7bfd3de..d30b3d67e 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 @@ -13,6 +13,7 @@ import { clearVodSourcePin, getVodSourcePin, setVodSourcePin, + clearVodSourcePinsForPlaylist, } from './vod-source-pin.operations'; const tmdbRow = { @@ -245,4 +246,30 @@ describe('vod-source-pin.operations', () => { ); }); }); + + describe('clearVodSourcePinsForPlaylist', () => { + it('deletes by playlist, with no key cap to truncate', async () => { + const { db, deleteFn, deleteWhere } = createDbMock(); + + await expect( + clearVodSourcePinsForPlaylist(db, 'playlist-1') + ).resolves.toEqual({ success: true }); + + // The keyed clear caps its IN list at MAX_KEYS_PER_LOOKUP, so a + // playlist with more pinned movies than that kept the surplus — + // and still reported success. + expect(deleteFn).toHaveBeenCalledWith(schema.vodSourcePins); + expect(deleteWhere).toHaveBeenCalled(); + }); + + it('refuses a blank playlist id rather than clearing everything', async () => { + const { db, deleteFn } = createDbMock(); + + await expect( + clearVodSourcePinsForPlaylist(db, '') + ).resolves.toEqual({ success: false }); + + expect(deleteFn).not.toHaveBeenCalled(); + }); + }); }); 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 77888f924..a0ccfc64a 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 @@ -148,6 +148,29 @@ export async function clearVodSourcePin( return { success: true }; } +/** + * Drop every pin pointing at one playlist. + * + * NOT expressible through `clearVodSourcePin`: that takes match keys and caps + * them at `MAX_KEYS_PER_LOOKUP` to bound an IN list, so a playlist with more + * pinned movies than the cap would have had the rest silently survive — while + * the call still reported success. + */ +export async function clearVodSourcePinsForPlaylist( + db: AppDatabase, + playlistId: string +): Promise<{ success: boolean }> { + if (typeof playlistId !== 'string' || playlistId === '') { + return { success: false }; + } + + await db + .delete(schema.vodSourcePins) + .where(eq(schema.vodSourcePins.playlistId, playlistId)); + + 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 be5d73004..451dcd300 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 @@ -20,6 +20,11 @@ handleWorkerRequest('DB_LIST_VOD_SOURCE_PINS', (playlistId: string) => ({ playlistId, })); +handleWorkerRequest( + 'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST', + (playlistId: string) => ({ playlistId }) +); + handleWorkerRequest( 'DB_SET_VOD_SOURCE_PIN', (pin: VodSourcePin, retireKeys: string[] = []) => ({ pin, retireKeys }) 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 c9a749b2b..18bacebc0 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 @@ -365,6 +365,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ args: ['playlist-1'], payload: { playlistId: 'playlist-1' }, }, + { + operation: 'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST', + args: ['playlist-1'], + payload: { playlistId: 'playlist-1' }, + }, { operation: 'DB_SET_VOD_SOURCE_PIN', args: [vodSourcePin, ['title:dune:']], 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 8cac58e76..6f5ee2aef 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -59,6 +59,7 @@ export const DB_WORKER_OPERATIONS = [ 'DB_FIND_TITLE_SOURCES', 'DB_GET_VOD_SOURCE_PIN', 'DB_LIST_VOD_SOURCE_PINS', + 'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST', 'DB_SET_VOD_SOURCE_PIN', 'DB_CLEAR_VOD_SOURCE_PIN', ] as const; diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 5c7d435ff..d2e3324df 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -94,6 +94,7 @@ import { import { clearVodSourcePin, getVodSourcePin, + clearVodSourcePinsForPlaylist, listVodSourcePinsForPlaylist, setVodSourcePin, } from '../database/operations/vod-source-pin.operations'; @@ -805,6 +806,11 @@ async function executeRequest( return getVodSourcePin(db, payload.matchKeys); } + case 'DB_CLEAR_VOD_SOURCE_PINS_FOR_PLAYLIST': { + const payload = message.payload as { playlistId: string }; + return clearVodSourcePinsForPlaylist(db, payload.playlistId); + } + case 'DB_LIST_VOD_SOURCE_PINS': { const payload = message.payload as { playlistId: string }; return listVodSourcePinsForPlaylist(db, payload.playlistId); diff --git a/docs/architecture/playlist-backup-restore.md b/docs/architecture/playlist-backup-restore.md index fc2b39fec..b45eedae0 100644 --- a/docs/architecture/playlist-backup-restore.md +++ b/docs/architecture/playlist-backup-restore.md @@ -162,6 +162,12 @@ worker IPC boundary in the snake_case wire shape declared by `XCategoryFromDb`/`XtreamCategoryFromDb`; the category operations project their Drizzle rows explicitly to keep that contract true. +Clearing the playlist's existing pins goes through a dedicated +delete-by-playlist operation, not the keyed clear: that one caps its key list +to bound an IN clause, so a playlist with more pinned movies than the cap kept +the surplus while still reporting success. A failure now fails the entry +rather than leaving the union of old and archived pins. + `sourcePins` (VOD multi-source) is the one **optional** collection, and the normalizer preserves that: an absent field stays absent rather than becoming `[]`, because restore treats a PRESENT collection as authoritative and clears 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 c1f7b2a76..a4b77fbd6 100644 --- a/libs/services/src/lib/playlist-backup.service.pins.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.pins.spec.ts @@ -68,6 +68,7 @@ describe('PlaylistBackupService Xtream source pins', () => { listForPlaylist: jest.fn().mockResolvedValue([]), set: setPin, clear: jest.fn().mockResolvedValue(true), + clearForPlaylist: jest.fn().mockResolvedValue(true), }, }); @@ -128,7 +129,8 @@ describe('PlaylistBackupService Xtream source pins', () => { }, ]), set: jest.fn().mockResolvedValue(true), - clear: clearPins, + clear: jest.fn().mockResolvedValue(true), + clearForPlaylist: clearPins, }, }); @@ -137,7 +139,32 @@ describe('PlaylistBackupService Xtream source pins', () => { // archive deliberately does not contain. await service.importBackup(JSON.stringify(createXtreamManifest([], []))); - expect(clearPins).toHaveBeenCalledWith(['tmdb:999']); + // 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: { + listForPlaylist: jest.fn().mockResolvedValue([]), + set: jest.fn().mockResolvedValue(true), + clear: jest.fn().mockResolvedValue(true), + clearForPlaylist: jest.fn().mockResolvedValue(false), + }, + }); + + const summary = 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 }) + ); }); it('leaves pins alone for an archive that has no opinion', async () => { @@ -157,7 +184,8 @@ describe('PlaylistBackupService Xtream source pins', () => { }, ]), set: jest.fn().mockResolvedValue(true), - clear: clearPins, + clear: jest.fn().mockResolvedValue(true), + clearForPlaylist: clearPins, }, }); @@ -177,6 +205,7 @@ describe('PlaylistBackupService Xtream source pins', () => { listForPlaylist: jest.fn().mockResolvedValue([]), set: setPin, clear: jest.fn().mockResolvedValue(true), + clearForPlaylist: jest.fn().mockResolvedValue(true), }, }); diff --git a/libs/services/src/lib/playlist-backup.service.test-helpers.ts b/libs/services/src/lib/playlist-backup.service.test-helpers.ts index 91a27d246..6676e8131 100644 --- a/libs/services/src/lib/playlist-backup.service.test-helpers.ts +++ b/libs/services/src/lib/playlist-backup.service.test-helpers.ts @@ -66,6 +66,7 @@ export function createPlaylistBackupService( listForPlaylist: jest.fn().mockResolvedValue([]), set: jest.fn().mockResolvedValue(true), clear: jest.fn().mockResolvedValue(true), + clearForPlaylist: jest.fn().mockResolvedValue(true), }, pendingRestoreService: { set: jest.fn(), @@ -258,6 +259,12 @@ export function createStatefulBackupCollaborators( ); return true; }, + clearForPlaylist: async (playlistId: string) => { + state.sourcePins = state.sourcePins.filter( + (pin) => pin.playlistId !== playlistId + ); + return true; + }, }, pendingRestoreService: new XtreamPendingRestoreService(), }; diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index f419b09d7..52adf7c18 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -774,13 +774,19 @@ export class PlaylistBackupService { }); } - /** Drops every pin currently pointing at this playlist. */ + /** + * 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 { - const existing = - await this.vodSourcePinService.listForPlaylist(playlistId); - const matchKeys = existing.map((pin) => pin.matchKey).filter(Boolean); - if (matchKeys.length > 0) { - await this.vodSourcePinService.clear(matchKeys); + if (!(await this.vodSourcePinService.clearForPlaylist(playlistId))) { + throw new PlaylistBackupError( + `Clearing the existing 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 cc23fc36d..70aab3cdc 100644 --- a/libs/services/src/lib/vod-source-pin.service.ts +++ b/libs/services/src/lib/vod-source-pin.service.ts @@ -41,6 +41,32 @@ export class VodSourcePinService { } } + /** + * Drop every pin pointing at this playlist, in one statement. + * + * `clear()` caps its key list, so clearing a playlist through it would + * silently leave the surplus behind while reporting success. + */ + async clearForPlaylist(playlistId: string): Promise { + if (!this.isAvailable || !playlistId) { + return false; + } + + try { + const result = + await window.electron.dbClearVodSourcePinsForPlaylist( + playlistId + ); + return result?.success === true; + } catch (error) { + console.warn( + 'Clearing pinned VOD sources failed:', + redactSensitiveData(error) + ); + return false; + } + } + /** Every pin pointing at this playlist. Used by playlist backup. */ async listForPlaylist(playlistId: string): Promise { if (!this.isAvailable || !playlistId) { diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index c4dcd7d4e..193a4259d 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -909,6 +909,10 @@ export interface ElectronBridgeApi { dbGetVodSourcePin: (matchKeys: string[]) => Promise; /** Every pin pointing at this playlist — used by playlist backup. */ dbListVodSourcePins: (playlistId: string) => Promise; + /** Bulk clear: not keyed, so no `MAX_KEYS_PER_LOOKUP` truncation. */ + dbClearVodSourcePinsForPlaylist: ( + playlistId: string + ) => Promise; /** `retireKeys` are removed in the SAME transaction as the write. */ dbSetVodSourcePin: ( pin: VodSourcePin,