diff --git a/src/main/lib/history-manager.ts b/src/main/lib/history-manager.ts index b948fe6..b94222d 100644 --- a/src/main/lib/history-manager.ts +++ b/src/main/lib/history-manager.ts @@ -1,8 +1,7 @@ import { existsSync, readFileSync, renameSync } from 'node:fs' import { join } from 'node:path' -import type { Database as BetterSqlite3Instance } from 'better-sqlite3' import DatabaseConstructor from 'better-sqlite3' -import { eq } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3' import { drizzle } from 'drizzle-orm/better-sqlite3' import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' @@ -12,7 +11,161 @@ import type { DownloadHistoryItem } from '../../shared/types' const logger = log.scope('history-manager') +const TAG_SEPARATOR = '\n' + +const createDownloadHistoryTableSql = sql` + CREATE TABLE IF NOT EXISTS download_history ( + id TEXT PRIMARY KEY, + url TEXT NOT NULL, + title TEXT NOT NULL, + thumbnail TEXT, + type TEXT NOT NULL, + status TEXT NOT NULL, + download_path TEXT, + saved_file_name TEXT, + file_size INTEGER, + duration INTEGER, + downloaded_at INTEGER NOT NULL, + completed_at INTEGER, + sort_key INTEGER NOT NULL, + error TEXT, + description TEXT, + channel TEXT, + uploader TEXT, + view_count INTEGER, + tags TEXT, + origin TEXT, + subscription_id TEXT, + selected_format TEXT, + playlist_id TEXT, + playlist_title TEXT, + playlist_index INTEGER, + playlist_size INTEGER + ) +` + +const renameDownloadHistoryTableSql = sql` + ALTER TABLE download_history RENAME TO download_history_legacy +` + +const dropLegacyDownloadHistoryTableSql = sql` + DROP TABLE download_history_legacy +` + +const copyDownloadHistoryFromLegacySql = sql` + INSERT INTO download_history ( + id, + url, + title, + thumbnail, + type, + status, + download_path, + saved_file_name, + file_size, + duration, + downloaded_at, + completed_at, + sort_key, + error, + description, + channel, + uploader, + view_count, + tags, + origin, + subscription_id, + selected_format, + playlist_id, + playlist_title, + playlist_index, + playlist_size + ) + SELECT + id, + url, + title, + thumbnail, + type, + status, + download_path, + saved_file_name, + file_size, + duration, + downloaded_at, + completed_at, + sort_key, + error, + description, + channel, + uploader, + view_count, + tags, + origin, + subscription_id, + selected_format, + playlist_id, + playlist_title, + playlist_index, + playlist_size + FROM download_history_legacy +` + +const sanitizeList = (values?: string[]): string[] => { + if (!values || values.length === 0) { + return [] + } + return values + .map((value) => value.trim()) + .filter((value, index, array) => value.length > 0 && array.indexOf(value) === index) +} + +const serializeTags = (values?: string[]): string | null => { + const sanitized = sanitizeList(values) + return sanitized.length > 0 ? sanitized.join(TAG_SEPARATOR) : null +} + +const parseTags = (value: string | null): string[] | undefined => { + if (!value) { + return undefined + } + const parsed = value + .split(TAG_SEPARATOR) + .map((tag) => tag.trim()) + .filter((tag, index, array) => tag.length > 0 && array.indexOf(tag) === index) + return parsed.length > 0 ? parsed : undefined +} + const downloadHistoryTable = sqliteTable('download_history', { + id: text('id').primaryKey(), + url: text('url').notNull(), + title: text('title').notNull(), + thumbnail: text('thumbnail'), + type: text('type').notNull(), + status: text('status').notNull(), + downloadPath: text('download_path'), + savedFileName: text('saved_file_name'), + fileSize: integer('file_size', { mode: 'number' }), + duration: integer('duration', { mode: 'number' }), + downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(), + completedAt: integer('completed_at', { mode: 'number' }), + sortKey: integer('sort_key', { mode: 'number' }).notNull(), + error: text('error'), + description: text('description'), + channel: text('channel'), + uploader: text('uploader'), + viewCount: integer('view_count', { mode: 'number' }), + tags: text('tags'), + origin: text('origin'), + subscriptionId: text('subscription_id'), + selectedFormat: text('selected_format'), + playlistId: text('playlist_id'), + playlistTitle: text('playlist_title'), + playlistIndex: integer('playlist_index', { mode: 'number' }), + playlistSize: integer('playlist_size', { mode: 'number' }) +}) + +const legacyDownloadHistoryTable = sqliteTable('download_history_legacy', { id: text('id').primaryKey(), status: text('status').notNull(), downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(), @@ -23,11 +176,12 @@ const downloadHistoryTable = sqliteTable('download_history', { type DownloadHistoryRow = typeof downloadHistoryTable.$inferSelect type DownloadHistoryInsert = typeof downloadHistoryTable.$inferInsert +type LegacyDownloadHistoryRow = typeof legacyDownloadHistoryTable.$inferSelect class HistoryManager { - private sqlite: BetterSqlite3Instance | null = null private db: BetterSQLite3Database | null = null private history: Map = new Map() + private schemaChecked = false private migrationChecked = false constructor() { @@ -37,6 +191,7 @@ class HistoryManager { private initialize(): void { try { this.getDatabase() + this.ensureStructuredSchema() this.ensureLegacyMigration() this.loadHistoryFromDatabase() } catch (error) { @@ -51,23 +206,14 @@ class HistoryManager { const databasePath = this.getDatabasePath() - this.sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 }) - this.sqlite.pragma('journal_mode = WAL') - this.sqlite.pragma('foreign_keys = ON') - this.sqlite - .prepare( - `CREATE TABLE IF NOT EXISTS download_history ( - id TEXT PRIMARY KEY, - status TEXT NOT NULL, - downloaded_at INTEGER NOT NULL, - completed_at INTEGER, - sort_key INTEGER NOT NULL, - payload TEXT NOT NULL - )` - ) - .run() + const sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 }) + sqlite.pragma('journal_mode = WAL') + sqlite.pragma('foreign_keys = ON') - this.db = drizzle(this.sqlite) + const database = drizzle(sqlite) + database.run(createDownloadHistoryTableSql) + + this.db = database logger.info(`history-db initialized at ${databasePath}`) return this.db } @@ -80,6 +226,105 @@ class HistoryManager { return join(app.getPath('userData'), 'download-history.json') } + private ensureStructuredSchema(): void { + if (this.schemaChecked) { + return + } + this.schemaChecked = true + + try { + const database = this.getDatabase() + const columns = database.all<{ name: string }>(sql`PRAGMA table_info(download_history)`) + const hasPayloadColumn = columns.some((column) => column.name === 'payload') + const hasUrlColumn = columns.some((column) => column.name === 'url') + if (hasPayloadColumn || !hasUrlColumn) { + this.migrateLegacyPayloadTable() + return + } + + const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec'] + const needsRebuild = columns.some((column) => deprecatedColumns.includes(column.name)) + if (needsRebuild) { + this.rebuildDownloadHistoryTable() + } + } catch (error) { + logger.error('history-db failed to inspect schema', error) + } + } + + private migrateLegacyPayloadTable(): void { + const database = this.getDatabase() + logger.info('history-db migrating legacy payload schema to structured columns') + + try { + let migratedCount = 0 + database.transaction( + (tx) => { + tx.run(renameDownloadHistoryTableSql) + tx.run(createDownloadHistoryTableSql) + + const legacyRows = tx.select().from(legacyDownloadHistoryTable).all() + migratedCount = legacyRows.length + for (const legacyRow of legacyRows) { + const normalized = this.normalizeItem(this.mapLegacyRowToItem(legacyRow)) + tx.insert(downloadHistoryTable).values(this.mapItemToInsert(normalized)).run() + } + + tx.run(dropLegacyDownloadHistoryTableSql) + }, + { behavior: 'immediate' } + ) + logger.info(`history-db migrated ${migratedCount} rows to new schema`) + } catch (error) { + logger.error('history-db failed to migrate legacy payload rows', error) + throw error + } + } + + private rebuildDownloadHistoryTable(): void { + const database = this.getDatabase() + logger.info('history-db rebuilding download_history table to latest schema') + + try { + database.transaction( + (tx) => { + tx.run(renameDownloadHistoryTableSql) + tx.run(createDownloadHistoryTableSql) + tx.run(copyDownloadHistoryFromLegacySql) + tx.run(dropLegacyDownloadHistoryTableSql) + }, + { behavior: 'immediate' } + ) + logger.info('history-db rebuilt download_history table') + } catch (error) { + logger.error('history-db failed to rebuild download_history schema', error) + throw error + } + } + + private mapLegacyRowToItem(row: LegacyDownloadHistoryRow): DownloadHistoryItem { + try { + const parsed = JSON.parse(row.payload) as DownloadHistoryItem + return { + ...parsed, + status: (parsed.status ?? row.status) as DownloadHistoryItem['status'], + downloadedAt: parsed.downloadedAt ?? row.downloadedAt, + completedAt: parsed.completedAt ?? row.completedAt ?? undefined + } + } catch (error) { + logger.warn('history-db falling back while migrating payload row', { id: row.id, error }) + return { + id: row.id, + url: row.id, + title: `Download ${row.id}`, + type: 'video', + status: row.status as DownloadHistoryItem['status'], + downloadedAt: row.downloadedAt, + completedAt: row.completedAt ?? undefined + } + } + } + private ensureLegacyMigration(): void { if (this.migrationChecked) { return @@ -154,11 +399,31 @@ class HistoryManager { private mapItemToInsert(item: DownloadHistoryItem): DownloadHistoryInsert { return { id: item.id, + url: item.url, + title: item.title, + thumbnail: item.thumbnail ?? null, + type: item.type, status: item.status, + downloadPath: item.downloadPath ?? null, + savedFileName: item.savedFileName ?? null, + fileSize: item.fileSize ?? null, + duration: item.duration ?? null, downloadedAt: item.downloadedAt, completedAt: item.completedAt ?? null, sortKey: item.completedAt ?? item.downloadedAt, - payload: JSON.stringify(item) + error: item.error ?? null, + description: item.description ?? null, + channel: item.channel ?? null, + uploader: item.uploader ?? null, + viewCount: item.viewCount ?? null, + tags: serializeTags(item.tags) ?? null, + origin: item.origin ?? null, + subscriptionId: item.subscriptionId ?? null, + selectedFormat: item.selectedFormat ? JSON.stringify(item.selectedFormat) : null, + playlistId: item.playlistId ?? null, + playlistTitle: item.playlistTitle ?? null, + playlistIndex: item.playlistIndex ?? null, + playlistSize: item.playlistSize ?? null } } @@ -168,26 +433,44 @@ class HistoryManager { } private mapRowToItem(row: DownloadHistoryRow): DownloadHistoryItem { - try { - const parsed = JSON.parse(row.payload) as DownloadHistoryItem - return { - ...parsed, - status: row.status as DownloadHistoryItem['status'], - downloadedAt: row.downloadedAt, - completedAt: row.completedAt ?? undefined - } - } catch (error) { - logger.error('history-db failed to parse stored payload', { id: row.id, error }) - return { - id: row.id, - url: '', - title: 'Unknown download', - type: 'video', - status: row.status as DownloadHistoryItem['status'], - downloadedAt: row.downloadedAt, - completedAt: row.completedAt ?? undefined + let selectedFormat: DownloadHistoryItem['selectedFormat'] + if (row.selectedFormat) { + try { + selectedFormat = JSON.parse(row.selectedFormat) as DownloadHistoryItem['selectedFormat'] + } catch (error) { + logger.warn('history-db failed to parse stored selectedFormat', { id: row.id, error }) } } + + const tags = parseTags(row.tags ?? null) + + return { + id: row.id, + url: row.url, + title: row.title, + thumbnail: row.thumbnail ?? undefined, + type: row.type as DownloadHistoryItem['type'], + status: row.status as DownloadHistoryItem['status'], + downloadPath: row.downloadPath ?? undefined, + savedFileName: row.savedFileName ?? undefined, + fileSize: row.fileSize ?? undefined, + duration: row.duration ?? undefined, + downloadedAt: row.downloadedAt, + completedAt: row.completedAt ?? undefined, + error: row.error ?? undefined, + description: row.description ?? undefined, + channel: row.channel ?? undefined, + uploader: row.uploader ?? undefined, + viewCount: row.viewCount ?? undefined, + tags, + origin: row.origin ? (row.origin as DownloadHistoryItem['origin']) : undefined, + subscriptionId: row.subscriptionId ?? undefined, + selectedFormat, + playlistId: row.playlistId ?? undefined, + playlistTitle: row.playlistTitle ?? undefined, + playlistIndex: row.playlistIndex ?? undefined, + playlistSize: row.playlistSize ?? undefined + } } addHistoryItem(item: DownloadHistoryItem): void {