mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -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
@@ -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"]')
|
||||
|
||||
Reference in new issue
Block a user