From 9deb4325e28558555feb22d5563aec97970c1122 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 28 Jul 2026 19:17:39 +0200 Subject: [PATCH] feat(portals): carry VOD source pins through playlist backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new pins table was invisible to backup: exporting a playlist and re-importing it on a new machine silently dropped every "main source" choice, with nothing in the archive to say the choice had ever been made. Pins now ride along under the playlist they point AT — carrying them anywhere else would restore a preference for a portal the archive never contained. `matchKey` names the film rather than the portal, so it survives untouched and only the playlist id is remapped to the imported copy. `sourcePins` is the one optional collection in the Xtream user state: archives written before multi-source existed simply do not have it, so its absence is age rather than damage. Only a wrong type is rejected, and pins without a usable match key or content id are dropped, since writing one would occupy the unique key of a film it does not describe. Adds `DB_LIST_VOD_SOURCE_PINS` through the usual six seams (operation, worker case, event, preload, bridge contract, service). Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 1 + .../src/app/api/main.preload.spec-data.ts | 6 ++ .../src/app/api/main.preload.ts | 2 + .../operations/vod-source-pin.operations.ts | 25 +++++- .../app/events/database/vod-sources.events.ts | 4 + .../database/worker-ipc-contract.spec-data.ts | 5 ++ .../src/app/workers/database-worker.types.ts | 1 + .../src/app/workers/database.worker.ts | 6 ++ docs/architecture/playlist-backup-restore.md | 11 +++ docs/architecture/vod-multi-source.md | 8 ++ .../playlist-backup.service.roundtrip.spec.ts | 9 ++ .../playlist-backup.service.test-helpers.ts | 21 +++++ .../src/lib/playlist-backup.service.ts | 36 ++++++++ ...list-backup.service.xtream-restore.spec.ts | 88 ++++++++++++++++++- .../src/lib/vod-source-pin.service.ts | 19 ++++ .../src/lib/electron-api.interface.ts | 2 + .../src/lib/playlist-backup.interface.ts | 14 +++ .../src/lib/xtream-restore-state.util.ts | 41 +++++++++ 18 files changed, 297 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 98532d606..76a7bee83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -838,6 +838,7 @@ engine` (restart required) or - Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read *before* the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point. `handleInlineTimeUpdate` returns that verdict and the route feeds multi-source the requested `startTime` until the engine reaches it — one latch for both, or a switch during the initial seek would restart the film. Before anything plays there is no live position at all, so the controller is seeded from the persisted one (`seedResumeSeconds`, one-way: a live value always wins). Portal failures in the multi-source path log through the redacting `createLogger`/`redactSensitiveData` — an Xtream error message carries the stream URL, and that URL is built out of the username and password. - Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}` else the yearless `title:{base}:`, `vod_source_pins` table); enrichment supplies the id and the year late, so a pin may sit under any poorer form — three key sets (`pinKeysFor`): `lookup` passes every alias most-trusted-first, `write` holds only keys naming exactly one film, and `loaded` records where the pin on screen was found — the yearless alias is readable but never written or deleted on spec, since it is shared by every remake, with the single exception of the row this session actually read. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. Starting a pinned source loads THAT source's own playback position — progress is keyed by (playlist, stream), so the row the page loaded belongs to the route's copy. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` feeds one `ownsContent()` predicate used by BOTH the session matcher and the playback-position bridge — if they disagree, the page shows Stop for a session whose progress it throws away and a later switch rewinds hours. Two identity keys: `vodMultiSourceMovieKey` (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while `vodMultiSourceSessionKey` (`playlistId:contentId`) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them. - Claims in the present tense (the "Playing from" caption and the source row's `Playing` badge) are gated on `VodDetailsRouteComponent.playbackLive`, never on `isActive` — discovery marks a source active before anything plays and it stays active after the player closes. Inline that means a `timeupdate` has arrived (`inlinePlayback()` is only the request to play); external it means the session is past `launching`. A merely selected row reads `Current`. +- Pins are included in playlist backup as the optional `sourcePins` collection, carried under the playlist they point at; `matchKey` survives untouched and only the playlist id is remapped on restore (older archives simply lack the field). - Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only — the toggle is hidden in settings and in the sources menu on MPV, VLC and Embedded MPV, since only the built-in web players raise the playback diagnostic that triggers it (`reportsPlaybackFailures()`); it awaits a discovery still in flight before concluding there is nowhere to go (a stream can fail faster than SQLite answers) and re-checks the session afterwards, since the user can navigate during that wait; pinned Play takes the same guarded wait. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally — but SELECTION is not an attempt: `setActiveSource` only selects, `markPlaying` spends the turn, and `runFailover` retires whatever is on screen before picking, so discovery selecting the route row (or a pin selecting an alternative) before anything plays cannot burn a healthy fallback; and it continues past candidates that fail to resolve rather than stopping at the first one — `switchTo` reports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist (through `playlistDisplayLabel`, since a stored playlist name is routinely the pasted URL with credentials), offers Undo, and warns "dub may differ" only when both sides state an audio track as fact. - HEAD probe reuses the main-process handler extracted to `apps/electron-backend/src/app/events/stream-probe.ts` (`STREAM_PROBE_URL`; `XTREAM_PROBE_URL` still delegates there for catchup), and carries the playlist's own `userAgent`/`referer`/`origin` (`StreamProbeHeaders`) — a panel that requires them answers 401/403 otherwise and a working source would be shown as dead. No ffprobe — the binary is not bundled. - See `docs/architecture/vod-multi-source.md` 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 52bce2a0b..7d584856d 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 @@ -412,6 +412,12 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ channel: 'DB_GET_VOD_SOURCE_PIN', forwardedArgs: [vodSourceMatchKeys], }, + { + method: 'dbListVodSourcePins', + args: ['playlist-1'], + channel: 'DB_LIST_VOD_SOURCE_PINS', + forwardedArgs: ['playlist-1'], + }, { method: 'dbSetVodSourcePin', args: [vodSourcePin], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 05202aa7c..be49ac46b 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -883,6 +883,8 @@ const electronApi: ElectronBridgeApi = { }) => ipcRenderer.invoke('DB_FIND_TITLE_SOURCES', request), dbGetVodSourcePin: (matchKeys: string[]) => ipcRenderer.invoke('DB_GET_VOD_SOURCE_PIN', matchKeys), + dbListVodSourcePins: (playlistId: string) => + ipcRenderer.invoke('DB_LIST_VOD_SOURCE_PINS', playlistId), dbSetVodSourcePin: (pin: VodSourcePin) => ipcRenderer.invoke('DB_SET_VOD_SOURCE_PIN', pin), dbClearVodSourcePin: (matchKeys: string[]) => 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 3d8623d6e..82745a55b 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 @@ -1,4 +1,4 @@ -import { inArray } from 'drizzle-orm'; +import { eq, inArray } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; import type { VodSourcePin } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; @@ -48,6 +48,29 @@ export async function getVodSourcePin( return null; } +/** + * Every pin pointing AT this playlist. + * + * A pin belongs to a movie but names one playlist, so the playlist it points + * at is the one that owns it for backup purposes — exporting it anywhere else + * would restore a preference for a portal that is not in the archive. + */ +export async function listVodSourcePinsForPlaylist( + db: AppDatabase, + playlistId: string +): Promise { + if (typeof playlistId !== 'string' || playlistId === '') { + return []; + } + + const rows = await db + .select() + .from(schema.vodSourcePins) + .where(eq(schema.vodSourcePins.playlistId, playlistId)); + + return rows.map(toPin); +} + export async function setVodSourcePin( db: AppDatabase, pin: VodSourcePin 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 37e90c8a6..5846e2504 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 @@ -16,6 +16,10 @@ handleWorkerRequest('DB_GET_VOD_SOURCE_PIN', (matchKeys: string[]) => ({ matchKeys, })); +handleWorkerRequest('DB_LIST_VOD_SOURCE_PINS', (playlistId: string) => ({ + playlistId, +})); + handleWorkerRequest('DB_SET_VOD_SOURCE_PIN', (pin: VodSourcePin) => ({ pin })); handleWorkerRequest('DB_CLEAR_VOD_SOURCE_PIN', (matchKeys: string[]) => ({ 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 7e89c15a0..fb7eb54cc 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 @@ -360,6 +360,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ args: [vodSourceMatchKeys], payload: { matchKeys: vodSourceMatchKeys }, }, + { + operation: 'DB_LIST_VOD_SOURCE_PINS', + args: ['playlist-1'], + payload: { playlistId: 'playlist-1' }, + }, { operation: 'DB_SET_VOD_SOURCE_PIN', args: [vodSourcePin], 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 cf78b1b0e..8cac58e76 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -58,6 +58,7 @@ export const DB_WORKER_OPERATIONS = [ 'DB_MATCH_TITLES', 'DB_FIND_TITLE_SOURCES', 'DB_GET_VOD_SOURCE_PIN', + 'DB_LIST_VOD_SOURCE_PINS', '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 48427e5fe..d0ac87095 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, + listVodSourcePinsForPlaylist, setVodSourcePin, } from '../database/operations/vod-source-pin.operations'; import { @@ -804,6 +805,11 @@ async function executeRequest( return getVodSourcePin(db, payload.matchKeys); } + case 'DB_LIST_VOD_SOURCE_PINS': { + const payload = message.payload as { playlistId: string }; + return listVodSourcePinsForPlaylist(db, payload.playlistId); + } + case 'DB_SET_VOD_SOURCE_PIN': { const payload = message.payload as { pin: VodSourcePin }; return setVodSourcePin(db, payload.pin); diff --git a/docs/architecture/playlist-backup-restore.md b/docs/architecture/playlist-backup-restore.md index 2f3f6f045..f6e60b61e 100644 --- a/docs/architecture/playlist-backup-restore.md +++ b/docs/architecture/playlist-backup-restore.md @@ -162,12 +162,23 @@ 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. +`sourcePins` (VOD multi-source) is the one **optional** collection: archives +written before multi-source existed simply do not have it, so its absence is +age rather than damage and only a wrong type is rejected. A pin is carried +under the playlist it points AT — exporting it anywhere else would restore a +preference for a portal the archive never contained. Its `matchKey` identifies +the film rather than the portal, so it survives untouched; only the playlist id +is remapped to the imported copy. Pins whose match key or content id is +unusable are dropped, since writing one would occupy the unique key of a film +it does not describe. + Electron restore behavior: 1. Category import reads pending hidden-category state while saving categories. 2. After content import, favorites/recent state is restored by typed `{ contentType, xtreamId }` matching. 3. Playback positions are cleared and re-applied from backup state. +4. VOD source pins are re-applied against the IMPORTED playlist id. For existing Xtream playlists with a fully populated offline cache, backup import applies the restore immediately. Otherwise the typed restore payload is diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index fdaf2c1a7..1ffe897df 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -478,6 +478,14 @@ Two things read it, and they must agree: the "Playing from" caption, and the source row's badge — which reads `Current` when a source is merely selected and `Playing` once one really is. +## Backup + +Pins ride along with playlist backup, under the playlist they point at, as the +optional `sourcePins` collection. See +`docs/architecture/playlist-backup-restore.md` for the remapping and +sanitizing rules — the short version is that `matchKey` names the film and +survives as-is, while the playlist id becomes the imported copy's. + ## Which engines can fail over Only the built-in web players (HTML5, Video.js, ArtPlayer) raise the playback diff --git a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts index ab5fd7962..16a1c6b58 100644 --- a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts @@ -150,6 +150,15 @@ describe('PlaylistBackupService export → import round-trip', () => { updatedAt: '2026-07-05T20:00:00.000Z', }, ], + sourcePins: [ + { + matchKey: 'tmdb:603', + playlistId: 'xtream-1', + contentId: 501, + portalType: 'xtream', + updatedAt: '2026-07-06T09:00:00.000Z', + }, + ], epgUrls: ['https://epg.example.com/guide.xml'], }; } 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 f3e5a57a4..7e074dbc9 100644 --- a/libs/services/src/lib/playlist-backup.service.test-helpers.ts +++ b/libs/services/src/lib/playlist-backup.service.test-helpers.ts @@ -2,6 +2,7 @@ import { of } from 'rxjs'; import { PlaybackPositionData, Playlist, + VodSourcePin, XtreamBackupFavoriteItem, XtreamBackupRecentlyViewedItem, } from '@iptvnator/shared/interfaces'; @@ -61,6 +62,10 @@ export function createPlaylistBackupService( clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined), savePlaybackPosition: jest.fn().mockResolvedValue(undefined), }, + vodSourcePinService: { + listForPlaylist: jest.fn().mockResolvedValue([]), + set: jest.fn().mockResolvedValue(true), + }, pendingRestoreService: { set: jest.fn(), clear: jest.fn(), @@ -100,6 +105,7 @@ export interface FakeBackupBackendState { xtreamFavorites: FakeXtreamContentRow[]; xtreamRecent: FakeXtreamContentRow[]; playbackPositions: PlaybackPositionData[]; + sourcePins: VodSourcePin[]; epgUrls: string[]; } @@ -231,6 +237,21 @@ export function createStatefulBackupCollaborators( state.playbackPositions.push({ ...position }); }, }, + vodSourcePinService: { + listForPlaylist: async (playlistId: string) => + state.sourcePins + .filter((pin) => pin.playlistId === playlistId) + .map((pin) => ({ ...pin })), + // The real table keys on matchKey alone, so a second write for the + // same film replaces the first rather than adding a row. + set: async (pin: VodSourcePin) => { + state.sourcePins = state.sourcePins.filter( + (existing) => existing.matchKey !== pin.matchKey + ); + state.sourcePins.push({ ...pin }); + 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 26cd646bf..ab0091223 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -3,6 +3,7 @@ import { firstValueFrom } from 'rxjs'; import { PlaylistsService } from './playlists.service'; import { SettingsStore } from './settings-store.service'; import { DatabaseService } from './database-electron.service'; +import { VodSourcePinService } from './vod-source-pin.service'; import { PlaybackPositionService } from './playback-position.service'; import { XtreamPendingRestoreService } from './xtream-pending-restore.service'; import { @@ -59,6 +60,7 @@ export class PlaylistBackupService { private readonly settingsStore = inject(SettingsStore); private readonly databaseService = inject(DatabaseService); private readonly playbackPositionService = inject(PlaybackPositionService); + private readonly vodSourcePinService = inject(VodSourcePinService); private readonly pendingRestoreService = inject( XtreamPendingRestoreService ); @@ -252,6 +254,7 @@ export class PlaylistBackupService { favorites: [], recentlyViewed: [], playbackPositions: [], + sourcePins: [], }, }; } @@ -263,6 +266,7 @@ export class PlaylistBackupService { favorites, recent, playbackPositions, + sourcePins, ] = await Promise.all([ this.databaseService.getAllXtreamCategories(playlist._id, 'live'), this.databaseService.getAllXtreamCategories(playlist._id, 'movies'), @@ -270,6 +274,7 @@ export class PlaylistBackupService { this.databaseService.getFavorites(playlist._id), this.databaseService.getRecentItems(playlist._id), this.playbackPositionService.getAllPlaybackPositions(playlist._id), + this.vodSourcePinService.listForPlaylist(playlist._id), ]); return { @@ -305,6 +310,14 @@ export class PlaylistBackupService { playbackPositions: playbackPositions.map((item) => ({ ...item, })), + // 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 } : {}), + })), }, }; } @@ -460,6 +473,17 @@ export class PlaylistBackupService { `Xtream backup "${entry.title}" has incomplete user state.` ); } + + // Absent in archives written before multi-source existed, so + // its absence is not damage — only a wrong type is. + if ( + entry.userState.sourcePins !== undefined && + !Array.isArray(entry.userState.sourcePins) + ) { + throw new PlaylistBackupError( + `Xtream backup "${entry.title}" has incomplete user state.` + ); + } break; case 'stalker': if ( @@ -839,6 +863,18 @@ export class PlaylistBackupService { playbackPosition ); } + + // 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 ?? []) { + await this.vodSourcePinService.set({ + matchKey: pin.matchKey, + playlistId, + contentId: pin.contentId, + portalType: 'xtream', + ...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}), + }); + } } private async restoreXtreamCategoryVisibility( diff --git a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts index dcfa79507..f008c0858 100644 --- a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts @@ -64,7 +64,8 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => { } as Playlist; function createXtreamManifest( - hiddenCategories: unknown[] + hiddenCategories: unknown[], + sourcePins?: unknown[] ): PlaylistBackupManifestV1 { return { kind: PLAYLIST_BACKUP_KIND, @@ -87,6 +88,7 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => { favorites: [], recentlyViewed: [], playbackPositions: [], + ...(sourcePins ? { sourcePins } : {}), }, } as unknown as XtreamPlaylistBackupEntry, ], @@ -190,6 +192,90 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => { ); }); + it('exports the pins that point at this playlist', async () => { + const collaborators = createRestoreCollaborators(); + const service = createPlaylistBackupService({ + playlistsService: collaborators.playlistsService, + databaseService: collaborators.databaseService, + vodSourcePinService: { + listForPlaylist: jest.fn().mockResolvedValue([ + { + matchKey: 'tmdb:603', + playlistId: 'xtream-1', + contentId: 501, + portalType: 'xtream', + updatedAt: '2026-07-06T09:00:00.000Z', + }, + ]), + set: jest.fn().mockResolvedValue(true), + }, + }); + + const backup = await service.exportBackup(); + + const entry = backup.manifest + .playlists[0] as XtreamPlaylistBackupEntry; + // Without this every "main source" choice vanishes on restore, with + // nothing in the archive to say it was ever made. + expect(entry.userState.sourcePins).toEqual([ + { + matchKey: 'tmdb:603', + contentId: 501, + updatedAt: '2026-07-06T09:00:00.000Z', + }, + ]); + }); + + it('restores pins against the imported playlist, not the exported one', async () => { + const collaborators = createRestoreCollaborators(); + const setPin = jest.fn().mockResolvedValue(true); + const service = createPlaylistBackupService({ + ...collaborators, + vodSourcePinService: { + listForPlaylist: jest.fn().mockResolvedValue([]), + set: setPin, + }, + }); + + const manifest = createXtreamManifest( + [], + [{ matchKey: 'tmdb:603', contentId: 501 }] + ); + + 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', + }); + }); + + it('imports an archive written before pins existed', async () => { + const collaborators = createRestoreCollaborators(); + const setPin = jest.fn().mockResolvedValue(true); + const service = createPlaylistBackupService({ + ...collaborators, + vodSourcePinService: { + listForPlaylist: jest.fn().mockResolvedValue([]), + set: setPin, + }, + }); + + // No `sourcePins` at all — absence is age, not damage. + const summary = await service.importBackup( + JSON.stringify(createXtreamManifest([])) + ); + + expect(summary).toEqual( + expect.objectContaining({ merged: 1, failed: 0 }) + ); + expect(setPin).not.toHaveBeenCalled(); + }); + it('rejects entries with missing user-state collections instead of wiping user data', async () => { const collaborators = createRestoreCollaborators(); const service = createPlaylistBackupService(collaborators); diff --git a/libs/services/src/lib/vod-source-pin.service.ts b/libs/services/src/lib/vod-source-pin.service.ts index 1f350d311..bcb586bc3 100644 --- a/libs/services/src/lib/vod-source-pin.service.ts +++ b/libs/services/src/lib/vod-source-pin.service.ts @@ -41,6 +41,25 @@ export class VodSourcePinService { } } + /** Every pin pointing at this playlist. Used by playlist backup. */ + async listForPlaylist(playlistId: string): Promise { + if (!this.isAvailable || !playlistId) { + return []; + } + + try { + return ( + (await window.electron.dbListVodSourcePins(playlistId)) ?? [] + ); + } catch (error) { + console.warn( + 'Listing pinned VOD sources failed:', + redactSensitiveData(error) + ); + return []; + } + } + async set(pin: VodSourcePin): Promise { if (!this.isAvailable) { return false; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 7c979ded4..962cec3f4 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -880,6 +880,8 @@ export interface ElectronBridgeApi { }) => Promise; /** Per-movie pinned source; keys are passed most-trusted first */ dbGetVodSourcePin: (matchKeys: string[]) => Promise; + /** Every pin pointing at this playlist — used by playlist backup. */ + dbListVodSourcePins: (playlistId: string) => Promise; dbSetVodSourcePin: (pin: VodSourcePin) => Promise; dbClearVodSourcePin: ( matchKeys: string[] diff --git a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts index 2ff9300db..a22017fd8 100644 --- a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts @@ -58,11 +58,25 @@ export interface XtreamBackupRecentlyViewedItem { viewedAt: string; } +/** + * A per-movie preferred source, carried under the playlist it points AT. + * + * `matchKey` identifies the film, not the portal, so it survives the restore + * untouched — only the playlist id has to be remapped to the imported copy. + */ +export interface XtreamBackupSourcePin { + matchKey: string; + contentId: number; + updatedAt?: string; +} + export interface XtreamBackupUserState { hiddenCategories: XtreamBackupHiddenCategory[]; favorites: XtreamBackupFavoriteItem[]; recentlyViewed: XtreamBackupRecentlyViewedItem[]; playbackPositions: PlaybackPositionData[]; + /** Optional: absent in archives written before multi-source existed. */ + sourcePins?: XtreamBackupSourcePin[]; } export interface XtreamPlaylistBackupEntry extends PlaylistBackupBaseEntry { diff --git a/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts b/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts index 00a9a8093..5b99a207a 100644 --- a/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts +++ b/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts @@ -3,6 +3,7 @@ import { XtreamBackupFavoriteItem, XtreamBackupHiddenCategory, XtreamBackupRecentlyViewedItem, + XtreamBackupSourcePin, } from './playlist-backup.interface'; import { PlaybackPositionData } from './playback-position.interface'; @@ -11,6 +12,11 @@ export interface XtreamPendingRestoreState { favorites: XtreamBackupFavoriteItem[]; recentlyViewed: XtreamBackupRecentlyViewedItem[]; playbackPositions: PlaybackPositionData[]; + /** + * Optional: absent from archives and persisted entries written before + * multi-source existed. The normalizer always fills it. + */ + sourcePins?: XtreamBackupSourcePin[]; } export function getXtreamPendingRestoreStorageKey(playlistId: string): string { @@ -28,6 +34,7 @@ interface RestoreStateCandidate { favorites?: unknown; recentlyViewed?: unknown; playbackPositions?: unknown; + sourcePins?: unknown; } interface RestoreEntryCandidate { @@ -99,6 +106,7 @@ export function normalizeXtreamPendingRestoreState( favorites: [], recentlyViewed: [], playbackPositions: [], + sourcePins: [], }; } @@ -136,5 +144,38 @@ export function normalizeXtreamPendingRestoreState( playbackPositions: toArray(candidate.playbackPositions).filter( (item): item is PlaybackPositionData => isRecord(item) ), + sourcePins: normalizeSourcePins(candidate.sourcePins), }; } + +/** + * A pin without a usable match key or content id cannot address anything, and + * writing it would occupy the unique key of a film it does not describe. + */ +function normalizeSourcePins(value: unknown): XtreamBackupSourcePin[] { + const pins: XtreamBackupSourcePin[] = []; + + for (const item of toArray(value)) { + if (!isRecord(item)) { + continue; + } + + const matchKey = (item as { matchKey?: unknown }).matchKey; + const contentId = normalizeXtreamBackupId( + (item as { contentId?: unknown }).contentId + ); + const updatedAt = (item as { updatedAt?: unknown }).updatedAt; + + if (typeof matchKey !== 'string' || !matchKey || contentId === null) { + continue; + } + + pins.push({ + matchKey, + contentId, + ...(typeof updatedAt === 'string' ? { updatedAt } : {}), + }); + } + + return pins; +}