Files
iptvnator/apps/electron-backend/src/app/workers/epg-database.ts
T

320 lines
12 KiB
TypeScript

import type BetterSqlite3 from 'better-sqlite3';
import { getIptvnatorDatabasePath } from '@iptvnator/shared/database/path-utils';
import type { ParsedChannel, ParsedProgram } from './epg-streaming-parser';
import { restoreSurvivingChannelMetadata } from './epg-source-metadata';
/**
* Database helper for worker-owned EPG operations.
* Creates its own connection to avoid blocking the main thread.
*/
export class EpgDatabase {
private readonly db: BetterSqlite3.Database;
private readonly knownChannelIds = new Set<string>();
private readonly insertChannelStmt: BetterSqlite3.Statement;
private readonly insertChannelSourceStmt: BetterSqlite3.Statement;
private readonly deleteChannelSourcesStmt: BetterSqlite3.Statement;
private readonly insertProgramStmt: BetterSqlite3.Statement;
private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement;
private readonly deleteTodayAndFutureStmt: BetterSqlite3.Statement;
constructor(Database: typeof BetterSqlite3) {
this.db = new Database(getIptvnatorDatabasePath());
this.db.pragma('foreign_keys = ON');
this.db.pragma('journal_mode = WAL');
this.db.pragma('busy_timeout = 5000');
this.insertChannelStmt = this.db.prepare(`
INSERT INTO epg_channels (id, display_name, icon_url, url, source_url, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
ON CONFLICT(id) DO UPDATE SET
display_name = excluded.display_name,
icon_url = excluded.icon_url,
url = excluded.url,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
`);
this.insertChannelSourceStmt = this.db.prepare(`
INSERT INTO epg_channel_sources
(channel_id, display_name, icon_url, url, source_url, updated_at)
VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
ON CONFLICT(channel_id, source_url) DO UPDATE SET
display_name = excluded.display_name,
icon_url = excluded.icon_url,
url = excluded.url,
updated_at = excluded.updated_at
`);
this.deleteChannelSourcesStmt = this.db.prepare(
'DELETE FROM epg_channel_sources WHERE source_url = ?'
);
// Guard against duplicate entries when the clearFirst logic misses old
// rows. The same channel + start + title + source is treated as the
// same programme — a later import with a corrected stop time simply
// updates the earlier row. `source_url` is part of the key so that
// programmes imported from different EPG sources that happen to share
// channel_id/start/title stay isolated: the query layer scopes by
// source_url, and source-scoped deletes must not clobber another
// source's rows.
// The upsert (instead of INSERT OR REPLACE) keeps the epg_programs_fts
// triggers consistent: REPLACE deletes rows without firing the delete
// trigger unless recursive_triggers is enabled, leaving ghost FTS rows.
const dedupIndexReady = this.ensureProgramDedupIndex();
this.insertProgramStmt = this.db.prepare(
dedupIndexReady
? `INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num, source_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(channel_id, start, title, source_url) DO UPDATE SET
stop = excluded.stop,
description = excluded.description,
category = excluded.category,
icon_url = excluded.icon_url,
rating = excluded.rating,
episode_num = excluded.episode_num`
: `INSERT INTO epg_programs (channel_id, start, stop, title, description, category, icon_url, rating, episode_num, source_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
this.deleteOrphanChannelsForSourceStmt = this.db.prepare(`
DELETE FROM epg_channels
WHERE source_url = ?
AND NOT EXISTS (
SELECT 1
FROM epg_programs
WHERE epg_programs.channel_id = epg_channels.id
)
AND NOT EXISTS (
SELECT 1 FROM epg_channel_sources
WHERE epg_channel_sources.channel_id = epg_channels.id
)
`);
this.deleteTodayAndFutureStmt = this.db.prepare(`
DELETE FROM epg_programs
WHERE source_url = ?
AND (start >= date('now') OR start < date('now', '-7 days'))
`);
}
/**
* Insert a batch of channels. When `clearTodayAndFuture` is true, the
* selective delete (today+future and older-than-7-days) runs inside the
* same transaction so a parse failure after the delete atomically rolls
* back both — no gap left in the schedule.
*/
insertChannels(
channels: ParsedChannel[],
sourceUrl: string,
clearTodayAndFuture = false
): void {
const insertMany = this.db.transaction((channels: ParsedChannel[]) => {
if (clearTodayAndFuture) {
restoreSurvivingChannelMetadata(this.db, sourceUrl);
this.deleteTodayAndFutureStmt.run(sourceUrl);
this.deleteChannelSourcesStmt.run(sourceUrl);
this.deleteOrphanChannelsForSourceStmt.run(sourceUrl);
this.knownChannelIds.clear();
}
for (const channel of channels) {
const displayName =
channel.displayName?.[0]?.value || channel.id;
const iconUrl = channel.icon?.[0]?.src || null;
const url = channel.url?.[0] || null;
this.insertChannelStmt.run(
channel.id,
displayName,
iconUrl,
url,
sourceUrl
);
this.insertChannelSourceStmt.run(
channel.id,
displayName,
iconUrl,
url,
sourceUrl
);
this.knownChannelIds.add(channel.id);
}
});
insertMany(channels);
}
/**
* Insert programs for channels already seen during the current parse.
*/
insertPrograms(programs: ParsedProgram[], sourceUrl: string): number {
let insertedCount = 0;
const insertMany = this.db.transaction((programs: ParsedProgram[]) => {
for (const program of programs) {
if (!this.knownChannelIds.has(program.channel)) continue;
const title = program.title?.[0]?.value || 'Unknown';
const description = program.desc?.[0]?.value || null;
const category = program.category?.[0]?.value || null;
const iconUrl = program.icon?.[0]?.src || null;
const rating = program.rating?.[0]?.value || null;
const episodeNum = program.episodeNum?.[0]?.value || null;
try {
this.insertProgramStmt.run(
program.channel,
program.start,
program.stop,
title,
description,
category,
iconUrl,
rating,
episodeNum,
sourceUrl
);
insertedCount++;
} catch {
// Skip individual failures such as FK constraint errors.
}
}
});
insertMany(programs);
return insertedCount;
}
close(): void {
this.db.close();
}
/**
* Create the unique (channel_id, start, title, source_url) dedup index.
*
* Databases that predate the index may already contain duplicate rows —
* exactly the situation the index is meant to prevent — so those are
* removed first; a plain `CREATE UNIQUE INDEX` would otherwise throw in
* this constructor and permanently break EPG imports for upgrading users.
*
* The `_v2` name migrates users off the earlier source-blind index
* (`idx_epg_programs_dedup` on `channel_id, start, title`), which
* collapsed programmes imported from different EPG sources that shared
* those three columns. The old index is dropped so it can no longer
* enforce the source-blind uniqueness.
*
* Returns false when the index could not be created, in which case the
* insert statement falls back to the previous plain-INSERT behaviour.
*/
private ensureProgramDedupIndex(): boolean {
try {
const exists = this.db
.prepare(
`SELECT 1 FROM sqlite_master
WHERE type = 'index' AND name = 'idx_epg_programs_dedup_v2'`
)
.get();
if (!exists) {
this.db
.prepare(`DROP INDEX IF EXISTS idx_epg_programs_dedup`)
.run();
this.db
.prepare(
`DELETE FROM epg_programs
WHERE id NOT IN (
SELECT MIN(id) FROM epg_programs
GROUP BY channel_id, start, title, source_url
)`
)
.run();
this.db
.prepare(
`CREATE UNIQUE INDEX idx_epg_programs_dedup_v2
ON epg_programs(channel_id, start, title, source_url)`
)
.run();
}
return true;
} catch {
return false;
}
}
}
export class EpgDatabaseClearOperation {
private readonly db: BetterSqlite3.Database;
constructor(Database: typeof BetterSqlite3) {
this.db = new Database(getIptvnatorDatabasePath());
this.db.pragma('busy_timeout = 5000');
}
run(): void {
this.db.exec('BEGIN');
try {
this.db.exec('DELETE FROM epg_programs');
this.db.exec('DELETE FROM epg_channel_sources');
this.db.exec('DELETE FROM epg_channels');
this.db.exec('COMMIT');
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
}
close(): void {
this.db.close();
}
}
export class EpgDatabaseSourceClearOperation {
private readonly db: BetterSqlite3.Database;
private readonly deleteProgramsForSourceStmt: BetterSqlite3.Statement;
private readonly deleteOrphanChannelsForSourceStmt: BetterSqlite3.Statement;
constructor(Database: typeof BetterSqlite3) {
this.db = new Database(getIptvnatorDatabasePath());
this.db.pragma('busy_timeout = 5000');
this.deleteProgramsForSourceStmt = this.db.prepare(`
DELETE FROM epg_programs WHERE source_url = ?
`);
this.deleteOrphanChannelsForSourceStmt = this.db.prepare(`
DELETE FROM epg_channels
WHERE source_url = ?
AND NOT EXISTS (
SELECT 1
FROM epg_programs
WHERE epg_programs.channel_id = epg_channels.id
)
AND NOT EXISTS (
SELECT 1 FROM epg_channel_sources
WHERE epg_channel_sources.channel_id = epg_channels.id
)
`);
}
run(sourceUrl: string): void {
const normalizedSourceUrl = sourceUrl.trim();
if (!normalizedSourceUrl) {
return;
}
const clearSource = this.db.transaction((url: string) => {
// Capture affected IDs while removed-source provenance still exists.
restoreSurvivingChannelMetadata(this.db, url);
this.deleteProgramsForSourceStmt.run(url);
this.db
.prepare('DELETE FROM epg_channel_sources WHERE source_url = ?')
.run(url);
this.deleteOrphanChannelsForSourceStmt.run(url);
});
clearSource(normalizedSourceUrl);
}
close(): void {
this.db.close();
}
}