mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
1 parent
2b400cb81d
commit
2fa03c8efc
25 files changed
+1037
-142
No files matched your search
@@ -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: (
|
||||
|
||||
+165
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+74
-8
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user