feat(portals): carry VOD source pins through playlist backup

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 19:17:39 +02:00
1 parent 7cce227268
commit 9deb4325e2
18 files changed
+297 -2

No files matched your search

@@ -880,6 +880,8 @@ export interface ElectronBridgeApi {
}) => Promise<VodSourceCandidateRow[]>;
/** Per-movie pinned source; keys are passed most-trusted first */
dbGetVodSourcePin: (matchKeys: string[]) => Promise<VodSourcePin | null>;
/** Every pin pointing at this playlist — used by playlist backup. */
dbListVodSourcePins: (playlistId: string) => Promise<VodSourcePin[]>;
dbSetVodSourcePin: (pin: VodSourcePin) => Promise<ElectronBridgeResult>;
dbClearVodSourcePin: (
matchKeys: string[]
@@ -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 {
@@ -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;
}