fix(xtream): preserve pending backups and restore positions atomically (#1873)

* fix(xtream): preserve pending backups and restore positions atomically

* fix(xtream): retain sparse duplicate playback metadata on restore

* fix(xtream): require atomic restore in SQLite capability gate

* fix(xtream): preflight atomic restore without changing storage
This commit is contained in:
4gray authored and GitHub committed 2026-10-10 16:27:01 +02:00
1 parent 2b400cb81d
commit 2fa03c8efc
25 files changed
+1037 -142

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: xtream
---
Xtream backups preserve imported favorites, history, hidden categories, resume positions, and pinned sources even before the catalog loads. Storage failures now report a backup or restore error; failed position restores preserve the previous positions and remain available to retry.
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Locator, Page } from '@playwright/test';
import type { PlaylistBackupManifestV1 } from '@iptvnator/shared/interfaces';
import {
addXtreamPortal,
closeElectronApp,
@@ -21,20 +22,20 @@ import { readVisibleSidebarCategories } from './sidebar-categories.e2e-support';
/**
* Full backup round-trip through the real UI, DB worker and IPC stack:
* hide a category, export the backup, delete the source, import the file
* back and verify the restored portal hides the same category again after
* its content is re-imported from the mock server (regression for #1017 —
* exported hidden categories lost their xtream IDs and the restore either
* hid everything or nothing).
* Preserve hidden categories, collections, positions and source pins through
* an immediate re-export while catalog restoration is still pending, then
* verify the restored database after reopening the portal. The category UI
* assertions also guard #1017's missing provider category IDs.
*/
test.describe('Electron playlist backup round-trip', () => {
test('exports a backup and re-imports it with hidden categories restored', async ({
test('preserves pending backup state on re-export and restores it after restart', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const portalName = 'Backup Roundtrip Xtream';
const exportPath = join(dataDir, 'roundtrip-backup.json');
const pendingExportPath = join(dataDir, 'pending-backup.json');
const app = await launchElectronApp(dataDir);
try {
@@ -61,6 +62,36 @@ test.describe('Electron playlist backup round-trip', () => {
sidebarCategoryById(app.mainWindow, targetCategory.id)
).toHaveCount(0);
const playlistId =
app.mainWindow.url().match(/xtreams\/([^/]+)/)?.[1] ?? '';
expect(playlistId).not.toEqual('');
// Seed realistic resume/collection state through the same IPC used
// by playback. No player runs here to overwrite the saved position.
const movieXtreamId = await app.mainWindow.evaluate(async (id) => {
const [movie] = await window.electron.dbGetContent(id, 'movie');
if (!movie) throw new Error('Mock catalog has no movie.');
const results = await Promise.all([
window.electron.dbAddFavorite(movie.id, id),
window.electron.dbAddRecentItem(movie.id, id),
window.electron.dbSavePlaybackPosition(id, {
contentXtreamId: movie.xtream_id,
contentType: 'vod',
positionSeconds: 123,
durationSeconds: 7200,
}),
window.electron.dbSetVodSourcePin({
matchKey: 'title:backup-roundtrip',
playlistId: id,
contentId: movie.xtream_id,
portalType: 'xtream',
}),
]);
if (results.some((result) => !result.success)) {
throw new Error('Seeding backup state through IPC failed.');
}
return movie.xtream_id;
}, playlistId);
// Export through the real settings flow with the native save
// dialog stubbed to a fixed path inside the test data dir.
await app.electronApp.evaluate(({ dialog: nativeDialog }, path) => {
@@ -83,17 +114,9 @@ test.describe('Electron playlist backup round-trip', () => {
// The exported manifest must reference hidden categories by
// numeric xtream ID — the #1017 regression exported anonymous
// { categoryType } entries.
const manifest = JSON.parse(readFileSync(exportPath, 'utf-8')) as {
playlists: Array<{
portalType: string;
userState?: {
hiddenCategories?: Array<{
categoryType?: string;
xtreamId?: unknown;
}>;
};
}>;
};
const manifest = JSON.parse(
readFileSync(exportPath, 'utf-8')
) as PlaylistBackupManifestV1;
const xtreamEntry = manifest.playlists.find(
(entry) => entry.portalType === 'xtream'
);
@@ -107,6 +130,18 @@ test.describe('Electron playlist backup round-trip', () => {
)
).toBe(true);
expect(xtreamEntry?.userState.favorites).toHaveLength(1);
expect(xtreamEntry?.userState.recentlyViewed).toHaveLength(1);
expect(xtreamEntry?.userState.sourcePins).toHaveLength(1);
expect(xtreamEntry?.userState.playbackPositions).toEqual([
expect.objectContaining({
contentXtreamId: movieXtreamId,
contentType: 'vod',
positionSeconds: 123,
durationSeconds: 7200,
}),
]);
await openSources(app.mainWindow);
await deleteSource(app.mainWindow, portalName);
await expect(
@@ -128,6 +163,46 @@ test.describe('Electron playlist backup round-trip', () => {
app.mainWindow.getByText(/Backup import finished: 1 imported/)
).toBeVisible({ timeout: 15000 });
// No catalog has been opened since import. Verify this is a
// genuinely pending restore, then export it again through the UI.
const pendingBeforeExport = await app.mainWindow.evaluate(
async (id) => ({
pending: localStorage.getItem(`xtream-restore-${id}`),
hasMovies: await window.electron.dbHasContent(id, 'movie'),
positions:
await window.electron.dbGetAllPlaybackPositions(id),
}),
playlistId
);
expect(pendingBeforeExport.pending).not.toBeNull();
expect(pendingBeforeExport.hasMovies).toBe(false);
expect(pendingBeforeExport.positions).toEqual([]);
await app.electronApp.evaluate(({ dialog: nativeDialog }, path) => {
nativeDialog.showSaveDialog = async () => ({
canceled: false,
filePath: path,
});
}, pendingExportPath);
await backupSection
.getByRole('button', { name: 'Export', exact: true })
.click();
await expect(
app.mainWindow.getByText('Playlist backup exported.')
).toBeVisible({ timeout: 15000 });
const pendingManifest = JSON.parse(
readFileSync(pendingExportPath, 'utf-8')
) as PlaylistBackupManifestV1;
const pendingEntry = pendingManifest.playlists.find(
(entry) => entry.portalType === 'xtream'
);
expect(pendingEntry?.userState).toEqual(xtreamEntry?.userState);
expect(
await app.mainWindow.evaluate(
(id) => localStorage.getItem(`xtream-restore-${id}`),
playlistId
)
).toEqual(pendingBeforeExport.pending);
// Restart before opening the restored portal: the root-provided
// XtreamStore still holds the deleted portal's in-memory state
// under the same playlist id and would skip content
@@ -179,6 +254,40 @@ test.describe('Electron playlist backup round-trip', () => {
.map((row) => row.name)
).toEqual([targetCategory.name]);
const restoredState = await app.mainWindow.evaluate(
async (id) => ({
positions:
await window.electron.dbGetAllPlaybackPositions(id),
favorites: await window.electron.dbGetFavorites(id),
recent: await window.electron.dbGetRecentItems(id),
pins: await window.electron.dbListVodSourcePins(id),
pending: localStorage.getItem(`xtream-restore-${id}`),
}),
restoredPlaylistId
);
expect(restoredState.positions).toEqual([
expect.objectContaining({
contentXtreamId: movieXtreamId,
contentType: 'vod',
positionSeconds: 123,
durationSeconds: 7200,
}),
]);
expect(
restoredState.favorites.map((item) => item.xtream_id)
).toEqual([movieXtreamId]);
expect(restoredState.recent.map((item) => item.xtream_id)).toEqual([
movieXtreamId,
]);
expect(restoredState.pins).toEqual([
expect.objectContaining({
matchKey: 'title:backup-roundtrip',
playlistId: restoredPlaylistId,
contentId: movieXtreamId,
}),
]);
expect(restoredState.pending).toBeNull();
dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog
.locator('input[type="search"]')
@@ -414,6 +414,12 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
channel: 'DB_CLEAR_PLAYBACK_POSITION',
forwardedArgs: [playlistId, 42, 'vod'],
},
{
method: 'dbReplaceAllPlaybackPositions',
args: [playlistId, [playbackData]],
channel: 'DB_REPLACE_ALL_PLAYBACK_POSITIONS',
forwardedArgs: [playlistId, [playbackData]],
},
{
method: 'dbSavePlaybackPositionsBatch',
args: [playlistId, [playbackData]],
@@ -1078,6 +1078,10 @@ const electronApi: ElectronBridgeApi = {
),
dbGetAllPlaybackPositions: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_ALL_PLAYBACK_POSITIONS', playlistId),
dbReplaceAllPlaybackPositions: (
playlistId: string,
items: ElectronBridgePlaybackPositionInput[]
) => ipcRenderer.invoke('DB_REPLACE_ALL_PLAYBACK_POSITIONS', playlistId, items),
dbClearAllPlaybackPositions: (playlistId: string) =>
ipcRenderer.invoke('DB_CLEAR_ALL_PLAYBACK_POSITIONS', playlistId),
dbClearPlaybackPosition: (
@@ -0,0 +1,165 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { getTableColumns } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import {
getAllPlaybackPositions,
replaceAllPlaybackPositions,
savePlaybackPosition,
} from './playback-position.operations';
describe('atomic playback-position replacement against SQLite', () => {
let sqlite: Database.Database;
let db: AppDatabase;
const replacement = [
{
contentXtreamId: 42,
contentType: 'vod' as const,
positionSeconds: 120,
durationSeconds: 3600,
},
{
contentXtreamId: 42,
contentType: 'episode' as const,
positionSeconds: 90,
seriesXtreamId: 2,
seasonNumber: 1,
episodeNumber: 3,
},
];
beforeEach(() => {
sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
sqlite.exec(`
CREATE TABLE playlists (${Object.values(
getTableColumns(schema.playlists)
)
.map(
(column) =>
`"${column.name}" ${column.getSQLType()}${column.primary ? ' PRIMARY KEY' : ''}`
)
.join(', ')});
INSERT INTO playlists (id) VALUES ('target'), ('other');
CREATE TABLE playback_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
content_xtream_id INTEGER NOT NULL,
content_type TEXT NOT NULL,
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
position_seconds INTEGER NOT NULL DEFAULT 0,
duration_seconds INTEGER,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(content_xtream_id, playlist_id, content_type)
);
INSERT INTO playback_positions (playlist_id, content_xtream_id, content_type, position_seconds)
VALUES ('target', 1, 'vod', 50), ('other', 1, 'vod', 80);
`);
db = drizzle(sqlite, { schema });
});
afterEach(() => sqlite.close());
it('replaces only the requested playlist and keeps typed identities distinct', async () => {
await expect(
replaceAllPlaybackPositions(db, 'target', replacement)
).resolves.toEqual({ success: true });
expect(await getAllPlaybackPositions(db, 'target')).toEqual([
expect.objectContaining({
...replacement[0],
playlistId: 'target',
}),
expect.objectContaining({
...replacement[1],
playlistId: 'target',
}),
]);
expect(await getAllPlaybackPositions(db, 'other')).toEqual([
expect.objectContaining({
contentXtreamId: 1,
positionSeconds: 80,
}),
]);
await replaceAllPlaybackPositions(db, 'target', []);
expect(await getAllPlaybackPositions(db, 'target')).toEqual([]);
expect(await getAllPlaybackPositions(db, 'other')).toHaveLength(1);
});
it('rejects restoring rows for an unregistered playlist without creating placeholder metadata', async () => {
await expect(
replaceAllPlaybackPositions(db, 'missing', replacement)
).rejects.toThrow();
expect(
sqlite
.prepare("SELECT id FROM playlists WHERE id = 'missing'")
.all()
).toEqual([]);
expect(await getAllPlaybackPositions(db, 'target')).toHaveLength(1);
});
it('preserves last-write-wins for repeated identities in older snapshots', async () => {
await replaceAllPlaybackPositions(db, 'target', [
...replacement,
{ ...replacement[0], positionSeconds: 600 },
]);
expect(await getAllPlaybackPositions(db, 'target')).toEqual([
expect.objectContaining({
contentXtreamId: 42,
contentType: 'vod',
positionSeconds: 600,
}),
expect.objectContaining({
contentXtreamId: 42,
contentType: 'episode',
positionSeconds: 90,
}),
]);
});
it('preserves earlier episode metadata when a duplicate only updates position', async () => {
const items = [
{ ...replacement[1], durationSeconds: 2400 },
{
contentXtreamId: 42,
contentType: 'episode' as const,
positionSeconds: 600,
},
];
// Compare with the former restore loop's actual SQLite update behavior.
for (const item of items) await savePlaybackPosition(db, 'other', item);
await replaceAllPlaybackPositions(db, 'target', items);
const [restored] = await getAllPlaybackPositions(db, 'target');
const prior = (await getAllPlaybackPositions(db, 'other')).find(
(item) => item.contentType === 'episode'
);
expect(restored).toEqual(
expect.objectContaining({
contentXtreamId: prior?.contentXtreamId,
contentType: 'episode',
positionSeconds: 600,
seriesXtreamId: prior?.seriesXtreamId,
seasonNumber: prior?.seasonNumber,
episodeNumber: prior?.episodeNumber,
durationSeconds: prior?.durationSeconds,
})
);
expect(prior?.seriesXtreamId).toBe(2);
});
it('rolls back both deletion and earlier inserts when a later insert fails', async () => {
const before = await getAllPlaybackPositions(db, 'target');
sqlite.exec(`CREATE TRIGGER reject_episode BEFORE INSERT ON playback_positions
WHEN NEW.content_type = 'episode' BEGIN SELECT RAISE(ABORT, 'injected write failure'); END;`);
await expect(
replaceAllPlaybackPositions(db, 'target', replacement)
).rejects.toThrow();
expect(await getAllPlaybackPositions(db, 'target')).toEqual(before);
expect(await getAllPlaybackPositions(db, 'other')).toHaveLength(1);
sqlite.exec('DROP TRIGGER reject_episode');
await replaceAllPlaybackPositions(db, 'target', replacement);
expect(await getAllPlaybackPositions(db, 'target')).toHaveLength(2);
});
});
@@ -232,6 +232,46 @@ export async function getAllPlaybackPositions(
.where(eq(schema.playbackPositions.playlistId, playlistId));
}
/** Backup replacement must never commit deletion without all restored rows. */
export async function replaceAllPlaybackPositions(
db: AppDatabase,
playlistId: string,
items: PlaybackPositionPayload[]
): Promise<{ success: boolean }> {
db.transaction((tx) => {
tx.delete(schema.playbackPositions)
.where(eq(schema.playbackPositions.playlistId, playlistId))
.run();
for (const item of items) {
const values = {
playlistId,
contentXtreamId: item.contentXtreamId,
contentType: item.contentType,
seriesXtreamId: item.seriesXtreamId,
seasonNumber: item.seasonNumber,
episodeNumber: item.episodeNumber,
positionSeconds: item.positionSeconds,
durationSeconds: item.durationSeconds,
updatedAt: sql`CURRENT_TIMESTAMP`,
};
// Match the former save loop: later duplicate values win, while
// omitted optional fields retain the earlier snapshot's metadata.
tx.insert(schema.playbackPositions)
.values(values)
.onConflictDoUpdate({
target: [
schema.playbackPositions.contentXtreamId,
schema.playbackPositions.playlistId,
schema.playbackPositions.contentType,
],
set: values,
})
.run();
}
});
return { success: true };
}
export async function clearAllPlaybackPositions(
db: AppDatabase,
playlistId: string
@@ -84,3 +84,8 @@ handleWorkerRequest(
items,
})
);
handleWorkerRequest(
'DB_REPLACE_ALL_PLAYBACK_POSITIONS',
(playlistId: string, items: unknown[]) => ({ playlistId, items })
);
@@ -372,6 +372,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
args: [playlistId, 42, 'vod'],
payload: { playlistId, contentXtreamId: 42, contentType: 'vod' },
},
{
operation: 'DB_REPLACE_ALL_PLAYBACK_POSITIONS',
args: [playlistId, playbackBatchItems],
payload: { playlistId, items: playbackBatchItems },
},
{
operation: 'DB_SAVE_PLAYBACK_POSITIONS_BATCH',
args: [playlistId, playbackBatchItems],
@@ -54,6 +54,7 @@ export const DB_WORKER_OPERATIONS = [
'DB_GET_RECENT_PLAYBACK_POSITIONS',
'DB_GET_ALL_PLAYBACK_POSITIONS',
'DB_CLEAR_ALL_PLAYBACK_POSITIONS',
'DB_REPLACE_ALL_PLAYBACK_POSITIONS',
'DB_CLEAR_PLAYBACK_POSITION',
'DB_SAVE_PLAYBACK_POSITIONS_BATCH',
'DB_CLEAR_PLAYBACK_POSITIONS_BATCH',
@@ -52,6 +52,7 @@ import {
import { setContentMetadataIfMissing } from '../database/operations/content-metadata.operations';
import {
clearAllPlaybackPositions,
replaceAllPlaybackPositions,
clearPlaybackPosition,
clearPlaybackPositionsBatch,
getAllPlaybackPositions,
@@ -1201,6 +1202,14 @@ async function executeRequest(
return getAllPlaybackPositions(db, payload.playlistId);
}
case 'DB_REPLACE_ALL_PLAYBACK_POSITIONS': {
const payload = message.payload as {
playlistId: string;
items: Parameters<typeof replaceAllPlaybackPositions>[2];
};
return replaceAllPlaybackPositions(db, payload.playlistId, payload.items);
}
case 'DB_CLEAR_ALL_PLAYBACK_POSITIONS': {
const payload = message.payload as { playlistId: string };
return clearAllPlaybackPositions(db, payload.playlistId);
+40 -12
View File
@@ -5,13 +5,13 @@ settings screen.
## Entry Points
- UI: `/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings-backup-section.component.ts`
- UI: `apps/web/src/app/settings/settings-backup-section.component.ts`
(embedded in `settings.component.html`), with the file read/handoff in
`/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings-backup.facade.ts`
- Backup service: `/Users/4gray/Code/iptvnator/libs/services/src/lib/playlist-backup.service.ts`
- Manifest types: `/Users/4gray/Code/iptvnator/libs/shared/interfaces/src/lib/playlist-backup.interface.ts`
`apps/web/src/app/settings/settings-backup.facade.ts`
- Backup service: `libs/services/src/lib/playlist-backup.service.ts`
- Manifest types: `libs/shared/interfaces/src/lib/playlist-backup.interface.ts`
- Xtream pending restore storage:
`/Users/4gray/Code/iptvnator/libs/services/src/lib/xtream-pending-restore.service.ts`
`libs/services/src/lib/xtream-pending-restore.service.ts`
## Manifest Contract
@@ -63,6 +63,19 @@ Xtream backups export only connection metadata plus portable user state.
- favorites by `{ contentType, xtreamId, addedAt?, position? }`
- recently viewed by `{ contentType, xtreamId, viewedAt }`
- playback positions as `PlaybackPositionData[]`
- optional VOD source pins by `{ matchKey, contentId, updatedAt? }`
An imported Xtream archive can wait for its catalog to download. Export reads
that pending snapshot before consulting the catalog: its hidden categories,
favorites, history, and positions are authoritative, including empty lists.
Pending pins are authoritative when present; an absent `sourcePins` field keeps
the existing store's pins when that store is available, or stays absent when it
is unavailable. Re-export neither applies nor consumes the pending snapshot,
so it also works before the portal has been opened and in browser runtimes.
Parental category locks still come from the current lock store.
A failed pending-storage or playback-position read fails export. An unreadable
collection must never be serialized as a successful empty collection.
Explicitly excluded:
@@ -164,11 +177,10 @@ 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.
Replacing the playlist's pins deletes by playlist and writes the replacements
in one transaction. It does not use the capped keyed-clear operation, which
could leave surplus pins behind. Any replacement failure fails the entry and
retains the pending snapshot for retry.
`sourcePins` (VOD multi-source) is the one **optional** collection, and the
normalizer preserves that: an absent field stays absent rather than becoming
@@ -190,12 +202,28 @@ 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.
3. Playback positions are replaced for the destination playlist in one SQLite
transaction through `DB_REPLACE_ALL_PLAYBACK_POSITIONS`. Empty means clear;
a failed insert rolls back the deletion and all earlier inserts.
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
left pending until the next Xtream initialization/import.
left pending until the next Xtream initialization/import. Both paths use strict
playback-position replacement: unavailable storage, rejected IPC, or an
unsuccessful response fail the restore and leave the pending snapshot intact.
Atomic restore support is checked separately from the ordinary Xtream SQLite
capability gate. An older preload missing the replacement method keeps using
SQLite for playback and backup export. Import parks the snapshot first, then
rejects unsupported atomic restore before checking catalog completeness or
writing category visibility, collections, positions, or pins. Deferred restore
performs the same preflight before applying user state. The retained snapshot
can be retried once atomic replacement becomes available. Ordinary playback
keeps its existing capability checks and best-effort persistence API.
The whole restore is not one database transaction; already applied
categories/favorites/history can
remain after a later failure, but the snapshot remains available to retry the
complete restore. It is consumed only after every restore step succeeds.
## Current UX
+14
View File
@@ -661,6 +661,20 @@ is unavailable or the SQLite playlist migration has not completed.
4. `DB_GET_RECENT_PLAYBACK_POSITIONS`
5. `DB_GET_ALL_PLAYBACK_POSITIONS`
6. `DB_CLEAR_PLAYBACK_POSITION`
7. `DB_REPLACE_ALL_PLAYBACK_POSITIONS`
Backup restore uses `DB_REPLACE_ALL_PLAYBACK_POSITIONS` to replace only the
specified playlist's positions in one synchronous transaction. Every insert
and the delete use `.run()` so an insertion failure rolls the whole replacement
back. An empty replacement deliberately clears that playlist. Renderer backup
callers propagate read/write errors and retain pending restore state for retry;
normal playback persistence keeps its best-effort behavior. The renderer checks
atomic replacement support at the start of the immediate or deferred restore
pass, before updating existing categories, collections, positions, or pins.
Missing support fails restore with the pending snapshot retained; it does not
switch ordinary Xtream storage or backup export away from SQLite. Deferred
catalog import can still seed newly imported categories from pending visibility
state before that restore pass runs.
## SQLite Concurrency Rules
@@ -76,6 +76,8 @@ export function createPlaybackServiceMock() {
getAllPlaybackPositionsOrThrow: jest.fn().mockResolvedValue([]),
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined),
replaceAllPlaybackPositions: jest.fn().mockResolvedValue(undefined),
assertSupportsAtomicReplacement: jest.fn(),
savePlaybackPositionsBatch: jest.fn().mockResolvedValue(undefined),
clearPlaybackPositionsBatch: jest.fn().mockResolvedValue(undefined),
};
@@ -688,6 +688,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
restoreState: XtreamPendingRestoreState,
options?: XtreamOperationOptions
): Promise<void> {
this.playbackService.assertSupportsAtomicReplacement();
const categoriesByType = await Promise.all([
this.dbService.getAllXtreamCategories(playlistId, 'live'),
this.dbService.getAllXtreamCategories(playlistId, 'movies'),
@@ -737,14 +738,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
options
);
await this.playbackService.clearAllPlaybackPositions(playlistId);
for (const playbackPosition of restoreState.playbackPositions) {
await this.playbackService.savePlaybackPosition(
playlistId,
playbackPosition
);
}
await this.playbackService.replaceAllPlaybackPositions(
playlistId,
restoreState.playbackPositions
);
// The fresh-import path lands here rather than in the backup service:
// a new playlist has no content yet when the archive is read, so its
@@ -336,7 +336,73 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
).rejects.toThrow(playlistId);
});
it('restores user data, then resets and replays playback positions', async () => {
it('rejects unsupported deferred restore before reading or changing any user state', async () => {
harness.playbackService.assertSupportsAtomicReplacement.mockImplementation(
() => {
throw new Error(
'Playback position replacement method is unavailable'
);
}
);
await expect(
harness.dataSource.restoreUserData(playlistId, {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
sourcePins: [],
})
).rejects.toThrow('unavailable');
expect(
harness.dbService.getAllXtreamCategories
).not.toHaveBeenCalled();
expect(
harness.dbService.updateCategoryVisibility
).not.toHaveBeenCalled();
expect(
harness.dbService.restoreXtreamUserData
).not.toHaveBeenCalled();
expect(
harness.playbackService.replaceAllPlaybackPositions
).not.toHaveBeenCalled();
expect(
harness.vodSourcePinService.replaceForPlaylist
).not.toHaveBeenCalled();
});
it('rejects deferred restore when position replacement fails, retaining the caller retry', async () => {
const restoreState = {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [
{
contentXtreamId: 202,
contentType: 'vod',
positionSeconds: 60,
},
],
sourcePins: [],
} as never;
harness.playbackService.replaceAllPlaybackPositions.mockRejectedValue(
new Error('SQLITE_BUSY')
);
await expect(
harness.dataSource.restoreUserData(playlistId, restoreState)
).rejects.toThrow('SQLITE_BUSY');
expect(
harness.playbackService.clearAllPlaybackPositions
).not.toHaveBeenCalled();
expect(
harness.playbackService.savePlaybackPosition
).not.toHaveBeenCalled();
expect(
harness.vodSourcePinService.replaceForPlaylist
).not.toHaveBeenCalled();
});
it('restores user data, then replaces playback positions atomically', async () => {
const positionA = { contentXtreamId: 1 } as never;
const positionB = { contentXtreamId: 2 } as never;
const restoreState = {
@@ -387,14 +453,14 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
[[11], true],
]);
expect(
harness.playbackService.clearAllPlaybackPositions
).toHaveBeenCalledWith(playlistId);
harness.playbackService.replaceAllPlaybackPositions
).toHaveBeenCalledWith(playlistId, [positionA, positionB]);
expect(
harness.playbackService.savePlaybackPosition.mock.calls
).toEqual([
[playlistId, positionA],
[playlistId, positionB],
]);
harness.playbackService.clearAllPlaybackPositions
).not.toHaveBeenCalled();
expect(
harness.playbackService.savePlaybackPosition
).not.toHaveBeenCalled();
});
});
});
@@ -60,6 +60,95 @@ describe('PlaybackPositionRuntimeBridgeService', () => {
jest.restoreAllMocks();
});
describe('strict backup storage', () => {
it('checks atomic replacement support without reading or writing storage', () => {
expect(() => service.assertSupportsAtomicReplacement()).toThrow(
'unavailable'
);
runtimeCapabilities.supportsPlaybackPositionStorage = true;
window.electron = {} as typeof window.electron;
expect(() => service.assertSupportsAtomicReplacement()).toThrow(
'unavailable'
);
const replace = jest.fn();
const read = jest.fn();
window.electron = {
dbReplaceAllPlaybackPositions: replace,
dbGetAllPlaybackPositions: read,
} as unknown as typeof window.electron;
expect(() =>
service.assertSupportsAtomicReplacement()
).not.toThrow();
expect(replace).not.toHaveBeenCalled();
expect(read).not.toHaveBeenCalled();
});
it('rejects reads and replacements when storage is unavailable', async () => {
await expect(
service.getAllPlaybackPositionsOrThrow('playlist-1')
).rejects.toThrow('unavailable');
await expect(
service.replaceAllPlaybackPositions('playlist-1', [])
).rejects.toThrow('unavailable');
});
it('rejects a missing bridge method instead of exporting or restoring empty data', async () => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
window.electron = {} as typeof window.electron;
await expect(
service.getAllPlaybackPositionsOrThrow('playlist-1')
).rejects.toThrow('unavailable');
await expect(
service.replaceAllPlaybackPositions('playlist-1', [])
).rejects.toThrow('unavailable');
});
it('propagates read failures and rejects malformed read responses', async () => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
const read = jest.fn().mockRejectedValue(new Error('SQLITE_BUSY'));
window.electron = {
dbGetAllPlaybackPositions: read,
} as unknown as typeof window.electron;
await expect(
service.getAllPlaybackPositionsOrThrow('playlist-1')
).rejects.toThrow('SQLITE_BUSY');
read.mockResolvedValue(undefined);
await expect(
service.getAllPlaybackPositionsOrThrow('playlist-1')
).rejects.toThrow('did not succeed');
read.mockResolvedValue([]);
await expect(
service.getAllPlaybackPositionsOrThrow('playlist-1')
).resolves.toEqual([]);
});
it('requires an acknowledged atomic replacement, including authoritative empty lists', async () => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
const replace = jest.fn().mockResolvedValue({ success: false });
window.electron = {
dbReplaceAllPlaybackPositions: replace,
} as unknown as typeof window.electron;
await expect(
service.replaceAllPlaybackPositions(
'playlist-1',
batchSaveItems
)
).rejects.toThrow('did not succeed');
replace.mockRejectedValue(new Error('SQLITE_BUSY'));
await expect(
service.replaceAllPlaybackPositions(
'playlist-1',
batchSaveItems
)
).rejects.toThrow('SQLITE_BUSY');
replace.mockResolvedValue({ success: true });
await expect(
service.replaceAllPlaybackPositions('playlist-1', [])
).resolves.toBeUndefined();
expect(replace).toHaveBeenLastCalledWith('playlist-1', []);
});
});
it('does not call Electron playback-position methods when storage support is unavailable', async () => {
const dbSavePlaybackPosition = jest.fn().mockResolvedValue({
success: true,
@@ -226,12 +315,9 @@ describe('PlaybackPositionRuntimeBridgeService', () => {
dbSavePlaybackPosition: implementation,
} as unknown as typeof window.electron;
},
invokeLenient: (
target: PlaybackPositionRuntimeBridgeService
) => target.savePlaybackPosition('playlist-1', createPosition()),
invokeStrict: (
target: PlaybackPositionRuntimeBridgeService
) =>
invokeLenient: (target: PlaybackPositionRuntimeBridgeService) =>
target.savePlaybackPosition('playlist-1', createPosition()),
invokeStrict: (target: PlaybackPositionRuntimeBridgeService) =>
target.savePlaybackPositionOrThrow(
'playlist-1',
createPosition()
@@ -245,22 +331,10 @@ describe('PlaybackPositionRuntimeBridgeService', () => {
dbClearPlaybackPosition: implementation,
} as unknown as typeof window.electron;
},
invokeLenient: (
target: PlaybackPositionRuntimeBridgeService
) =>
target.clearPlaybackPosition(
'playlist-1',
100,
'vod'
),
invokeStrict: (
target: PlaybackPositionRuntimeBridgeService
) =>
target.clearPlaybackPositionOrThrow(
'playlist-1',
100,
'vod'
),
invokeLenient: (target: PlaybackPositionRuntimeBridgeService) =>
target.clearPlaybackPosition('playlist-1', 100, 'vod'),
invokeStrict: (target: PlaybackPositionRuntimeBridgeService) =>
target.clearPlaybackPositionOrThrow('playlist-1', 100, 'vod'),
},
])('$name persistence', (operation) => {
it('accepts only an explicit success result', async () => {
@@ -269,7 +343,9 @@ describe('PlaybackPositionRuntimeBridgeService', () => {
jest.fn().mockResolvedValue({ success: true })
);
await expect(operation.invokeStrict(service)).resolves.toBeUndefined();
await expect(
operation.invokeStrict(service)
).resolves.toBeUndefined();
});
it('propagates rejected IPC', async () => {
@@ -293,9 +369,7 @@ describe('PlaybackPositionRuntimeBridgeService', () => {
);
it('rejects when the storage capability is unavailable', async () => {
const bridgeMethod = jest
.fn()
.mockResolvedValue({ success: true });
const bridgeMethod = jest.fn().mockResolvedValue({ success: true });
operation.installBridge(bridgeMethod);
await expect(operation.invokeStrict(service)).rejects.toThrow(
@@ -352,10 +426,7 @@ describe('PlaybackPositionRuntimeBridgeService', () => {
} as unknown as typeof window.electron;
},
invoke: (target: PlaybackPositionRuntimeBridgeService) =>
target.savePlaybackPositionsBatch(
'playlist-1',
batchSaveItems
),
target.savePlaybackPositionsBatch('playlist-1', batchSaveItems),
invokeEmpty: (target: PlaybackPositionRuntimeBridgeService) =>
target.savePlaybackPositionsBatch('playlist-1', []),
},
@@ -378,9 +449,7 @@ describe('PlaybackPositionRuntimeBridgeService', () => {
},
])('$name persistence', (operation) => {
it('silently no-ops when the storage capability is unavailable', async () => {
const bridgeMethod = jest
.fn()
.mockResolvedValue({ success: true });
const bridgeMethod = jest.fn().mockResolvedValue({ success: true });
operation.installBridge(bridgeMethod);
await expect(operation.invoke(service)).resolves.toBeUndefined();
@@ -389,9 +458,7 @@ describe('PlaybackPositionRuntimeBridgeService', () => {
it('silently no-ops on an empty item list', async () => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
const bridgeMethod = jest
.fn()
.mockResolvedValue({ success: true });
const bridgeMethod = jest.fn().mockResolvedValue({ success: true });
operation.installBridge(bridgeMethod);
await expect(
@@ -402,9 +469,7 @@ describe('PlaybackPositionRuntimeBridgeService', () => {
it('invokes the batch bridge method with the playlist and items', async () => {
runtimeCapabilities.supportsPlaybackPositionStorage = true;
const bridgeMethod = jest
.fn()
.mockResolvedValue({ success: true });
const bridgeMethod = jest.fn().mockResolvedValue({ success: true });
operation.installBridge(bridgeMethod);
await expect(operation.invoke(service)).resolves.toBeUndefined();
@@ -25,6 +25,10 @@ type PlaybackPositionElectronBridge = Partial<{
dbGetAllPlaybackPositions: (
playlistId: string
) => Promise<PlaybackPositionData[]>;
dbReplaceAllPlaybackPositions: (
playlistId: string,
items: PlaybackPositionData[]
) => Promise<{ success: boolean }>;
dbClearAllPlaybackPositions: (
playlistId: string
) => Promise<{ success: boolean }>;
@@ -86,9 +90,7 @@ export class PlaybackPositionRuntimeBridgeService {
const bridge = this.bridge;
if (typeof bridge?.dbSavePlaybackPosition !== 'function') {
throw new Error(
'Playback position save method is unavailable'
);
throw new Error('Playback position save method is unavailable');
}
const result = await bridge.dbSavePlaybackPosition(playlistId, data);
@@ -158,6 +160,57 @@ export class PlaybackPositionRuntimeBridgeService {
);
}
async getAllPlaybackPositionsOrThrow(
playlistId: string
): Promise<PlaybackPositionData[]> {
if (!this.supportsStorage) {
throw new Error('Playback position storage is unavailable');
}
const bridge = this.bridge;
if (typeof bridge?.dbGetAllPlaybackPositions !== 'function') {
throw new Error('Playback position read method is unavailable');
}
const result = await bridge.dbGetAllPlaybackPositions(playlistId);
if (!Array.isArray(result)) {
throw new Error('Playback position read did not succeed');
}
return result;
}
/** Preflight restores before any other user-state mutation or IPC work. */
assertSupportsAtomicReplacement(): void {
if (!this.supportsStorage) {
throw new Error('Playback position storage is unavailable');
}
if (typeof this.bridge?.dbReplaceAllPlaybackPositions !== 'function') {
throw new Error(
'Playback position replacement method is unavailable'
);
}
}
async replaceAllPlaybackPositions(
playlistId: string,
items: PlaybackPositionData[]
): Promise<void> {
if (!this.supportsStorage) {
throw new Error('Playback position storage is unavailable');
}
const bridge = this.bridge;
if (typeof bridge?.dbReplaceAllPlaybackPositions !== 'function') {
throw new Error(
'Playback position replacement method is unavailable'
);
}
const result = await bridge.dbReplaceAllPlaybackPositions(
playlistId,
items
);
if (result?.success !== true) {
throw new Error('Playback position replacement did not succeed');
}
}
async clearAllPlaybackPositions(playlistId: string): Promise<void> {
if (!this.supportsStorage) {
return;
@@ -193,9 +246,7 @@ export class PlaybackPositionRuntimeBridgeService {
const bridge = this.bridge;
if (typeof bridge?.dbClearPlaybackPosition !== 'function') {
throw new Error(
'Playback position clear method is unavailable'
);
throw new Error('Playback position clear method is unavailable');
}
const result = await bridge.dbClearPlaybackPosition(
@@ -18,6 +18,9 @@ describe('PlaybackPositionService', () => {
| 'getSeriesPlaybackPositions'
| 'getRecentPlaybackPositions'
| 'getAllPlaybackPositions'
| 'getAllPlaybackPositionsOrThrow'
| 'replaceAllPlaybackPositions'
| 'assertSupportsAtomicReplacement'
| 'clearAllPlaybackPositions'
| 'clearPlaybackPosition'
| 'savePlaybackPositionsBatch'
@@ -32,6 +35,9 @@ describe('PlaybackPositionService', () => {
getSeriesPlaybackPositions: jest.fn().mockResolvedValue([]),
getRecentPlaybackPositions: jest.fn().mockResolvedValue([]),
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
getAllPlaybackPositionsOrThrow: jest.fn().mockResolvedValue([]),
replaceAllPlaybackPositions: jest.fn().mockResolvedValue(undefined),
assertSupportsAtomicReplacement: jest.fn(),
clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined),
clearPlaybackPosition: jest.fn().mockResolvedValue(undefined),
savePlaybackPositionsBatch: jest.fn().mockResolvedValue(undefined),
@@ -58,6 +64,38 @@ describe('PlaybackPositionService', () => {
jest.restoreAllMocks();
});
it('preflights atomic restore support without altering any positions', () => {
bridge.assertSupportsAtomicReplacement.mockImplementation(() => {
throw new Error(
'Playback position replacement method is unavailable'
);
});
expect(() => service.assertSupportsAtomicReplacement()).toThrow(
'unavailable'
);
expect(bridge.replaceAllPlaybackPositions).not.toHaveBeenCalled();
expect(bridge.clearAllPlaybackPositions).not.toHaveBeenCalled();
expect(bridge.savePlaybackPosition).not.toHaveBeenCalled();
});
it('propagates strict backup read and replacement failures', async () => {
const error = new Error('SQLITE_BUSY');
bridge.getAllPlaybackPositionsOrThrow.mockRejectedValue(error);
bridge.replaceAllPlaybackPositions.mockRejectedValue(error);
await expect(
service.getAllPlaybackPositionsOrThrow('playlist-1')
).rejects.toBe(error);
await expect(
service.replaceAllPlaybackPositions('playlist-1', [])
).rejects.toBe(error);
expect(bridge.getAllPlaybackPositions).not.toHaveBeenCalled();
expect(bridge.clearAllPlaybackPositions).not.toHaveBeenCalled();
expect(bridge.replaceAllPlaybackPositions).toHaveBeenCalledWith(
'playlist-1',
[]
);
});
it('delegates playback-position storage through the runtime bridge', async () => {
const position = createPosition();
@@ -192,9 +230,7 @@ describe('PlaybackPositionService', () => {
bridge.clearPlaybackPositionsBatch.mockRejectedValue(clearError);
await expect(
service.savePlaybackPositionsBatch('playlist-1', [
createPosition(),
])
service.savePlaybackPositionsBatch('playlist-1', [createPosition()])
).rejects.toBe(saveError);
await expect(
service.clearPlaybackPositionsBatch('playlist-1', [
@@ -92,7 +92,24 @@ export class PlaybackPositionService {
getAllPlaybackPositionsOrThrow(
playlistId: string
): Promise<PlaybackPositionData[]> {
return this.playbackPositionBridge.getAllPlaybackPositions(playlistId);
return this.playbackPositionBridge.getAllPlaybackPositionsOrThrow(
playlistId
);
}
assertSupportsAtomicReplacement(): void {
this.playbackPositionBridge.assertSupportsAtomicReplacement();
}
/** Replace a backup snapshot atomically; failures must remain retryable. */
replaceAllPlaybackPositions(
playlistId: string,
items: PlaybackPositionData[]
): Promise<void> {
return this.playbackPositionBridge.replaceAllPlaybackPositions(
playlistId,
items
);
}
async clearAllPlaybackPositions(playlistId: string): Promise<void> {
@@ -1,6 +1,8 @@
import {
Playlist,
PlaylistBackupManifestV1,
XtreamPlaylistBackupEntry,
XtreamPendingRestoreState,
} from '@iptvnator/shared/interfaces';
import {
createPlaylistBackupService,
@@ -169,6 +171,233 @@ describe('PlaylistBackupService export → import round-trip', () => {
return { ...manifest, exportedAt: 'normalized' };
}
function xtreamEntry(
manifest: PlaylistBackupManifestV1
): XtreamPlaylistBackupEntry {
const entry = manifest.playlists.find(
(item) => item.portalType === 'xtream'
);
if (!entry || entry.portalType !== 'xtream')
throw new Error('Missing Xtream entry');
return entry;
}
it.each([true, false])(
'preserves a fresh pending import on re-export (Electron: %s)',
async (electron) => {
const state = seedState();
state.xtreamFavorites.push({
xtream_id: 501,
type: 'series',
position: 1,
});
const collaborators = createStatefulBackupCollaborators(state);
const service = createPlaylistBackupService(collaborators);
const original = await service.exportBackup();
const originalState = xtreamEntry(original.manifest).userState;
expect(originalState.sourcePins).toHaveLength(1);
state.xtreamCategories = [];
state.xtreamFavorites = [];
state.xtreamRecent = [];
state.playbackPositions = [];
state.sourcePins = [];
collaborators.databaseService.getXtreamImportStatus = async () =>
'idle';
if (!electron) delete electronWindow.electron;
const summary = await service.importBackup(original.json);
expect(summary.failed).toBe(0);
const snapshot =
collaborators.pendingRestoreService.getSnapshotOrThrow(
'xtream-1'
);
expect(snapshot).not.toBeNull();
// Pending state must remain exportable even while catalog/storage
// reads are unavailable; it is already the complete restore input.
jest.spyOn(
collaborators.databaseService,
'getFavorites'
).mockRejectedValue(new Error('Catalog unavailable'));
jest.spyOn(
collaborators.playbackPositionService,
'getAllPlaybackPositionsOrThrow'
).mockRejectedValue(new Error('Position storage unavailable'));
const exported = await service.exportBackup();
expect(xtreamEntry(exported.manifest).userState).toEqual(
originalState
);
expect(
collaborators.pendingRestoreService.getSnapshotOrThrow(
'xtream-1'
)
).toEqual(snapshot);
}
);
it.each([true, false])(
'honors empty pending collections and optional source pins (present: %s)',
async (pinsPresent) => {
const state = seedState();
const collaborators = createStatefulBackupCollaborators(state);
const pending: XtreamPendingRestoreState = {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
...(pinsPresent ? { sourcePins: [] } : {}),
};
collaborators.pendingRestoreService.set('xtream-1', pending);
const service = createPlaylistBackupService(collaborators);
const exported = await service.exportBackup();
const userState = xtreamEntry(exported.manifest).userState;
expect(userState).toEqual({
...pending,
sourcePins: pinsPresent
? []
: [
{
matchKey: 'tmdb:603',
contentId: 501,
updatedAt: '2026-07-06T09:00:00.000Z',
},
],
});
}
);
it('keeps absent pending pins absent when the pin store is unavailable', async () => {
const state = seedState();
const collaborators = createStatefulBackupCollaborators(state);
collaborators.vodSourcePinService.isAvailable = false;
const pending: XtreamPendingRestoreState = {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
};
collaborators.pendingRestoreService.set('xtream-1', pending);
const exported =
await createPlaylistBackupService(collaborators).exportBackup();
expect(xtreamEntry(exported.manifest).userState).toEqual(pending);
});
it('refuses to export if pending storage cannot be read', async () => {
const collaborators = createStatefulBackupCollaborators(seedState());
jest.spyOn(
collaborators.pendingRestoreService,
'getOrThrow'
).mockImplementation(() => {
throw new Error('Storage unavailable');
});
await expect(
createPlaylistBackupService(collaborators).exportBackup()
).rejects.toThrow('Storage unavailable');
});
it('refuses to export when playback positions cannot be read', async () => {
const collaborators = createStatefulBackupCollaborators(seedState());
jest.spyOn(
collaborators.playbackPositionService,
'getAllPlaybackPositionsOrThrow'
).mockRejectedValue(new Error('SQLITE_BUSY'));
await expect(
createPlaylistBackupService(collaborators).exportBackup()
).rejects.toThrow('SQLITE_BUSY');
});
it.each(['completed', 'idle'])(
'keeps SQLite exports and parks unsupported atomic restores before writes (%s catalog)',
async (catalogStatus) => {
const state = seedState();
const collaborators = createStatefulBackupCollaborators(state);
const preflight =
collaborators.playbackPositionService
.assertSupportsAtomicReplacement;
preflight.mockImplementation(() => {
throw new Error(
'Atomic playback position replacement unavailable'
);
});
const service = createPlaylistBackupService(collaborators);
const original = await service.exportBackup();
const userState = xtreamEntry(original.manifest).userState;
expect(userState.favorites).toHaveLength(1);
expect(userState.playbackPositions).toEqual(
state.playbackPositions
);
expect(userState.hiddenCategories).toHaveLength(2);
expect(userState.sourcePins).toHaveLength(1);
expect(preflight).not.toHaveBeenCalled();
collaborators.databaseService.getXtreamImportStatus = async () =>
catalogStatus;
const visibility = jest.spyOn(
collaborators.databaseService,
'updateCategoryVisibility'
);
const collections = jest.spyOn(
collaborators.databaseService,
'restoreXtreamUserData'
);
const positions = jest.spyOn(
collaborators.playbackPositionService,
'replaceAllPlaybackPositions'
);
const pins = jest.spyOn(
collaborators.vodSourcePinService,
'replaceForPlaylist'
);
const failed = await service.importBackup(original.json);
expect(failed.failed).toBe(1);
expect(visibility).not.toHaveBeenCalled();
expect(collections).not.toHaveBeenCalled();
expect(positions).not.toHaveBeenCalled();
expect(pins).not.toHaveBeenCalled();
expect(
collaborators.pendingRestoreService.getOrThrow('xtream-1')
).toEqual(userState);
const parkedExport = await service.exportBackup();
expect(xtreamEntry(parkedExport.manifest).userState).toEqual(
userState
);
preflight.mockImplementation(() => undefined);
collaborators.databaseService.getXtreamImportStatus = async () =>
'completed';
const retried = await service.importBackup(original.json);
expect(retried.failed).toBe(0);
expect(positions).toHaveBeenCalledTimes(1);
expect(
collaborators.pendingRestoreService.getOrThrow('xtream-1')
).toBeNull();
}
);
it('retains failed position restores for retry and consumes only after success', async () => {
const state = seedState();
const collaborators = createStatefulBackupCollaborators(state);
const service = createPlaylistBackupService(collaborators);
const original = await service.exportBackup();
const replace = jest
.spyOn(
collaborators.playbackPositionService,
'replaceAllPlaybackPositions'
)
.mockRejectedValueOnce(new Error('SQLITE_BUSY'));
const failed = await service.importBackup(original.json);
expect(failed.failed).toBe(1);
expect(
collaborators.pendingRestoreService.getOrThrow('xtream-1')
?.playbackPositions
).toEqual(state.playbackPositions);
const retried = await service.importBackup(original.json);
expect(retried.failed).toBe(0);
expect(replace).toHaveBeenCalledTimes(2);
expect(
collaborators.pendingRestoreService.getOrThrow('xtream-1')
).toBeNull();
});
it('re-importing its own export restores the full state and exports an identical manifest', async () => {
const state = seedState();
const collaborators = createStatefulBackupCollaborators(state);
@@ -28,6 +28,7 @@ export function createPlaylistBackupService(
let pendingSnapshot: XtreamPendingRestoreSnapshot | null = null;
let lastConsumedRevision: number | null = null;
const pendingRestoreService = {
getOrThrow: jest.fn(() => pendingSnapshot?.state ?? null),
set: jest.fn(
(
playlistId: string,
@@ -109,6 +110,9 @@ export function createPlaylistBackupService(
updateCategoryVisibility: jest.fn().mockResolvedValue(true),
},
playbackPositionService: {
assertSupportsAtomicReplacement: jest.fn(),
getAllPlaybackPositionsOrThrow: jest.fn().mockResolvedValue([]),
replaceAllPlaybackPositions: jest.fn().mockResolvedValue(undefined),
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined),
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
@@ -283,6 +287,17 @@ export function createStatefulBackupCollaborators(
},
},
playbackPositionService: {
assertSupportsAtomicReplacement: jest.fn(),
getAllPlaybackPositionsOrThrow: async () =>
state.playbackPositions.map((item) => ({ ...item })),
replaceAllPlaybackPositions: async (
_playlistId: string,
positions: PlaybackPositionData[]
) => {
state.playbackPositions = positions.map((item) => ({
...item,
}));
},
getAllPlaybackPositions: async () =>
state.playbackPositions.map((item) => ({ ...item })),
clearAllPlaybackPositions: async () => {
@@ -296,6 +311,16 @@ export function createStatefulBackupCollaborators(
},
},
vodSourcePinService: {
isAvailable: true,
replaceForPlaylist: async (
playlistId: string,
pins: VodSourcePin[]
) => {
state.sourcePins = state.sourcePins
.filter((pin) => pin.playlistId !== playlistId)
.concat(pins.map((pin) => ({ ...pin })));
return true;
},
listForPlaylistOrThrow: async (playlistId: string) =>
state.sourcePins
.filter((pin) => pin.playlistId === playlistId)
@@ -296,37 +296,43 @@ export class PlaylistBackupService {
private async buildXtreamEntry(
playlist: Playlist
): Promise<XtreamPlaylistBackupEntry> {
if (!this.hasElectronApi()) {
const entry: XtreamPlaylistBackupEntry = {
portalType: 'xtream',
exportedId: playlist._id,
title: playlist.title,
autoRefresh: Boolean(playlist.autoRefresh),
position: playlist.position,
connection: {
serverUrl: playlist.serverUrl ?? '',
username: playlist.username ?? '',
...(playlist.password ? { password: playlist.password } : {}),
},
userState: {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
...this.optionalLockedXtreamCategories(playlist._id),
},
};
const pending = this.pendingRestoreService.getOrThrow(playlist._id);
if (pending) {
// A parked import is authoritative, including empty collections.
// Reading the incomplete catalog instead would silently lose it.
const sourcePins =
pending.sourcePins ??
(await this.readSourcePinsForExport(playlist._id));
return {
portalType: 'xtream',
exportedId: playlist._id,
title: playlist.title,
autoRefresh: Boolean(playlist.autoRefresh),
position: playlist.position,
connection: {
serverUrl: playlist.serverUrl ?? '',
username: playlist.username ?? '',
...(playlist.password
? { password: playlist.password }
: {}),
},
...entry,
userState: {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
...pending,
...this.optionalLockedXtreamCategories(playlist._id),
// NOT `[]`. Pins are Electron-only, so out here we cannot
// read them — which is not the same as knowing there are
// none. Restore treats the collection as authoritative and
// clears the playlist's pins before applying it, so an
// empty array exported from the web would wipe them the
// moment the archive was imported on the desktop. Omitted,
// the archive says "no opinion", exactly as one written
// before pins existed does.
...(sourcePins ? { sourcePins } : {}),
},
};
}
// An unavailable pin store is no opinion, never an authoritative [].
if (!this.hasElectronApi()) return entry;
const [
liveCategories,
@@ -342,7 +348,9 @@ export class PlaylistBackupService {
this.databaseService.getAllXtreamCategories(playlist._id, 'series'),
this.databaseService.getFavorites(playlist._id),
this.databaseService.getRecentItems(playlist._id),
this.playbackPositionService.getAllPlaybackPositions(playlist._id),
this.playbackPositionService.getAllPlaybackPositionsOrThrow(
playlist._id
),
// Throws rather than reading a failure as "no pins": restore
// treats this collection as authoritative and clears the
// playlist's pins before applying it, so an empty list born of a
@@ -352,16 +360,7 @@ export class PlaylistBackupService {
]);
return {
portalType: 'xtream',
exportedId: playlist._id,
title: playlist.title,
autoRefresh: Boolean(playlist.autoRefresh),
position: playlist.position,
connection: {
serverUrl: playlist.serverUrl ?? '',
username: playlist.username ?? '',
...(playlist.password ? { password: playlist.password } : {}),
},
...entry,
userState: {
hiddenCategories: [
...this.mapHiddenCategories(liveCategories, 'live'),
@@ -938,6 +937,8 @@ export class PlaylistBackupService {
return;
}
this.playbackPositionService.assertSupportsAtomicReplacement();
if (!(await this.hasCompletedOfflineCache(playlistId))) {
return;
}
@@ -1002,6 +1003,7 @@ export class PlaylistBackupService {
playlistId: string,
state: XtreamPendingRestoreState
): Promise<void> {
this.playbackPositionService.assertSupportsAtomicReplacement();
await this.restoreXtreamCategoryVisibility(playlistId, state);
await this.databaseService.restoreXtreamUserData(
playlistId,
@@ -1009,17 +1011,11 @@ export class PlaylistBackupService {
state.recentlyViewed
);
await this.playbackPositionService.clearAllPlaybackPositions(
playlistId
await this.playbackPositionService.replaceAllPlaybackPositions(
playlistId,
state.playbackPositions
);
for (const playbackPosition of state.playbackPositions) {
await this.playbackPositionService.savePlaybackPosition(
playlistId,
playbackPosition
);
}
// Present-but-empty is an answer, like the positions cleared above: a
// backup that holds no pin for this playlist means the user had none,
// so leaving the current ones would resurrect preferences the archive
@@ -118,6 +118,7 @@ describe('RuntimeCapabilitiesService', () => {
onPlaybackPositionUpdate: jest.fn(),
dbDeleteXtreamContent: jest.fn(),
dbRestoreXtreamUserData: jest.fn(),
dbReplaceAllPlaybackPositions: jest.fn(),
downloadsStart: jest.fn(),
downloadsCancel: jest.fn(),
downloadsPause: jest.fn(),
@@ -594,6 +595,18 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsXtreamSectionNavigation).toBe(true);
});
it('keeps Xtream SQLite and ordinary position storage when atomic restore is unavailable', () => {
const bridge = createXtreamSqliteBridge();
testWindow.electron = bridge;
const service = new RuntimeCapabilitiesService();
expect(service.supportsXtreamSqliteDataSource).toBe(true);
delete bridge['dbReplaceAllPlaybackPositions'];
expect(service.supportsXtreamSqliteDataSource).toBe(true);
expect(service.supportsPlaybackPositionStorage).toBe(true);
bridge['dbReplaceAllPlaybackPositions'] = jest.fn();
expect(service.supportsXtreamSqliteDataSource).toBe(true);
});
it('supports Xtream section navigation in Electron when only the SQLite data source is available', () => {
testWindow.electron = createXtreamSqliteBridge();
@@ -648,6 +661,7 @@ function createXtreamSqliteBridge(): Record<string, jest.Mock> {
dbRemoveFavorite: jest.fn(),
dbRemoveRecentItem: jest.fn(),
dbRestoreXtreamUserData: jest.fn(),
dbReplaceAllPlaybackPositions: jest.fn(),
dbSaveCategories: jest.fn(),
dbSaveContent: jest.fn(),
dbSavePlaybackPosition: jest.fn(),
@@ -5,9 +5,10 @@ export type RuntimeEnvironment = 'electron' | 'pwa';
type RuntimeElectronBridge = Partial<ElectronBridgeApi>;
// The full method set the position-storage layer may invoke — including the
// season-batch variants, so a bridge lacking them degrades to the in-memory
// path wholesale instead of throwing mid-action.
// Ordinary playback methods include season batches, so partial bridges use
// the in-memory path instead of throwing mid-action. Atomic backup restore is
// checked separately by a restore-only preflight; it never changes data-source
// selection or disables ordinary SQLite reads and playback writes.
const playbackPositionStorageMethods = [
'dbSavePlaybackPosition',
'dbGetPlaybackPosition',
@@ -1246,6 +1246,10 @@ export interface ElectronBridgeApi {
dbGetAllPlaybackPositions: (
playlistId: string
) => Promise<PlaybackPositionData[]>;
dbReplaceAllPlaybackPositions: (
playlistId: string,
items: ElectronBridgePlaybackPositionInput[]
) => Promise<ElectronBridgeResult>;
dbClearAllPlaybackPositions: (
playlistId: string
) => Promise<ElectronBridgeResult>;