mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
* feat(tmdb): opt-in TMDB metadata enrichment for Xtream and Stalker portals Adds an opt-in TMDB integration (Settings > Metadata) that enriches detail views with a field-level merge — the provider stays authoritative for stream data, TMDB fills editorial fields when the match is confident. Enrichment: - Movie/series details: plot, cast (avatar chips), director, genres, rating, poster/backdrop, official YouTube trailers - Confidence-gated matching: provider tmdb_id trusted; otherwise normalized-title search with year gate (±1; series accept earlier premieres), season-suffix stripping, Cyrillic search-language override, and language-prefix fallback variants - Lazy season/episode enrichment: real episode names, overviews, stills - "Similar" rail (Xtream): TMDB recommendations matched to the catalog - Actor pages per portal with full filmography, availability filter and an Electron-only "All portals" scope backed by a batched DB_MATCH_TITLES worker op over the trigram FTS index Infrastructure: - SQLite cache table tmdb_metadata (details, search verdicts, seasons, persons; per-language, TTL-guarded), in-memory fallback for the PWA - Settings: enable toggle, own-API-key override with a live "check key" button; TMDB attribution in Settings and About - Embedded key stays an empty placeholder; CI injects TMDB_API_KEY via tools/tmdb/inject-tmdb-key.mjs when the secret is configured - normalizeTitle shared between renderer and DB worker - CSP: allow YouTube embeds (frame-src was 'none'; trailers never worked) Fixes and refactors along the way: - fix(stalker): Advanced Search sent bare get_ordered_list requests and skipped the auth handshake when isFullStalkerPortal was missing on the active-playlist meta — full portals answered "Authorization failed." and search looked empty; now mirrors the catalog request shape and routes through makeAuthenticatedRequest with URL-based detection - fix(stalker): TMDB fields survive info re-normalization; detail views prefer the store copy patched by async enrichment over stale snapshots - refactor(xtream): split oversized vod/serial detail components into component-scoped playback services; detail routes re-initialize on route param changes (router reuses them for detail-to-detail nav) - i18n: all new keys translated across the 18 locales Docs: docs/architecture/tmdb-metadata-enrichment.md + CLAUDE.md updates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tmdb): provide route params observable to inline collection details, linearize regexes The global-collection inline detail host builds a fake ActivatedRoute for VodDetailsRouteComponent/SerialDetailsComponent with only snapshot.params. Since the detail components now read route.params via toSignal() (detail-> detail re-init), the missing observable crashed component construction and the content hero never rendered — broke dashboard-activation, favorites and recent Electron E2E on all platforms. Provide the params observable alongside the snapshot and assert it in the component spec. Also resolves both CodeQL js/polynomial-redos alerts: bracket-stripping in normalizeTitle now excludes opening delimiters inside the classes, and youtubeEmbedUrl extracts watch?v= ids with a linear two-pass match instead of "watch\?.*v=". Combining-diacritics range rewritten as explicit \u escapes (greptile note). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tmdb): surface TMDB-only VOD score in the rating badge, drop youtube.com from CSP Review follow-ups on PR #1123: the Xtream VOD detail badge renders rating_imdb, but the merge wrote the TMDB score only into `rating`, so a TMDB-only score was never displayed (Codex P2) — fill rating_imdb when the provider left it empty, mirroring the Stalker merge. All trailer iframes are normalized to youtube-nocookie.com, so the extra youtube.com frame-src allowance was dead surface (greptile) — removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tmdb): resolve confirmed review findings — matching correctness, race guards, cache schema Fixes the confirmed findings from the PR #1123 code review: - Stalker search: setSelectedContentType now runs BEFORE setSelectedItem, so the TMDB enrichment gate in the selection hook no longer sees the content type of the previously open tab (wrong/no enrichment after ITV -> search -> movie). - Title normalization is now two-tier (normalizeTitleKeys): the exact normalized form keeps a trailing year, the base form strips it and remembers the tag. Year stripping is anchored to the end of the title ("2001: A Space Odyssey" keeps its year) and language-prefix stripping is UPPERCASE-only ("It: Chapter Two" is no longer amputated). - All catalog matching (similar rail, actor pages, DB worker DB_MATCH_TITLES) compares exact forms first and only accepts year-stripped matches when the stripped tag is year-compatible (+-1) with the TMDB year — "Blade Runner" (1982) can no longer claim a catalog "Blade Runner 2049". CatalogTitleMatch carries the stripped trailingYear so the renderer can apply the guard to worker matches. - mergedBackdrops tolerates a plain-string backdrop_path; enrichment merge+patch blocks are wrapped in try/catch so a malformed provider payload can no longer become an unhandled rejection. - loadGlobalMatches (both actor routes) guards against actor->actor navigation races — a slow match for the previous person no longer overwrites the current one's results. - tmdb_metadata media_type CHECK widened to ('movie','tv','person') and person rows now use the honest 'person' type (TmdbCacheMediaType). Pre-release dev DBs with the narrow CHECK are rebuilt in place — the table is a pure cache, so the migration is a self-healing drop-and-recreate keyed off sqlite_master. Docs updated (tmdb-metadata-enrichment.md, CLAUDE.md). New regression coverage: title-normalization.util.spec.ts, two-tier cases in tmdb-similar.util.spec.ts and title-match.operations.spec.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
883 lines
33 KiB
TypeScript
883 lines
33 KiB
TypeScript
/**
|
|
* Database connection and initialization for IPTVnator
|
|
* Uses Drizzle ORM with better-sqlite3
|
|
* Stores database file under ~/.iptvnator/databases/ by default.
|
|
* E2E tests can override the root with IPTVNATOR_E2E_DATA_DIR.
|
|
*
|
|
* Provides two connection modes:
|
|
* - Full access (for electron-backend): creates tables, read-write
|
|
* - Read-only access (for agent-backend): no table creation, read-only queries
|
|
*/
|
|
|
|
import Database from 'better-sqlite3';
|
|
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|
import * as schema from './schema';
|
|
import { getIptvnatorDatabasePath } from './path-utils';
|
|
|
|
export type DatabaseInstance = BetterSQLite3Database<typeof schema>;
|
|
|
|
const TRACE_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
|
|
|
let db: DatabaseInstance | null = null;
|
|
let sqlite: Database.Database | null = null;
|
|
let initPromise: Promise<DatabaseInstance> | null = null;
|
|
|
|
const XTREAM_ADDED_EPOCH_MILLISECONDS_THRESHOLD = 10_000_000_000;
|
|
const XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY =
|
|
'migration:xtream-content-added-epoch-seconds:v1';
|
|
const CONTENT_TITLE_FTS_MIGRATION_KEY =
|
|
'migration:content-title-fts-trigram:v1';
|
|
const EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY =
|
|
'migration:epg-program-source-url-backfill:v1';
|
|
const EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE = 50_000;
|
|
|
|
function readTraceFlag(name: string): boolean {
|
|
const value = process.env[name]?.trim().toLowerCase();
|
|
return value ? TRACE_ENV_TRUE_VALUES.has(value) : false;
|
|
}
|
|
|
|
function isSqlTraceEnabled(): boolean {
|
|
return (
|
|
readTraceFlag('IPTVNATOR_TRACE_STARTUP') ||
|
|
readTraceFlag('IPTVNATOR_TRACE_DB') ||
|
|
readTraceFlag('IPTVNATOR_TRACE_SQL')
|
|
);
|
|
}
|
|
|
|
function compactSqlForTrace(sql: string): string {
|
|
const compactSql = sql.replace(/\s+/g, ' ').trim();
|
|
return compactSql.length <= 180
|
|
? compactSql
|
|
: `${compactSql.slice(0, 177)}...`;
|
|
}
|
|
|
|
function traceSql(scope: string, message: string, payload?: unknown): void {
|
|
if (payload === undefined) {
|
|
console.log(`[IPTVnator Trace][${scope}] ${message}`);
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
`[IPTVnator Trace][${scope}] ${message} ${JSON.stringify(payload)}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the database file path
|
|
*/
|
|
export function getDatabasePath(): string {
|
|
return getIptvnatorDatabasePath();
|
|
}
|
|
|
|
/**
|
|
* SQL statements for creating all tables
|
|
*/
|
|
const TMDB_METADATA_TABLE_SQL = `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'))
|
|
)`;
|
|
const TMDB_METADATA_INDEX_SQL = `CREATE UNIQUE INDEX IF NOT EXISTS tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language)`;
|
|
|
|
const CREATE_TABLE_STATEMENTS = [
|
|
`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)`,
|
|
// Partial covering index for visible categories — supports the dashboard's
|
|
// getGlobalRecentlyAdded plus searchContent/globalSearch when excludeHidden
|
|
// is set. SQLite can satisfy the join (category_id PK lookup) plus the
|
|
// hidden = 0 filter directly from this index without touching the
|
|
// categories row, and hidden categories are absent so they're skipped
|
|
// before any row lookup.
|
|
`CREATE INDEX IF NOT EXISTS idx_categories_visible ON categories(id, playlist_id, type) WHERE hidden = 0`,
|
|
// Trigram FTS index for global Xtream title search. It supports fast
|
|
// contains matches such as "max" -> "beIN MAX" and "Cinemax" without
|
|
// scanning the full content table.
|
|
`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)`,
|
|
// EPG tables
|
|
`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
|
|
)`,
|
|
// EPG indexes
|
|
`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)`,
|
|
// FTS5 virtual table for full-text search on EPG programs
|
|
`CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5(
|
|
title,
|
|
description,
|
|
category,
|
|
content='epg_programs',
|
|
content_rowid='id'
|
|
)`,
|
|
// Triggers to keep FTS index in sync with epg_programs table
|
|
`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`,
|
|
// Playback Positions table
|
|
`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)`,
|
|
// Downloads table
|
|
`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)`,
|
|
// TMDB metadata cache (details payloads + search match resolutions)
|
|
TMDB_METADATA_TABLE_SQL,
|
|
TMDB_METADATA_INDEX_SQL,
|
|
];
|
|
|
|
/**
|
|
* Migration statements that may fail if already applied
|
|
* These are run with try-catch to handle existing columns
|
|
*/
|
|
const COLUMN_MIGRATION_STATEMENTS = [
|
|
// v1.0.0 -> v1.1.0: Add hidden column to categories for category management
|
|
`ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0`,
|
|
// v1.1.0 -> v1.2.0: Add playlist metadata/payload columns for M3U + unified playlist persistence
|
|
`ALTER TABLE playlists ADD COLUMN portal_url TEXT`,
|
|
`ALTER TABLE playlists ADD COLUMN count INTEGER`,
|
|
`ALTER TABLE playlists ADD COLUMN import_date TEXT`,
|
|
`ALTER TABLE playlists ADD COLUMN update_date INTEGER`,
|
|
`ALTER TABLE playlists ADD COLUMN position INTEGER`,
|
|
`ALTER TABLE playlists ADD COLUMN favorites TEXT`,
|
|
`ALTER TABLE playlists ADD COLUMN recently_viewed TEXT`,
|
|
`ALTER TABLE playlists ADD COLUMN payload TEXT`,
|
|
// v1.2.1: Keep M3U-detected EPG URLs available in lightweight playlist metadata
|
|
`ALTER TABLE playlists ADD COLUMN epg_urls TEXT`,
|
|
`ALTER TABLE playlists ADD COLUMN detected_epg_urls TEXT`,
|
|
`ALTER TABLE playlists ADD COLUMN manual_epg_urls TEXT`,
|
|
`ALTER TABLE playlists ADD COLUMN disabled_epg_urls TEXT`,
|
|
// v1.2.0 -> v1.3.0: Add position column to favorites for global favorites ordering
|
|
`ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0`,
|
|
// v1.4.0 -> v1.5.0: Preserve Xtream live metadata required for EPG/catch-up
|
|
`ALTER TABLE content ADD COLUMN epg_channel_id TEXT`,
|
|
`ALTER TABLE content ADD COLUMN tv_archive INTEGER`,
|
|
`ALTER TABLE content ADD COLUMN tv_archive_duration INTEGER`,
|
|
`ALTER TABLE content ADD COLUMN direct_source TEXT`,
|
|
// v1.5.0 -> v1.6.0: Cinematic backdrop persisted on first detail fetch
|
|
`ALTER TABLE content ADD COLUMN backdrop_url TEXT`,
|
|
// v1.7.1: Scope XMLTV programs to their source URL for playlist-local EPG lookup
|
|
`ALTER TABLE epg_programs ADD COLUMN source_url TEXT`,
|
|
];
|
|
|
|
const INDEX_MIGRATION_STATEMENTS = [
|
|
// v1.3.0 -> v1.4.0: Prevent duplicate Xtream categories/content rows
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)`,
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)`,
|
|
// v1.6.0 -> v1.7.0: Query global favorites in stable display order
|
|
`CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)`,
|
|
// v1.7.1 -> v1.7.2: Query playlist-scoped EPG by source URL and channel/time
|
|
`CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)`,
|
|
];
|
|
|
|
export const __databaseConnectionTestHooks = {
|
|
createTables,
|
|
createTableStatements: CREATE_TABLE_STATEMENTS,
|
|
columnMigrationStatements: COLUMN_MIGRATION_STATEMENTS,
|
|
indexMigrationStatements: INDEX_MIGRATION_STATEMENTS,
|
|
normalizeXtreamContentAddedEpochs,
|
|
ensureContentTitleFts,
|
|
backfillEpgProgramSourceUrls,
|
|
runMigrations,
|
|
} as const;
|
|
|
|
/**
|
|
* Create tables if they don't exist
|
|
*/
|
|
function createTables(sqliteDb: Database.Database): void {
|
|
for (const stmt of CREATE_TABLE_STATEMENTS) {
|
|
sqliteDb.exec(stmt);
|
|
}
|
|
}
|
|
|
|
function isDuplicateColumnError(error: unknown): boolean {
|
|
const message =
|
|
typeof error === 'object' && error !== null && 'message' in error
|
|
? String((error as { message?: unknown }).message ?? '')
|
|
: '';
|
|
|
|
return message.toLowerCase().includes('duplicate column name');
|
|
}
|
|
|
|
type XtreamCategoryDuplicateGroup = {
|
|
playlistId: string;
|
|
type: 'live' | 'movies' | 'series';
|
|
xtreamId: number;
|
|
};
|
|
|
|
type XtreamCategoryCandidate = {
|
|
id: number;
|
|
hidden: number;
|
|
contentCount: number;
|
|
};
|
|
|
|
type XtreamContentDuplicateGroup = {
|
|
categoryId: number;
|
|
type: 'live' | 'movie' | 'series';
|
|
xtreamId: number;
|
|
};
|
|
|
|
type XtreamContentCandidate = {
|
|
id: number;
|
|
};
|
|
|
|
function deduplicateXtreamCache(sqliteDb: Database.Database): void {
|
|
const executeCleanup = sqliteDb.transaction(() => {
|
|
const duplicateCategoryGroups = sqliteDb
|
|
.prepare(
|
|
`SELECT
|
|
playlist_id AS playlistId,
|
|
type,
|
|
xtream_id AS xtreamId
|
|
FROM categories
|
|
GROUP BY playlist_id, type, xtream_id
|
|
HAVING COUNT(*) > 1`
|
|
)
|
|
.all() as XtreamCategoryDuplicateGroup[];
|
|
|
|
const selectCategoryCandidates = sqliteDb.prepare(
|
|
`SELECT
|
|
categories.id AS id,
|
|
COALESCE(categories.hidden, 0) AS hidden,
|
|
COUNT(content.id) AS contentCount
|
|
FROM categories
|
|
LEFT JOIN content ON content.category_id = categories.id
|
|
WHERE categories.playlist_id = ?
|
|
AND categories.type = ?
|
|
AND categories.xtream_id = ?
|
|
GROUP BY categories.id, categories.hidden
|
|
ORDER BY COUNT(content.id) DESC, COALESCE(categories.hidden, 0) ASC, categories.id ASC`
|
|
);
|
|
const updateContentCategory = sqliteDb.prepare(
|
|
`UPDATE content SET category_id = ? WHERE category_id = ?`
|
|
);
|
|
const deleteCategory = sqliteDb.prepare(
|
|
`DELETE FROM categories WHERE id = ?`
|
|
);
|
|
|
|
for (const group of duplicateCategoryGroups) {
|
|
const candidates = selectCategoryCandidates.all(
|
|
group.playlistId,
|
|
group.type,
|
|
group.xtreamId
|
|
) as XtreamCategoryCandidate[];
|
|
const canonicalCategoryId = candidates[0]?.id;
|
|
|
|
if (!canonicalCategoryId) {
|
|
continue;
|
|
}
|
|
|
|
for (const candidate of candidates.slice(1)) {
|
|
updateContentCategory.run(canonicalCategoryId, candidate.id);
|
|
deleteCategory.run(candidate.id);
|
|
}
|
|
}
|
|
|
|
const duplicateContentGroups = sqliteDb
|
|
.prepare(
|
|
`SELECT
|
|
category_id AS categoryId,
|
|
type,
|
|
xtream_id AS xtreamId
|
|
FROM content
|
|
GROUP BY category_id, type, xtream_id
|
|
HAVING COUNT(*) > 1`
|
|
)
|
|
.all() as XtreamContentDuplicateGroup[];
|
|
|
|
const selectContentCandidates = sqliteDb.prepare(
|
|
`SELECT id
|
|
FROM content
|
|
WHERE category_id = ?
|
|
AND type = ?
|
|
AND xtream_id = ?
|
|
ORDER BY id ASC`
|
|
);
|
|
const moveFavorites = sqliteDb.prepare(
|
|
`INSERT INTO favorites (content_id, playlist_id, added_at, position)
|
|
SELECT ?, playlist_id, added_at, COALESCE(position, 0)
|
|
FROM favorites
|
|
WHERE content_id = ?
|
|
ON CONFLICT(content_id, playlist_id) DO NOTHING`
|
|
);
|
|
const deleteFavorites = sqliteDb.prepare(
|
|
`DELETE FROM favorites WHERE content_id = ?`
|
|
);
|
|
const moveRecentlyViewed = sqliteDb.prepare(
|
|
`INSERT INTO recently_viewed (content_id, playlist_id, viewed_at)
|
|
SELECT ?, playlist_id, viewed_at
|
|
FROM recently_viewed
|
|
WHERE content_id = ?
|
|
ON CONFLICT(content_id, playlist_id) DO UPDATE SET
|
|
viewed_at = CASE
|
|
WHEN excluded.viewed_at > recently_viewed.viewed_at
|
|
THEN excluded.viewed_at
|
|
ELSE recently_viewed.viewed_at
|
|
END`
|
|
);
|
|
const deleteRecentlyViewed = sqliteDb.prepare(
|
|
`DELETE FROM recently_viewed WHERE content_id = ?`
|
|
);
|
|
const deleteContent = sqliteDb.prepare(
|
|
`DELETE FROM content WHERE id = ?`
|
|
);
|
|
|
|
for (const group of duplicateContentGroups) {
|
|
const candidates = selectContentCandidates.all(
|
|
group.categoryId,
|
|
group.type,
|
|
group.xtreamId
|
|
) as XtreamContentCandidate[];
|
|
const canonicalContentId = candidates[0]?.id;
|
|
|
|
if (!canonicalContentId) {
|
|
continue;
|
|
}
|
|
|
|
for (const candidate of candidates.slice(1)) {
|
|
moveFavorites.run(canonicalContentId, candidate.id);
|
|
deleteFavorites.run(candidate.id);
|
|
moveRecentlyViewed.run(canonicalContentId, candidate.id);
|
|
deleteRecentlyViewed.run(candidate.id);
|
|
deleteContent.run(candidate.id);
|
|
}
|
|
}
|
|
});
|
|
|
|
executeCleanup();
|
|
}
|
|
|
|
function normalizeXtreamContentAddedEpochs(sqliteDb: Database.Database): void {
|
|
try {
|
|
const migrationState = sqliteDb
|
|
.prepare(`SELECT value FROM app_state WHERE key = ?`)
|
|
.get(XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY) as
|
|
| { value?: unknown }
|
|
| undefined;
|
|
|
|
if (migrationState?.value === 'done') {
|
|
return;
|
|
}
|
|
|
|
const executeMigration = sqliteDb.transaction(() => {
|
|
sqliteDb
|
|
.prepare(
|
|
`UPDATE content
|
|
SET added = CAST(CAST(added AS INTEGER) / 1000 AS TEXT)
|
|
WHERE added IS NOT NULL
|
|
AND added <> ''
|
|
AND added NOT GLOB '*[^0-9]*'
|
|
AND CAST(added AS INTEGER) >= ?
|
|
AND CAST(added AS INTEGER) / 1000 < ?`
|
|
)
|
|
.run(
|
|
XTREAM_ADDED_EPOCH_MILLISECONDS_THRESHOLD,
|
|
XTREAM_ADDED_EPOCH_MILLISECONDS_THRESHOLD
|
|
);
|
|
|
|
sqliteDb
|
|
.prepare(
|
|
`INSERT INTO app_state (key, value, updated_at)
|
|
VALUES (?, 'done', datetime('now'))
|
|
ON CONFLICT(key) DO UPDATE SET
|
|
value = excluded.value,
|
|
updated_at = excluded.updated_at`
|
|
)
|
|
.run(XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY);
|
|
});
|
|
|
|
executeMigration();
|
|
} catch (error) {
|
|
const message =
|
|
typeof error === 'object' && error !== null && 'message' in error
|
|
? String((error as { message?: unknown }).message ?? error)
|
|
: String(error);
|
|
|
|
console.warn(
|
|
`Xtream added timestamp normalization failed (continuing): ${message}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function ensureContentTitleFts(sqliteDb: Database.Database): void {
|
|
try {
|
|
const migrationState = sqliteDb
|
|
.prepare(`SELECT value FROM app_state WHERE key = ?`)
|
|
.get(CONTENT_TITLE_FTS_MIGRATION_KEY) as
|
|
| { value?: unknown }
|
|
| undefined;
|
|
|
|
if (migrationState?.value === 'done') {
|
|
return;
|
|
}
|
|
|
|
const executeMigration = sqliteDb.transaction(() => {
|
|
sqliteDb
|
|
.prepare(
|
|
`INSERT INTO content_title_fts(content_title_fts)
|
|
VALUES ('rebuild')`
|
|
)
|
|
.run();
|
|
|
|
sqliteDb
|
|
.prepare(
|
|
`INSERT INTO app_state (key, value, updated_at)
|
|
VALUES (?, 'done', datetime('now'))
|
|
ON CONFLICT(key) DO UPDATE SET
|
|
value = excluded.value,
|
|
updated_at = excluded.updated_at`
|
|
)
|
|
.run(CONTENT_TITLE_FTS_MIGRATION_KEY);
|
|
});
|
|
|
|
executeMigration();
|
|
} catch (error) {
|
|
const message =
|
|
typeof error === 'object' && error !== null && 'message' in error
|
|
? String((error as { message?: unknown }).message ?? error)
|
|
: String(error);
|
|
|
|
console.warn(
|
|
`Content title FTS rebuild failed (continuing): ${message}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function backfillEpgProgramSourceUrls(sqliteDb: Database.Database): void {
|
|
try {
|
|
const migrationState = sqliteDb
|
|
.prepare(`SELECT value FROM app_state WHERE key = ?`)
|
|
.get(EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY) as
|
|
| { value?: unknown }
|
|
| undefined;
|
|
|
|
if (migrationState?.value === 'done') {
|
|
return;
|
|
}
|
|
|
|
const backfillStatement = sqliteDb.prepare(
|
|
`UPDATE epg_programs
|
|
SET source_url = (
|
|
SELECT epg_channels.source_url
|
|
FROM epg_channels
|
|
WHERE epg_channels.id = epg_programs.channel_id
|
|
LIMIT 1
|
|
)
|
|
WHERE id IN (
|
|
SELECT pending_programs.id
|
|
FROM epg_programs AS pending_programs
|
|
JOIN epg_channels
|
|
ON epg_channels.id = pending_programs.channel_id
|
|
WHERE pending_programs.source_url IS NULL
|
|
AND epg_channels.source_url IS NOT NULL
|
|
AND epg_channels.source_url <> ''
|
|
LIMIT ${EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE}
|
|
)`
|
|
);
|
|
const markMigrationDoneStatement = sqliteDb.prepare(
|
|
`INSERT INTO app_state (key, value, updated_at)
|
|
VALUES (?, 'done', datetime('now'))
|
|
ON CONFLICT(key) DO UPDATE SET
|
|
value = excluded.value,
|
|
updated_at = excluded.updated_at`
|
|
);
|
|
|
|
const executeBackfillBatch = sqliteDb.transaction((): number => {
|
|
const result = backfillStatement.run();
|
|
return typeof result === 'object' &&
|
|
result !== null &&
|
|
'changes' in result &&
|
|
typeof result.changes === 'number'
|
|
? result.changes
|
|
: 0;
|
|
});
|
|
const markMigrationDone = sqliteDb.transaction(() => {
|
|
markMigrationDoneStatement.run(
|
|
EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY
|
|
);
|
|
});
|
|
|
|
let updatedRows = 0;
|
|
do {
|
|
updatedRows = executeBackfillBatch();
|
|
} while (updatedRows === EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE);
|
|
|
|
markMigrationDone();
|
|
} catch (error) {
|
|
const message =
|
|
typeof error === 'object' && error !== null && 'message' in error
|
|
? String((error as { message?: unknown }).message ?? error)
|
|
: String(error);
|
|
|
|
console.warn(
|
|
`EPG program source URL backfill failed (continuing): ${message}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function runMigrationStatements(
|
|
sqliteDb: Database.Database,
|
|
statements: string[]
|
|
): void {
|
|
for (const stmt of statements) {
|
|
try {
|
|
sqliteDb.exec(stmt);
|
|
} catch (error) {
|
|
// Ignore idempotent ALTER TABLE errors on existing columns.
|
|
if (isDuplicateColumnError(error)) {
|
|
continue;
|
|
}
|
|
|
|
const compactStmt = stmt.replace(/\s+/g, ' ').trim();
|
|
const message =
|
|
typeof error === 'object' &&
|
|
error !== null &&
|
|
'message' in error
|
|
? String((error as { message?: unknown }).message ?? error)
|
|
: String(error);
|
|
|
|
console.warn(
|
|
`Migration failed (continuing): ${compactStmt} :: ${message}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pre-release installs created tmdb_metadata with a CHECK that only
|
|
* allowed 'movie'/'tv'; person rows need 'person'. The table is a pure
|
|
* cache, so the cheapest "migration" is a drop-and-recreate with the
|
|
* widened constraint. Self-healing via sqlite_master — no app_state key.
|
|
*/
|
|
function widenTmdbMetadataMediaTypeCheck(sqliteDb: Database.Database): void {
|
|
try {
|
|
const row = sqliteDb
|
|
.prepare(
|
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tmdb_metadata'`
|
|
)
|
|
.get() as { sql?: string } | undefined;
|
|
if (!row?.sql || row.sql.includes(`'person'`)) {
|
|
return;
|
|
}
|
|
const rebuild = sqliteDb.transaction(() => {
|
|
sqliteDb.prepare(`DROP TABLE IF EXISTS tmdb_metadata`).run();
|
|
sqliteDb.prepare(TMDB_METADATA_TABLE_SQL).run();
|
|
sqliteDb.prepare(TMDB_METADATA_INDEX_SQL).run();
|
|
});
|
|
rebuild();
|
|
console.log(
|
|
'[DB] Rebuilt tmdb_metadata cache with widened media_type CHECK'
|
|
);
|
|
} catch (error) {
|
|
console.warn('[DB] tmdb_metadata CHECK widening failed:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run migrations that may fail if already applied
|
|
*/
|
|
function runMigrations(sqliteDb: Database.Database): void {
|
|
widenTmdbMetadataMediaTypeCheck(sqliteDb);
|
|
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
|
|
ensureContentTitleFts(sqliteDb);
|
|
deduplicateXtreamCache(sqliteDb);
|
|
normalizeXtreamContentAddedEpochs(sqliteDb);
|
|
runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS);
|
|
backfillEpgProgramSourceUrls(sqliteDb);
|
|
}
|
|
|
|
export interface DatabaseOptions {
|
|
/** Open database in read-only mode (for agent-backend) */
|
|
readonly?: boolean;
|
|
/** Skip table creation (useful for read-only connections) */
|
|
skipTableCreation?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Initialize the database connection with full access (read-write)
|
|
* Creates tables if they don't exist
|
|
* Used by electron-backend
|
|
*/
|
|
export async function initDatabase(
|
|
options: DatabaseOptions = {}
|
|
): Promise<DatabaseInstance> {
|
|
const { readonly = false, skipTableCreation = false } = options;
|
|
|
|
if (db) return db;
|
|
if (initPromise) return initPromise;
|
|
|
|
initPromise = (async () => {
|
|
const filePath = getDatabasePath();
|
|
sqlite = new Database(filePath, {
|
|
readonly,
|
|
verbose: isSqlTraceEnabled()
|
|
? (message?: unknown) => {
|
|
traceSql('sql-main', 'query', {
|
|
sql: compactSqlForTrace(String(message ?? '')),
|
|
});
|
|
}
|
|
: undefined,
|
|
});
|
|
|
|
if (isSqlTraceEnabled()) {
|
|
traceSql('sql-main', 'open', {
|
|
filePath,
|
|
readonly,
|
|
});
|
|
}
|
|
|
|
// Enable foreign keys
|
|
sqlite.pragma('foreign_keys = ON');
|
|
sqlite.pragma('busy_timeout = 5000');
|
|
|
|
if (!readonly) {
|
|
sqlite.pragma('journal_mode = WAL');
|
|
sqlite.pragma('synchronous = NORMAL');
|
|
}
|
|
|
|
sqlite.pragma('cache_size = -64000');
|
|
sqlite.pragma('temp_store = MEMORY');
|
|
sqlite.pragma('mmap_size = 268435456');
|
|
|
|
// Create tables only for read-write connections
|
|
if (!readonly && !skipTableCreation) {
|
|
createTables(sqlite);
|
|
runMigrations(sqlite);
|
|
}
|
|
|
|
const database = drizzle(sqlite, { schema });
|
|
db = database;
|
|
return database;
|
|
})();
|
|
|
|
return initPromise;
|
|
}
|
|
|
|
/**
|
|
* Get the database instance
|
|
* Initializes the database if not already initialized (with defaults for full access)
|
|
* Used by electron-backend
|
|
*/
|
|
export async function getDatabase(
|
|
options?: DatabaseOptions
|
|
): Promise<DatabaseInstance> {
|
|
if (db) return db;
|
|
return initDatabase(options);
|
|
}
|
|
|
|
/**
|
|
* Get a read-only database connection
|
|
* Used by agent-backend for safe read-only queries
|
|
*/
|
|
export async function getReadOnlyDatabase(): Promise<DatabaseInstance> {
|
|
return getDatabase({ readonly: true, skipTableCreation: true });
|
|
}
|
|
|
|
/**
|
|
* Close the database connection
|
|
*/
|
|
export function closeDatabase(): void {
|
|
if (sqlite) {
|
|
try {
|
|
sqlite.pragma('optimize');
|
|
} catch {
|
|
// Optimize is advisory; never block close on it.
|
|
}
|
|
|
|
sqlite.close();
|
|
|
|
if (isSqlTraceEnabled()) {
|
|
traceSql('sql-main', 'close');
|
|
}
|
|
|
|
sqlite = null;
|
|
db = null;
|
|
initPromise = null;
|
|
}
|
|
}
|