test(database): cover direct upgrades from 0.19 through 0.23

This commit is contained in:
4gray committed 2026-09-10 23:06:45 +02:00
1 parent df5c8aad10
commit 84bb1b836d
9 files changed
+442 -5

No files matched your search

@@ -0,0 +1,7 @@
---
type: fix
area: database
issues: [1580]
---
Updating directly from older versions no longer blocks playlist loading with a missing database column error. Existing sources, favorites, and playback history are preserved.
+13 -5
View File
@@ -62,8 +62,16 @@ When changing initialization:
install, and repeated startup. If a release changes migration ordering, cover
each distinct affected historical schema.
For the upgrade failure reported in #1580, the 0.24 fix should cover databases
from 0.19, 0.20, 0.21, 0.22, and 0.23: the `epg_channel_id` column is absent in
0.19, present from 0.20, and indexed from 0.23. The index must be created only
after the column migration; an existing index must remain valid. This documents
the required regression coverage, not a claim that the fix is already applied.
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
in 0.19, present from 0.20, and indexed from 0.23. Each case checks preserved user
rows, foreign keys, database integrity, index availability, and repeated startup;
an existing EPG index must keep its definition and root page. Snapshots live in
`src/lib/testing/fixtures/` and are independent of the current schema, so moving
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`.
@@ -0,0 +1,54 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
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'])(
'preserves %s data and indexes across upgrade and repeated startup',
(version) => {
const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-upgrade-'));
const electronPath = createRequire(__filename)(
'electron'
) as string;
const fixture =
version === 'fresh'
? 'fresh'
: resolve(
__dirname,
'testing/fixtures',
`v${version}-schema.sql`
);
try {
const output = execFileSync(
electronPath,
[
'--import',
'tsx',
resolve(__dirname, 'testing/connection-upgrade.ts'),
fixture,
],
{
cwd: process.cwd(),
encoding: 'utf8',
timeout: 30_000,
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
IPTVNATOR_E2E_DATA_DIR: dataDir,
TSX_TSCONFIG_PATH: resolve(
process.cwd(),
'tsconfig.base.json'
),
},
}
);
expect(output).toBe('upgrade verified');
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
},
35_000
);
});
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import Database from 'better-sqlite3';
import { closeDatabase, getDatabasePath, initDatabase } from '../connection';
const tables = [
'playlists',
'categories',
'content',
'favorites',
'recently_viewed',
'playback_positions',
'downloads',
] as const;
function seed(sqlite: Database.Database) {
sqlite.exec(`
INSERT INTO playlists (id, name, type, serverUrl, username, password)
VALUES ('saved', 'Saved source', 'xtream', 'https://source.invalid',
'synthetic-user', 'synthetic-password');
INSERT INTO categories (id, playlist_id, name, type, xtream_id, hidden)
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)
VALUES ('saved', 7, 'vod', 123, 1800);
INSERT INTO downloads (playlist_id, xtream_id, content_type, title, url, status)
VALUES ('saved', 7, 'vod', 'Saved movie', 'https://source.invalid/movie', 'completed');
`);
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'");
}
}
function snapshot(sqlite: Database.Database) {
return tables.map((table) => {
const columns = sqlite.pragma(`table_info(${table})`) as {
name: string;
}[];
const query = `SELECT ${columns.map(({ name }) => `"${name}"`).join(', ')} FROM ${table} ORDER BY id`;
return { query, rows: sqlite.prepare(query).all() };
});
}
function epgIndex(sqlite: Database.Database) {
return sqlite
.prepare(
"SELECT sql, rootpage FROM sqlite_master WHERE name = 'idx_content_epg_channel'"
)
.get();
}
async function main() {
const fixture = process.argv[2];
assert(
process.env['IPTVNATOR_E2E_DATA_DIR'],
'Never open the user database'
);
const warnings: unknown[][] = [];
console.warn = (...args) => warnings.push(args);
console.log = () => undefined;
const databasePath = getDatabasePath();
if (fixture === 'fresh') {
await initDatabase();
closeDatabase();
}
const legacy = new Database(databasePath);
legacy.pragma('foreign_keys = ON');
if (fixture !== 'fresh') legacy.exec(readFileSync(fixture, 'utf8'));
seed(legacy);
const before = snapshot(legacy);
const originalIndex = epgIndex(legacy);
legacy.close();
let firstSchema: unknown;
for (let startup = 0; startup < 2; startup++) {
try {
await initDatabase();
const sqlite = new Database(databasePath, { readonly: true });
try {
for (const { query, rows } of before) {
assert.deepEqual(sqlite.prepare(query).all(), rows, query);
}
assert.deepEqual(sqlite.pragma('foreign_key_check'), []);
assert.deepEqual(sqlite.pragma('integrity_check'), [
{ integrity_check: 'ok' },
]);
const index = epgIndex(sqlite);
assert(index, 'EPG index must exist after initialization');
assert.deepEqual(
(
sqlite.pragma(
'index_info(idx_content_epg_channel)'
) as { name: string }[]
).map(({ name }) => name),
['epg_channel_id']
);
if (originalIndex) assert.deepEqual(index, originalIndex);
// closeDatabase() may add SQLite's own planner statistics via
// PRAGMA optimize; compare only application schema objects.
const schema = sqlite
.prepare(
"SELECT type, name, sql FROM sqlite_master WHERE name NOT GLOB 'sqlite_*' ORDER BY type, name"
)
.all();
if (startup === 0) firstSchema = schema;
else assert.deepEqual(schema, firstSchema);
} finally {
sqlite.close();
}
} finally {
closeDatabase();
}
}
assert.deepEqual(
warnings,
[],
'Initialization must not silently skip failed migrations'
);
process.stdout.write('upgrade verified');
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,39 @@
-- Fresh-install schema from tag v0.19.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, 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,42 @@
-- Fresh-install schema from tag v0.20.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, 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, 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, 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')), 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_content_type_added ON content(type, added);
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_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 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,45 @@
-- Fresh-install schema from tag v0.21.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, 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, 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 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_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_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 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')), 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,51 @@
-- Fresh-install schema from tag v0.22.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, 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_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 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')), 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);
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);
@@ -0,0 +1,60 @@
-- Fresh-install schema from tag v0.23.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_epg_channel ON content(epg_channel_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_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')), 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);
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);