* Add collapsed playlist progress * Improve history bulk actions and selection UX * Fix ffmpeg download resolution in setup script
627 lines
18 KiB
TypeScript
627 lines
18 KiB
TypeScript
import { existsSync, readFileSync, renameSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import DatabaseConstructor from 'better-sqlite3'
|
|
import { eq, inArray, 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'
|
|
import { app } from 'electron'
|
|
import log from 'electron-log/main'
|
|
import type { DownloadHistoryItem } from '../../shared/types'
|
|
import { runMigrations } from './database/migrate'
|
|
import {
|
|
type DownloadHistoryInsert,
|
|
type DownloadHistoryRow,
|
|
downloadHistoryTable
|
|
} from './database/schema'
|
|
import { getDatabaseFilePath } from './database-path'
|
|
|
|
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 legacyDownloadHistoryTable = sqliteTable('download_history_legacy', {
|
|
id: text('id').primaryKey(),
|
|
status: text('status').notNull(),
|
|
downloadedAt: integer('downloaded_at', { mode: 'number' }).notNull(),
|
|
completedAt: integer('completed_at', { mode: 'number' }),
|
|
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
|
|
payload: text('payload').notNull()
|
|
})
|
|
type LegacyDownloadHistoryRow = typeof legacyDownloadHistoryTable.$inferSelect
|
|
|
|
class HistoryManager {
|
|
private db: BetterSQLite3Database | null = null
|
|
private history: Map<string, DownloadHistoryItem> = new Map()
|
|
private schemaChecked = false
|
|
private migrationChecked = false
|
|
|
|
constructor() {
|
|
this.initialize()
|
|
}
|
|
|
|
private initialize(): void {
|
|
try {
|
|
this.getDatabase()
|
|
this.ensureStructuredSchema()
|
|
this.ensureLegacyMigration()
|
|
this.loadHistoryFromDatabase()
|
|
} catch (error) {
|
|
logger.error('history-db failed to initialize', error)
|
|
}
|
|
}
|
|
|
|
private getDatabase(): BetterSQLite3Database {
|
|
if (this.db) {
|
|
return this.db
|
|
}
|
|
|
|
const databasePath = this.getDatabasePath()
|
|
|
|
const sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
|
|
sqlite.pragma('journal_mode = WAL')
|
|
sqlite.pragma('foreign_keys = ON')
|
|
|
|
const database = drizzle(sqlite)
|
|
runMigrations(database)
|
|
|
|
this.db = database
|
|
logger.info(`history-db initialized at ${databasePath}`)
|
|
return this.db
|
|
}
|
|
|
|
private getDatabasePath(): string {
|
|
return getDatabaseFilePath()
|
|
}
|
|
|
|
private getLegacyStorePath(): string {
|
|
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
|
|
}
|
|
this.migrationChecked = true
|
|
|
|
const legacyPath = this.getLegacyStorePath()
|
|
if (!existsSync(legacyPath)) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const raw = readFileSync(legacyPath, 'utf-8')
|
|
const parsed = JSON.parse(raw) as { items?: DownloadHistoryItem[] }
|
|
const items = parsed.items ?? []
|
|
if (items.length === 0) {
|
|
return
|
|
}
|
|
|
|
const database = this.getDatabase()
|
|
for (const legacyItem of items) {
|
|
const normalized = this.normalizeItem(legacyItem)
|
|
const insertPayload = this.mapItemToInsert(normalized)
|
|
database
|
|
.insert(downloadHistoryTable)
|
|
.values(insertPayload)
|
|
.onConflictDoUpdate({
|
|
target: downloadHistoryTable.id,
|
|
set: this.mapItemToUpdate(insertPayload)
|
|
})
|
|
.run()
|
|
}
|
|
|
|
try {
|
|
renameSync(legacyPath, `${legacyPath}.bak`)
|
|
} catch (renameError) {
|
|
logger.warn(
|
|
'history-db migrated legacy store but could not rename original file',
|
|
renameError
|
|
)
|
|
}
|
|
|
|
logger.info(`history-db migrated ${items.length} entries from legacy electron-store data`)
|
|
} catch (error) {
|
|
logger.error('history-db failed to migrate legacy store', error)
|
|
}
|
|
}
|
|
|
|
private loadHistoryFromDatabase(): void {
|
|
try {
|
|
const database = this.getDatabase()
|
|
const rows = database.select().from(downloadHistoryTable).all()
|
|
this.history = new Map(rows.map((row) => [row.id, this.mapRowToItem(row)]))
|
|
} catch (error) {
|
|
logger.error('history-db failed to load rows', error)
|
|
this.history = new Map()
|
|
}
|
|
}
|
|
|
|
private normalizeItem(item: DownloadHistoryItem): DownloadHistoryItem {
|
|
const fallbackTimestamp = Date.now()
|
|
const downloadedAt = item.downloadedAt ?? item.completedAt ?? fallbackTimestamp
|
|
const status = item.status ?? 'pending'
|
|
|
|
return {
|
|
...item,
|
|
status,
|
|
downloadedAt
|
|
}
|
|
}
|
|
|
|
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,
|
|
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
|
|
}
|
|
}
|
|
|
|
private mapItemToUpdate(payload: DownloadHistoryInsert): Omit<DownloadHistoryInsert, 'id'> {
|
|
const { id: _id, ...rest } = payload
|
|
return rest
|
|
}
|
|
|
|
private mapRowToItem(row: DownloadHistoryRow): DownloadHistoryItem {
|
|
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 {
|
|
const normalized = this.normalizeItem(item)
|
|
const insertPayload = this.mapItemToInsert(normalized)
|
|
try {
|
|
const database = this.getDatabase()
|
|
database
|
|
.insert(downloadHistoryTable)
|
|
.values(insertPayload)
|
|
.onConflictDoUpdate({
|
|
target: downloadHistoryTable.id,
|
|
set: this.mapItemToUpdate(insertPayload)
|
|
})
|
|
.run()
|
|
this.history.set(normalized.id, normalized)
|
|
} catch (error) {
|
|
logger.error('history-db failed to upsert item', { id: normalized.id, error })
|
|
}
|
|
}
|
|
|
|
getHistory(): DownloadHistoryItem[] {
|
|
return Array.from(this.history.values()).sort((a, b) => {
|
|
const aTime = a.completedAt ?? a.downloadedAt
|
|
const bTime = b.completedAt ?? b.downloadedAt
|
|
return bTime - aTime
|
|
})
|
|
}
|
|
|
|
getHistoryById(id: string): DownloadHistoryItem | undefined {
|
|
return this.history.get(id)
|
|
}
|
|
|
|
removeHistoryItem(id: string): boolean {
|
|
try {
|
|
const database = this.getDatabase()
|
|
const result = database
|
|
.delete(downloadHistoryTable)
|
|
.where(eq(downloadHistoryTable.id, id))
|
|
.run()
|
|
const removedFromMap = this.history.delete(id)
|
|
return result.changes > 0 || removedFromMap
|
|
} catch (error) {
|
|
logger.error('history-db failed to delete item', { id, error })
|
|
return false
|
|
}
|
|
}
|
|
|
|
removeHistoryItems(ids: string[]): number {
|
|
const uniqueIds = Array.from(new Set(ids)).filter((id) => id.trim().length > 0)
|
|
if (uniqueIds.length === 0) {
|
|
return 0
|
|
}
|
|
let removedCount = 0
|
|
try {
|
|
const database = this.getDatabase()
|
|
const result = database
|
|
.delete(downloadHistoryTable)
|
|
.where(inArray(downloadHistoryTable.id, uniqueIds))
|
|
.run()
|
|
for (const id of uniqueIds) {
|
|
if (this.history.delete(id)) {
|
|
removedCount++
|
|
}
|
|
}
|
|
if ((result.changes ?? 0) > removedCount) {
|
|
removedCount = result.changes ?? removedCount
|
|
}
|
|
return removedCount
|
|
} catch (error) {
|
|
logger.error('history-db failed to delete items', { count: uniqueIds.length, error })
|
|
return removedCount
|
|
}
|
|
}
|
|
|
|
removeHistoryByPlaylistId(playlistId: string): number {
|
|
const normalized = playlistId.trim()
|
|
if (!normalized) {
|
|
return 0
|
|
}
|
|
let removedCount = 0
|
|
try {
|
|
const database = this.getDatabase()
|
|
const result = database
|
|
.delete(downloadHistoryTable)
|
|
.where(eq(downloadHistoryTable.playlistId, normalized))
|
|
.run()
|
|
for (const [id, item] of this.history.entries()) {
|
|
if (item.playlistId === normalized) {
|
|
this.history.delete(id)
|
|
removedCount++
|
|
}
|
|
}
|
|
if ((result.changes ?? 0) > removedCount) {
|
|
removedCount = result.changes ?? removedCount
|
|
}
|
|
return removedCount
|
|
} catch (error) {
|
|
logger.error('history-db failed to delete playlist items', { playlistId: normalized, error })
|
|
return removedCount
|
|
}
|
|
}
|
|
|
|
clearHistory(): void {
|
|
try {
|
|
const database = this.getDatabase()
|
|
database.delete(downloadHistoryTable).run()
|
|
this.history.clear()
|
|
} catch (error) {
|
|
logger.error('history-db failed to clear items', error)
|
|
}
|
|
}
|
|
|
|
clearHistoryByStatus(status: DownloadHistoryItem['status']): number {
|
|
let removedCount = 0
|
|
try {
|
|
const database = this.getDatabase()
|
|
const result = database
|
|
.delete(downloadHistoryTable)
|
|
.where(eq(downloadHistoryTable.status, status))
|
|
.run()
|
|
for (const [id, item] of this.history.entries()) {
|
|
if (item.status === status) {
|
|
this.history.delete(id)
|
|
removedCount++
|
|
}
|
|
}
|
|
if ((result.changes ?? 0) > removedCount) {
|
|
removedCount = result.changes ?? removedCount
|
|
}
|
|
return removedCount
|
|
} catch (error) {
|
|
logger.error('history-db failed to clear items by status', { status, error })
|
|
return removedCount
|
|
}
|
|
}
|
|
|
|
getHistoryCount(): {
|
|
active: number
|
|
completed: number
|
|
error: number
|
|
cancelled: number
|
|
total: number
|
|
} {
|
|
const counts = {
|
|
active: 0,
|
|
completed: 0,
|
|
error: 0,
|
|
cancelled: 0,
|
|
total: this.history.size
|
|
}
|
|
|
|
for (const item of this.history.values()) {
|
|
if (item.status === 'completed') {
|
|
counts.completed++
|
|
} else if (item.status === 'error') {
|
|
counts.error++
|
|
} else if (item.status === 'cancelled') {
|
|
counts.cancelled++
|
|
} else {
|
|
counts.active++
|
|
}
|
|
}
|
|
|
|
return counts
|
|
}
|
|
|
|
hasHistoryForUrl(url: string): boolean {
|
|
for (const item of this.history.values()) {
|
|
if (item.url === url) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
export const historyManager = new HistoryManager()
|