test(database): guard upgrades across skipped releases (#1582)

* docs(database): require upgrades across skipped releases

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

* test(database): verify upgraded schemas against current contract
This commit is contained in:
4gray authored and GitHub committed 2026-09-11 07:08:45 +02:00
1 parent fff022afe4
commit fadcbb4673
11 files changed
+527 -2

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.
+8
View File
@@ -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.
+10 -2
View File
@@ -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
+45
View File
@@ -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`.
@@ -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,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;
});
@@ -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);