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:
4gray authored and GitHub committed 2026-09-06 00:45:35 +02:00
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();
}
}