mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 09:26:14 -08:00
* feat(downloads): track live-TV recordings in the download manager Embedded MPV recordings were written to disk and forgotten: no list, no reveal/play, no missing-file handling, and the channel/EPG context was lost the moment the recording stopped. Recordings now live beside downloads: - New `recordings` table (no unique index, no playlist FK — recordings survive source deletion; playlist name stored via playlistDisplayLabel). - EmbeddedMpvRecordingTracker persists the lifecycle: start/stop hooks plus a session-snapshot observer for implicit stops (stream-replacement auto-stop, frame-copy helper crash, session error/close); startup repair turns rows a hard kill left behind into playable `interrupted` partials. - Channel/EPG metadata is captured at recording START in all four live hosts (M3U, Xtream, Stalker ITV, unified live tab); a clean stop triggers renderer-side enrichment with every program overlapping the recorded window, keyed by target path — covering recordings that span a program boundary. Provider EPG never reaches SQLite, so post-hoc lookup is impossible by design. - Own RECORDINGS_* IPC surface + RECORDINGS_UPDATE_EVENT ping and a separate supportsRecordings capability gate (the supportsDownloads allowlist is all-or-nothing and stays untouched). Reveal/play shell IPCs are gated on the recordings table, so the renderer-supplied recording directory stays a write-location preference, not a shell-access grant. - Manager UI: `recording` filter chip, "Recording now" queue section (REC pulse, elapsed, live file size — no percentage, the length is unknown), 16:9 channel-logo Recordings library, Needs attention with Remove only, focused detail at /workspace/downloads/recording/:recordingId. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): close the stop-enrichment race and repair player stubs Greptile spotted a real ordering bug: the stop IPC returns as soon as mpv acknowledges, while the recording row's terminal-state update is still queued in the tracker. The renderer answers that snapshot with stop enrichment, whose handler only accepts a terminal row — so the covered-program metadata could be silently dropped with "Recording not found". - EmbeddedMpvRecordingTracker.whenSettled() exposes the serialized write chain; RECORDINGS_UPDATE_PROGRAMS awaits it before the terminal-row lookup. Regression covered from both sides: the handler must not touch the database until the barrier resolves, and the barrier must imply a committed row. CI also caught spec stubs that had not learned the new player inputs (my local run-many had been an Nx cache hit, so the failures only surfaced in CI): - Teach the `app-web-player-view` and `app-embedded-mpv-player` stubs the `recordingMetadata` input and `recordingStopped` output across the m3u, Xtream, Stalker, unified-live-tab and web-player-view specs. - The races spec now asserts the metadata argument explicitly instead of matching a two-argument call. - Extract the Stalker and unified-live-tab spec stubs into sibling `*.spec-stubs.ts` files (the pattern ui/playback already uses) so both specs stay under the 1200-line test limit without shaving assertions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(downloads): make the recordings events spec a module The spec deliberately has no static imports — every dependency is swapped through jest.doMock before the harness's dynamic import — which also made it a TS script rather than a module, so its top-level `registeredHandlers` landed in the global scope and collided with the same-named const in stream-probe.spec.ts (TS2451). Local per-project runs compile the specs separately and stayed green; only the Tier A coverage suite builds them into one program, so CI caught it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): address Codex review on recording lifecycle Four findings from the Codex review, all real: - P1: `addon.stopRecording()` only dispatches — native-view uses `mpv_set_property_async`, frame-copy writes a helper command — so finalizing inside the stop hook could stat a file mpv had not flushed and even unlink bytes still being written. The tracker now treats the hook as a request and finalizes on the acknowledged inactive snapshot, with a 10 s bound so a lost acknowledgement cannot strand the row. Only a recording that never went active has its empty reservation removed. Stop enrichment follows through `whenFinalized(targetPath)` (bounded) instead of merely draining the write queue. - Live file size: `file_size_bytes` is written at finalization only, so the manager's 15 s refresh reported nothing while recording. Active rows are now decorated with a current `fs.stat` size. - Manager-initiated Stop bypassed both player stop paths, so recordings spanning program boundaries kept only the start-time program. `EmbeddedMpvPlayerComponent` now owns the active→inactive edge and emits `recordingStopped` for every trigger; the adapter and legacy toggle no longer emit it themselves. - Startup recovery could terminate a row another live instance was still writing under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES. Rows carry `owner_pid` and recovery skips those whose owner process is alive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): derive the enrichment wait from the stop fallback Greptile caught the seam my previous fix left: the enrichment barrier waited 5 s while the tracker's acknowledgement fallback only finalizes at 10 s, so a stop mpv never confirms let the terminal-row lookup expire early and drop the covered programs with no retry — precisely the case the fallback exists for. The wait is now derived from the acknowledgement bound (fallback + 1 s), with a regression test that fails if the two ever drift apart again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): address the second Codex pass on recordings Four more findings, all real: - P1 (macOS native-view): `StopRecording` clears `recordingActive` *before* dispatching the async property set and restores it if the request is rejected, so the first inactive snapshot is optimistic, not an acknowledgement — the tracker could finalize (and stat) a file mpv was still writing, and a rejected stop would leave the row `completed` while recording continued. An inactive snapshot now has to survive a 1.5 s settle window (three poll cycles); a revived recording cancels the pending finalization. - Removing a failed row unlinked its path unconditionally, which takes the file of a newer recording that reused the freed name within the same timestamp second. The cleanup now runs only while no other row claims it. - The All chip and the header's active badge ignored recordings, so a manager holding only recordings read "All 0" and an active recording never showed up in the badge. - Switching channels auto-stops the recording, but by the time the host handled the stop its `activeChannel`/EPG already described the NEW channel, so the old recording was enriched with the wrong schedule (and an unrelated program could be promoted to its title). The stop event now carries the EPG key captured while the recording was active and every host compares it before enriching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): close the persistence race and two recording UX gaps - Greptile P1: the enrichment deadline (fallback + 1 s) still raced the terminal write — if the tracker queue or the UPDATE took longer than the remaining margin, `whenFinalized` returned while the row was still `recording` and the one-shot enrichment was dropped. The deadline now bounds only the wait for mpv; `finalize()` removes the entry synchronously, so once it has started the wait follows the write itself. - Codex: `RECORDINGS_STOP` ignored `owner_pid`. Session ids restart per process, so under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES stopping another instance's row could stop an unrelated local recording. Foreign rows are now refused. - Codex: the In progress chip counted active recordings while its filter deliberately hid them, so clicking it showed "no matches". Active recordings now belong to that filter — a chip whose count disagrees with its page is a lie. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(downloads): drop the enrichment barrier instead of tuning it Three review rounds circled the same class: synchronizing mpv's asynchronous stop acknowledgement with a one-shot program enrichment. Each fix moved the deadline (5 s → fallback+1 s → wait-on-the-write) without removing the reason a deadline existed at all — the handler insisted on a *terminal* row. It never needed one. `openSync('wx')` makes the reserved path exclusive while a recording owns it, so the newest row for that path IS the recording that was stopped, and `finalize()` writes only status/end time/size and never `programs_json`. Enrichment and finalization are therefore order-independent: - `RECORDINGS_UPDATE_PROGRAMS` matches the newest row for the path in any status and awaits only the tracker's write queue, which exists solely to guarantee the INSERT committed (a recording stopped milliseconds after it started). - `whenFinalized`, its deadline constant, and the per-entry finalized promise are gone; the tracker keeps only the settle window and fallback that make *finalization* itself correct. No behavior is lost and the whole timing class disappears with the code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): bind recording finalization to its entry and shield live rows from startup repair Two races from the Codex review: - Tracker timers finalized by reusable session id, so a stop followed by an immediate restart on the same session let the old settle timer finalize the NEW row (marked completed while mpv kept writing) and strand the old row in 'recording'. Finalization is now bound to the exact open entry, and replacing a session's entry arms the old entry's settle timer so an unobserved stop still finalizes it. - reconcileStaleRecordings() runs after the renderer is interactive; a recording started during bootstrap has ownerPid === process.pid and was repaired to interrupted/failed mid-write. Recovery now skips rows the tracker reports as actively tracked (activeRowIds()). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): harden recording startup repair against recycled pids and stale renderer lists Second Codex pass on the recovery path: - A live ownerPid alone no longer shields a row: after a crash the OS can recycle the pid for an unrelated process, which would park the row in 'recording' with no instance able to finalize it. Recovery now also checks (best-effort, ps/tasklist) that the process looks like an IPTVnator/Electron instance; an unreadable name stays conservative and keeps the skip. - The renderer loads before the repair pass runs and may already hold the pre-repair list with a stale Stop affordance; recovery now broadcasts one RECORDINGS_UPDATE_EVENT after changing any rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): defer teardown finalization behind the flush window and bound the live-size stat Third Codex pass: - A synthetic error/closed snapshot from disposeSession() arrives while the frame-copy helper may still be flushing (0.5 s quit grace + 2 s SIGTERM grace before SIGKILL). Finalizing there statted a file mid-write — short captures became terminal 'failed', longer rows persisted a truncated size, and startup recovery could repair neither. The tracker now defers that finalization behind a 2.5 s flush window; the row stays 'recording' (repairable) meanwhile, and an already-acknowledged stop's settle timer keeps its 'completed' verdict instead of being relabelled 'interrupted'. - The active row's live file size used a bare await stat(): one stat hanging on a dead network filesystem wedged every RECORDINGS_GET_LIST. The probe now mirrors the availability probe's contract — in-flight coalescing plus a 1 s deadline degrading to no size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): unmask recycled recording owners, guard the PWA recording route, and unblock file probes Fourth Codex pass: - Recycled-pid discrimination no longer stops at the process-name family check (any Electron app could shield the row): a live holder must also not provably have started after the recording did (ps -o etime= / PowerShell StartTime). A pid frees only when its previous owner dies, so a recycled pid's holder is always younger than the recording; unreadable evidence stays conservative. - /workspace/downloads/recording/:recordingId gets a supportsRecordings capability guard redirecting the PWA to the manager — RecordingsService never becomes authoritative there, so the detail rendered a permanently blank workspace. - Finalization and startup repair stat through a bounded async probe (3 s deadline, ENOENT/ENOTDIR as the only proof of absence) instead of main-thread statSync: a dead network mount no longer freezes the main thread or the tracker queue, repair leaves unjudgeable rows recoverable, and finalization keeps the requested status with an unknown size rather than branding a likely-good file failed. The 0-byte reservation unlink is fire-and-forget for the same reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): keep inconclusive recording probes out of Needs attention and bound repair batches Fifth Codex pass: - Recording list decoration now uses the bounded availability variant that preserves 'unknown': a timed-out or permission-errored probe is not proof of absence, so a good recording on a slow mount no longer lands in Needs attention with its Play/Reveal hidden. ElectronRecordingItem.fileAvailability widens accordingly; consumers already gate on === 'missing'. - Startup repair probes its whole batch concurrently, so main.ts awaits roughly one 3 s deadline instead of one per stale row. Cross-process ping propagation under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES stays out of scope (debug-only flag, same single-window design as DOWNLOADS_UPDATE_EVENT) — rationale left on the review thread. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): fix duration rounding at hour boundaries and bound owner-process probes Sixth Codex pass: - The recording duration formatter rounded minutes after flooring hours, so 59:45 read '60 min' and 1:59:45 read '1 h 60 min'. One shared recordingDurationLabel() now rounds the total minutes before splitting (both the detail page and the library card used a duplicated copy). - Startup repair's synchronous ps/tasklist/PowerShell ownership probes get a 2 s spawn timeout and are memoized per unique pid, so a batch of rows from one crashed instance costs at most one name query and one start-time query, and a hung process query degrades to the conservative fallback instead of blocking the main thread. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): return to the manager through history from the recording detail Seventh Codex pass (single finding): with a validated returnUrl the manager is already the previous history entry, so Back now uses Location.back() instead of pushing a third entry that made the browser Back button reopen the detail; router navigation remains the fallback for direct links — matching the offline-detail navigation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): bound removal cleanup and shell gates, date interrupted rows by file mtime Eighth Codex pass: - RECORDINGS_REMOVE no longer awaits an unbounded unlink of a failed row's leftover reservation: cleanup is raced against the 1 s deadline, so a hung network unlink cannot keep the Remove action busy — the row deletion is what matters. - Reveal/Play swap the synchronous lstat gate for the bounded async availability probe: a dead mount no longer blocks the main process, and only PROVEN absence refuses the action — an inconclusive probe lets the shell try and answer honestly. - Startup repair dates an interrupted row's endedAt from the captured file's mtime (mpv's last write) instead of the repair time, so an overnight shutdown no longer inflates a five-minute capture into an hours-long recording; the repair-time fallback remains when mtime is unreadable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): keep recording-start program metadata fresh across EPG boundaries Ninth Codex pass (single finding): the unified live tab's recordingMetadata computed cached its Date.now() verdict — starting a recording after an EPG boundary snapshotted the previous show. It now tracks the existing 30 s progress tick. The Stalker live layout's currentProgram had the same memoization (feeding recording metadata, the EPG panel summary, and external-player metadata); it gains a 30 s clock tick with interval cleanup in ngOnDestroy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): re-select the Xtream current program against the 30 s tick at recording start Tenth Codex pass (single finding): the Xtream live layout's recording snapshot read withEpg().currentEpgItem, a computed whose Date.now() verdict stays cached until epgItems changes — a recording started after an EPG boundary snapshotted the previous show. The selection logic is extracted as the pure findCurrentEpgItem(items, nowMs), the store computed delegates to it unchanged, and recordingMetadata re-selects with the layout's existing 30 s currentTimeMs tick. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): scope stop enrichment to the exact recorded list item Eleventh Codex pass (single finding): the stop-enrichment guard compared only the EPG key, which is not unique for M3U items — two list entries sharing a tvgId (or the display-name fallback) could hand the first item's recording the second item's schedule after a switch-triggered auto-stop. RecordingStartMetadata/RecordingStoppedEvent gain an opaque sourceItemKey (unified tab: item.uid; M3U player: channel.id), captured while the recording is active exactly like the EPG key, carried through the player's stop edge, and compared by the hosts before enriching. Xtream/Stalker keys are already playlist+id-scoped and need no extra key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): derive the M3U start-snapshot program from the active channel's schedule Twelfth Codex pass (single finding): the M3U recording snapshot read the NgRx currentEpgProgram, which retains its last value across a channel switch and through EPG gaps (the mirror effect only dispatches when a program exists) — a recording started on a channel with no airing program could persist the previous channel's title, which stop enrichment deliberately never overwrites. The snapshot now derives the program from the active channel's own schedule against the existing 30 s clock, and an EPG gap snapshots no program. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): keep finalizing rows in the recovery ledger and guard the repair update Thirteenth Codex pass (single finding): finalize() removes an entry from the open map before its queued terminal update commits, so activeRowIds() briefly omitted a row still persisted as 'recording' — startup recovery overlapping a clean stop could relabel it interrupted, after which the tracker's status-guarded update could not restore 'completed'. Finalizing entries now stay in a dedicated ledger until the update settles, and the repair UPDATE itself is guarded on status='recording' as a second belt against a finalization that commits between recovery's SELECT and its write. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): register update listeners before the initial list load Fourteenth Codex pass (single finding): RecordingsService awaited its initial RECORDINGS_GET_LIST before subscribing to the update ping — a recording transition during that request pinged into the void while the response still reflected the pre-transition state, and recording pings are rare enough that nothing self-healed until the 15 s poll (armed only once an active row is visible). The listener now registers first so the load-state coalescing queues the trailing refresh. DownloadsService had the same latent window and gets the same reorder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: 4gray <fourgray@proton.me>
1269 lines
48 KiB
TypeScript
1269 lines
48 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 {
|
|
redactSensitiveData,
|
|
summarizeSqlStatementForTrace,
|
|
} from '@iptvnator/shared/logging';
|
|
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 CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY =
|
|
'migration:content-title-fts-remove-diacritics:v1';
|
|
const EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY =
|
|
'migration:epg-program-source-url-backfill:v1';
|
|
const TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY =
|
|
'migration:tmdb-search-lookup-v2-cache-cleanup: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 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(
|
|
redactSensitiveData(payload)
|
|
)}`
|
|
);
|
|
}
|
|
|
|
function traceSqlStatement(sql: unknown): void {
|
|
traceSql('sql-main', 'query', summarizeSqlStatementForTrace(sql));
|
|
}
|
|
|
|
/**
|
|
* 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 DOWNLOADS_TABLE_SQL = `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'))
|
|
)`;
|
|
const DOWNLOADS_INDEX_STATEMENTS = [
|
|
`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)`,
|
|
];
|
|
// No unique index: re-recording the same channel is a normal workflow, and
|
|
// playlist_id carries no FK so recordings survive source deletion.
|
|
const RECORDINGS_TABLE_SQL = `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'))
|
|
)`;
|
|
const RECORDINGS_INDEX_STATEMENTS = [
|
|
`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)`,
|
|
];
|
|
|
|
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,
|
|
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)`,
|
|
// 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`,
|
|
// EPG channel mappings (manual user overrides)
|
|
`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)`,
|
|
// VOD multi-source pins — the per-movie preferred playlist. Keyed by a
|
|
// portal-agnostic match key, since the same film has a different provider
|
|
// id in every portal.
|
|
`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)`,
|
|
// 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
|
|
DOWNLOADS_TABLE_SQL,
|
|
...DOWNLOADS_INDEX_STATEMENTS,
|
|
// Live-TV recordings table
|
|
RECORDINGS_TABLE_SQL,
|
|
...RECORDINGS_INDEX_STATEMENTS,
|
|
// 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`,
|
|
// Identity resolved by a detail view, so activity rows can repeat that
|
|
// lookup instead of rebuilding a weaker one from the display title
|
|
`ALTER TABLE content ADD COLUMN tmdb_id INTEGER`,
|
|
`ALTER TABLE content ADD COLUMN release_year INTEGER`,
|
|
`ALTER TABLE content ADD COLUMN original_title 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`,
|
|
// Pause/resume: entity validator (ETag/Last-Modified) sent as If-Range on resume
|
|
`ALTER TABLE downloads ADD COLUMN resume_validator TEXT`,
|
|
// Offline details: provider-neutral display metadata captured at download time
|
|
`ALTER TABLE downloads ADD COLUMN metadata_snapshot TEXT`,
|
|
// Series queue: scope coordinate compatibility across provider series modes
|
|
`ALTER TABLE downloads ADD COLUMN episode_identity_scope 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,
|
|
ensureDownloadsPauseResumeSchema,
|
|
normalizeXtreamContentAddedEpochs,
|
|
ensureContentTitleFts,
|
|
upgradeContentTitleFtsTokenizer,
|
|
contentTitleFtsStatement,
|
|
backfillEpgProgramSourceUrls,
|
|
cleanupLegacyTmdbSearchCache,
|
|
runMigrations,
|
|
traceSqlStatement,
|
|
} 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}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The title index, folding diacritics when the runtime can.
|
|
*
|
|
* Cross-playlist matching compares NORMALIZED titles ("Amélie" -> "amelie"),
|
|
* but the index holds the raw title, and the trigram tokenizer does not fold
|
|
* diacritics by default. Every accented title was therefore invisible to it:
|
|
* two identical `Amélie` entries produced no candidates at all.
|
|
*
|
|
* `remove_diacritics` needs SQLite 3.45+, so an older runtime keeps the plain
|
|
* tokenizer rather than losing the index — accented titles stay unmatched
|
|
* there, which is exactly the behaviour it had before.
|
|
*/
|
|
function contentTitleFtsStatement(removeDiacritics: boolean): string {
|
|
const tokenize = removeDiacritics
|
|
? `'trigram remove_diacritics 1'`
|
|
: `'trigram'`;
|
|
return `CREATE VIRTUAL TABLE IF NOT EXISTS content_title_fts USING fts5(
|
|
title,
|
|
content='content',
|
|
content_rowid='id',
|
|
tokenize=${tokenize}
|
|
)`;
|
|
}
|
|
|
|
/** Whether this SQLite accepts the folding tokenizer, asked without risk. */
|
|
/**
|
|
* Whether the title index that actually exists folds diacritics, read from its
|
|
* own stored DDL rather than from the migration record.
|
|
*
|
|
* A missing table answers `false`, which is the useful answer: there is nothing
|
|
* folded to keep, so the caller rebuilds.
|
|
*/
|
|
function contentTitleFtsFoldsDiacritics(sqliteDb: Database.Database): boolean {
|
|
const row = sqliteDb
|
|
.prepare(
|
|
`SELECT sql FROM sqlite_master
|
|
WHERE type = 'table' AND name = 'content_title_fts'`
|
|
)
|
|
.get() as { sql?: unknown } | undefined;
|
|
|
|
return (
|
|
typeof row?.sql === 'string' && row.sql.includes('remove_diacritics')
|
|
);
|
|
}
|
|
|
|
function supportsTrigramDiacriticFolding(sqliteDb: Database.Database): boolean {
|
|
try {
|
|
sqliteDb.exec(
|
|
`CREATE VIRTUAL TABLE temp.content_title_fts_probe USING fts5(
|
|
title, tokenize='trigram remove_diacritics 1'
|
|
)`
|
|
);
|
|
sqliteDb.exec(`DROP TABLE temp.content_title_fts_probe`);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rebuild the title index with diacritic folding.
|
|
*
|
|
* The tokenizer is fixed at CREATE time, so an existing database keeps the
|
|
* old one until the table is recreated. Wrapped in a transaction: if the
|
|
* CREATE is rejected the drop rolls back and the working index survives.
|
|
*/
|
|
function upgradeContentTitleFtsTokenizer(sqliteDb: Database.Database): boolean {
|
|
try {
|
|
const migrationState = sqliteDb
|
|
.prepare(`SELECT value FROM app_state WHERE key = ?`)
|
|
.get(CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY) as
|
|
{ value?: unknown } | undefined;
|
|
|
|
// The marker alone is not evidence. `createTables` declares this table
|
|
// too, with the plain tokenizer, so a table recreated by that path
|
|
// after the marker was written would be silently unfolded — and a
|
|
// degraded index is invisible: discovery just stops finding "Pokémon"
|
|
// for "pokemon". Ask the live table instead of trusting the record.
|
|
if (
|
|
migrationState?.value === 'done' &&
|
|
contentTitleFtsFoldsDiacritics(sqliteDb)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (!supportsTrigramDiacriticFolding(sqliteDb)) {
|
|
// Not marked done: a later app version ships a newer SQLite, and
|
|
// this should upgrade itself then rather than stay degraded.
|
|
return false;
|
|
}
|
|
|
|
const executeMigration = sqliteDb.transaction(() => {
|
|
sqliteDb.exec(`DROP TABLE IF EXISTS content_title_fts`);
|
|
sqliteDb.exec(contentTitleFtsStatement(true));
|
|
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_DIACRITICS_MIGRATION_KEY);
|
|
});
|
|
|
|
executeMigration();
|
|
return true;
|
|
} 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 tokenizer upgrade failed (continuing): ${message}`
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search-match cache keys gained a v2 suffix when title normalization changed.
|
|
* Remove the now-unreachable unversioned rows once rather than leaving negative
|
|
* resolutions and other legacy search matches in long-lived installations.
|
|
*/
|
|
function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void {
|
|
try {
|
|
const migrationState = sqliteDb
|
|
.prepare(`SELECT value FROM app_state WHERE key = ?`)
|
|
.get(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY) as
|
|
{ value?: unknown } | undefined;
|
|
|
|
if (migrationState?.value === 'done') {
|
|
return;
|
|
}
|
|
|
|
const executeCleanup = sqliteDb.transaction(() => {
|
|
sqliteDb
|
|
.prepare(
|
|
`DELETE FROM tmdb_metadata
|
|
WHERE lookup_key LIKE 'title:%|year:%'
|
|
AND lookup_key NOT LIKE 'title:%|year:%|v%'`
|
|
)
|
|
.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(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY);
|
|
});
|
|
|
|
executeCleanup();
|
|
} catch (error) {
|
|
const message =
|
|
typeof error === 'object' && error !== null && 'message' in error
|
|
? String((error as { message?: unknown }).message ?? error)
|
|
: String(error);
|
|
|
|
console.warn(
|
|
`Legacy TMDB search cache cleanup failed (continuing): ${message}`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Downloads remain locally owned after their source playlist is removed.
|
|
* SQLite cannot alter CHECK or foreign-key constraints in place, so rebuild
|
|
* when the table still has the old pause/header contract or source cascade.
|
|
*/
|
|
function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
|
|
try {
|
|
const row = sqliteDb
|
|
.prepare(
|
|
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'`
|
|
)
|
|
.get() as { sql?: string } | undefined;
|
|
if (!row?.sql) {
|
|
return;
|
|
}
|
|
|
|
const hasPausedStatus = row.sql.includes(`'paused'`);
|
|
const hasRequestHeaders = row.sql.includes('request_headers');
|
|
const hasPlaylistForeignKey = /\bREFERENCES\s+["`[]?playlists\b/i.test(
|
|
row.sql
|
|
);
|
|
if (hasPausedStatus && hasRequestHeaders && !hasPlaylistForeignKey) {
|
|
return;
|
|
}
|
|
|
|
const legacyHeadersSelect = hasRequestHeaders
|
|
? 'request_headers'
|
|
: 'NULL AS request_headers';
|
|
const hasMetadataSnapshot = row.sql.includes('metadata_snapshot');
|
|
const legacyMetadataSnapshotSelect = hasMetadataSnapshot
|
|
? 'metadata_snapshot'
|
|
: 'NULL AS metadata_snapshot';
|
|
const hasResumeValidator = row.sql.includes('resume_validator');
|
|
const legacyResumeValidatorSelect = hasResumeValidator
|
|
? 'resume_validator'
|
|
: 'NULL AS resume_validator';
|
|
const hasEpisodeIdentityScope = row.sql.includes(
|
|
'episode_identity_scope'
|
|
);
|
|
const legacyEpisodeIdentityScopeSelect = hasEpisodeIdentityScope
|
|
? 'episode_identity_scope'
|
|
: 'NULL AS episode_identity_scope';
|
|
const rebuild = sqliteDb.transaction(() => {
|
|
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
|
|
const match = statement.match(
|
|
/^CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\s+([^\s]+)\s+/i
|
|
);
|
|
if (match?.[1]) {
|
|
sqliteDb.prepare(`DROP INDEX IF EXISTS ${match[1]}`).run();
|
|
}
|
|
}
|
|
|
|
sqliteDb
|
|
.prepare(
|
|
`ALTER TABLE downloads RENAME TO downloads_pause_resume_legacy`
|
|
)
|
|
.run();
|
|
sqliteDb.prepare(DOWNLOADS_TABLE_SQL).run();
|
|
sqliteDb
|
|
.prepare(
|
|
`INSERT INTO downloads (
|
|
id,
|
|
playlist_id,
|
|
xtream_id,
|
|
content_type,
|
|
series_xtream_id,
|
|
season_number,
|
|
episode_number,
|
|
episode_identity_scope,
|
|
title,
|
|
url,
|
|
file_name,
|
|
file_path,
|
|
poster_url,
|
|
request_headers,
|
|
resume_validator,
|
|
metadata_snapshot,
|
|
status,
|
|
bytes_downloaded,
|
|
total_bytes,
|
|
error_message,
|
|
created_at,
|
|
updated_at
|
|
)
|
|
SELECT
|
|
id,
|
|
playlist_id,
|
|
xtream_id,
|
|
content_type,
|
|
series_xtream_id,
|
|
season_number,
|
|
episode_number,
|
|
${legacyEpisodeIdentityScopeSelect},
|
|
title,
|
|
url,
|
|
file_name,
|
|
file_path,
|
|
poster_url,
|
|
${legacyHeadersSelect},
|
|
${legacyResumeValidatorSelect},
|
|
${legacyMetadataSnapshotSelect},
|
|
status,
|
|
bytes_downloaded,
|
|
total_bytes,
|
|
error_message,
|
|
created_at,
|
|
updated_at
|
|
FROM downloads_pause_resume_legacy`
|
|
)
|
|
.run();
|
|
sqliteDb.prepare(`DROP TABLE downloads_pause_resume_legacy`).run();
|
|
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
|
|
sqliteDb.prepare(statement).run();
|
|
}
|
|
});
|
|
|
|
rebuild();
|
|
console.log('[DB] Rebuilt downloads table with local ownership schema');
|
|
} catch (error) {
|
|
console.warn('[DB] downloads ownership migration failed:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run migrations that may fail if already applied
|
|
*/
|
|
function runMigrations(sqliteDb: Database.Database): void {
|
|
widenTmdbMetadataMediaTypeCheck(sqliteDb);
|
|
cleanupLegacyTmdbSearchCache(sqliteDb);
|
|
ensureDownloadsPauseResumeSchema(sqliteDb);
|
|
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
|
|
// The tokenizer upgrade recreates and rebuilds the index itself, so the
|
|
// plain rebuild below would only repeat work it just did.
|
|
if (!upgradeContentTitleFtsTokenizer(sqliteDb)) {
|
|
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) => traceSqlStatement(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;
|
|
}
|
|
}
|