fix(xtream): preserve favorites across catalog refreshes

This commit is contained in:
4gray committed 2026-10-13 23:31:25 +02:00
1 parent ec4b9286a7
commit 8ccfcb68cc
121 files changed
+4000 -398

No files matched your search

+22 -2
View File
@@ -46,13 +46,30 @@ root is resolved in this order:
Users may skip releases. The application must apply every required migration in
dependency order when opening an older database, preserving user data without
requiring intermediate application installations or a database reset. This is
the repository policy mirrored in `AGENTS.md` and `CLAUDE.md`.
the repository policy in `AGENTS.md`, which `CLAUDE.md` imports.
`src/lib/connection.ts` owns initialization: `createTables()` creates missing
schema objects, then `runMigrations()` applies column/index migrations and
dedicated schema/data upgrades. One-off data migrations can record completion
in `app_state`. Keep existing migration paths when adding new ones.
Xtream favorites are migrated by `src/lib/favorites-table.ts` before legacy
content/category deduplication. The transactional table rebuild backfills typed
provider identity and saved display metadata while retaining favorite IDs,
dates and positions. Duplicate favorites of the same playlist/type/provider ID
collapse to the earliest favorite record. `content_id` becomes nullable with
`ON DELETE SET NULL`; owning playlist deletion still cascades. The legacy
content deduplicator updates references without replacing favorite IDs.
Favorites whose owning playlist was already missing remain stored. The rebuild
temporarily disables foreign-key enforcement outside its transaction, permits
only those pre-existing playlist violations by favorite ID, and rolls back if
it introduces another violation. The original enforcement setting is restored
on both success and failure.
Historical upgrade tests include tagged 0.19–0.24 schemas, duplicate categories,
orphaned favorites, repeated startup and forced rollbacks. Keep snapshot
migration before content deduplication so the originally favorited row's
metadata survives.
When changing initialization:
- Create required tables before migrating them. `CREATE TABLE IF NOT EXISTS`
@@ -102,7 +119,7 @@ playlists) are guarded in `runMigrations()`:
The #1580 index-ordering fix is included in 0.24 through PR #1550.
`src/lib/connection-upgrades.spec.ts` exercises `initDatabase()` with real
SQLite under the Electron runtime, using fresh-install schema snapshots from
tags 0.19–0.23 and a fresh current database. The `epg_channel_id` column is absent
tags 0.19–0.24 and a fresh current database. The `epg_channel_id` column is absent
in 0.19, present from 0.20, and indexed from 0.23. Each case checks current Drizzle
tables, columns/types, and named indexes/uniqueness, as well as preserved user
rows, foreign keys, database integrity, index availability, and repeated startup;
@@ -115,3 +132,6 @@ the index ahead of its column migration makes the 0.19 case fail again.
Run this coverage with `pnpm nx test database --runInBand`. The Electron UI and
worker upgrade flow is also covered by
`pnpm nx run electron-backend-e2e:e2e-ci--src/legacy-playlist-migration.e2e.ts`.
For an orphaned 0.24 favorites table, initial startup and restart through the
real Electron UI and worker are covered by
`pnpm nx run electron-backend-e2e:e2e-ci--src/favorites-legacy.e2e.ts`.
@@ -40,7 +40,11 @@ function createSqliteMock(
const rule = rules.find(([pattern]) => compact.includes(pattern));
return {
all: jest.fn(() => []),
all: jest.fn(() =>
compact === "PRAGMA table_info('favorites')"
? [{ name: 'xtream_id' }]
: []
),
get: jest.fn(),
run: jest.fn(),
...(rule?.[1] ?? {}),
@@ -299,7 +303,7 @@ describe('runMigrations Xtream cache deduplication', () => {
'SELECT id FROM content WHERE category_id = ?',
{ all: () => [{ id: 10 }, { id: 11 }] },
],
['INSERT INTO favorites', { run: moveFavoritesRun }],
['UPDATE OR IGNORE favorites', { run: moveFavoritesRun }],
[
'DELETE FROM favorites WHERE content_id = ?',
{ run: deleteFavoritesRun },
@@ -5,7 +5,19 @@ import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
describe('database initialization across skipped releases (#1580)', () => {
it.each(['0.19.0', '0.20.0', '0.21.0', '0.22.0', '0.23.0', 'fresh'])(
it.each([
'0.19.0',
'0.20.0',
'0.21.0',
'0.22.0',
'0.23.0',
'0.24.0',
'0.19.0-duplicates',
'0.19.0-orphans',
'0.24.0-orphans',
'fresh',
'rollback',
])(
'preserves %s data and indexes across upgrade and repeated startup',
(version) => {
const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-upgrade-'));
@@ -13,12 +25,12 @@ describe('database initialization across skipped releases (#1580)', () => {
'electron'
) as string;
const fixture =
version === 'fresh'
? 'fresh'
version === 'fresh' || version === 'rollback'
? version
: resolve(
__dirname,
'testing/fixtures',
`v${version}-schema.sql`
`v${version.replace(/-(duplicates|orphans)$/, '')}-schema.sql`
);
try {
const output = execFileSync(
@@ -28,6 +40,11 @@ describe('database initialization across skipped releases (#1580)', () => {
'tsx',
resolve(__dirname, 'testing/connection-upgrade.ts'),
fixture,
version.endsWith('-duplicates')
? 'deduplicate'
: version.endsWith('-orphans')
? 'orphans'
: '',
],
{
cwd: process.cwd(),
@@ -486,6 +486,9 @@ describe('database schema statements', () => {
).runMigrations;
const sqlite = {
exec: jest.fn(),
pragma: jest.fn((statement: string) =>
statement === 'foreign_keys' ? 1 : []
),
prepare: jest.fn((statement: string) => {
if (statement.includes('SELECT value FROM app_state')) {
return {
@@ -556,6 +559,9 @@ describe('database schema statements', () => {
}
).runMigrations;
const sqlite = {
pragma: jest.fn((statement: string) =>
statement === 'foreign_keys' ? 1 : []
),
exec: jest.fn((statement: string) => {
if (statement.includes('idx_epg_programs_source')) {
callOrder.push('epg-source-index');
+11 -14
View File
@@ -7,6 +7,11 @@ import {
ensurePlaylistsPayloadLast,
playlistsTableSql,
} from './playlists-table';
import {
ensureDurableFavorites,
favoritesTableSql,
FAVORITES_RECEIPT_CLEANUP_TRIGGER,
} from './favorites-table';
import { xtreamCacheUniqueIndexesExist } from './xtream-cache-unique-indexes';
/**
* Database connection and initialization for IPTVnator
@@ -183,15 +188,8 @@ const CREATE_TABLE_STATEMENTS = [
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')),
position INTEGER DEFAULT 0,
FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE CASCADE,
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
)`,
favoritesTableSql(),
FAVORITES_RECEIPT_CLEANUP_TRIGGER,
`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)`,
@@ -407,6 +405,7 @@ const INDEX_MIGRATION_STATEMENTS = [
// v1.3.0 -> v1.4.0: Prevent duplicate Xtream categories/content rows
`CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS favorites_playlist_type_xtream_unique ON favorites(playlist_id, type, xtream_id)`,
// v1.6.0 -> v1.7.0: Query global favorites in stable display order
`CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)`,
// v1.7.1 -> v1.7.2: Query playlist-scoped EPG by source URL and channel/time
@@ -550,11 +549,7 @@ function deduplicateXtreamCache(sqliteDb: Database.Database): void {
ORDER BY id ASC`
);
const moveFavorites = sqliteDb.prepare(
`INSERT INTO favorites (content_id, playlist_id, added_at, position)
SELECT ?, playlist_id, added_at, COALESCE(position, 0)
FROM favorites
WHERE content_id = ?
ON CONFLICT(content_id, playlist_id) DO NOTHING`
`UPDATE OR IGNORE favorites SET content_id = ? WHERE content_id = ?`
);
const deleteFavorites = sqliteDb.prepare(
`DELETE FROM favorites WHERE content_id = ?`
@@ -1183,6 +1178,8 @@ function runMigrations(sqliteDb: Database.Database): void {
if (!upgradeContentTitleFtsTokenizer(sqliteDb)) {
ensureContentTitleFts(sqliteDb);
}
// Capture the favorited row's metadata before duplicate cache rows disappear.
ensureDurableFavorites(sqliteDb);
deduplicateXtreamCache(sqliteDb);
normalizeXtreamContentAddedEpochs(sqliteDb);
runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS);
@@ -0,0 +1,106 @@
import type Database from 'better-sqlite3';
export function favoritesTableSql(name = 'favorites'): string {
return `CREATE TABLE IF NOT EXISTS ${name} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_id INTEGER,
playlist_id TEXT NOT NULL,
xtream_id INTEGER NOT NULL,
type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')),
title TEXT NOT NULL,
poster_url TEXT,
backdrop_url TEXT,
tmdb_id INTEGER,
release_year INTEGER,
original_title TEXT,
added_at TEXT DEFAULT (datetime('now')),
position INTEGER DEFAULT 0,
FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE SET NULL,
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
)`;
}
/** Runs after column upgrades, before cache deduplication and dependent indexes. */
export function ensureDurableFavorites(sqlite: Database.Database): void {
const columns = sqlite.prepare("PRAGMA table_info('favorites')").all() as {
name: string;
}[];
if (columns.some((column) => column.name === 'xtream_id')) return;
// Historical profiles may already contain favorites with a missing owner.
// Retain those exact records, but never let this rebuild introduce new ones.
const orphanIds = new Set(
(
sqlite
.prepare(
`SELECT f.id FROM favorites f
LEFT JOIN playlists p ON p.id = f.playlist_id
WHERE p.id IS NULL`
)
.all() as { id: number }[]
).map(({ id }) => id)
);
const foreignKeys = sqlite.pragma('foreign_keys', { simple: true });
// This must happen outside the transaction; SQLite ignores it inside one.
sqlite.pragma('foreign_keys = OFF');
try {
sqlite.transaction(() => {
sqlite.exec(favoritesTableSql('favorites_durable'));
// Multiple categories may contain the same provider item. Keep the first
// saved favorite's primary key, timestamp and position deterministically.
sqlite.exec(`INSERT INTO favorites_durable
(id, content_id, playlist_id, xtream_id, type, title, poster_url,
backdrop_url, tmdb_id, release_year, original_title, added_at, position)
SELECT id, content_id, playlist_id, xtream_id, type, title, poster_url,
backdrop_url, tmdb_id, release_year, original_title, added_at, position
FROM (
SELECT f.id, c.id AS content_id, f.playlist_id,
COALESCE(c.xtream_id, -f.id) AS xtream_id,
COALESCE(c.type, 'movie') AS type,
COALESCE(c.title, 'Saved favorite') AS title,
c.poster_url, c.backdrop_url, c.tmdb_id, c.release_year, c.original_title,
f.added_at, f.position,
ROW_NUMBER() OVER (PARTITION BY f.playlist_id, c.type,
COALESCE(c.xtream_id, -f.id) ORDER BY f.id) AS identity_rank
FROM favorites f LEFT JOIN content c ON c.id = f.content_id
) WHERE identity_rank = 1`);
sqlite.exec('DROP TABLE favorites');
sqlite.exec('ALTER TABLE favorites_durable RENAME TO favorites');
sqlite.exec(
'CREATE UNIQUE INDEX favorites_content_playlist_unique ON favorites(content_id, playlist_id)'
);
sqlite.exec(
'CREATE INDEX favorites_playlist_idx ON favorites(playlist_id)'
);
sqlite.exec(
'CREATE INDEX favorites_content_idx ON favorites(content_id)'
);
const violations = sqlite.pragma(
'foreign_key_check(favorites)'
) as {
rowid: number;
parent: string;
}[];
if (
violations.some(
({ rowid, parent }) =>
parent !== 'playlists' || !orphanIds.has(rowid)
)
) {
throw new Error(
'Favorites migration introduced a foreign key violation'
);
}
})();
} finally {
sqlite.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`);
}
}
/** Receipts are playlist-owned even though app_state is a generic key/value table. */
export const FAVORITES_RECEIPT_CLEANUP_TRIGGER = `CREATE TRIGGER IF NOT EXISTS favorites_receipts_playlist_delete
AFTER DELETE ON playlists BEGIN
DELETE FROM app_state WHERE substr(key, 1, length('xtream-favorites-restore:' || length(OLD.id) || ':' || OLD.id || ':')) =
'xtream-favorites-restore:' || length(OLD.id) || ':' || OLD.id || ':'
OR key IN ('xtream-import-status:' || OLD.id || ':live',
'xtream-import-status:' || OLD.id || ':movie', 'xtream-import-status:' || OLD.id || ':series');
END`;
+16 -3
View File
@@ -170,17 +170,30 @@ export const favorites = sqliteTable(
'favorites',
{
id: integer('id').primaryKey({ autoIncrement: true }),
contentId: integer('content_id')
.notNull()
.references(() => content.id, { onDelete: 'cascade' }),
contentId: integer('content_id').references(() => content.id, {
onDelete: 'set null',
}),
playlistId: text('playlist_id')
.notNull()
.references(() => playlists.id, { onDelete: 'cascade' }),
xtreamId: integer('xtream_id').notNull(),
type: text('type', { enum: ['live', 'movie', 'series'] }).notNull(),
title: text('title').notNull(),
posterUrl: text('poster_url'),
backdropUrl: text('backdrop_url'),
tmdbId: integer('tmdb_id'),
releaseYear: integer('release_year'),
originalTitle: text('original_title'),
addedAt: text('added_at').default(sql`CURRENT_TIMESTAMP`),
/** Display order position in the global favorites list (lower = first) */
position: integer('position').default(0),
},
(table) => ({
identityUnique: uniqueIndex('favorites_playlist_type_xtream_unique').on(
table.playlistId,
table.type,
table.xtreamId
),
contentPlaylistUnique: uniqueIndex(
'favorites_content_playlist_unique'
).on(table.contentId, table.playlistId),
@@ -1,3 +1,4 @@
import { verifyFavoriteMigrationRollback } from './favorites-migration-rollback';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import Database from 'better-sqlite3';
@@ -25,7 +26,6 @@ function seed(sqlite: Database.Database) {
VALUES (1, 'saved', 'Movies', 'movies', 42, 1);
INSERT INTO content (id, category_id, title, xtream_id, type, added)
VALUES (1, 1, 'Saved movie', 7, 'movie', '1700000000');
INSERT INTO favorites (content_id, playlist_id) VALUES (1, 'saved');
INSERT INTO recently_viewed (content_id, playlist_id) VALUES (1, 'saved');
INSERT INTO playback_positions
(playlist_id, content_xtream_id, content_type, position_seconds, duration_seconds)
@@ -33,6 +33,18 @@ function seed(sqlite: Database.Database) {
INSERT INTO downloads (playlist_id, xtream_id, content_type, title, url, status)
VALUES ('saved', 7, 'vod', 'Saved movie', 'https://source.invalid/movie', 'completed');
`);
const favoriteColumns = sqlite.pragma('table_info(favorites)') as {
name: string;
}[];
const durable = favoriteColumns.some(({ name }) => name === 'xtream_id');
sqlite.exec(
durable
? `INSERT INTO favorites (id, content_id, playlist_id, added_at, position, xtream_id, type, title) VALUES (42, 1, 'saved', '2025-01-01', 9, 7, 'movie', 'Saved movie')`
: `INSERT INTO favorites (id, content_id, playlist_id, added_at) VALUES (42, 1, 'saved', '2025-01-01')`
);
if (favoriteColumns.some(({ name }) => name === 'position')) {
sqlite.exec('UPDATE favorites SET position = 9');
}
const columns = sqlite.pragma('table_info(content)') as { name: string }[];
if (columns.some(({ name }) => name === 'epg_channel_id')) {
sqlite.exec("UPDATE content SET epg_channel_id = 'retained-epg-id'");
@@ -139,6 +151,18 @@ function epgIndex(sqlite: Database.Database) {
.get();
}
function foreignKeyViolations(sqlite: Database.Database) {
// Rebuilding a table can reorder its FK definitions without changing the
// affected row or parent, so compare identities rather than FK ordinals.
return (
sqlite.pragma('foreign_key_check') as {
table: string;
rowid: number;
parent: string;
}[]
).map(({ table, rowid, parent }) => ({ table, rowid, parent }));
}
function verifyCurrentSchema(sqlite: Database.Database) {
for (const table of Object.values(currentSchema)) {
const { name, columns, indexes } = getTableConfig(table);
@@ -173,6 +197,11 @@ function verifyCurrentSchema(sqlite: Database.Database) {
async function main() {
const fixture = process.argv[2];
if (fixture === 'rollback') {
verifyFavoriteMigrationRollback();
process.stdout.write('upgrade verified');
return;
}
assert(
process.env['IPTVNATOR_E2E_DATA_DIR'],
'Never open the user database'
@@ -191,7 +220,21 @@ async function main() {
legacy.pragma('foreign_keys = ON');
if (fixture !== 'fresh') legacy.exec(readFileSync(fixture, 'utf8'));
seed(legacy);
if (process.argv[3] === 'orphans') {
legacy.pragma('foreign_keys = OFF');
legacy.exec(`INSERT INTO favorites (id, content_id, playlist_id, added_at)
VALUES (43, 1, 'deleted-playlist', '2024-01-01')`);
legacy.pragma('foreign_keys = ON');
}
const before = snapshot(legacy);
const originalViolations = foreignKeyViolations(legacy);
if (process.argv[3] === 'deduplicate') {
legacy.exec(`INSERT INTO categories (id, playlist_id, name, type, xtream_id, hidden)
VALUES (2, 'saved', 'Movies', 'movies', 42, 1);
INSERT INTO content (id, category_id, title, xtream_id, type, added)
VALUES (2, 2, 'Original favorite title', 7, 'movie', '1700000000');
UPDATE favorites SET content_id = 2;`);
}
const originalIndex = epgIndex(legacy);
legacy.close();
@@ -214,7 +257,37 @@ async function main() {
for (const { query, rows } of before) {
assert.deepEqual(sqlite.prepare(query).all(), rows, query);
}
assert.deepEqual(sqlite.pragma('foreign_key_check'), []);
assert.deepEqual(
sqlite
.prepare(
'SELECT id, content_id, playlist_id, xtream_id, type, title, added_at FROM favorites'
)
.get(),
{
id: 42,
content_id: 1,
playlist_id: 'saved',
xtream_id: 7,
type: 'movie',
title:
process.argv[3] === 'deduplicate'
? 'Original favorite title'
: 'Saved movie',
added_at: '2025-01-01',
}
);
const favoriteForeignKeys = sqlite.pragma(
'foreign_key_list(favorites)'
) as { from: string; on_delete: string }[];
assert.equal(
favoriteForeignKeys.find((key) => key.from === 'content_id')
?.on_delete,
'SET NULL'
);
assert.deepEqual(
foreignKeyViolations(sqlite),
originalViolations
);
assert.deepEqual(sqlite.pragma('integrity_check'), [
{ integrity_check: 'ok' },
]);
@@ -0,0 +1,115 @@
import assert from 'node:assert/strict';
import Database from 'better-sqlite3';
import { ensureDurableFavorites } from '../favorites-table';
export function verifyFavoriteMigrationRollback() {
const sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
try {
sqlite.exec(`CREATE TABLE playlists (id TEXT PRIMARY KEY);
CREATE TABLE content (id INTEGER PRIMARY KEY, xtream_id INTEGER, type TEXT,
title TEXT, poster_url TEXT, backdrop_url TEXT, tmdb_id INTEGER,
release_year INTEGER, original_title TEXT);
CREATE TABLE favorites (id INTEGER PRIMARY KEY, content_id INTEGER NOT NULL
REFERENCES content(id) ON DELETE CASCADE, playlist_id TEXT REFERENCES playlists(id),
added_at TEXT, position INTEGER);
INSERT INTO playlists VALUES ('p');
INSERT INTO content (id, xtream_id, type, title) VALUES (1, 7, 'movie', 'ABC');
INSERT INTO favorites VALUES (42, 1, 'p', '2025-01-01', 9);
CREATE VIEW favorites_dependency AS SELECT id FROM favorites;`);
const before = sqlite.prepare('SELECT * FROM favorites').all();
assert.throws(
() => ensureDurableFavorites(sqlite),
/favorites_dependency/
);
assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1);
assert.deepEqual(
sqlite.prepare('SELECT * FROM favorites').all(),
before
);
assert.equal(
sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE name = 'favorites_durable'"
)
.get(),
undefined
);
sqlite.exec('DROP VIEW favorites_dependency');
sqlite.pragma('foreign_keys = OFF');
sqlite.exec(`INSERT INTO favorites VALUES (43, 1, 'deleted', '2024-01-01', 10);
INSERT INTO favorites VALUES (44, 999, 'p', '2024-01-02', 11);`);
sqlite.pragma('foreign_keys = ON');
const withOrphans = sqlite.prepare('SELECT * FROM favorites').all();
const exec = sqlite.exec;
sqlite.exec = function injectInvalidOwner(statement) {
const result = exec.call(this, statement);
if (
statement ===
'ALTER TABLE favorites_durable RENAME TO favorites'
) {
exec.call(
this,
"UPDATE favorites SET playlist_id = 'new-orphan' WHERE id = 42"
);
}
return result;
};
// The missing content link is repaired during the rebuild. A mere
// before/after violation count would conceal a new orphan in its place.
try {
assert.throws(
() => ensureDurableFavorites(sqlite),
/introduced a foreign key violation/
);
} finally {
sqlite.exec = exec;
}
assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1);
assert.deepEqual(
sqlite.prepare('SELECT * FROM favorites').all(),
withOrphans
);
assert.equal(
sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE name = 'favorites_durable'"
)
.get(),
undefined
);
ensureDurableFavorites(sqlite);
assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1);
ensureDurableFavorites(sqlite);
sqlite.exec('DELETE FROM content');
assert.deepEqual(
sqlite
.prepare(
'SELECT id, content_id, title, added_at, position FROM favorites'
)
.get(),
{
id: 42,
content_id: null,
title: 'ABC',
added_at: '2025-01-01',
position: 9,
}
);
assert.deepEqual(
(
sqlite.pragma('foreign_key_check') as {
rowid: number;
parent: string;
}[]
).map(({ rowid, parent }) => ({ rowid, parent })),
[{ rowid: 43, parent: 'playlists' }]
);
assert.equal(
sqlite.prepare('SELECT id FROM favorites').all().length,
3
);
} finally {
sqlite.close();
}
}
@@ -0,0 +1,61 @@
-- Fresh-install schema from tag v0.24.0, libs/shared/database/src/lib/connection.ts.
-- Whitespace compacted; statements and referenced constants retain their original order.
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, epg_urls TEXT, detected_epg_urls TEXT, manual_epg_urls TEXT, disabled_epg_urls TEXT, autoRefresh INTEGER DEFAULT 0, macAddress TEXT, url TEXT, portal_url TEXT, count INTEGER, import_date TEXT, update_date INTEGER, position INTEGER, favorites TEXT, recently_viewed TEXT, payload TEXT, last_usage TEXT );
CREATE TABLE IF NOT EXISTS app_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) );
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, UNIQUE(playlist_id, type, xtream_id), 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, backdrop_url TEXT, tmdb_id INTEGER, release_year INTEGER, original_title TEXT, epg_channel_id TEXT, tv_archive INTEGER, tv_archive_duration INTEGER, direct_source TEXT, xtream_id INTEGER NOT NULL, type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')), UNIQUE(category_id, type, xtream_id), 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')), position INTEGER DEFAULT 0, 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_content_type_added ON content(type, added);
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
CREATE INDEX IF NOT EXISTS idx_categories_visible ON categories(id, playlist_id, type) WHERE hidden = 0;
CREATE VIRTUAL TABLE IF NOT EXISTS content_title_fts USING fts5( title, content='content', content_rowid='id', tokenize='trigram' );
CREATE TRIGGER IF NOT EXISTS content_title_fts_ai AFTER INSERT ON content BEGIN INSERT INTO content_title_fts(rowid, title) VALUES (new.id, new.title); END;
CREATE TRIGGER IF NOT EXISTS content_title_fts_ad AFTER DELETE ON content BEGIN INSERT INTO content_title_fts(content_title_fts, rowid, title) VALUES ('delete', old.id, old.title); END;
CREATE TRIGGER IF NOT EXISTS content_title_fts_au AFTER UPDATE ON content BEGIN INSERT INTO content_title_fts(content_title_fts, rowid, title) VALUES ('delete', old.id, old.title); INSERT INTO content_title_fts(rowid, title) VALUES (new.id, new.title); END;
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 INDEX IF NOT EXISTS recently_viewed_playlist_viewed_idx ON recently_viewed(playlist_id, viewed_at DESC);
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_channel_sources ( channel_id TEXT NOT NULL, source_url TEXT NOT NULL, display_name TEXT NOT NULL, icon_url TEXT, url TEXT, updated_at TEXT DEFAULT (datetime('now')), write_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (channel_id, source_url), FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE );
CREATE INDEX IF NOT EXISTS idx_epg_channel_sources_source ON epg_channel_sources(source_url);
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, source_url 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_stop ON epg_programs(stop);
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 epg_channel_mappings ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_key TEXT NOT NULL UNIQUE, epg_channel_id TEXT NOT NULL, playlist_id TEXT, updated_at TEXT DEFAULT (datetime('now')) );
CREATE INDEX IF NOT EXISTS idx_epg_channel_mappings_playlist ON epg_channel_mappings(playlist_id);
CREATE TABLE IF NOT EXISTS vod_source_pins ( id INTEGER PRIMARY KEY AUTOINCREMENT, match_key TEXT NOT NULL UNIQUE, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, content_id INTEGER NOT NULL, portal_type TEXT NOT NULL CHECK (portal_type IN ('xtream', 'stalker', 'm3u')), updated_at TEXT DEFAULT (datetime('now')) );
CREATE INDEX IF NOT EXISTS idx_vod_source_pins_playlist ON vod_source_pins(playlist_id);
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 INDEX IF NOT EXISTS playback_positions_playlist_updated_idx ON playback_positions(playlist_id, updated_at DESC);
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', 'catchup')), programme_start INTEGER NOT NULL DEFAULT 0, catchup TEXT, series_xtream_id INTEGER, season_number INTEGER, episode_number INTEGER, episode_identity_scope TEXT, title TEXT NOT NULL, url TEXT NOT NULL, file_name TEXT, file_path TEXT, poster_url TEXT, request_headers TEXT, resume_validator TEXT, metadata_snapshot TEXT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'paused', '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')) );
CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type) WHERE content_type != 'catchup';
CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id);
CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status);
CREATE TABLE IF NOT EXISTS recordings ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, owner_pid INTEGER, status TEXT NOT NULL DEFAULT 'recording' CHECK (status IN ('recording', 'completed', 'interrupted', 'failed')), file_path TEXT NOT NULL, file_size_bytes INTEGER, channel_name TEXT NOT NULL, channel_logo_url TEXT, playlist_id TEXT, playlist_name TEXT, source_type TEXT CHECK (source_type IN ('m3u', 'xtream', 'stalker')), epg_channel_id TEXT, program_title TEXT, program_description TEXT, program_start TEXT, program_stop TEXT, programs_json TEXT, error_message TEXT, started_at TEXT NOT NULL, ended_at TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) );
CREATE INDEX IF NOT EXISTS recordings_status_idx ON recordings(status);
CREATE INDEX IF NOT EXISTS recordings_file_path_idx ON recordings(file_path);
CREATE INDEX IF NOT EXISTS recordings_playlist_idx ON recordings(playlist_id);
CREATE TABLE IF NOT EXISTS tmdb_metadata ( id INTEGER PRIMARY KEY AUTOINCREMENT, media_type TEXT NOT NULL CHECK (media_type IN ('movie', 'tv', 'person')), lookup_key TEXT NOT NULL, language TEXT NOT NULL, tmdb_id INTEGER, payload TEXT, fetched_at TEXT DEFAULT (datetime('now')) );
CREATE UNIQUE INDEX IF NOT EXISTS tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language);
+1
View File
@@ -81,6 +81,7 @@ export * from './lib/xtream-live-stream.interface';
export * from './lib/xtream-response.interface';
export * from './lib/xtream-portal.utils';
export * from './lib/xtream-restore-state.util';
export * from './lib/xtream-favorite.interface';
export * from './lib/xtream-server-timezone.util';
export * from './lib/xtream-recently-added.utils';
export * from './lib/xtream-performance-marker.interface';
@@ -564,7 +564,20 @@ export interface ElectronBridgeGlobalRecentItem extends ElectronBridgeXtreamCont
viewed_at: string;
}
export interface ElectronBridgeGlobalFavoriteItem extends ElectronBridgeXtreamContent {
export interface ElectronBridgeXtreamFavorite extends Omit<
ElectronBridgeXtreamContent,
'id' | 'category_id'
> {
id: number | null;
category_id: number | null;
favorite_id?: number;
catalog_status?: import('./xtream-favorite.interface').XtreamFavoriteCatalogStatus;
tmdb_id?: number | null;
release_year?: number | null;
original_title?: string | null;
}
export interface ElectronBridgeGlobalFavoriteItem extends ElectronBridgeXtreamFavorite {
playlist_id: string;
playlist_name: string;
added_at: string;
@@ -577,7 +590,8 @@ export interface ElectronBridgeGlobalRecentlyAddedItem extends ElectronBridgeXtr
}
export interface ElectronBridgeFavoriteReorderUpdate {
content_id: number;
content_id?: number;
favorite_id?: number;
/** Favorites are playlist-scoped — scope the position write per playlist */
playlist_id: string;
position: number;
@@ -1033,6 +1047,11 @@ export interface ElectronBridgeApi {
recentlyViewed: XtreamBackupRecentlyViewedItem[];
hiddenCategories: XtreamBackupHiddenCategory[];
}>;
dbRestoreXtreamFavorites: (
playlistId: string,
favorites: XtreamBackupFavoriteItem[],
restoreId?: string
) => Promise<ElectronBridgeResult>;
dbRestoreXtreamUserData: (
playlistId: string,
favorites: XtreamBackupFavoriteItem[],
@@ -1109,15 +1128,18 @@ export interface ElectronBridgeApi {
contentId: number,
playlistId: string,
backdropUrl?: string
) => Promise<ElectronBridgeResult>;
) => Promise<ElectronBridgeErrorResult>;
dbRemoveFavorite: (
contentId: number,
contentId: import('./xtream-favorite.interface').XtreamFavoriteRemovalRef,
playlistId: string
) => Promise<ElectronBridgeResult>;
dbIsFavorite: (contentId: number, playlistId: string) => Promise<boolean>;
dbGetFavoritesForBackup: (
playlistId: string
) => Promise<ElectronBridgeXtreamFavorite[]>;
dbGetFavorites: (
playlistId: string
) => Promise<ElectronBridgeXtreamContent[]>;
) => Promise<ElectronBridgeXtreamFavorite[]>;
dbGetGlobalFavorites: () => Promise<ElectronBridgeGlobalFavoriteItem[]>;
dbGetAllGlobalFavorites: () => Promise<ElectronBridgeGlobalFavoriteItem[]>;
dbReorderGlobalFavorites: (
@@ -1,3 +1,4 @@
import type { XtreamFavoriteSnapshot } from './xtream-favorite.interface';
import { PlaybackPositionData } from './playback-position.interface';
import { M3uRecentlyViewedItem } from './playlist-recently-viewed.interface';
import { StalkerPortalItem } from './stalker-portal-item.interface';
@@ -56,6 +57,7 @@ export interface XtreamBackupFavoriteItem {
xtreamId: number;
addedAt?: string;
position?: number | null;
snapshot?: XtreamFavoriteSnapshot;
}
export interface XtreamBackupRecentlyViewedItem {
@@ -44,6 +44,8 @@ export interface PortalActivityItem {
/** The year the PROVIDER stated, never one read out of the title */
release_year?: number;
original_title?: string;
favorite_id?: number;
catalogStatus?: import('./xtream-favorite.interface').XtreamFavoriteCatalogStatus;
source?: 'xtream' | 'stalker' | 'm3u';
/** Original stalker item for navigation state; undefined for xtream. */
stalker_item?: StalkerPortalItem;
@@ -0,0 +1,30 @@
/** Catalog membership is separate from whether a stream can currently play. */
export const XTREAM_FAVORITE_CATALOG_STATUS = {
PRESENT: 'present',
MISSING: 'missing',
UNKNOWN: 'unknown',
} as const;
export type XtreamFavoriteCatalogStatus =
(typeof XTREAM_FAVORITE_CATALOG_STATUS)[keyof typeof XTREAM_FAVORITE_CATALOG_STATUS];
export interface XtreamFavoriteSnapshot {
title: string;
posterUrl?: string | null;
backdropUrl?: string | null;
tmdbId?: number | null;
releaseYear?: number | null;
originalTitle?: string | null;
}
/** Numeric references are legacy catalog row IDs, never provider IDs. */
export type XtreamFavoriteRemovalRef = number | { favoriteId: number };
export const XTREAM_FAVORITE_IDENTITY_CONFLICT =
'XTREAM_FAVORITE_IDENTITY_CONFLICT';
export function isCatalogUnavailable(item: {
catalogStatus?: XtreamFavoriteCatalogStatus;
}): boolean {
return item.catalogStatus === 'missing' || item.catalogStatus === 'unknown';
}
@@ -1,6 +1,37 @@
import { normalizeXtreamPendingRestoreState } from './xtream-restore-state.util';
describe('normalizeXtreamPendingRestoreState', () => {
it('retains only display metadata in a durable favorite snapshot', () => {
const state = normalizeXtreamPendingRestoreState({
favorites: [
{
contentType: 'movie',
xtreamId: 7,
position: 2,
snapshot: {
title: 'ABC',
posterUrl: 'https://example.test/poster.jpg',
tmdbId: 42,
password: 'private',
streamUrl: 'https://example.test/secret',
},
},
],
});
expect(state.favorites).toEqual([
{
contentType: 'movie',
xtreamId: 7,
position: 2,
snapshot: {
title: 'ABC',
posterUrl: 'https://example.test/poster.jpg',
tmdbId: 42,
},
},
]);
});
// No `sourcePins`: absent has to stay absent, or an older archive would
// read as "there are no pins" and restore would clear the user's.
const emptyState = {
@@ -5,6 +5,7 @@ import {
XtreamBackupRecentlyViewedItem,
XtreamBackupSourcePin,
} from './playlist-backup.interface';
import { XtreamFavoriteSnapshot } from './xtream-favorite.interface';
import { PlaybackPositionData } from './playback-position.interface';
export interface XtreamPendingRestoreState {
@@ -18,6 +19,8 @@ export interface XtreamPendingRestoreState {
* can distinguish "no opinion" from an authoritative empty collection.
*/
sourcePins?: XtreamBackupSourcePin[];
/** Internal replay receipt; never exported in an archive. */
favoritesRestoreId?: string;
}
export function getXtreamPendingRestoreStorageKey(playlistId: string): string {
@@ -36,6 +39,7 @@ interface RestoreStateCandidate {
recentlyViewed?: unknown;
playbackPositions?: unknown;
sourcePins?: unknown;
favoritesRestoreId?: unknown;
}
interface RestoreEntryCandidate {
@@ -135,9 +139,12 @@ export function normalizeXtreamPendingRestoreState(
return {
hiddenCategories,
favorites: withNumericXtreamId<XtreamBackupFavoriteItem>(
toArray(candidate.favorites)
),
favorites: normalizeFavorites(candidate.favorites),
...(typeof candidate.favoritesRestoreId === 'string' &&
candidate.favoritesRestoreId.length > 0 &&
candidate.favoritesRestoreId.length <= 200
? { favoritesRestoreId: candidate.favoritesRestoreId }
: {}),
recentlyViewed: withNumericXtreamId<XtreamBackupRecentlyViewedItem>(
toArray(candidate.recentlyViewed)
),
@@ -184,3 +191,51 @@ function normalizeSourcePins(value: unknown): XtreamBackupSourcePin[] {
return pins;
}
/** Snapshot fields are display metadata, never a raw provider response. */
function normalizeFavorites(value: unknown): XtreamBackupFavoriteItem[] {
return withNumericXtreamId<XtreamBackupFavoriteItem>(toArray(value))
.filter((item) =>
['live', 'movie', 'series'].includes(item.contentType)
)
.map((item) => {
const snapshot = normalizeFavoriteSnapshot(item.snapshot);
return {
contentType: item.contentType,
xtreamId: item.xtreamId,
...(typeof item.addedAt === 'string'
? { addedAt: item.addedAt }
: {}),
...(item.position === null ||
(typeof item.position === 'number' &&
Number.isFinite(item.position))
? { position: item.position }
: {}),
...(snapshot ? { snapshot } : {}),
};
});
}
function normalizeFavoriteSnapshot(
value: unknown
): XtreamFavoriteSnapshot | undefined {
if (
!isRecord(value) ||
typeof value['title'] !== 'string' ||
!value['title'].trim()
)
return undefined;
const snapshot: XtreamFavoriteSnapshot = { title: value['title'].trim() };
for (const key of ['posterUrl', 'backdropUrl', 'originalTitle'] as const) {
const field = value[key];
if (typeof field === 'string') snapshot[key] = field;
else if (field === null) snapshot[key] = null;
}
for (const key of ['tmdbId', 'releaseYear'] as const) {
const field = value[key];
if (typeof field === 'number' && Number.isInteger(field) && field > 0)
snapshot[key] = field;
else if (field === null) snapshot[key] = null;
}
return snapshot;
}