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

@@ -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);