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

+1
View File
@@ -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`
@@ -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],
@@ -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[]) =>
@@ -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<VodSourcePin[]> {
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
@@ -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[]) => ({
@@ -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],
@@ -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;
@@ -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);
@@ -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
+8
View File
@@ -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
@@ -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'],
};
}
@@ -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(),
};
}
@@ -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(
@@ -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);
@@ -41,6 +41,25 @@ export class VodSourcePinService {
}
}
/** Every pin pointing at this playlist. Used by playlist backup. */
async listForPlaylist(playlistId: string): Promise<VodSourcePin[]> {
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<boolean> {
if (!this.isAvailable) {
return false;
@@ -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;
}