mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
feat(downloads): persist offline metadata snapshots
This commit is contained in:
1 parent
2e243cc4af
commit
664d333f0f
6 files changed
+128
-3
No files matched your search
@@ -1,4 +1,6 @@
|
||||
import { getTableColumns } from 'drizzle-orm';
|
||||
import { __databaseConnectionTestHooks } from './connection';
|
||||
import { downloads } from './schema';
|
||||
|
||||
function compactSql(statement: string): string {
|
||||
return statement.replace(/\s+/g, ' ').trim();
|
||||
@@ -61,8 +63,12 @@ describe('database schema statements', () => {
|
||||
}
|
||||
});
|
||||
|
||||
function createRebuildSqlite(legacyTableSql: string | undefined) {
|
||||
function createRebuildSqlite(
|
||||
legacyTableSql: string | undefined,
|
||||
legacyMetadataSnapshot?: string
|
||||
) {
|
||||
const statements: string[] = [];
|
||||
let rebuiltMetadataSnapshot: string | undefined;
|
||||
const transaction = jest.fn((callback: () => void) => callback);
|
||||
const prepare = jest.fn((statement: string) => {
|
||||
if (statement.includes('FROM sqlite_master')) {
|
||||
@@ -75,16 +81,36 @@ describe('database schema statements', () => {
|
||||
}
|
||||
return {
|
||||
run: () => {
|
||||
statements.push(compactSql(statement));
|
||||
const compactStatement = compactSql(statement);
|
||||
statements.push(compactStatement);
|
||||
if (
|
||||
compactStatement.startsWith(
|
||||
'INSERT INTO downloads'
|
||||
) &&
|
||||
compactStatement.includes('metadata_snapshot') &&
|
||||
!compactStatement.includes(
|
||||
'NULL AS metadata_snapshot'
|
||||
)
|
||||
) {
|
||||
rebuiltMetadataSnapshot = legacyMetadataSnapshot;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { prepare, statements, transaction };
|
||||
return {
|
||||
getRebuiltMetadataSnapshot: () => rebuiltMetadataSnapshot,
|
||||
prepare,
|
||||
statements,
|
||||
transaction,
|
||||
};
|
||||
}
|
||||
|
||||
it('defines the core fresh-install tables, indexes, and FTS triggers', () => {
|
||||
const schemaSql = createTableStatements.map(compactSql).join('\n');
|
||||
const downloadColumns = Object.values(getTableColumns(downloads)).map(
|
||||
(column) => column.name
|
||||
);
|
||||
|
||||
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS playlists');
|
||||
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS categories');
|
||||
@@ -104,6 +130,8 @@ describe('database schema statements', () => {
|
||||
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS downloads');
|
||||
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 +177,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,6 +519,9 @@ 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]).toContain(
|
||||
'NULL AS metadata_snapshot'
|
||||
);
|
||||
expect(statements[copyIndex]).not.toContain('resume_validator');
|
||||
});
|
||||
|
||||
@@ -506,6 +540,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 sqlite = createRebuildSqlite(
|
||||
`CREATE TABLE downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
request_headers TEXT,
|
||||
metadata_snapshot TEXT,
|
||||
status TEXT CHECK (
|
||||
status IN (
|
||||
'queued',
|
||||
'downloading',
|
||||
'completed',
|
||||
'failed',
|
||||
'canceled'
|
||||
)
|
||||
)
|
||||
)`,
|
||||
metadataSnapshot
|
||||
);
|
||||
|
||||
ensureDownloadsPauseResumeSchema(sqlite);
|
||||
|
||||
const copy = sqlite.statements.find((statement) =>
|
||||
statement.startsWith('INSERT INTO downloads')
|
||||
);
|
||||
expect(copy).toBeDefined();
|
||||
expect(copy).toContain('metadata_snapshot');
|
||||
expect(copy).not.toContain('NULL AS metadata_snapshot');
|
||||
expect(sqlite.getRebuiltMetadataSnapshot()).toBe(metadataSnapshot);
|
||||
});
|
||||
|
||||
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')))`
|
||||
|
||||
@@ -105,6 +105,7 @@ 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,
|
||||
@@ -379,6 +380,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 = [
|
||||
@@ -1002,6 +1005,10 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
|
||||
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 rebuild = sqliteDb.transaction(() => {
|
||||
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
|
||||
const match = statement.match(
|
||||
@@ -1034,6 +1041,7 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
|
||||
file_path,
|
||||
poster_url,
|
||||
request_headers,
|
||||
metadata_snapshot,
|
||||
status,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
@@ -1055,6 +1063,7 @@ function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
|
||||
file_path,
|
||||
poster_url,
|
||||
${legacyHeadersSelect},
|
||||
${legacyMetadataSnapshotSelect},
|
||||
status,
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
|
||||
@@ -335,6 +335,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: [
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './lib/channel-drm.interface';
|
||||
export * from './lib/channel.interface';
|
||||
export * from './lib/channel.model';
|
||||
export * from './lib/dev-logger.util';
|
||||
export * from './lib/download-metadata.interface';
|
||||
export * from './lib/embedded-mpv-session.interface';
|
||||
export * from './lib/electron-api.interface';
|
||||
export * from './lib/epg-channel-metadata.model';
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface DownloadMetadataPerson {
|
||||
tmdbPersonId?: number;
|
||||
name: string;
|
||||
role?: string;
|
||||
profileUrl?: string;
|
||||
}
|
||||
|
||||
export interface DownloadEpisodeMetadata {
|
||||
title?: string;
|
||||
plot?: string;
|
||||
stillUrl?: string;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
}
|
||||
|
||||
export interface DownloadMetadataSnapshot {
|
||||
version: 1;
|
||||
language: string;
|
||||
mediaKind: 'movie' | 'series';
|
||||
title: string;
|
||||
originalTitle?: string;
|
||||
plot?: string;
|
||||
releaseDate?: string;
|
||||
year?: number;
|
||||
durationMinutes?: number;
|
||||
genres?: string[];
|
||||
rating?: number;
|
||||
status?: string;
|
||||
posterUrl?: string;
|
||||
backdropUrl?: string;
|
||||
tmdbId?: number;
|
||||
providerCategoryId?: string;
|
||||
cast?: DownloadMetadataPerson[];
|
||||
creators?: DownloadMetadataPerson[];
|
||||
episode?: DownloadEpisodeMetadata;
|
||||
enrichedAt?: string;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
EmbeddedMpvSession,
|
||||
EmbeddedMpvSupport,
|
||||
} from './embedded-mpv-session.interface';
|
||||
import { DownloadMetadataSnapshot } from './download-metadata.interface';
|
||||
import { EpgChannelMetadata } from './epg-channel-metadata.model';
|
||||
import { EpgProgram } from './epg-program.model';
|
||||
import { ExternalPlayerSession } from './external-player-session.interface';
|
||||
@@ -516,6 +517,7 @@ export interface ElectronBridgeDownloadStartPayload {
|
||||
title: string;
|
||||
url: string;
|
||||
posterUrl?: string;
|
||||
metadataSnapshot?: DownloadMetadataSnapshot;
|
||||
downloadFolder: string;
|
||||
headers?: ElectronBridgeDownloadHeaders;
|
||||
seriesXtreamId?: number;
|
||||
@@ -549,6 +551,7 @@ export interface ElectronDownloadItem {
|
||||
fileName?: string;
|
||||
filePath?: string;
|
||||
posterUrl?: string;
|
||||
metadataSnapshot?: DownloadMetadataSnapshot;
|
||||
status: ElectronBridgeDownloadStatus;
|
||||
fileAvailability: ElectronDownloadFileAvailability;
|
||||
bytesDownloaded?: number;
|
||||
@@ -1083,6 +1086,10 @@ export interface ElectronBridgeApi {
|
||||
downloadsRemove: (downloadId: number) => Promise<ElectronBridgeErrorResult>;
|
||||
downloadsGetList: (playlistId?: string) => Promise<ElectronDownloadItem[]>;
|
||||
downloadsGet: (downloadId: number) => Promise<ElectronDownloadItem | null>;
|
||||
downloadsUpdateMetadata: (
|
||||
downloadId: number,
|
||||
metadataSnapshot: DownloadMetadataSnapshot
|
||||
) => Promise<ElectronBridgeErrorResult>;
|
||||
downloadsGetDefaultFolder: () => Promise<string>;
|
||||
downloadsSelectFolder: () => Promise<string | null>;
|
||||
downloadsRevealFile: (
|
||||
|
||||
Reference in new issue
Block a user