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; +}