diff --git a/.changes/database-skipped-release-upgrades.md b/.changes/database-skipped-release-upgrades.md new file mode 100644 index 000000000..2e972dc95 --- /dev/null +++ b/.changes/database-skipped-release-upgrades.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index e5c67996c..38c8857f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,14 @@ preserves Linux window identity without a shared `linux.desktop.entry` object enable AppImageUpdate/zsync. Contract: `docs/architecture/release-pipeline.md` (AppImage external-manager metadata). +## Upgrade And Migration Compatibility + +- Users may skip releases. The application must apply all required migrations in dependency order when upgrading directly from an older release; never assume that users installed or launched every intermediate version. +- Preserve migration paths for existing persisted data. Do not make deleting a database/profile or reinstalling the application a normal upgrade requirement. Any unavoidable intermediate-version requirement must be an explicitly documented exception. +- Create required tables first, add missing columns before dependent indexes/triggers/queries, and make startup migrations safe to run again. `CREATE TABLE IF NOT EXISTS` does not update an existing table's columns. +- For persistence changes, test real SQLite initialization with representative historical schemas and data, including skipped releases, the previous release, a fresh database, and repeated startup. Assert preservation of user data as well as the resulting schema; SQL mocks alone cannot verify upgrade compatibility. Cover equivalent persisted-state transitions for non-SQLite stores. +- See `libs/shared/database/README.md` for SQLite migration ownership and validation guidance. + ## Regression Prevention And Test Updates - Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required. diff --git a/CLAUDE.md b/CLAUDE.md index ec455f414..808011503 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -> The process sections below (Plan Mode, Documentation After Changes, Regression Prevention, Agent Bootstrap, Electron CDP Debugging) are mirrored in `AGENTS.md`, which is the canonical copy for agent workflows. When updating one, keep the other in sync. +> The process sections below (Plan Mode, Documentation After Changes, Upgrade And Migration Compatibility, Regression Prevention, Agent Bootstrap, Electron CDP Debugging) are mirrored in `AGENTS.md`, which is the canonical copy for agent workflows. When updating one, keep the other in sync. ## Plan Mode @@ -53,6 +53,14 @@ preserves Linux window identity without a shared `linux.desktop.entry` object enable AppImageUpdate/zsync. Contract: `docs/architecture/release-pipeline.md` (AppImage external-manager metadata). +## Upgrade And Migration Compatibility + +- Users may skip releases. The application must apply all required migrations in dependency order when upgrading directly from an older release; never assume that users installed or launched every intermediate version. +- Preserve migration paths for existing persisted data. Do not make deleting a database/profile or reinstalling the application a normal upgrade requirement. Any unavoidable intermediate-version requirement must be an explicitly documented exception. +- Create required tables first, add missing columns before dependent indexes/triggers/queries, and make startup migrations safe to run again. `CREATE TABLE IF NOT EXISTS` does not update an existing table's columns. +- For persistence changes, test real SQLite initialization with representative historical schemas and data, including skipped releases, the previous release, a fresh database, and repeated startup. Assert preservation of user data as well as the resulting schema; SQL mocks alone cannot verify upgrade compatibility. Cover equivalent persisted-state transitions for non-SQLite stores. +- See `libs/shared/database/README.md` for SQLite migration ownership and validation guidance. + ## Regression Prevention And Test Updates - Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, Claude Code must complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required. @@ -1699,7 +1707,7 @@ The Electron backend depends on the web app being built first: ### Database Migrations -No formal migration system yet. Schema changes are applied via raw SQL in the `createTables()` function in `libs/shared/database/src/lib/connection.ts` using `CREATE TABLE IF NOT EXISTS`. One-off data migrations run guarded by keys stored in the `appState` table. +Database initialization is owned by `libs/shared/database/src/lib/connection.ts`. `createTables()` creates missing schema objects, and `runMigrations()` applies column/index migrations and dedicated schema/data upgrades. `CREATE TABLE IF NOT EXISTS` does not add columns to existing tables. One-off data migrations use completion keys stored in `app_state` (exported as `appState`). Follow the Upgrade And Migration Compatibility policy above and the validation guidance in `libs/shared/database/README.md`; a new release must not depend on users having launched intermediate releases. ### Common Patterns diff --git a/libs/shared/database/README.md b/libs/shared/database/README.md index cd04e865a..d9fe92911 100644 --- a/libs/shared/database/README.md +++ b/libs/shared/database/README.md @@ -31,3 +31,48 @@ import { content, categories, playlists, type Content } from '@iptvnator/shared/ ## Database Location The SQLite database is stored at: `~/.iptvnator/databases/iptvnator.db` + +## Upgrade Compatibility And Migrations + +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`. + +`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. + +When changing initialization: + +- Create required tables before migrating them. `CREATE TABLE IF NOT EXISTS` + leaves existing columns unchanged. +- Add missing columns before creating indexes or triggers, or running queries, + that depend on those columns. Put indexes depending on migrated columns in + `INDEX_MIGRATION_STATEMENTS`, after `COLUMN_MIGRATION_STATEMENTS`, rather than + in the earlier schema creation phase. +- Preserve existing data and make migrations safe on repeated startup. Record + completion only after the associated migration succeeds. +- Exercise the actual initialization path with real SQLite and historical + schema fixtures containing representative playlists, favorites, history, and + playback positions. Verify both schema changes and preservation of those rows; + mocked SQL calls alone do not establish upgrade compatibility. +- Include direct upgrades across skipped releases, the previous release, a fresh + install, and repeated startup. If a release changes migration ordering, cover + each distinct affected historical schema. + +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 current Drizzle +tables, columns/types, and named indexes/uniqueness, as well as 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`. diff --git a/libs/shared/database/src/lib/connection-upgrades.spec.ts b/libs/shared/database/src/lib/connection-upgrades.spec.ts new file mode 100644 index 000000000..bce379e57 --- /dev/null +++ b/libs/shared/database/src/lib/connection-upgrades.spec.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 + ); +}); diff --git a/libs/shared/database/src/lib/testing/connection-upgrade.ts b/libs/shared/database/src/lib/testing/connection-upgrade.ts new file mode 100644 index 000000000..94cdaa19a --- /dev/null +++ b/libs/shared/database/src/lib/testing/connection-upgrade.ts @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import Database from 'better-sqlite3'; +import { getTableConfig } from 'drizzle-orm/sqlite-core'; +import { closeDatabase, getDatabasePath, initDatabase } from '../connection'; +import * as currentSchema from '../schema'; + +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(); +} + +function verifyCurrentSchema(sqlite: Database.Database) { + for (const table of Object.values(currentSchema)) { + const { name, columns, indexes } = getTableConfig(table); + const actualColumns = sqlite.pragma(`table_info(${name})`) as { + name: string; + type: string; + }[]; + assert(actualColumns.length > 0, `Missing current table: ${name}`); + for (const column of columns) { + const actual = actualColumns.find( + (item) => item.name === column.name + ); + assert(actual, `Missing current column: ${name}.${column.name}`); + assert.equal( + actual.type.toLowerCase(), + column.getSQLType().toLowerCase() + ); + } + const actualIndexes = sqlite.pragma(`index_list(${name})`) as { + name: string; + unique: number; + }[]; + for (const { config } of indexes) { + const actual = actualIndexes.find( + (item) => item.name === config.name + ); + assert(actual, `Missing current index: ${config.name}`); + assert.equal(actual.unique, Number(config.unique), config.name); + } + } +} + +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 { + verifyCurrentSchema(sqlite); + 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; +}); diff --git a/libs/shared/database/src/lib/testing/fixtures/v0.19.0-schema.sql b/libs/shared/database/src/lib/testing/fixtures/v0.19.0-schema.sql new file mode 100644 index 000000000..da28a6fce --- /dev/null +++ b/libs/shared/database/src/lib/testing/fixtures/v0.19.0-schema.sql @@ -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); diff --git a/libs/shared/database/src/lib/testing/fixtures/v0.20.0-schema.sql b/libs/shared/database/src/lib/testing/fixtures/v0.20.0-schema.sql new file mode 100644 index 000000000..fa4fa8301 --- /dev/null +++ b/libs/shared/database/src/lib/testing/fixtures/v0.20.0-schema.sql @@ -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); diff --git a/libs/shared/database/src/lib/testing/fixtures/v0.21.0-schema.sql b/libs/shared/database/src/lib/testing/fixtures/v0.21.0-schema.sql new file mode 100644 index 000000000..d6645e79c --- /dev/null +++ b/libs/shared/database/src/lib/testing/fixtures/v0.21.0-schema.sql @@ -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); diff --git a/libs/shared/database/src/lib/testing/fixtures/v0.22.0-schema.sql b/libs/shared/database/src/lib/testing/fixtures/v0.22.0-schema.sql new file mode 100644 index 000000000..c67c51ac6 --- /dev/null +++ b/libs/shared/database/src/lib/testing/fixtures/v0.22.0-schema.sql @@ -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); diff --git a/libs/shared/database/src/lib/testing/fixtures/v0.23.0-schema.sql b/libs/shared/database/src/lib/testing/fixtures/v0.23.0-schema.sql new file mode 100644 index 000000000..4c003aa1c --- /dev/null +++ b/libs/shared/database/src/lib/testing/fixtures/v0.23.0-schema.sql @@ -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);