feat(downloads): save completed Xtream archive programmes as TS

This commit is contained in:
4gray committed 2026-09-07 21:32:58 +02:00
1 parent dcaeb26849
commit 316243f45d
74 files changed
+1678 -269

No files matched your search

@@ -56,7 +56,7 @@ const completedMigrationStateRule: HandlerRule = [
/** The live title index, already carrying the folding tokenizer. */
const foldedIndexRule: HandlerRule = [
'SELECT sql FROM sqlite_master',
"name = 'content_title_fts'",
{
get: () => ({
sql: "CREATE VIRTUAL TABLE content_title_fts USING fts5(title, tokenize='trigram remove_diacritics 1')",
+6 -29
View File
@@ -1,3 +1,8 @@
import {
DOWNLOADS_TABLE_SQL,
DOWNLOADS_INDEX_STATEMENTS,
ensureDownloadsCatchupSchema,
} from './download-schema';
/**
* Database connection and initialization for IPTVnator
* Uses Drizzle ORM with better-sqlite3
@@ -90,35 +95,6 @@ const TMDB_METADATA_TABLE_SQL = `CREATE TABLE IF NOT EXISTS tmdb_metadata (
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 (
@@ -1165,6 +1141,7 @@ function runMigrations(sqliteDb: Database.Database): void {
cleanupLegacyTmdbSearchCache(sqliteDb);
ensureDownloadsPauseResumeSchema(sqliteDb);
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
ensureDownloadsCatchupSchema(sqliteDb);
// The tokenizer upgrade recreates and rebuilds the index itself, so the
// plain rebuild below would only repeat work it just did.
if (!upgradeContentTitleFtsTokenizer(sqliteDb)) {
@@ -0,0 +1,59 @@
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
it('migrates existing downloads without losing files and separates programme identities', () => {
const electron = createRequire(__filename)('electron') as string;
const moduleUrl = pathToFileURL(
resolve(__dirname, 'download-schema.ts')
).href;
const result = execFileSync(
electron,
[
'--import',
'tsx',
'--eval',
`
const { default: Database } = await import('better-sqlite3');
const { DOWNLOADS_TABLE_SQL, ensureDownloadsCatchupSchema } = await import(${JSON.stringify(moduleUrl)});
const db = new Database(':memory:');
db.exec(DOWNLOADS_TABLE_SQL.replace(", 'catchup'", '').replace('programme_start INTEGER NOT NULL DEFAULT 0,', '').replace('catchup TEXT,', ''));
db.exec("CREATE UNIQUE INDEX downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)");
db.prepare("INSERT INTO downloads (id,playlist_id,xtream_id,content_type,title,url,status,file_path,bytes_downloaded,resume_validator,request_headers) VALUES (42,'p',1,'vod','Movie','https://host/movie','paused','/safe/movie.mp4',123,'etag','headers')").run();
ensureDownloadsCatchupSchema(db);
ensureDownloadsCatchupSchema(db);
const insert = db.prepare("INSERT INTO downloads (playlist_id,xtream_id,content_type,programme_start,title,url) VALUES ('p',1,?,?, 'Show','https://host/archive')");
insert.run('catchup', 1000); insert.run('catchup', 2000);
let duplicateArchive = false, duplicateMovie = false;
try { insert.run('catchup', 1000); } catch { duplicateArchive = true; }
try { insert.run('vod', 0); } catch { duplicateMovie = true; }
process.stdout.write(JSON.stringify({row:db.prepare('SELECT * FROM downloads WHERE id=42').get(), count:db.prepare('SELECT count(*) AS n FROM downloads').get().n, duplicateArchive,duplicateMovie}));
`,
],
{
cwd: process.cwd(),
encoding: 'utf8',
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
TSX_TSCONFIG_PATH: resolve(process.cwd(), 'tsconfig.base.json'),
},
}
);
expect(JSON.parse(result)).toMatchObject({
count: 3,
duplicateArchive: true,
duplicateMovie: true,
row: {
id: 42,
status: 'paused',
file_path: '/safe/movie.mp4',
bytes_downloaded: 123,
resume_validator: 'etag',
request_headers: 'headers',
programme_start: 0,
catchup: null,
},
});
});
@@ -0,0 +1,87 @@
import type Database from 'better-sqlite3';
export 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', 'catchup')),
programme_start INTEGER NOT NULL DEFAULT 0,
catchup TEXT,
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'))
)`;
export const DOWNLOADS_INDEX_STATEMENTS = [
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type) WHERE content_type != 'catchup'`,
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
];
const CATCHUP_INDEX = `CREATE UNIQUE INDEX IF NOT EXISTS downloads_catchup_unique
ON downloads(xtream_id, playlist_id, programme_start) WHERE content_type = 'catchup'`;
/** Widen the CHECK transactionally; existing ids, files and resume state survive. */
export function ensureDownloadsCatchupSchema(db: Database.Database): void {
const row = db
.prepare(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'"
)
.get() as { sql: string } | undefined;
if (!row) return;
if (row.sql.includes("'catchup'")) {
db.exec(CATCHUP_INDEX);
return;
}
const columns = [
'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',
];
db.transaction(() => {
db.exec('DROP INDEX IF EXISTS downloads_xtream_playlist_unique');
db.exec('DROP INDEX IF EXISTS downloads_playlist_idx');
db.exec('DROP INDEX IF EXISTS downloads_status_idx');
db.exec('ALTER TABLE downloads RENAME TO downloads_catchup_legacy');
db.exec(DOWNLOADS_TABLE_SQL);
db.exec(
`INSERT INTO downloads (${columns.join(',')}) SELECT ${columns.join(',')} FROM downloads_catchup_legacy`
);
db.exec('DROP TABLE downloads_catchup_legacy');
for (const statement of DOWNLOADS_INDEX_STATEMENTS) db.exec(statement);
db.exec(CATCHUP_INDEX);
})();
}
+12 -4
View File
@@ -1,3 +1,4 @@
import type { CatchupDownloadMetadata } from '@iptvnator/shared/interfaces';
/**
* Drizzle ORM schema for IPTVnator database
* This schema defines the structure for Xtream Codes API data storage
@@ -344,8 +345,12 @@ export const downloads = sqliteTable(
// Content identifiers
xtreamId: integer('xtream_id').notNull(),
contentType: text('content_type', {
enum: ['vod', 'episode'],
enum: ['vod', 'episode', 'catchup'],
}).notNull(),
programmeStart: integer('programme_start').notNull().default(0),
catchup: text('catchup', {
mode: 'json',
}).$type<CatchupDownloadMetadata>(),
// For episodes: store series info
seriesXtreamId: integer('series_xtream_id'),
seasonNumber: integer('season_number'),
@@ -383,9 +388,12 @@ export const downloads = sqliteTable(
(table) => ({
playlistIdx: index('downloads_playlist_idx').on(table.playlistId),
statusIdx: index('downloads_status_idx').on(table.status),
xtreamPlaylistUnique: uniqueIndex(
'downloads_xtream_playlist_unique'
).on(table.xtreamId, table.playlistId, table.contentType),
xtreamPlaylistUnique: uniqueIndex('downloads_xtream_playlist_unique')
.on(table.xtreamId, table.playlistId, table.contentType)
.where(sql`${table.contentType} != 'catchup'`),
catchupUnique: uniqueIndex('downloads_catchup_unique')
.on(table.xtreamId, table.playlistId, table.programmeStart)
.where(sql`${table.contentType} = 'catchup'`),
})
);