mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(migration): preserve and recover legacy desktop sources (#1550)
* fix(migration): recover legacy desktop sources without replacing current data * test(migration): cover legacy recovery IPC contracts * test(migration): use static legacy Electron bootstrap
This commit is contained in:
1 parent
002faafbd7
commit
9bcdbc0efb
26 files changed
+1505
-38
No files matched your search
@@ -0,0 +1,155 @@
|
||||
-- Verbatim CREATE statements from v0.19.0 libs/shared/database/src/lib/connection.ts
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
serverUrl TEXT,
|
||||
username TEXT,
|
||||
password TEXT,
|
||||
date_created TEXT DEFAULT (datetime('now')),
|
||||
last_updated TEXT,
|
||||
type TEXT NOT NULL CHECK (type IN ('xtream', 'stalker', 'm3u-file', 'm3u-text', 'm3u-url')),
|
||||
userAgent TEXT,
|
||||
origin TEXT,
|
||||
referrer TEXT,
|
||||
filePath TEXT,
|
||||
autoRefresh INTEGER DEFAULT 0,
|
||||
macAddress TEXT,
|
||||
url TEXT,
|
||||
last_usage TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('live', 'movies', 'series')),
|
||||
xtream_id INTEGER NOT NULL,
|
||||
hidden INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS content (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
rating TEXT,
|
||||
added TEXT,
|
||||
poster_url TEXT,
|
||||
xtream_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')),
|
||||
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS recently_viewed (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content_id INTEGER NOT NULL,
|
||||
playlist_id TEXT NOT NULL,
|
||||
viewed_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (content_id) REFERENCES content (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content_id INTEGER NOT NULL,
|
||||
playlist_id TEXT NOT NULL,
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_type ON content(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_title ON content(title);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique ON favorites(content_id, playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS favorites_playlist_idx ON favorites(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS favorites_content_idx ON favorites(content_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique ON recently_viewed(content_id, playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS recently_viewed_playlist_idx ON recently_viewed(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS recently_viewed_viewed_at_idx ON recently_viewed(viewed_at);
|
||||
CREATE TABLE IF NOT EXISTS epg_channels (
|
||||
id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
icon_url TEXT,
|
||||
url TEXT,
|
||||
source_url TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS epg_programs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel_id TEXT NOT NULL,
|
||||
start TEXT NOT NULL,
|
||||
stop TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
icon_url TEXT,
|
||||
rating TEXT,
|
||||
episode_num TEXT,
|
||||
FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_channels_source ON epg_channels(source_url);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_channels_name ON epg_channels(display_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_programs_channel ON epg_programs(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_programs_start ON epg_programs(start);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_programs_time_range ON epg_programs(channel_id, start, stop);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5(
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
content='epg_programs',
|
||||
content_rowid='id'
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS epg_programs_ai AFTER INSERT ON epg_programs BEGIN
|
||||
INSERT INTO epg_programs_fts(rowid, title, description, category)
|
||||
VALUES (new.id, new.title, new.description, new.category);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS epg_programs_ad AFTER DELETE ON epg_programs BEGIN
|
||||
INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category)
|
||||
VALUES ('delete', old.id, old.title, old.description, old.category);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS epg_programs_au AFTER UPDATE ON epg_programs BEGIN
|
||||
INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category)
|
||||
VALUES ('delete', old.id, old.title, old.description, old.category);
|
||||
INSERT INTO epg_programs_fts(rowid, title, description, category)
|
||||
VALUES (new.id, new.title, new.description, new.category);
|
||||
END;
|
||||
CREATE TABLE IF NOT EXISTS playback_positions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL,
|
||||
content_xtream_id INTEGER NOT NULL,
|
||||
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
|
||||
series_xtream_id INTEGER,
|
||||
season_number INTEGER,
|
||||
episode_number INTEGER,
|
||||
position_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
duration_seconds INTEGER,
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS playback_positions_content_playlist_unique ON playback_positions(content_xtream_id, playlist_id, content_type);
|
||||
CREATE INDEX IF NOT EXISTS playback_positions_playlist_idx ON playback_positions(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS playback_positions_series_idx ON playback_positions(series_xtream_id);
|
||||
CREATE INDEX IF NOT EXISTS playback_positions_updated_idx ON playback_positions(updated_at);
|
||||
CREATE TABLE IF NOT EXISTS downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL,
|
||||
xtream_id INTEGER NOT NULL,
|
||||
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
|
||||
series_xtream_id INTEGER,
|
||||
season_number INTEGER,
|
||||
episode_number INTEGER,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
file_name TEXT,
|
||||
file_path TEXT,
|
||||
poster_url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')),
|
||||
bytes_downloaded INTEGER DEFAULT 0,
|
||||
total_bytes INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type);
|
||||
CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status);
|
||||
@@ -0,0 +1,410 @@
|
||||
import {
|
||||
_electron as electron,
|
||||
ElectronApplication,
|
||||
Page,
|
||||
} from '@playwright/test';
|
||||
import { join } from 'path';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
closeElectronApp,
|
||||
buildElectronLaunchEnvironment,
|
||||
electronMainPath,
|
||||
workspaceRoot,
|
||||
openSources,
|
||||
} from './electron-test-fixtures';
|
||||
import { seedLegacyProfile, legacyPlaylists } from './legacy-profile-fixture';
|
||||
|
||||
const migrationKey = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
const recoveryKey = 'playlists-electron-backend-profile-v1';
|
||||
|
||||
async function sql(
|
||||
app: ElectronApplication,
|
||||
dataDir: string,
|
||||
statement: string
|
||||
) {
|
||||
return app.evaluate(
|
||||
(_electron, { file, dependency, statement }) => {
|
||||
const Database = process
|
||||
.getBuiltinModule('module')
|
||||
.createRequire(dependency)(dependency),
|
||||
db = new Database(file);
|
||||
try {
|
||||
return db.prepare(statement).reader
|
||||
? db.prepare(statement).all()
|
||||
: db.prepare(statement).run();
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
{
|
||||
file: join(dataDir, 'databases/iptvnator.db'),
|
||||
dependency: join(workspaceRoot, 'node_modules/better-sqlite3'),
|
||||
statement,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function sourceCount(page: Page) {
|
||||
return page.evaluate(
|
||||
async () => (await window.electron.dbGetAppPlaylists()).length
|
||||
);
|
||||
}
|
||||
|
||||
async function launchWithRecoveryChoice(
|
||||
dataDir: string,
|
||||
response: number,
|
||||
retry = false
|
||||
) {
|
||||
const wrapper = join(dataDir, 'recovery-launch.cjs');
|
||||
await writeFile(
|
||||
wrapper,
|
||||
`const {dialog}=require('electron'); dialog.showMessageBox=async()=>({response:${response},checkboxChecked:false}); require(${JSON.stringify(electronMainPath)});`
|
||||
);
|
||||
const app = await electron.launch({
|
||||
args: [
|
||||
...(process.platform === 'linux' && process.env['CI']
|
||||
? ['--no-sandbox', '--disable-gpu']
|
||||
: []),
|
||||
wrapper,
|
||||
...(retry ? ['--recover-legacy-playlists'] : []),
|
||||
],
|
||||
env: buildElectronLaunchEnvironment(dataDir),
|
||||
});
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForSelector('app-root');
|
||||
await page.waitForFunction(
|
||||
() => typeof window.electron?.dbGetAppPlaylists === 'function'
|
||||
);
|
||||
return { app, page };
|
||||
}
|
||||
|
||||
test.describe('v0.19 profile migration', () => {
|
||||
for (const active of ['xtream-2', 'stalker-60', 'm3u']) {
|
||||
test(`imports all 65 sources with ${active} last active, offline`, async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
await seedLegacyProfile(dataDir, active);
|
||||
let launched = await launchElectronApp(dataDir);
|
||||
try {
|
||||
const page = launched.mainWindow;
|
||||
await expect.poll(() => sourceCount(page)).toBe(65);
|
||||
expect(
|
||||
await launched.electronApp.evaluate(({ app }) =>
|
||||
app.getPath('userData')
|
||||
)
|
||||
).toBe(join(dataDir, 'electron-backend'));
|
||||
await openSources(page);
|
||||
await expect(
|
||||
page.getByText('Legacy Stalker 0', { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Legacy Xtream 0', { exact: true })
|
||||
).toBeVisible();
|
||||
const sources = await page.evaluate(() =>
|
||||
window.electron.dbGetAppPlaylists()
|
||||
);
|
||||
expect(sources.filter((p) => p.macAddress)).toHaveLength(61);
|
||||
expect(sources.filter((p) => p.serverUrl)).toHaveLength(3);
|
||||
expect(sources.find((p) => p._id === active)?.lastUsage).toBe(
|
||||
'2025-12-31T00:00:00.000Z'
|
||||
);
|
||||
expect(sources.find((p) => p._id === 'xtream-2')).toMatchObject(
|
||||
{
|
||||
autoRefresh: true,
|
||||
serverUrl: 'https://xtream-2.invalid',
|
||||
username: 'synthetic-2',
|
||||
password: 'synthetic-only',
|
||||
}
|
||||
);
|
||||
expect(
|
||||
sources.find((p) => p._id === 'stalker-60')
|
||||
).toMatchObject({
|
||||
stalkerSerialNumber: 'synthetic-serial-60',
|
||||
stalkerDeviceId1: 'synthetic-device-60',
|
||||
favorites: legacyPlaylists().find(
|
||||
(p) => p._id === 'stalker-60'
|
||||
)?.['favorites'],
|
||||
});
|
||||
expect(sources.find((p) => p._id === 'm3u')).toMatchObject({
|
||||
favorites: ['channel-1'],
|
||||
userAgent: 'LegacySyntheticAgent',
|
||||
count: 1,
|
||||
});
|
||||
expect(
|
||||
await page.evaluate(() => localStorage.getItem('volume'))
|
||||
).toBe('0.37');
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
const r = indexedDB.open('ngStorage', 1);
|
||||
r.onerror = () => reject(r.error);
|
||||
r.onsuccess = () => {
|
||||
const db = r.result,
|
||||
q = db
|
||||
.transaction('localStorage')
|
||||
.objectStore('localStorage')
|
||||
.get('settings');
|
||||
q.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(q.result);
|
||||
};
|
||||
};
|
||||
})
|
||||
)
|
||||
).toMatchObject({ language: 'en', theme: 'dark' });
|
||||
expect(
|
||||
await sql(
|
||||
launched.electronApp,
|
||||
dataDir,
|
||||
'SELECT count(*) AS count FROM content'
|
||||
)
|
||||
).toEqual([{ count: 1 }]);
|
||||
expect(
|
||||
await sql(
|
||||
launched.electronApp,
|
||||
dataDir,
|
||||
'SELECT count(*) AS count FROM favorites'
|
||||
)
|
||||
).toEqual([{ count: 1 }]);
|
||||
expect(
|
||||
await sql(
|
||||
launched.electronApp,
|
||||
dataDir,
|
||||
'SELECT count(*) AS count FROM recently_viewed'
|
||||
)
|
||||
).toEqual([{ count: 1 }]);
|
||||
// Retained source database is independent of current deletion.
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<number>((resolve, reject) => {
|
||||
const r = indexedDB.open('iptvnator', 1);
|
||||
r.onerror = () => reject(r.error);
|
||||
r.onsuccess = () => {
|
||||
const db = r.result,
|
||||
q = db
|
||||
.transaction('playlists')
|
||||
.objectStore('playlists')
|
||||
.count();
|
||||
q.onsuccess = () => {
|
||||
db.close();
|
||||
resolve(q.result);
|
||||
};
|
||||
};
|
||||
})
|
||||
)
|
||||
).toBe(65);
|
||||
await page.evaluate(() =>
|
||||
window.electron.dbDeletePlaylist('stalker-0')
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(launched);
|
||||
}
|
||||
launched = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await expect
|
||||
.poll(() => sourceCount(launched.mainWindow))
|
||||
.toBe(64);
|
||||
} finally {
|
||||
await closeElectronApp(launched);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('rolls back all rows and the receipt on one failed SQLite write, then retries safely', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const { electronApp: app, mainWindow: page } =
|
||||
await launchElectronApp(dataDir);
|
||||
try {
|
||||
await page.evaluate(
|
||||
async (key) => window.electron.dbSetAppState(key, ''),
|
||||
migrationKey
|
||||
);
|
||||
await sql(
|
||||
app,
|
||||
dataDir,
|
||||
"CREATE TRIGGER synthetic_migration_failure BEFORE INSERT ON playlists WHEN NEW.id = 'xtream-1' BEGIN SELECT RAISE(ABORT, 'synthetic write failure'); END"
|
||||
);
|
||||
const result = await page.evaluate(async (sources) => {
|
||||
try {
|
||||
await window.electron.dbMigrateAppPlaylists(
|
||||
sources as Parameters<
|
||||
typeof window.electron.dbMigrateAppPlaylists
|
||||
>[0]
|
||||
);
|
||||
return 'unexpected success';
|
||||
} catch {
|
||||
return 'failed';
|
||||
}
|
||||
}, legacyPlaylists());
|
||||
expect(result).toBe('failed');
|
||||
expect(await sourceCount(page)).toBe(0);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
(key) => window.electron.dbGetAppState(key),
|
||||
migrationKey
|
||||
)
|
||||
).not.toBe('1');
|
||||
await sql(app, dataDir, 'DROP TRIGGER synthetic_migration_failure');
|
||||
await page.evaluate(
|
||||
(sources) =>
|
||||
window.electron.dbMigrateAppPlaylists(
|
||||
sources as Parameters<
|
||||
typeof window.electron.dbMigrateAppPlaylists
|
||||
>[0]
|
||||
),
|
||||
legacyPlaylists()
|
||||
);
|
||||
expect(await sourceCount(page)).toBe(65);
|
||||
await page.evaluate(
|
||||
(sources) =>
|
||||
window.electron.dbMigrateAppPlaylists(
|
||||
sources as Parameters<
|
||||
typeof window.electron.dbMigrateAppPlaylists
|
||||
>[0]
|
||||
),
|
||||
legacyPlaylists()
|
||||
);
|
||||
expect(await sourceCount(page)).toBe(65);
|
||||
} finally {
|
||||
await closeElectronApp({ electronApp: app, mainWindow: page });
|
||||
}
|
||||
});
|
||||
test('requires explicit recovery for an upgraded profile and preserves current sources/settings and deletions', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const initial = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await initial.mainWindow.evaluate(async () => {
|
||||
await window.electron.dbUpsertAppPlaylist({
|
||||
_id: 'xtream-0',
|
||||
title: 'Current edited source',
|
||||
serverUrl: 'https://current.invalid',
|
||||
username: 'current-user',
|
||||
password: 'current-password',
|
||||
count: 0,
|
||||
importDate: '2026-01-01',
|
||||
lastUsage: '2026-01-01',
|
||||
autoRefresh: false,
|
||||
});
|
||||
localStorage.setItem('volume', '0.73');
|
||||
});
|
||||
} finally {
|
||||
await closeElectronApp(initial);
|
||||
}
|
||||
await seedLegacyProfile(dataDir, 'xtream-2', false);
|
||||
let recovered = await launchWithRecoveryChoice(dataDir, 0);
|
||||
try {
|
||||
await expect
|
||||
.poll(() =>
|
||||
recovered.page.evaluate(
|
||||
(key) => window.electron.dbGetAppState(key),
|
||||
recoveryKey
|
||||
)
|
||||
)
|
||||
.toBe('declined');
|
||||
expect(await sourceCount(recovered.page)).toBe(1);
|
||||
expect(
|
||||
await recovered.app.evaluate(({ app }) =>
|
||||
app.getPath('userData')
|
||||
)
|
||||
).toBe(join(dataDir, 'user-data'));
|
||||
} finally {
|
||||
await closeElectronApp({
|
||||
electronApp: recovered.app,
|
||||
mainWindow: recovered.page,
|
||||
});
|
||||
}
|
||||
recovered = await launchWithRecoveryChoice(dataDir, 1, true);
|
||||
try {
|
||||
await expect.poll(() => sourceCount(recovered.page)).toBe(65);
|
||||
expect(
|
||||
await recovered.page.evaluate(() =>
|
||||
window.electron.dbGetAppPlaylist('xtream-0')
|
||||
)
|
||||
).toMatchObject({
|
||||
title: 'Current edited source',
|
||||
serverUrl: 'https://current.invalid',
|
||||
username: 'current-user',
|
||||
});
|
||||
expect(
|
||||
await recovered.page.evaluate(() =>
|
||||
localStorage.getItem('volume')
|
||||
)
|
||||
).toBe('0.73');
|
||||
await recovered.page.evaluate(() =>
|
||||
window.electron.dbDeletePlaylist('stalker-0')
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp({
|
||||
electronApp: recovered.app,
|
||||
mainWindow: recovered.page,
|
||||
});
|
||||
}
|
||||
recovered = await launchWithRecoveryChoice(dataDir, 1, true);
|
||||
try {
|
||||
await expect.poll(() => sourceCount(recovered.page)).toBe(64);
|
||||
expect(
|
||||
await recovered.page.evaluate(() =>
|
||||
window.electron.dbGetAppPlaylist('stalker-0')
|
||||
)
|
||||
).toBeNull();
|
||||
} finally {
|
||||
await closeElectronApp({
|
||||
electronApp: recovered.app,
|
||||
mainWindow: recovered.page,
|
||||
});
|
||||
}
|
||||
});
|
||||
test('restarts a failed real legacy import without losing the original store or cached data', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
await seedLegacyProfile(dataDir, 'xtream-2', true, true);
|
||||
let launched = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await expect(
|
||||
launched.mainWindow.evaluate(
|
||||
(sources) =>
|
||||
window.electron.dbMigrateAppPlaylists(
|
||||
sources as Parameters<
|
||||
typeof window.electron.dbMigrateAppPlaylists
|
||||
>[0]
|
||||
),
|
||||
legacyPlaylists()
|
||||
)
|
||||
).rejects.toThrow('Legacy playlist migration failed');
|
||||
expect(await sourceCount(launched.mainWindow)).toBe(1);
|
||||
expect(
|
||||
await launched.mainWindow.evaluate(
|
||||
(key) => window.electron.dbGetAppState(key),
|
||||
migrationKey
|
||||
)
|
||||
).not.toBe('1');
|
||||
expect(
|
||||
await sql(
|
||||
launched.electronApp,
|
||||
dataDir,
|
||||
'SELECT count(*) AS count FROM favorites'
|
||||
)
|
||||
).toEqual([{ count: 1 }]);
|
||||
await sql(
|
||||
launched.electronApp,
|
||||
dataDir,
|
||||
'DROP TRIGGER synthetic_migration_failure'
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(launched);
|
||||
}
|
||||
launched = await launchElectronApp(dataDir);
|
||||
try {
|
||||
await expect.poll(() => sourceCount(launched.mainWindow)).toBe(65);
|
||||
} finally {
|
||||
await closeElectronApp(launched);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { _electron as electron } from '@playwright/test';
|
||||
import { mkdir, writeFile, readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { workspaceRoot } from './electron-test-fixtures';
|
||||
|
||||
interface LegacyPlaylist extends Record<string, unknown> {
|
||||
_id: string;
|
||||
title: string;
|
||||
count: number;
|
||||
importDate: string;
|
||||
lastUsage: string;
|
||||
autoRefresh: boolean;
|
||||
}
|
||||
|
||||
/** v0.19 Playlist shape, with no type discriminator; endpoints never resolve. */
|
||||
export function legacyPlaylists(active = 'xtream-2') {
|
||||
const base = {
|
||||
count: 0,
|
||||
importDate: '2025-01-01T00:00:00.000Z',
|
||||
lastUsage: '2025-01-01T00:00:00.000Z',
|
||||
autoRefresh: false,
|
||||
favorites: [],
|
||||
};
|
||||
const portals: LegacyPlaylist[] = Array.from({ length: 61 }, (_, i) => ({
|
||||
...base,
|
||||
_id: `stalker-${i}`,
|
||||
title: `Legacy Stalker ${i}`,
|
||||
portalUrl: `https://stalker-${i}.invalid/stalker_portal/c/`,
|
||||
macAddress: `00:1A:79:00:00:${i.toString(16).padStart(2, '0')}`,
|
||||
stalkerSerialNumber: `synthetic-serial-${i}`,
|
||||
stalkerDeviceId1: `synthetic-device-${i}`,
|
||||
favorites: [
|
||||
{
|
||||
id: '7',
|
||||
name: 'Synthetic favorite',
|
||||
cmd: 'ffmpeg http://synthetic.invalid/7',
|
||||
stream_type: 'live',
|
||||
},
|
||||
],
|
||||
recentlyViewed: [
|
||||
{ id: '8', name: 'Synthetic recent', added_at: '2025-01-02' },
|
||||
],
|
||||
}));
|
||||
for (let i = 0; i < 3; i++)
|
||||
portals.push({
|
||||
...base,
|
||||
_id: `xtream-${i}`,
|
||||
title: `Legacy Xtream ${i}`,
|
||||
serverUrl: `https://xtream-${i}.invalid`,
|
||||
username: `synthetic-${i}`,
|
||||
password: 'synthetic-only',
|
||||
autoRefresh: true,
|
||||
});
|
||||
portals.push({
|
||||
...base,
|
||||
_id: 'm3u',
|
||||
title: 'Legacy M3U',
|
||||
count: 1,
|
||||
favorites: ['channel-1'],
|
||||
userAgent: 'LegacySyntheticAgent',
|
||||
playlist: {
|
||||
header: { raw: '#EXTM3U' },
|
||||
items: [
|
||||
{
|
||||
id: 'channel-1',
|
||||
name: 'Synthetic channel',
|
||||
url: 'https://channel.invalid/live',
|
||||
group: { title: 'Test' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return portals.map((p) =>
|
||||
p._id === active ? { ...p, lastUsage: '2025-12-31T00:00:00.000Z' } : p
|
||||
);
|
||||
}
|
||||
|
||||
export async function seedLegacyProfile(
|
||||
dataDir: string,
|
||||
active = 'xtream-2',
|
||||
seedSqlite = true,
|
||||
failWrite = false
|
||||
) {
|
||||
const fixture = join(dataDir, 'seed');
|
||||
const profile = join(dataDir, 'electron-backend');
|
||||
await mkdir(fixture, { recursive: true });
|
||||
await mkdir(profile, { recursive: true });
|
||||
const index = join(fixture, 'index.html');
|
||||
await writeFile(
|
||||
index,
|
||||
'<!doctype html><title>Synthetic v0.19 profile</title>'
|
||||
);
|
||||
await writeFile(
|
||||
join(fixture, 'package.json'),
|
||||
JSON.stringify({
|
||||
name: 'electron-backend',
|
||||
version: '0.0.1',
|
||||
main: 'main.cjs',
|
||||
})
|
||||
);
|
||||
await writeFile(
|
||||
join(fixture, 'main.cjs'),
|
||||
`const { app, BrowserWindow } = require('electron');
|
||||
const { join } = require('path');
|
||||
app.setPath('userData', join(__dirname, '..', 'electron-backend'));
|
||||
app.whenReady().then(() => new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: { sandbox: true },
|
||||
}).loadFile(join(__dirname, 'index.html')));`
|
||||
);
|
||||
const app = await electron.launch({
|
||||
args: [
|
||||
...(process.platform === 'linux' && process.env['CI']
|
||||
? ['--no-sandbox', '--disable-gpu']
|
||||
: []),
|
||||
fixture,
|
||||
],
|
||||
env: { ...process.env, NODE_ENV: 'test' },
|
||||
});
|
||||
try {
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForLoadState();
|
||||
await page.evaluate(async (playlists) => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.open('iptvnator', 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const store = req.result.createObjectStore('playlists', {
|
||||
keyPath: '_id',
|
||||
autoIncrement: false,
|
||||
});
|
||||
// Exact v0.19 indexed-db.config.ts schema.
|
||||
for (const field of [
|
||||
'_id',
|
||||
'filename',
|
||||
'title',
|
||||
'count',
|
||||
'playlist',
|
||||
'importDate',
|
||||
'lastUsage',
|
||||
'favorites',
|
||||
'recentlyViewed',
|
||||
'autoRefresh',
|
||||
'url',
|
||||
'filePath',
|
||||
])
|
||||
store.createIndex(field, field, { unique: false });
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () => {
|
||||
const db = req.result,
|
||||
tx = db.transaction('playlists', 'readwrite');
|
||||
for (const p of playlists)
|
||||
tx.objectStore('playlists').put(p);
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
tx.onabort = () => reject(tx.error);
|
||||
};
|
||||
});
|
||||
// ngx-pwa/local-storage v0.19 defaults; unrelated preferences survive.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = indexedDB.open('ngStorage', 1);
|
||||
req.onupgradeneeded = () =>
|
||||
req.result.createObjectStore('localStorage');
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () => {
|
||||
const db = req.result,
|
||||
tx = db.transaction('localStorage', 'readwrite');
|
||||
tx.objectStore('localStorage').put(
|
||||
{ language: 'en', player: 'html5', theme: 'dark' },
|
||||
'settings'
|
||||
);
|
||||
tx.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
};
|
||||
});
|
||||
localStorage.setItem('volume', '0.37');
|
||||
}, legacyPlaylists(active));
|
||||
if (seedSqlite) {
|
||||
await mkdir(join(dataDir, 'databases'), { recursive: true });
|
||||
const sql = await readFile(
|
||||
join(
|
||||
workspaceRoot,
|
||||
'apps/electron-backend-e2e/src/fixtures/v019-schema.sql'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
await app.evaluate(
|
||||
(_electron, { path, dependency, sql, failWrite }) => {
|
||||
const Database = process
|
||||
.getBuiltinModule('module')
|
||||
.createRequire(dependency)(dependency);
|
||||
const db = new Database(path);
|
||||
db.exec(sql);
|
||||
db.prepare(
|
||||
'INSERT INTO playlists (id,name,type,serverUrl,username,password) VALUES (?,?,?,?,?,?)'
|
||||
).run(
|
||||
'xtream-2',
|
||||
'Legacy Xtream 2',
|
||||
'xtream',
|
||||
'https://stale-cache.invalid',
|
||||
'stale-user',
|
||||
'stale-password'
|
||||
);
|
||||
db.exec(
|
||||
"INSERT INTO categories (id,playlist_id,name,type,xtream_id) VALUES (1,'xtream-2','Synthetic','live',1); INSERT INTO content (id,category_id,title,xtream_id,type) VALUES (1,1,'Cached channel',7,'live'); INSERT INTO favorites (content_id,playlist_id) VALUES (1,'xtream-2'); INSERT INTO recently_viewed (content_id,playlist_id) VALUES (1,'xtream-2');"
|
||||
);
|
||||
if (failWrite)
|
||||
db.exec(
|
||||
"CREATE TRIGGER synthetic_migration_failure BEFORE INSERT ON playlists WHEN NEW.id = 'xtream-1' BEGIN SELECT RAISE(ABORT, 'synthetic write failure'); END"
|
||||
);
|
||||
db.close();
|
||||
},
|
||||
{
|
||||
path: join(dataDir, 'databases', 'iptvnator.db'),
|
||||
dependency: join(
|
||||
workspaceRoot,
|
||||
'node_modules/better-sqlite3'
|
||||
),
|
||||
sql,
|
||||
failWrite,
|
||||
}
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user