mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
feat(downloads): redesign download manager (#1313)
* docs(downloads): specify manager MVP redesign * docs(downloads): plan manager MVP implementation * docs(downloads): tighten manager validation plan * fix(downloads): keep renderer download state global * fix(downloads): make active count accessible * feat(downloads): derive queue and library view model * test(downloads): close view model coverage gaps * fix(downloads): stabilize malformed view model data * refactor(downloads): isolate library navigation * fix(downloads): report library navigation failures * feat(downloads): add ready-to-watch library * feat(downloads): add active download queue * feat(downloads): finish manager MVP * docs(downloads): clarify detail-first offline behavior * docs(downloads): plan detail navigation follow-up * fix(downloads): open completed movies in details * test(downloads): cover pending series navigation * fix(downloads): honor the global cover size * fix(downloads): prefer local playback in shared details * fix(downloads): preserve external launch priority * fix(downloads): prefer local playback in Xtream details * test(downloads): cover offline detail journey * docs(downloads): document offline detail behavior * docs(downloads): format detail navigation plan * fix(downloads): open Stalker items in provider details * docs(downloads): clarify Stalker navigation fallback * fix(xtream): isolate reused detail identities * fix(xtream): ignore stale VOD positions * fix(downloads): keep offline Xtream playback available * docs(downloads): clarify provider playback availability * docs(downloads): design missing-file recovery * docs(downloads): plan missing-file recovery * feat(downloads): derive completed file availability * feat(downloads): recover missing completed files * feat(downloads): refresh missing local files * feat(downloads): separate missing files from ready media * feat(downloads): surface missing files for recovery * refactor(downloads): simplify ready cards * test(downloads): cover missing-file and series journeys * feat(downloads): finish missing-file recovery * docs(downloads): design offline detail views * docs(downloads): plan offline detail views * feat(downloads): persist offline metadata snapshots * fix(downloads): complete metadata snapshot bridge contract * feat(downloads): manage offline metadata snapshots * fix(downloads): harden metadata snapshot updates * fix(downloads): restrict snapshot artwork * fix(downloads): guard restart artwork URL * fix(downloads): refine artwork URL checks * feat(downloads): expose offline metadata updates * fix(downloads): keep metadata service change focused * fix(downloads): preserve metadata error conventions * feat(downloads): derive offline detail content * fix(downloads): preserve unknown episode coordinates * feat(downloads): add focused offline detail routes * fix(downloads): ignore fragments in shell route state * fix(downloads): normalize fragments before queries * feat(downloads): open ready cards in offline details * fix(downloads): use native disabled card styles * feat(downloads): enrich offline detail metadata * fix(downloads): harden offline metadata resolution * fix(downloads): preserve stalker provider titles * fix(downloads): distinguish stalker metadata seeds * fix(downloads): stabilize offline metadata refresh * fix(downloads): throttle sparse metadata refreshes * fix(downloads): type metadata language settings * feat(downloads): render offline movie and series details * fix(downloads): harden offline detail interactions * fix(downloads): close offline detail edge cases * feat(downloads): hand off to provider-only details * fix(downloads): preserve stalker provider handoff * feat(downloads): capture metadata at download time * fix(downloads): preserve snapshot source semantics * fix(downloads): preserve episode snapshot identity * docs(downloads): document offline details flow * docs(downloads): clarify stalker provider fallback * test(downloads): cover offline detail journeys * test(downloads): stabilize offline detail selectors * style(downloads): format changed files * docs(downloads): clean design spec formatting * fix(downloads): preserve offline library ownership * test(downloads): fix Windows workspace navigation * test(database): preserve Electron tsconfig resolution * perf(downloads): avoid blocking file availability probes
This commit is contained in:
1 parent
46c58f4f57
commit
760099358b
188 files changed
+31245
-2281
No files matched your search
@@ -1,4 +1,11 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { getTableColumns } from 'drizzle-orm';
|
||||
import { getTableConfig } from 'drizzle-orm/sqlite-core';
|
||||
import { __databaseConnectionTestHooks } from './connection';
|
||||
import { categories, downloads } from './schema';
|
||||
|
||||
function compactSql(statement: string): string {
|
||||
return statement.replace(/\s+/g, ' ').trim();
|
||||
@@ -17,6 +24,117 @@ function createdObjectNames(prefix: string, statements: readonly string[]) {
|
||||
.filter((name): name is string => Boolean(name));
|
||||
}
|
||||
|
||||
function rebuildDownloadsInElectron(metadataSnapshot: string): {
|
||||
metadataSnapshot: string;
|
||||
retainedAfterPlaylistDelete: boolean;
|
||||
schemaSql: string;
|
||||
} {
|
||||
const electronPath = createRequire(__filename)('electron') as string;
|
||||
const connectionUrl = pathToFileURL(
|
||||
resolve(__dirname, 'connection.ts')
|
||||
).href;
|
||||
const script = `
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
const { __databaseConnectionTestHooks } = await import(${JSON.stringify(
|
||||
connectionUrl
|
||||
)});
|
||||
const metadataSnapshot = ${JSON.stringify(metadataSnapshot)};
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
sqlite.exec(\`
|
||||
CREATE TABLE playlists (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL,
|
||||
xtream_id INTEGER NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
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,
|
||||
request_headers TEXT,
|
||||
metadata_snapshot TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued'
|
||||
CHECK (status IN (
|
||||
'queued',
|
||||
'downloading',
|
||||
'completed',
|
||||
'failed',
|
||||
'canceled'
|
||||
)),
|
||||
bytes_downloaded INTEGER,
|
||||
total_bytes INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
, FOREIGN KEY (playlist_id) REFERENCES playlists (id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
\`);
|
||||
sqlite.prepare('INSERT INTO playlists (id) VALUES (?)')
|
||||
.run('playlist-1');
|
||||
sqlite.prepare(\`
|
||||
INSERT INTO downloads (
|
||||
playlist_id,
|
||||
xtream_id,
|
||||
content_type,
|
||||
title,
|
||||
url,
|
||||
metadata_snapshot
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
\`).run(
|
||||
'playlist-1',
|
||||
42,
|
||||
'vod',
|
||||
'Offline title',
|
||||
'https://example.com/movie',
|
||||
metadataSnapshot
|
||||
);
|
||||
console.log = () => undefined;
|
||||
__databaseConnectionTestHooks.ensureDownloadsPauseResumeSchema(sqlite);
|
||||
const row = sqlite.prepare(
|
||||
'SELECT metadata_snapshot FROM downloads WHERE id = 1'
|
||||
).get();
|
||||
const table = sqlite.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'"
|
||||
).get();
|
||||
sqlite.prepare('DELETE FROM playlists WHERE id = ?')
|
||||
.run('playlist-1');
|
||||
const retainedAfterPlaylistDelete = Boolean(sqlite.prepare(
|
||||
'SELECT id FROM downloads WHERE id = 1'
|
||||
).get());
|
||||
sqlite.close();
|
||||
process.stdout.write(JSON.stringify({
|
||||
metadataSnapshot: row.metadata_snapshot,
|
||||
retainedAfterPlaylistDelete,
|
||||
schemaSql: table.sql,
|
||||
}));
|
||||
`;
|
||||
const output = execFileSync(
|
||||
electronPath,
|
||||
['--import', 'tsx', '--eval', script],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
TSX_TSCONFIG_PATH: resolve(process.cwd(), 'tsconfig.base.json'),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return JSON.parse(output) as {
|
||||
metadataSnapshot: string;
|
||||
retainedAfterPlaylistDelete: boolean;
|
||||
schemaSql: string;
|
||||
};
|
||||
}
|
||||
|
||||
describe('database schema statements', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
@@ -85,6 +203,17 @@ describe('database schema statements', () => {
|
||||
|
||||
it('defines the core fresh-install tables, indexes, and FTS triggers', () => {
|
||||
const schemaSql = createTableStatements.map(compactSql).join('\n');
|
||||
const downloadsSchemaSql =
|
||||
createTableStatements
|
||||
.map(compactSql)
|
||||
.find((statement) =>
|
||||
statement.startsWith('CREATE TABLE IF NOT EXISTS downloads')
|
||||
) ?? '';
|
||||
const downloadColumns = Object.values(getTableColumns(downloads)).map(
|
||||
(column) => column.name
|
||||
);
|
||||
const categoryForeignKeys = getTableConfig(categories).foreignKeys;
|
||||
const downloadForeignKeys = getTableConfig(downloads).foreignKeys;
|
||||
|
||||
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS playlists');
|
||||
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS categories');
|
||||
@@ -102,8 +231,15 @@ describe('database schema statements', () => {
|
||||
'CREATE TABLE IF NOT EXISTS playback_positions'
|
||||
);
|
||||
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS downloads');
|
||||
expect(downloadsSchemaSql).not.toContain(
|
||||
'FOREIGN KEY (playlist_id) REFERENCES playlists'
|
||||
);
|
||||
expect(categoryForeignKeys).toHaveLength(1);
|
||||
expect(downloadForeignKeys).toHaveLength(0);
|
||||
expect(schemaSql).toContain('request_headers TEXT');
|
||||
expect(schemaSql).toContain('resume_validator TEXT');
|
||||
expect(schemaSql).toContain('metadata_snapshot TEXT');
|
||||
expect(downloadColumns).toContain('metadata_snapshot');
|
||||
expect(schemaSql).toContain("'paused'");
|
||||
expect(schemaSql).toContain(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique'
|
||||
@@ -149,6 +285,9 @@ describe('database schema statements', () => {
|
||||
'ALTER TABLE content ADD COLUMN backdrop_url TEXT',
|
||||
])
|
||||
);
|
||||
expect(columnMigrationStatements).toContain(
|
||||
'ALTER TABLE downloads ADD COLUMN metadata_snapshot TEXT'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps legacy index migrations idempotent IF NOT EXISTS statements', () => {
|
||||
@@ -488,7 +627,8 @@ describe('database schema statements', () => {
|
||||
expect(statements[createIndex]).toContain('request_headers TEXT');
|
||||
expect(statements[createIndex]).toContain('resume_validator TEXT');
|
||||
expect(statements[copyIndex]).toContain('NULL AS request_headers');
|
||||
expect(statements[copyIndex]).not.toContain('resume_validator');
|
||||
expect(statements[copyIndex]).toContain('NULL AS metadata_snapshot');
|
||||
expect(statements[copyIndex]).toContain('NULL AS resume_validator');
|
||||
});
|
||||
|
||||
it('copies stored headers when only the paused status is missing', () => {
|
||||
@@ -506,6 +646,42 @@ describe('database schema statements', () => {
|
||||
expect(copy).toContain('request_headers');
|
||||
});
|
||||
|
||||
it('preserves metadata snapshots when rebuilding the pause/resume schema', () => {
|
||||
const metadataSnapshot = JSON.stringify({
|
||||
version: 1,
|
||||
language: 'en',
|
||||
mediaKind: 'movie',
|
||||
title: 'Offline title',
|
||||
});
|
||||
const rebuilt = rebuildDownloadsInElectron(metadataSnapshot);
|
||||
|
||||
expect(rebuilt.schemaSql).toContain(`'paused'`);
|
||||
expect(rebuilt.schemaSql).not.toContain('REFERENCES playlists');
|
||||
expect(rebuilt.metadataSnapshot).toBe(metadataSnapshot);
|
||||
expect(rebuilt.retainedAfterPlaylistDelete).toBe(true);
|
||||
});
|
||||
|
||||
it('rebuilds a current downloads table that still cascades with its source playlist', () => {
|
||||
const sqlite = createRebuildSqlite(
|
||||
`CREATE TABLE downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
request_headers TEXT,
|
||||
resume_validator TEXT,
|
||||
status TEXT CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled'))
|
||||
)`
|
||||
);
|
||||
|
||||
ensureDownloadsPauseResumeSchema(sqlite);
|
||||
|
||||
expect(sqlite.transaction).toHaveBeenCalledTimes(1);
|
||||
const replacement = sqlite.statements.find((statement) =>
|
||||
statement.startsWith('CREATE TABLE IF NOT EXISTS downloads')
|
||||
);
|
||||
expect(replacement).toBeDefined();
|
||||
expect(replacement).not.toContain('REFERENCES playlists');
|
||||
});
|
||||
|
||||
it('skips the downloads rebuild when the table already has the paused contract', () => {
|
||||
const sqlite = createRebuildSqlite(
|
||||
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, request_headers TEXT, resume_validator TEXT, status TEXT CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')))`
|
||||
@@ -518,8 +694,7 @@ describe('database schema statements', () => {
|
||||
});
|
||||
|
||||
it('backfills migrated EPG program source URLs in bounded batches', () => {
|
||||
const { backfillEpgProgramSourceUrls } =
|
||||
__databaseConnectionTestHooks;
|
||||
const { backfillEpgProgramSourceUrls } = __databaseConnectionTestHooks;
|
||||
let updateStatement = '';
|
||||
const backfillRun = jest
|
||||
.fn()
|
||||
|
||||
@@ -105,13 +105,13 @@ const DOWNLOADS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS downloads (
|
||||
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')),
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
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)`,
|
||||
@@ -379,6 +379,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
|
||||
`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`,
|
||||
];
|
||||
|
||||
const INDEX_MIGRATION_STATEMENTS = [
|
||||
@@ -579,8 +581,7 @@ function normalizeXtreamContentAddedEpochs(sqliteDb: Database.Database): void {
|
||||
const migrationState = sqliteDb
|
||||
.prepare(`SELECT value FROM app_state WHERE key = ?`)
|
||||
.get(XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY) as
|
||||
| { value?: unknown }
|
||||
| undefined;
|
||||
{ value?: unknown } | undefined;
|
||||
|
||||
if (migrationState?.value === 'done') {
|
||||
return;
|
||||
@@ -697,8 +698,7 @@ function upgradeContentTitleFtsTokenizer(sqliteDb: Database.Database): boolean {
|
||||
const migrationState = sqliteDb
|
||||
.prepare(`SELECT value FROM app_state WHERE key = ?`)
|
||||
.get(CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY) as
|
||||
| { value?: unknown }
|
||||
| undefined;
|
||||
{ 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
|
||||
@@ -759,8 +759,7 @@ function ensureContentTitleFts(sqliteDb: Database.Database): void {
|
||||
const migrationState = sqliteDb
|
||||
.prepare(`SELECT value FROM app_state WHERE key = ?`)
|
||||
.get(CONTENT_TITLE_FTS_MIGRATION_KEY) as
|
||||
| { value?: unknown }
|
||||
| undefined;
|
||||
{ value?: unknown } | undefined;
|
||||
|
||||
if (migrationState?.value === 'done') {
|
||||
return;
|
||||
@@ -803,8 +802,7 @@ function backfillEpgProgramSourceUrls(sqliteDb: Database.Database): void {
|
||||
const migrationState = sqliteDb
|
||||
.prepare(`SELECT value FROM app_state WHERE key = ?`)
|
||||
.get(EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY) as
|
||||
| { value?: unknown }
|
||||
| undefined;
|
||||
{ value?: unknown } | undefined;
|
||||
|
||||
if (migrationState?.value === 'done') {
|
||||
return;
|
||||
@@ -938,8 +936,7 @@ function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void {
|
||||
const migrationState = sqliteDb
|
||||
.prepare(`SELECT value FROM app_state WHERE key = ?`)
|
||||
.get(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY) as
|
||||
| { value?: unknown }
|
||||
| undefined;
|
||||
{ value?: unknown } | undefined;
|
||||
|
||||
if (migrationState?.value === 'done') {
|
||||
return;
|
||||
@@ -978,9 +975,9 @@ function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing downloads tables had a status CHECK without 'paused'. SQLite cannot
|
||||
* alter CHECK constraints in place, so rebuild only when the table SQL still
|
||||
* reflects the old contract or lacks request_headers.
|
||||
* 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 {
|
||||
@@ -995,13 +992,24 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
|
||||
|
||||
const hasPausedStatus = row.sql.includes(`'paused'`);
|
||||
const hasRequestHeaders = row.sql.includes('request_headers');
|
||||
if (hasPausedStatus && hasRequestHeaders) {
|
||||
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 rebuild = sqliteDb.transaction(() => {
|
||||
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
|
||||
const match = statement.match(
|
||||
@@ -1034,6 +1042,8 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
|
||||
file_path,
|
||||
poster_url,
|
||||
request_headers,
|
||||
resume_validator,
|
||||
metadata_snapshot,
|
||||
status,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
@@ -1055,6 +1065,8 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
|
||||
file_path,
|
||||
poster_url,
|
||||
${legacyHeadersSelect},
|
||||
${legacyResumeValidatorSelect},
|
||||
${legacyMetadataSnapshotSelect},
|
||||
status,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
@@ -1064,18 +1076,16 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
|
||||
FROM downloads_pause_resume_legacy`
|
||||
)
|
||||
.run();
|
||||
sqliteDb
|
||||
.prepare(`DROP TABLE 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 pause/resume schema');
|
||||
console.log('[DB] Rebuilt downloads table with local ownership schema');
|
||||
} catch (error) {
|
||||
console.warn('[DB] downloads pause/resume migration failed:', error);
|
||||
console.warn('[DB] downloads ownership migration failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,9 +115,7 @@ export const content = sqliteTable(
|
||||
categoryIdx: index('idx_content_category').on(table.categoryId),
|
||||
titleIdx: index('idx_content_title').on(table.title),
|
||||
xtreamIdx: index('idx_content_xtream').on(table.xtreamId),
|
||||
epgChannelIdx: index('idx_content_epg_channel').on(
|
||||
table.epgChannelId
|
||||
),
|
||||
epgChannelIdx: index('idx_content_epg_channel').on(table.epgChannelId),
|
||||
categoryTypeXtreamUnique: uniqueIndex(
|
||||
'content_category_type_xtream_unique'
|
||||
).on(table.categoryId, table.type, table.xtreamId),
|
||||
@@ -315,9 +313,7 @@ export const downloads = sqliteTable(
|
||||
'downloads',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
playlistId: text('playlist_id')
|
||||
.notNull()
|
||||
.references(() => playlists.id, { onDelete: 'cascade' }),
|
||||
playlistId: text('playlist_id').notNull(),
|
||||
// Content identifiers
|
||||
xtreamId: integer('xtream_id').notNull(),
|
||||
contentType: text('content_type', {
|
||||
@@ -335,6 +331,7 @@ export const downloads = sqliteTable(
|
||||
posterUrl: text('poster_url'),
|
||||
requestHeaders: text('request_headers'),
|
||||
resumeValidator: text('resume_validator'),
|
||||
metadataSnapshot: text('metadata_snapshot'),
|
||||
// Download progress
|
||||
status: text('status', {
|
||||
enum: [
|
||||
@@ -376,7 +373,9 @@ export const tmdbMetadata = sqliteTable(
|
||||
'tmdb_metadata',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
mediaType: text('media_type', { enum: ['movie', 'tv', 'person'] }).notNull(),
|
||||
mediaType: text('media_type', {
|
||||
enum: ['movie', 'tv', 'person'],
|
||||
}).notNull(),
|
||||
lookupKey: text('lookup_key').notNull(),
|
||||
language: text('language').notNull(),
|
||||
tmdbId: integer('tmdb_id'),
|
||||
|
||||
Reference in new issue
Block a user