feat: rss-subscriptions (#28)
* feat(subscriptions): add subscription atoms and types for feeds and rules * refactor(types): remove obsolete format/quality/codec fields and related logic to simplify download and avoid redundant storage * feat: add download_history schema, migration, and tag utils * refactor(dialog): convert to functional wrappers, simplify overlay and * feat(rss): add enclosure support, refine thumbnail lookup and UI labels feat(settings): add toggleable anonymous analytics collection and loader script * feat(settings): add toggleable anonymous analytics collection and loader script * feat(i18n): add subscriptions UI strings and import downloads types * refactor(subscriptions): remove legacy status fields and migrate items ordering * feat: add Radix HoverCard dependency and wrap tabs with HoverCard for * feat(db): move download history schema into migrate + drizzle config * feat(subscriptions): add item queueing UI and IPC handler * feat(downloads): normalize saved filenames, improve candidates and UI display
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import path from 'node:path'
|
||||
import type { AppSettings, DownloadOptions } from '../../shared/types'
|
||||
|
||||
export const sanitizeFilenameTemplate = (template: string): string => {
|
||||
const trimmed = template.trim()
|
||||
const sanitized = trimmed.replace(/[/\\]+/g, '-')
|
||||
return sanitized === '' ? '%(title)s via VidBee.%(ext)s' : sanitized
|
||||
}
|
||||
|
||||
export const resolveVideoFormatSelector = (options: DownloadOptions): string => {
|
||||
const format = options.format
|
||||
const audioFormat = options.audioFormat
|
||||
@@ -80,7 +86,12 @@ export const buildDownloadArgs = (
|
||||
}
|
||||
|
||||
// Output path with proper encoding handling
|
||||
const outputTemplate = path.join(downloadPath, '%(title)s via VidBee.%(ext)s')
|
||||
const baseDownloadPath = options.customDownloadPath?.trim() || downloadPath
|
||||
const filenameTemplate = sanitizeFilenameTemplate(
|
||||
options.customFilenameTemplate ?? '%(title)s via VidBee.%(ext)s'
|
||||
)
|
||||
const safeTemplate = filenameTemplate.replace(/^[\\/]+/, '')
|
||||
const outputTemplate = path.join(baseDownloadPath, safeTemplate)
|
||||
args.push('-o', outputTemplate)
|
||||
|
||||
// Add options for better filename handling
|
||||
|
||||
@@ -8,6 +8,8 @@ import { configureLogger } from './config/logger-config'
|
||||
import { services } from './ipc'
|
||||
import { downloadEngine } from './lib/download-engine'
|
||||
import { ffmpegManager } from './lib/ffmpeg-manager'
|
||||
import { subscriptionManager } from './lib/subscription-manager'
|
||||
import { subscriptionScheduler } from './lib/subscription-scheduler'
|
||||
import { ytdlpManager } from './lib/ytdlp-manager'
|
||||
import { settingsManager } from './settings'
|
||||
import { createTray, destroyTray } from './tray'
|
||||
@@ -22,6 +24,10 @@ configureLogger()
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let isQuitting = false
|
||||
|
||||
subscriptionManager.on('subscriptions:updated', (subscriptions) => {
|
||||
mainWindow?.webContents.send('subscriptions:updated', subscriptions)
|
||||
})
|
||||
|
||||
export function createWindow(): void {
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isWindows = process.platform === 'win32'
|
||||
@@ -80,6 +86,10 @@ export function createWindow(): void {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
mainWindow?.webContents.send('subscriptions:updated', subscriptionManager.getAll())
|
||||
})
|
||||
|
||||
// Setup download engine event forwarding to renderer
|
||||
setupDownloadEvents()
|
||||
}
|
||||
@@ -214,6 +224,8 @@ app.whenReady().then(async () => {
|
||||
// Create system tray
|
||||
createTray()
|
||||
|
||||
subscriptionScheduler.start()
|
||||
|
||||
app.on('activate', () => {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DownloadService } from './services/download-service'
|
||||
import { FileSystemService } from './services/file-system-service'
|
||||
import { HistoryService } from './services/history-service'
|
||||
import { SettingsService } from './services/settings-service'
|
||||
import { SubscriptionService } from './services/subscription-service'
|
||||
import { ThumbnailService } from './services/thumbnail-service'
|
||||
import { UpdateService } from './services/update-service'
|
||||
import { WindowService } from './services/window-service'
|
||||
@@ -15,6 +16,7 @@ export const services = createServices([
|
||||
FileSystemService,
|
||||
HistoryService,
|
||||
SettingsService,
|
||||
SubscriptionService,
|
||||
ThumbnailService,
|
||||
UpdateService,
|
||||
WindowService
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type { AppSettings } from '../../../shared/types'
|
||||
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||
import { settingsManager } from '../../settings'
|
||||
import { updateTrayMenu } from '../../tray'
|
||||
import { applyDockVisibility } from '../../utils/dock'
|
||||
@@ -9,12 +11,20 @@ class SettingsService extends IpcService {
|
||||
|
||||
@IpcMethod()
|
||||
get<K extends keyof AppSettings>(_context: IpcContext, key: K): AppSettings[K] {
|
||||
return settingsManager.get(key)
|
||||
const value = settingsManager.get(key)
|
||||
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
|
||||
return sanitizeFilenameTemplate(value) as AppSettings[K]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
set<K extends keyof AppSettings>(_context: IpcContext, key: K, value: AppSettings[K]): void {
|
||||
settingsManager.set(key, value)
|
||||
if (key === 'subscriptionFilenameTemplate' && typeof value === 'string') {
|
||||
settingsManager.set(key, sanitizeFilenameTemplate(value) as AppSettings[K])
|
||||
} else {
|
||||
settingsManager.set(key, value)
|
||||
}
|
||||
|
||||
if (key === 'language') {
|
||||
updateTrayMenu()
|
||||
@@ -23,15 +33,30 @@ class SettingsService extends IpcService {
|
||||
if (key === 'hideDockIcon') {
|
||||
applyDockVisibility(value as AppSettings['hideDockIcon'])
|
||||
}
|
||||
|
||||
if (key === 'subscriptionCheckIntervalHours') {
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getAll(_context: IpcContext): AppSettings {
|
||||
return settingsManager.getAll()
|
||||
const settings = settingsManager.getAll()
|
||||
if (typeof settings.subscriptionFilenameTemplate === 'string') {
|
||||
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
|
||||
settings.subscriptionFilenameTemplate
|
||||
)
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
setAll(_context: IpcContext, settings: Partial<AppSettings>): void {
|
||||
if (typeof settings.subscriptionFilenameTemplate === 'string') {
|
||||
settings.subscriptionFilenameTemplate = sanitizeFilenameTemplate(
|
||||
settings.subscriptionFilenameTemplate
|
||||
)
|
||||
}
|
||||
settingsManager.setAll(settings)
|
||||
|
||||
if (settings.language) {
|
||||
@@ -41,12 +66,17 @@ class SettingsService extends IpcService {
|
||||
if (typeof settings.hideDockIcon === 'boolean') {
|
||||
applyDockVisibility(settings.hideDockIcon)
|
||||
}
|
||||
|
||||
if (settings.subscriptionCheckIntervalHours !== undefined) {
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
reset(_context: IpcContext): void {
|
||||
settingsManager.reset()
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
subscriptionScheduler.refreshInterval()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
164
src/main/ipc/services/subscription-service.ts
Normal file
164
src/main/ipc/services/subscription-service.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||
import type {
|
||||
SubscriptionCreatePayload,
|
||||
SubscriptionResolvedFeed,
|
||||
SubscriptionRule,
|
||||
SubscriptionUpdatePayload
|
||||
} from '../../../shared/types'
|
||||
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
||||
import { subscriptionManager } from '../../lib/subscription-manager'
|
||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||
import { settingsManager } from '../../settings'
|
||||
|
||||
interface CreateSubscriptionOptions {
|
||||
url: string
|
||||
keywords?: string[]
|
||||
tags?: string[]
|
||||
onlyDownloadLatest?: boolean
|
||||
downloadDirectory?: string
|
||||
namingTemplate?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
const ensureUrlHasProtocol = (value: string): string => {
|
||||
if (!value) {
|
||||
return value
|
||||
}
|
||||
if (!/^https?:\/\//i.test(value)) {
|
||||
return `https://${value}`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const resolveFeedFromInput = (rawUrl: string): SubscriptionResolvedFeed => {
|
||||
const normalized = ensureUrlHasProtocol(rawUrl.trim())
|
||||
const youTubeChannelMatch = normalized.match(/youtube\.com\/channel\/([A-Za-z0-9_-]+)/i)
|
||||
if (youTubeChannelMatch) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: `https://www.youtube.com/feeds/videos.xml?channel_id=${youTubeChannelMatch[1]}`,
|
||||
platform: 'youtube'
|
||||
}
|
||||
}
|
||||
|
||||
if (/youtube\.com\/feeds\/videos\.xml/i.test(normalized)) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: normalized,
|
||||
platform: 'youtube'
|
||||
}
|
||||
}
|
||||
|
||||
const youTubeUserMatch = normalized.match(/youtube\.com\/(?:user|c)\/([^/?]+)/i)
|
||||
if (youTubeUserMatch) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: `https://www.youtube.com/feeds/videos.xml?user=${youTubeUserMatch[1]}`,
|
||||
platform: 'youtube'
|
||||
}
|
||||
}
|
||||
|
||||
const youTubeHandleMatch = normalized.match(/youtube\.com\/(@[^/?]+)/i)
|
||||
if (youTubeHandleMatch) {
|
||||
const handle = youTubeHandleMatch[1].replace('@', '')
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: `https://www.youtube.com/feeds/videos.xml?user=${handle}`,
|
||||
platform: 'youtube'
|
||||
}
|
||||
}
|
||||
|
||||
const biliSpaceMatch = normalized.match(/bilibili\.com\/(?:space|user)\/(\d+)/i)
|
||||
if (biliSpaceMatch) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: `https://rsshub.app/bilibili/user/video/${biliSpaceMatch[1]}`,
|
||||
platform: 'bilibili'
|
||||
}
|
||||
}
|
||||
|
||||
if (/rsshub\.app\/bilibili/i.test(normalized)) {
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: normalized,
|
||||
platform: 'bilibili'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceUrl: normalized,
|
||||
feedUrl: normalized,
|
||||
platform: 'custom'
|
||||
}
|
||||
}
|
||||
|
||||
class SubscriptionService extends IpcService {
|
||||
static readonly groupName = 'subscriptions'
|
||||
|
||||
@IpcMethod()
|
||||
list(_context: IpcContext): SubscriptionRule[] {
|
||||
return subscriptionManager.getAll()
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
resolve(_context: IpcContext, url: string): SubscriptionResolvedFeed {
|
||||
return resolveFeedFromInput(url)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async create(
|
||||
_context: IpcContext,
|
||||
options: CreateSubscriptionOptions
|
||||
): Promise<SubscriptionRule> {
|
||||
const resolved = resolveFeedFromInput(options.url)
|
||||
const settings = settingsManager.getAll()
|
||||
const payload: SubscriptionCreatePayload = {
|
||||
sourceUrl: resolved.sourceUrl,
|
||||
feedUrl: resolved.feedUrl,
|
||||
platform: resolved.platform,
|
||||
keywords: options.keywords,
|
||||
tags: options.tags,
|
||||
onlyDownloadLatest:
|
||||
options.onlyDownloadLatest ?? settings.subscriptionOnlyLatestDefault ?? true,
|
||||
downloadDirectory: options.downloadDirectory || settings.downloadPath,
|
||||
namingTemplate: sanitizeFilenameTemplate(
|
||||
options.namingTemplate || settings.subscriptionFilenameTemplate
|
||||
),
|
||||
enabled: options.enabled ?? true
|
||||
}
|
||||
|
||||
const created = subscriptionManager.add(payload)
|
||||
void subscriptionScheduler.runNow(created.id)
|
||||
return created
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
update(
|
||||
_context: IpcContext,
|
||||
id: string,
|
||||
updates: SubscriptionUpdatePayload
|
||||
): SubscriptionRule | undefined {
|
||||
const normalized: SubscriptionUpdatePayload = { ...updates }
|
||||
if (typeof normalized.namingTemplate === 'string') {
|
||||
normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate)
|
||||
}
|
||||
return subscriptionManager.update(id, normalized)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
remove(_context: IpcContext, id: string): boolean {
|
||||
return subscriptionManager.remove(id)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async refresh(_context: IpcContext, id?: string): Promise<void> {
|
||||
await subscriptionScheduler.runNow(id)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async queueItem(_context: IpcContext, id: string, itemId: string): Promise<boolean> {
|
||||
return subscriptionScheduler.queueItem(id, itemId)
|
||||
}
|
||||
}
|
||||
|
||||
export { SubscriptionService }
|
||||
6
src/main/lib/database-path.ts
Normal file
6
src/main/lib/database-path.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
|
||||
export const getDatabaseFilePath = (): string => {
|
||||
return join(app.getPath('userData'), 'vidbee.db')
|
||||
}
|
||||
97
src/main/lib/database/migrate.ts
Normal file
97
src/main/lib/database/migrate.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
|
||||
import { type MigrationMeta, readMigrationFiles } from 'drizzle-orm/migrator'
|
||||
import { app } from 'electron'
|
||||
import log from 'electron-log/main'
|
||||
|
||||
const MIGRATIONS_RELATIVE_PATH = 'resources/drizzle'
|
||||
const MIGRATIONS_TABLE = '__drizzle_migrations'
|
||||
const KNOWN_TABLES = ['download_history', 'subscriptions', 'subscription_items']
|
||||
|
||||
const migrationsTableDefinition = sql`
|
||||
CREATE TABLE IF NOT EXISTS ${sql.identifier(MIGRATIONS_TABLE)} (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
)
|
||||
`
|
||||
|
||||
export const runMigrations = (database: BetterSQLite3Database): void => {
|
||||
const migrationsFolder = resolveMigrationsFolder()
|
||||
if (!migrationsFolder) {
|
||||
log.warn('database: drizzle migrations folder not found, skipping migrations')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ensureBaseline(database, migrationsFolder)
|
||||
migrate(database, { migrationsFolder, migrationsTable: MIGRATIONS_TABLE })
|
||||
} catch (error) {
|
||||
log.error('database: failed to run drizzle migrations', error)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveMigrationsFolder = (): string | null => {
|
||||
const candidates = new Set<string>()
|
||||
candidates.add(resolve(process.cwd(), MIGRATIONS_RELATIVE_PATH))
|
||||
candidates.add(resolve(__dirname, '../../../../', MIGRATIONS_RELATIVE_PATH))
|
||||
|
||||
if (process.resourcesPath) {
|
||||
candidates.add(join(process.resourcesPath, MIGRATIONS_RELATIVE_PATH))
|
||||
candidates.add(join(process.resourcesPath, 'app.asar.unpacked', MIGRATIONS_RELATIVE_PATH))
|
||||
}
|
||||
|
||||
try {
|
||||
candidates.add(join(app.getAppPath(), MIGRATIONS_RELATIVE_PATH))
|
||||
} catch {
|
||||
// app might not be ready yet, ignore
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const ensureBaseline = (database: BetterSQLite3Database, migrationsFolder: string): void => {
|
||||
if (hasTable(database, MIGRATIONS_TABLE)) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasExistingSchema = KNOWN_TABLES.some((table) => hasTable(database, table))
|
||||
if (!hasExistingSchema) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info('database: detected existing schema, seeding drizzle migrations baseline')
|
||||
database.run(migrationsTableDefinition)
|
||||
|
||||
let migrations: MigrationMeta[]
|
||||
try {
|
||||
migrations = readMigrationFiles({ migrationsFolder, migrationsTable: MIGRATIONS_TABLE })
|
||||
} catch (error) {
|
||||
log.error('database: failed to read drizzle migrations while seeding baseline', error)
|
||||
return
|
||||
}
|
||||
|
||||
database.transaction((tx) => {
|
||||
for (const migration of migrations) {
|
||||
tx.run(
|
||||
sql`INSERT INTO ${sql.identifier(MIGRATIONS_TABLE)} ("hash", "created_at") VALUES(${migration.hash}, ${migration.folderMillis})`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const hasTable = (database: BetterSQLite3Database, tableName: string): boolean => {
|
||||
const rows = database.all<{ name: string }>(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${tableName}`
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
83
src/main/lib/database/schema.ts
Normal file
83
src/main/lib/database/schema.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
export 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' })
|
||||
})
|
||||
|
||||
export const subscriptionsTable = sqliteTable('subscriptions', {
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
sourceUrl: text('source_url').notNull(),
|
||||
feedUrl: text('feed_url').notNull(),
|
||||
platform: text('platform').notNull(),
|
||||
keywords: text('keywords').notNull(),
|
||||
tags: text('tags').notNull(),
|
||||
onlyDownloadLatest: integer('only_latest', { mode: 'number' }).notNull(),
|
||||
enabled: integer('enabled', { mode: 'number' }).notNull(),
|
||||
coverUrl: text('cover_url'),
|
||||
latestVideoTitle: text('latest_video_title'),
|
||||
latestVideoPublishedAt: integer('latest_video_published_at', { mode: 'number' }),
|
||||
lastCheckedAt: integer('last_checked_at', { mode: 'number' }),
|
||||
lastSuccessAt: integer('last_success_at', { mode: 'number' }),
|
||||
status: text('status').notNull(),
|
||||
lastError: text('last_error'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||
downloadDirectory: text('download_directory'),
|
||||
namingTemplate: text('naming_template')
|
||||
})
|
||||
|
||||
export const subscriptionItemsTable = sqliteTable(
|
||||
'subscription_items',
|
||||
{
|
||||
subscriptionId: text('subscription_id').notNull(),
|
||||
itemId: text('item_id').notNull(),
|
||||
title: text('title').notNull(),
|
||||
url: text('url').notNull(),
|
||||
publishedAt: integer('published_at', { mode: 'number' }).notNull(),
|
||||
thumbnail: text('thumbnail'),
|
||||
added: integer('added', { mode: 'number' }).notNull(),
|
||||
downloadId: text('download_id'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull()
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({
|
||||
columns: [table.subscriptionId, table.itemId],
|
||||
name: 'subscription_items_pk'
|
||||
}),
|
||||
subscriptionIdx: index('subscription_items_subscription_idx').on(table.subscriptionId)
|
||||
})
|
||||
)
|
||||
|
||||
export type DownloadHistoryRow = typeof downloadHistoryTable.$inferSelect
|
||||
export type DownloadHistoryInsert = typeof downloadHistoryTable.$inferInsert
|
||||
export type SubscriptionRow = typeof subscriptionsTable.$inferSelect
|
||||
export type SubscriptionInsert = typeof subscriptionsTable.$inferInsert
|
||||
export type SubscriptionItemRow = typeof subscriptionItemsTable.$inferSelect
|
||||
export type SubscriptionItemInsert = typeof subscriptionItemsTable.$inferInsert
|
||||
@@ -395,6 +395,8 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
const createdAt = Date.now()
|
||||
const settings = settingsManager.getAll()
|
||||
const targetDownloadPath = options.customDownloadPath?.trim() || settings.downloadPath
|
||||
const origin = options.origin ?? 'manual'
|
||||
|
||||
const item: DownloadItem = {
|
||||
id,
|
||||
@@ -402,7 +404,10 @@ class DownloadEngine extends EventEmitter {
|
||||
title: 'Downloading...',
|
||||
type: options.type,
|
||||
status: 'pending' as const,
|
||||
createdAt
|
||||
createdAt,
|
||||
tags: options.tags,
|
||||
origin,
|
||||
subscriptionId: options.subscriptionId
|
||||
}
|
||||
|
||||
this.queue.add(id, options, item)
|
||||
@@ -411,7 +416,10 @@ class DownloadEngine extends EventEmitter {
|
||||
title: item.title,
|
||||
status: 'pending',
|
||||
downloadedAt: createdAt,
|
||||
downloadPath: settings.downloadPath
|
||||
downloadPath: targetDownloadPath,
|
||||
tags: options.tags,
|
||||
origin,
|
||||
subscriptionId: options.subscriptionId
|
||||
})
|
||||
}
|
||||
|
||||
@@ -419,7 +427,8 @@ class DownloadEngine extends EventEmitter {
|
||||
scopedLoggers.download.info('Starting download execution for ID:', id, 'URL:', options.url)
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
const settings = settingsManager.getAll()
|
||||
const downloadPath = settings.downloadPath
|
||||
const defaultDownloadPath = settings.downloadPath
|
||||
const resolvedDownloadPath = options.customDownloadPath?.trim() || defaultDownloadPath
|
||||
|
||||
// Set environment variables for proper encoding on Windows
|
||||
if (process.platform === 'win32') {
|
||||
@@ -430,9 +439,8 @@ class DownloadEngine extends EventEmitter {
|
||||
let availableFormats: VideoFormat[] = []
|
||||
let selectedFormat: VideoFormat | undefined
|
||||
let actualFormat: string | null = null
|
||||
let actualQuality: string | null = null
|
||||
let actualCodec: string | null = null
|
||||
let videoInfo: VideoInfo | undefined
|
||||
let lastKnownOutputPath: string | undefined
|
||||
|
||||
// First, get detailed video info to capture basic metadata and formats
|
||||
try {
|
||||
@@ -444,20 +452,6 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
if (selectedFormat) {
|
||||
actualFormat = selectedFormat.ext || actualFormat
|
||||
|
||||
if (selectedFormat.height) {
|
||||
actualQuality = `${selectedFormat.height}p${
|
||||
selectedFormat.fps && selectedFormat.fps === 60 ? '60' : ''
|
||||
}`
|
||||
} else if (selectedFormat.format_note) {
|
||||
actualQuality = selectedFormat.format_note
|
||||
}
|
||||
|
||||
if (options.type === 'audio' || options.type === 'extract') {
|
||||
actualCodec = selectedFormat.acodec || actualCodec
|
||||
} else {
|
||||
actualCodec = selectedFormat.vcodec || selectedFormat.acodec || actualCodec
|
||||
}
|
||||
}
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
@@ -502,18 +496,6 @@ class DownloadEngine extends EventEmitter {
|
||||
selectedFormat = candidate
|
||||
actualFormat = candidate.ext || actualFormat
|
||||
|
||||
if (candidate.height) {
|
||||
actualQuality = `${candidate.height}p${candidate.fps === 60 ? '60' : ''}`
|
||||
} else if (candidate.format_note) {
|
||||
actualQuality = candidate.format_note
|
||||
}
|
||||
|
||||
if (options.type === 'audio' || options.type === 'extract') {
|
||||
actualCodec = candidate.acodec || actualCodec
|
||||
} else {
|
||||
actualCodec = candidate.vcodec || candidate.acodec || actualCodec
|
||||
}
|
||||
|
||||
this.updateDownloadInfo(id, {
|
||||
selectedFormat: candidate
|
||||
})
|
||||
@@ -521,7 +503,39 @@ class DownloadEngine extends EventEmitter {
|
||||
return true
|
||||
}
|
||||
|
||||
const args = buildDownloadArgs(options, downloadPath, settings)
|
||||
const args = buildDownloadArgs(options, resolvedDownloadPath, settings)
|
||||
|
||||
const captureOutputPath = (rawPath: string | undefined): void => {
|
||||
if (!rawPath) {
|
||||
return
|
||||
}
|
||||
const trimmed = rawPath.trim().replace(/^"|"$/g, '')
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
lastKnownOutputPath = path.isAbsolute(trimmed)
|
||||
? trimmed
|
||||
: path.join(resolvedDownloadPath, trimmed)
|
||||
}
|
||||
|
||||
const extractOutputPathFromLog = (message: string): void => {
|
||||
const destinationMatch = message.match(/Destination:\s*(.+)$/)
|
||||
if (destinationMatch) {
|
||||
captureOutputPath(destinationMatch[1])
|
||||
return
|
||||
}
|
||||
|
||||
const mergingMatch = message.match(/Merging formats into\s+"(.+?)"/)
|
||||
if (mergingMatch) {
|
||||
captureOutputPath(mergingMatch[1])
|
||||
return
|
||||
}
|
||||
|
||||
const movingMatch = message.match(/Moving file to\s+"(.+?)"/)
|
||||
if (movingMatch) {
|
||||
captureOutputPath(movingMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Check if format selector contains '+' which means video and audio will be merged
|
||||
const formatSelector =
|
||||
@@ -629,16 +643,6 @@ class DownloadEngine extends EventEmitter {
|
||||
if (extMatch && !actualFormat) {
|
||||
actualFormat = extMatch[1]
|
||||
}
|
||||
|
||||
const heightMatch = formatInfo.match(/(\d+)p/)
|
||||
if (heightMatch && !actualQuality) {
|
||||
actualQuality = `${heightMatch[1]}p`
|
||||
}
|
||||
|
||||
const codecMatch = formatInfo.match(/(?:vcodec|acodec)[:\s]*([^\s,]+)/)
|
||||
if (codecMatch && !actualCodec) {
|
||||
actualCodec = codecMatch[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,6 +653,10 @@ class DownloadEngine extends EventEmitter {
|
||||
applySelectedFormat(formatMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === 'download' || eventType === 'info') {
|
||||
extractOutputPathFromLog(eventData)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle completion
|
||||
@@ -675,32 +683,44 @@ class DownloadEngine extends EventEmitter {
|
||||
extension = actualFormat || 'mp4'
|
||||
}
|
||||
|
||||
const fileName = `${sanitizedTitle}.${extension}`
|
||||
const finalOutputPath = path.join(downloadPath, fileName)
|
||||
const fallbackFileName = `${sanitizedTitle}.${extension}`
|
||||
const fallbackOutputPath = path.join(resolvedDownloadPath, fallbackFileName)
|
||||
|
||||
scopedLoggers.download.info(
|
||||
'Generated file path for ID:',
|
||||
'Resolved output paths for ID:',
|
||||
id,
|
||||
'Path:',
|
||||
finalOutputPath,
|
||||
'Primary:',
|
||||
lastKnownOutputPath ?? fallbackOutputPath,
|
||||
'Fallback:',
|
||||
fallbackOutputPath,
|
||||
'Will merge:',
|
||||
willMerge
|
||||
)
|
||||
|
||||
let fileSize: number | undefined
|
||||
let actualFilePath = finalOutputPath
|
||||
let actualFilePath = lastKnownOutputPath ?? fallbackOutputPath
|
||||
const candidatePaths = lastKnownOutputPath
|
||||
? [lastKnownOutputPath, fallbackOutputPath]
|
||||
: [fallbackOutputPath]
|
||||
|
||||
try {
|
||||
const fs = await import('node:fs/promises')
|
||||
// Try to find the actual file - yt-dlp may generate files with slightly different names
|
||||
const stats = await fs.stat(finalOutputPath)
|
||||
fileSize = stats.size
|
||||
actualFilePath = finalOutputPath
|
||||
} catch (error) {
|
||||
// If the expected file doesn't exist, try to find it by scanning the directory
|
||||
try {
|
||||
const fs = await import('node:fs/promises')
|
||||
const files = await fs.readdir(downloadPath)
|
||||
// Look for files matching the title pattern with the correct extension
|
||||
let located = false
|
||||
for (const candidate of candidatePaths) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const stats = await fs.stat(candidate)
|
||||
fileSize = stats.size
|
||||
actualFilePath = candidate
|
||||
located = true
|
||||
break
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!located) {
|
||||
const files = await fs.readdir(resolvedDownloadPath)
|
||||
const matchingFiles = files.filter((file) => {
|
||||
const baseName = file.replace(/\.[^.]+$/, '')
|
||||
const fileExt = file.split('.').pop()?.toLowerCase()
|
||||
@@ -711,30 +731,33 @@ class DownloadEngine extends EventEmitter {
|
||||
})
|
||||
|
||||
if (matchingFiles.length > 0) {
|
||||
// Use the most recently modified file if multiple matches
|
||||
const fileStats = await Promise.all(
|
||||
matchingFiles.map(async (file) => {
|
||||
const filePath = path.join(downloadPath, file)
|
||||
const filePath = path.join(resolvedDownloadPath, file)
|
||||
const stats = await fs.stat(filePath)
|
||||
return { file, path: filePath, mtime: stats.mtime, size: stats.size }
|
||||
return { path: filePath, mtime: stats.mtime, size: stats.size }
|
||||
})
|
||||
)
|
||||
const mostRecent = fileStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())[0]
|
||||
actualFilePath = mostRecent.path
|
||||
fileSize = mostRecent.size
|
||||
located = true
|
||||
scopedLoggers.download.info('Found actual file:', actualFilePath, 'Size:', fileSize)
|
||||
} else if (latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileSize && latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
if (!located) {
|
||||
scopedLoggers.download.warn('File not found, using estimated size:', fileSize)
|
||||
} else {
|
||||
scopedLoggers.download.warn('Failed to find file for ID:', id, error)
|
||||
}
|
||||
} catch (scanError) {
|
||||
if (latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
} else {
|
||||
scopedLoggers.download.warn('Failed to get file size for ID:', id, scanError)
|
||||
}
|
||||
} else if (!fileSize) {
|
||||
scopedLoggers.download.warn('Failed to find file for ID:', id)
|
||||
}
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to resolve file details for ID:', id, error)
|
||||
if (latestKnownSizeBytes !== undefined) {
|
||||
fileSize = latestKnownSizeBytes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,9 +771,6 @@ class DownloadEngine extends EventEmitter {
|
||||
status: 'completed',
|
||||
completedAt: Date.now(),
|
||||
fileSize,
|
||||
format: willMerge ? 'mp4' : actualFormat || undefined,
|
||||
quality: actualQuality || undefined,
|
||||
codec: actualCodec || undefined,
|
||||
savedFileName
|
||||
})
|
||||
scopedLoggers.download.info('Download completed successfully for ID:', id)
|
||||
@@ -837,15 +857,6 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.fileSize !== undefined) {
|
||||
historyUpdates.fileSize = updates.fileSize
|
||||
}
|
||||
if (updates.format !== undefined) {
|
||||
historyUpdates.format = updates.format
|
||||
}
|
||||
if (updates.quality !== undefined) {
|
||||
historyUpdates.quality = updates.quality
|
||||
}
|
||||
if (updates.codec !== undefined) {
|
||||
historyUpdates.codec = updates.codec
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
historyUpdates.description = updates.description
|
||||
}
|
||||
@@ -873,6 +884,9 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.playlistSize !== undefined) {
|
||||
historyUpdates.playlistSize = updates.playlistSize
|
||||
}
|
||||
if (updates.selectedFormat !== undefined) {
|
||||
historyUpdates.selectedFormat = updates.selectedFormat
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
historyUpdates.status = updates.status
|
||||
}
|
||||
@@ -882,9 +896,6 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.error !== undefined) {
|
||||
historyUpdates.error = updates.error
|
||||
}
|
||||
if (updates.selectedFormat !== undefined) {
|
||||
historyUpdates.selectedFormat = updates.selectedFormat
|
||||
}
|
||||
if (updates.savedFileName !== undefined) {
|
||||
historyUpdates.savedFileName = updates.savedFileName
|
||||
}
|
||||
@@ -913,14 +924,13 @@ class DownloadEngine extends EventEmitter {
|
||||
error,
|
||||
duration: completedDownload?.item.duration,
|
||||
fileSize: completedDownload?.item.fileSize,
|
||||
format: completedDownload?.item.format,
|
||||
quality: completedDownload?.item.quality,
|
||||
codec: completedDownload?.item.codec,
|
||||
description: completedDownload?.item.description,
|
||||
channel: completedDownload?.item.channel,
|
||||
uploader: completedDownload?.item.uploader,
|
||||
viewCount: completedDownload?.item.viewCount,
|
||||
tags: completedDownload?.item.tags,
|
||||
origin: completedDownload?.item.origin,
|
||||
subscriptionId: completedDownload?.item.subscriptionId,
|
||||
playlistId: completedDownload?.item.playlistId,
|
||||
playlistTitle: completedDownload?.item.playlistTitle,
|
||||
playlistIndex: completedDownload?.item.playlistIndex,
|
||||
@@ -934,6 +944,8 @@ class DownloadEngine extends EventEmitter {
|
||||
updates: Partial<DownloadHistoryItem>
|
||||
): void {
|
||||
const existing = historyManager.getHistoryById(id)
|
||||
const resolvedDownloadPath =
|
||||
updates.downloadPath ?? existing?.downloadPath ?? options.customDownloadPath
|
||||
const base: DownloadHistoryItem = existing ?? {
|
||||
id,
|
||||
url: options.url,
|
||||
@@ -941,21 +953,20 @@ class DownloadEngine extends EventEmitter {
|
||||
thumbnail: updates.thumbnail,
|
||||
type: options.type,
|
||||
status: updates.status || 'pending',
|
||||
downloadPath: updates.downloadPath,
|
||||
downloadPath: resolvedDownloadPath,
|
||||
savedFileName: updates.savedFileName,
|
||||
fileSize: updates.fileSize,
|
||||
duration: updates.duration,
|
||||
downloadedAt: updates.downloadedAt ?? Date.now(),
|
||||
completedAt: updates.completedAt,
|
||||
error: updates.error,
|
||||
format: updates.format,
|
||||
quality: updates.quality,
|
||||
codec: updates.codec,
|
||||
description: updates.description,
|
||||
channel: updates.channel,
|
||||
uploader: updates.uploader,
|
||||
viewCount: updates.viewCount,
|
||||
tags: updates.tags,
|
||||
tags: updates.tags ?? options.tags,
|
||||
origin: updates.origin ?? options.origin,
|
||||
subscriptionId: updates.subscriptionId ?? options.subscriptionId,
|
||||
// Download-specific format info
|
||||
selectedFormat: updates.selectedFormat,
|
||||
playlistId: updates.playlistId,
|
||||
@@ -972,7 +983,11 @@ class DownloadEngine extends EventEmitter {
|
||||
type: updates.type ?? base.type,
|
||||
title: updates.title ?? base.title,
|
||||
status: updates.status ?? base.status,
|
||||
downloadedAt: updates.downloadedAt ?? base.downloadedAt
|
||||
downloadedAt: updates.downloadedAt ?? base.downloadedAt,
|
||||
downloadPath: resolvedDownloadPath ?? base.downloadPath,
|
||||
tags: updates.tags ?? base.tags,
|
||||
origin: updates.origin ?? base.origin,
|
||||
subscriptionId: updates.subscriptionId ?? base.subscriptionId
|
||||
}
|
||||
|
||||
historyManager.addHistoryItem(merged)
|
||||
|
||||
@@ -1,18 +1,149 @@
|
||||
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'
|
||||
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 downloadHistoryTable = sqliteTable('download_history', {
|
||||
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(),
|
||||
@@ -20,14 +151,12 @@ const downloadHistoryTable = sqliteTable('download_history', {
|
||||
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
|
||||
payload: text('payload').notNull()
|
||||
})
|
||||
|
||||
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<string, DownloadHistoryItem> = new Map()
|
||||
private schemaChecked = false
|
||||
private migrationChecked = false
|
||||
|
||||
constructor() {
|
||||
@@ -37,6 +166,7 @@ class HistoryManager {
|
||||
private initialize(): void {
|
||||
try {
|
||||
this.getDatabase()
|
||||
this.ensureStructuredSchema()
|
||||
this.ensureLegacyMigration()
|
||||
this.loadHistoryFromDatabase()
|
||||
} catch (error) {
|
||||
@@ -51,35 +181,125 @@ 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)
|
||||
runMigrations(database)
|
||||
|
||||
this.db = database
|
||||
logger.info(`history-db initialized at ${databasePath}`)
|
||||
return this.db
|
||||
}
|
||||
|
||||
private getDatabasePath(): string {
|
||||
return join(app.getPath('userData'), 'download-history.sqlite')
|
||||
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
|
||||
@@ -154,11 +374,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 +408,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 {
|
||||
@@ -299,6 +557,15 @@ class HistoryManager {
|
||||
|
||||
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()
|
||||
|
||||
641
src/main/lib/subscription-manager.ts
Normal file
641
src/main/lib/subscription-manager.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import type { Database as BetterSqlite3Instance } from 'better-sqlite3'
|
||||
import DatabaseConstructor from 'better-sqlite3'
|
||||
import { and, desc, eq, inArray } from 'drizzle-orm'
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import log from 'electron-log/main'
|
||||
import type {
|
||||
SubscriptionCreatePayload,
|
||||
SubscriptionFeedItem,
|
||||
SubscriptionRule,
|
||||
SubscriptionStatus,
|
||||
SubscriptionUpdatePayload
|
||||
} from '../../shared/types'
|
||||
import { sanitizeFilenameTemplate } from '../download-engine/args-builder'
|
||||
import { runMigrations } from './database/migrate'
|
||||
import {
|
||||
type SubscriptionInsert,
|
||||
type SubscriptionItemRow,
|
||||
type SubscriptionRow,
|
||||
subscriptionItemsTable,
|
||||
subscriptionsTable
|
||||
} from './database/schema'
|
||||
import { getDatabaseFilePath } from './database-path'
|
||||
|
||||
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 ensureDirectoryExists = (dir?: string): void => {
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
} catch (error) {
|
||||
log.error('Failed to ensure subscription directory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const booleanToNumber = (value: boolean): number => (value ? 1 : 0)
|
||||
const numberToBoolean = (value: number | null | undefined): boolean => value === 1
|
||||
|
||||
const parseStringArray = (value: string | null | undefined): string[] => {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
return Array.isArray(parsed) ? sanitizeList(parsed as string[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const stringifyArray = (values: string[]): string => JSON.stringify(sanitizeList(values))
|
||||
|
||||
export class SubscriptionManager extends EventEmitter {
|
||||
private sqlite: BetterSqlite3Instance | null = null
|
||||
private db: BetterSQLite3Database | null = null
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
try {
|
||||
this.getDatabase()
|
||||
this.migrateLegacyStore()
|
||||
} catch (error) {
|
||||
log.error('subscriptions: failed to initialize database', error)
|
||||
}
|
||||
}
|
||||
|
||||
getAll(): SubscriptionRule[] {
|
||||
const database = this.getDatabase()
|
||||
const rows = database
|
||||
.select()
|
||||
.from(subscriptionsTable)
|
||||
.orderBy(desc(subscriptionsTable.updatedAt))
|
||||
.all()
|
||||
return this.attachFeedItems(rows.map((row) => this.mapRowToRecord(row)))
|
||||
}
|
||||
|
||||
getById(id: string): SubscriptionRule | undefined {
|
||||
const database = this.getDatabase()
|
||||
const row = database
|
||||
.select()
|
||||
.from(subscriptionsTable)
|
||||
.where(eq(subscriptionsTable.id, id))
|
||||
.get()
|
||||
if (!row) {
|
||||
return undefined
|
||||
}
|
||||
return this.attachFeedItems([this.mapRowToRecord(row)])[0]
|
||||
}
|
||||
|
||||
add(payload: SubscriptionCreatePayload): SubscriptionRule {
|
||||
const timestamp = Date.now()
|
||||
const keywords = sanitizeList(payload.keywords)
|
||||
const tags = sanitizeList(payload.tags)
|
||||
const record: SubscriptionRule = {
|
||||
id: randomUUID(),
|
||||
title: payload.sourceUrl,
|
||||
sourceUrl: payload.sourceUrl,
|
||||
feedUrl: payload.feedUrl,
|
||||
platform: payload.platform,
|
||||
keywords,
|
||||
tags,
|
||||
onlyDownloadLatest: payload.onlyDownloadLatest ?? true,
|
||||
enabled: payload.enabled ?? true,
|
||||
coverUrl: undefined,
|
||||
latestVideoTitle: undefined,
|
||||
latestVideoPublishedAt: undefined,
|
||||
lastCheckedAt: undefined,
|
||||
lastSuccessAt: undefined,
|
||||
status: 'idle',
|
||||
lastError: undefined,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
downloadDirectory: payload.downloadDirectory,
|
||||
namingTemplate: payload.namingTemplate
|
||||
? sanitizeFilenameTemplate(payload.namingTemplate)
|
||||
: undefined,
|
||||
items: []
|
||||
}
|
||||
|
||||
ensureDirectoryExists(payload.downloadDirectory)
|
||||
this.insertRecord(record)
|
||||
this.emitUpdates()
|
||||
return record
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
updates: SubscriptionUpdatePayload & Partial<SubscriptionRule>
|
||||
): SubscriptionRule | undefined {
|
||||
const existing = this.getById(id)
|
||||
if (!existing) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const keywords = updates.keywords ? sanitizeList(updates.keywords) : undefined
|
||||
const tags = updates.tags ? sanitizeList(updates.tags) : undefined
|
||||
const next: SubscriptionRule = {
|
||||
...existing,
|
||||
...updates,
|
||||
keywords: keywords ?? existing.keywords,
|
||||
tags: tags ?? existing.tags,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
|
||||
// If sourceUrl is updated but title is not explicitly set, update title to match sourceUrl
|
||||
if (updates.sourceUrl && !updates.title && updates.sourceUrl !== existing.sourceUrl) {
|
||||
next.title = updates.sourceUrl
|
||||
}
|
||||
|
||||
if (updates.namingTemplate) {
|
||||
next.namingTemplate = sanitizeFilenameTemplate(updates.namingTemplate)
|
||||
}
|
||||
|
||||
ensureDirectoryExists(next.downloadDirectory)
|
||||
this.updateRecord(next)
|
||||
this.emitUpdates()
|
||||
return next
|
||||
}
|
||||
|
||||
remove(id: string): boolean {
|
||||
const database = this.getDatabase()
|
||||
const result = database.delete(subscriptionsTable).where(eq(subscriptionsTable.id, id)).run()
|
||||
if ((result.changes ?? 0) > 0) {
|
||||
database
|
||||
.delete(subscriptionItemsTable)
|
||||
.where(eq(subscriptionItemsTable.subscriptionId, id))
|
||||
.run()
|
||||
this.emitUpdates()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
replaceFeedItems(
|
||||
subscriptionId: string,
|
||||
items: SubscriptionFeedItem[],
|
||||
silent: boolean = false
|
||||
): void {
|
||||
const database = this.getDatabase()
|
||||
const orderedItems = [...items].sort((a, b) => b.publishedAt - a.publishedAt)
|
||||
const now = Date.now()
|
||||
database.transaction((tx) => {
|
||||
tx.delete(subscriptionItemsTable)
|
||||
.where(eq(subscriptionItemsTable.subscriptionId, subscriptionId))
|
||||
.run()
|
||||
for (const item of orderedItems) {
|
||||
tx.insert(subscriptionItemsTable)
|
||||
.values({
|
||||
subscriptionId,
|
||||
itemId: item.id,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
publishedAt: item.publishedAt,
|
||||
thumbnail: item.thumbnail ?? null,
|
||||
added: booleanToNumber(item.addedToQueue),
|
||||
downloadId: item.downloadId ?? null,
|
||||
createdAt: item.publishedAt,
|
||||
updatedAt: now
|
||||
})
|
||||
.run()
|
||||
}
|
||||
})
|
||||
if (!silent) {
|
||||
this.emitUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
updateFeedItemQueueState(
|
||||
subscriptionId: string,
|
||||
itemId: string,
|
||||
updates: { added?: boolean; downloadId?: string | null }
|
||||
): void {
|
||||
if (updates.added === undefined && !Object.hasOwn(updates, 'downloadId')) {
|
||||
return
|
||||
}
|
||||
|
||||
const setPayload: Partial<typeof subscriptionItemsTable.$inferInsert> = {
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
|
||||
if (updates.added !== undefined) {
|
||||
setPayload.added = booleanToNumber(updates.added)
|
||||
}
|
||||
if (Object.hasOwn(updates, 'downloadId')) {
|
||||
setPayload.downloadId = updates.downloadId ?? null
|
||||
}
|
||||
|
||||
const database = this.getDatabase()
|
||||
const result = database
|
||||
.update(subscriptionItemsTable)
|
||||
.set(setPayload)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionItemsTable.subscriptionId, subscriptionId),
|
||||
eq(subscriptionItemsTable.itemId, itemId)
|
||||
)
|
||||
)
|
||||
.run()
|
||||
|
||||
if ((result.changes ?? 0) > 0) {
|
||||
this.emitUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
private attachFeedItems(records: SubscriptionRule[]): SubscriptionRule[] {
|
||||
if (records.length === 0) {
|
||||
return records
|
||||
}
|
||||
const ids = records.map((record) => record.id)
|
||||
const database = this.getDatabase()
|
||||
const rows = database
|
||||
.select()
|
||||
.from(subscriptionItemsTable)
|
||||
.where(inArray(subscriptionItemsTable.subscriptionId, ids))
|
||||
.orderBy(desc(subscriptionItemsTable.publishedAt))
|
||||
.all()
|
||||
|
||||
const grouped = new Map<string, SubscriptionFeedItem[]>()
|
||||
for (const row of rows) {
|
||||
const item = this.mapItemRowToFeedItem(row)
|
||||
const list = grouped.get(row.subscriptionId)
|
||||
if (list) {
|
||||
list.push(item)
|
||||
} else {
|
||||
grouped.set(row.subscriptionId, [item])
|
||||
}
|
||||
}
|
||||
|
||||
return records.map((record) => ({
|
||||
...record,
|
||||
items: grouped.get(record.id) ?? []
|
||||
}))
|
||||
}
|
||||
|
||||
private getDatabase(): BetterSQLite3Database {
|
||||
if (this.db) {
|
||||
return this.db
|
||||
}
|
||||
|
||||
const databasePath = this.getDatabasePath()
|
||||
this.sqlite = new DatabaseConstructor(databasePath, { timeout: 5000 })
|
||||
this.sqlite.pragma('journal_mode = WAL')
|
||||
this.sqlite.pragma('foreign_keys = ON')
|
||||
this.db = drizzle(this.sqlite)
|
||||
runMigrations(this.db)
|
||||
this.ensureSubscriptionsSchema()
|
||||
this.ensureItemsSchema()
|
||||
log.info('subscriptions: database initialized at', databasePath)
|
||||
return this.db
|
||||
}
|
||||
|
||||
private ensureItemsSchema(): void {
|
||||
if (!this.sqlite) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const columns = this.sqlite.prepare(`PRAGMA table_info(subscription_items)`).all() as Array<{
|
||||
name: string
|
||||
}>
|
||||
const hasAdded = columns.some((column) => column.name === 'added')
|
||||
if (!hasAdded) {
|
||||
this.sqlite
|
||||
.prepare(`ALTER TABLE subscription_items ADD COLUMN added INTEGER NOT NULL DEFAULT 0`)
|
||||
.run()
|
||||
}
|
||||
const hasDownloaded = columns.some((column) => column.name === 'downloaded')
|
||||
const hasLegacyStatus = columns.some((column) => column.name === 'status')
|
||||
const needsMigration = hasDownloaded || hasLegacyStatus
|
||||
if (needsMigration) {
|
||||
if (hasDownloaded) {
|
||||
this.sqlite.prepare(`UPDATE subscription_items SET added = 1 WHERE downloaded = 1`).run()
|
||||
}
|
||||
const sqlite = this.sqlite
|
||||
const migrate = sqlite.transaction(() => {
|
||||
sqlite.prepare(`DROP TABLE IF EXISTS subscription_items_new`).run()
|
||||
sqlite
|
||||
.prepare(
|
||||
`CREATE TABLE subscription_items_new (
|
||||
subscription_id TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
published_at INTEGER NOT NULL,
|
||||
thumbnail TEXT,
|
||||
added INTEGER NOT NULL,
|
||||
download_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (subscription_id, item_id)
|
||||
)`
|
||||
)
|
||||
.run()
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO subscription_items_new (
|
||||
subscription_id,
|
||||
item_id,
|
||||
title,
|
||||
url,
|
||||
published_at,
|
||||
thumbnail,
|
||||
added,
|
||||
download_id,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
subscription_id,
|
||||
item_id,
|
||||
title,
|
||||
url,
|
||||
published_at,
|
||||
thumbnail,
|
||||
added,
|
||||
download_id,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM subscription_items`
|
||||
)
|
||||
.run()
|
||||
sqlite.prepare(`DROP TABLE subscription_items`).run()
|
||||
sqlite.prepare(`ALTER TABLE subscription_items_new RENAME TO subscription_items`).run()
|
||||
sqlite
|
||||
.prepare(
|
||||
'CREATE INDEX IF NOT EXISTS subscription_items_subscription_idx ON subscription_items (subscription_id)'
|
||||
)
|
||||
.run()
|
||||
})
|
||||
migrate()
|
||||
log.info('subscriptions: removed legacy columns from subscription_items')
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('subscriptions: failed to ensure subscription_items schema', error)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureSubscriptionsSchema(): void {
|
||||
if (!this.sqlite) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const columns = this.sqlite.prepare(`PRAGMA table_info(subscriptions)`).all() as Array<{
|
||||
name: string
|
||||
}>
|
||||
const hasLegacyColumns = columns.some((column) =>
|
||||
['seen_item_ids', 'last_item_id'].includes(column.name)
|
||||
)
|
||||
if (!hasLegacyColumns) {
|
||||
return
|
||||
}
|
||||
const sqlite = this.sqlite
|
||||
const migrate = sqlite.transaction(() => {
|
||||
sqlite.prepare(`DROP TABLE IF EXISTS subscriptions_new`).run()
|
||||
sqlite
|
||||
.prepare(
|
||||
`CREATE TABLE subscriptions_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
feed_url TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
keywords TEXT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
only_latest INTEGER NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
cover_url TEXT,
|
||||
latest_video_title TEXT,
|
||||
latest_video_published_at INTEGER,
|
||||
last_checked_at INTEGER,
|
||||
last_success_at INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
last_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
download_directory TEXT,
|
||||
naming_template TEXT
|
||||
)`
|
||||
)
|
||||
.run()
|
||||
sqlite
|
||||
.prepare(
|
||||
`INSERT INTO subscriptions_new (
|
||||
id,
|
||||
title,
|
||||
source_url,
|
||||
feed_url,
|
||||
platform,
|
||||
keywords,
|
||||
tags,
|
||||
only_latest,
|
||||
enabled,
|
||||
cover_url,
|
||||
latest_video_title,
|
||||
latest_video_published_at,
|
||||
last_checked_at,
|
||||
last_success_at,
|
||||
status,
|
||||
last_error,
|
||||
created_at,
|
||||
updated_at,
|
||||
download_directory,
|
||||
naming_template
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
source_url,
|
||||
feed_url,
|
||||
platform,
|
||||
keywords,
|
||||
tags,
|
||||
only_latest,
|
||||
enabled,
|
||||
cover_url,
|
||||
latest_video_title,
|
||||
latest_video_published_at,
|
||||
last_checked_at,
|
||||
last_success_at,
|
||||
status,
|
||||
last_error,
|
||||
created_at,
|
||||
updated_at,
|
||||
download_directory,
|
||||
naming_template
|
||||
FROM subscriptions`
|
||||
)
|
||||
.run()
|
||||
sqlite.prepare(`DROP TABLE subscriptions`).run()
|
||||
sqlite.prepare(`ALTER TABLE subscriptions_new RENAME TO subscriptions`).run()
|
||||
})
|
||||
migrate()
|
||||
log.info('subscriptions: removed legacy seen_item_ids and last_item_id columns')
|
||||
} catch (error) {
|
||||
log.warn('subscriptions: failed to ensure subscriptions schema', error)
|
||||
}
|
||||
}
|
||||
|
||||
private getDatabasePath(): string {
|
||||
return getDatabaseFilePath()
|
||||
}
|
||||
|
||||
private migrateLegacyStore(): void {
|
||||
try {
|
||||
const LegacyStore = require('electron-store')
|
||||
const store = new LegacyStore({
|
||||
name: 'subscriptions',
|
||||
defaults: {
|
||||
subscriptions: []
|
||||
}
|
||||
})
|
||||
const legacyData = store.get('subscriptions') as SubscriptionRule[] | undefined
|
||||
if (!legacyData || legacyData.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.getAll().length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const legacyItem of legacyData) {
|
||||
try {
|
||||
const normalized: SubscriptionRule = {
|
||||
...legacyItem,
|
||||
keywords: sanitizeList(legacyItem.keywords),
|
||||
tags: sanitizeList(legacyItem.tags),
|
||||
items: []
|
||||
}
|
||||
this.insertRecord(normalized)
|
||||
const legacyFeedItemsRaw = Array.isArray(
|
||||
(legacyItem as { items?: SubscriptionFeedItem[] }).items
|
||||
)
|
||||
? ((legacyItem as { items?: SubscriptionFeedItem[] }).items ?? [])
|
||||
: []
|
||||
if (legacyFeedItemsRaw.length > 0) {
|
||||
const converted = legacyFeedItemsRaw.map((item) => ({
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
publishedAt: item.publishedAt,
|
||||
thumbnail: item.thumbnail,
|
||||
addedToQueue: Boolean((item as { downloaded?: boolean }).downloaded),
|
||||
downloadId: item.downloadId
|
||||
}))
|
||||
this.replaceFeedItems(normalized.id, converted, true)
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('subscriptions: failed to migrate legacy record', error)
|
||||
}
|
||||
}
|
||||
|
||||
store.clear()
|
||||
this.emitUpdates()
|
||||
log.info(`subscriptions: migrated ${legacyData.length} legacy entries`)
|
||||
} catch (error) {
|
||||
log.warn('subscriptions: legacy migration skipped', error)
|
||||
}
|
||||
}
|
||||
|
||||
private insertRecord(record: SubscriptionRule): void {
|
||||
const database = this.getDatabase()
|
||||
const payload = this.mapRecordToInsert(record)
|
||||
database
|
||||
.insert(subscriptionsTable)
|
||||
.values(payload)
|
||||
.onConflictDoUpdate({ target: subscriptionsTable.id, set: payload })
|
||||
.run()
|
||||
}
|
||||
|
||||
private updateRecord(record: SubscriptionRule): void {
|
||||
const database = this.getDatabase()
|
||||
const payload = this.mapRecordToInsert(record)
|
||||
database
|
||||
.insert(subscriptionsTable)
|
||||
.values(payload)
|
||||
.onConflictDoUpdate({ target: subscriptionsTable.id, set: payload })
|
||||
.run()
|
||||
}
|
||||
|
||||
private mapRecordToInsert(record: SubscriptionRule): SubscriptionInsert {
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
sourceUrl: record.sourceUrl,
|
||||
feedUrl: record.feedUrl,
|
||||
platform: record.platform,
|
||||
keywords: stringifyArray(record.keywords),
|
||||
tags: stringifyArray(record.tags),
|
||||
onlyDownloadLatest: booleanToNumber(record.onlyDownloadLatest),
|
||||
enabled: booleanToNumber(record.enabled),
|
||||
coverUrl: record.coverUrl,
|
||||
latestVideoTitle: record.latestVideoTitle,
|
||||
latestVideoPublishedAt: record.latestVideoPublishedAt ?? null,
|
||||
lastCheckedAt: record.lastCheckedAt ?? null,
|
||||
lastSuccessAt: record.lastSuccessAt ?? null,
|
||||
status: record.status,
|
||||
lastError: record.lastError,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
downloadDirectory: record.downloadDirectory,
|
||||
namingTemplate: record.namingTemplate
|
||||
? sanitizeFilenameTemplate(record.namingTemplate)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
private mapRowToRecord(row: SubscriptionRow): SubscriptionRule {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
sourceUrl: row.sourceUrl,
|
||||
feedUrl: row.feedUrl,
|
||||
platform: row.platform as SubscriptionRule['platform'],
|
||||
keywords: parseStringArray(row.keywords),
|
||||
tags: parseStringArray(row.tags),
|
||||
onlyDownloadLatest: numberToBoolean(row.onlyDownloadLatest),
|
||||
enabled: numberToBoolean(row.enabled),
|
||||
coverUrl: row.coverUrl ?? undefined,
|
||||
latestVideoTitle: row.latestVideoTitle ?? undefined,
|
||||
latestVideoPublishedAt: row.latestVideoPublishedAt ?? undefined,
|
||||
lastCheckedAt: row.lastCheckedAt ?? undefined,
|
||||
lastSuccessAt: row.lastSuccessAt ?? undefined,
|
||||
status: row.status as SubscriptionStatus,
|
||||
lastError: row.lastError ?? undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
downloadDirectory: row.downloadDirectory ?? undefined,
|
||||
namingTemplate: row.namingTemplate ? sanitizeFilenameTemplate(row.namingTemplate) : undefined,
|
||||
items: []
|
||||
}
|
||||
}
|
||||
|
||||
private mapItemRowToFeedItem(row: SubscriptionItemRow): SubscriptionFeedItem {
|
||||
return {
|
||||
id: row.itemId,
|
||||
url: row.url,
|
||||
title: row.title,
|
||||
publishedAt: row.publishedAt,
|
||||
thumbnail: row.thumbnail ?? undefined,
|
||||
addedToQueue: numberToBoolean(row.added),
|
||||
downloadId: row.downloadId ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
private emitUpdates(): void {
|
||||
this.emit('subscriptions:updated', this.getAll())
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionManager = new SubscriptionManager()
|
||||
458
src/main/lib/subscription-scheduler.ts
Normal file
458
src/main/lib/subscription-scheduler.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import log from 'electron-log/main'
|
||||
import Parser from 'rss-parser'
|
||||
import type { SubscriptionFeedItem, SubscriptionRule } from '../../shared/types'
|
||||
import { settingsManager } from '../settings'
|
||||
import { downloadEngine } from './download-engine'
|
||||
import { historyManager } from './history-manager'
|
||||
import { subscriptionManager } from './subscription-manager'
|
||||
|
||||
const logger = log.scope('subscriptions')
|
||||
|
||||
type ParserItem = {
|
||||
title?: string
|
||||
link?: string
|
||||
guid?: string
|
||||
id?: string
|
||||
isoDate?: string
|
||||
pubDate?: string
|
||||
youtubeId?: string
|
||||
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
|
||||
mediaContent?: Array<{ url?: string }> | { url?: string }
|
||||
enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type TrackedDownload = {
|
||||
subscriptionId: string
|
||||
itemId: string
|
||||
url: string
|
||||
retries: number
|
||||
downloadId: string
|
||||
}
|
||||
|
||||
type FeedItem = {
|
||||
id: string
|
||||
url: string
|
||||
title: string
|
||||
publishedAt: number
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
const parser = new Parser<{ item: ParserItem }>({
|
||||
customFields: {
|
||||
item: [
|
||||
['yt:videoId', 'youtubeId'],
|
||||
['media:thumbnail', 'mediaThumbnail'],
|
||||
['media:content', 'mediaContent'],
|
||||
['enclosure', 'enclosure']
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const clampIntervalHours = (value: number | undefined): number => {
|
||||
if (!value || Number.isNaN(value)) {
|
||||
return 3
|
||||
}
|
||||
return Math.min(24, Math.max(1, value))
|
||||
}
|
||||
|
||||
const sanitizeDownloadId = (subscriptionId: string, itemId: string): string => {
|
||||
const base = Buffer.from(`${subscriptionId}:${itemId}`).toString('base64url')
|
||||
return `sub_${base}`
|
||||
}
|
||||
|
||||
const ensureDirectoryExists = (dir?: string): void => {
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
} catch (error) {
|
||||
logger.warn('Failed to ensure subscription download directory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export class SubscriptionScheduler extends EventEmitter {
|
||||
private timer?: NodeJS.Timeout
|
||||
private checking = false
|
||||
private pendingRun = false
|
||||
private downloads: Map<string, TrackedDownload> = new Map()
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
downloadEngine.on('download-completed', (id: string) => {
|
||||
const tracked = this.downloads.get(id)
|
||||
if (!tracked) {
|
||||
return
|
||||
}
|
||||
this.downloads.delete(id)
|
||||
subscriptionManager.update(tracked.subscriptionId, {
|
||||
status: 'up-to-date',
|
||||
lastSuccessAt: Date.now()
|
||||
})
|
||||
subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, {
|
||||
downloadId: id
|
||||
})
|
||||
})
|
||||
|
||||
downloadEngine.on('download-error', (id: string, error: Error) => {
|
||||
const tracked = this.downloads.get(id)
|
||||
if (!tracked) {
|
||||
return
|
||||
}
|
||||
const currentRetries = tracked.retries ?? 0
|
||||
if (currentRetries < 1) {
|
||||
logger.warn('Retrying failed subscription download', { id, error })
|
||||
this.queueDownload(
|
||||
tracked.subscriptionId,
|
||||
tracked.itemId,
|
||||
tracked.url,
|
||||
currentRetries + 1
|
||||
).catch((queueError) => {
|
||||
logger.error('Retry queue failed:', queueError)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.downloads.delete(id)
|
||||
subscriptionManager.update(tracked.subscriptionId, {
|
||||
status: 'failed',
|
||||
lastCheckedAt: Date.now()
|
||||
})
|
||||
subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, {
|
||||
downloadId: null
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.scheduleNextRun(0)
|
||||
}
|
||||
|
||||
refreshInterval(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
this.scheduleNextRun()
|
||||
}
|
||||
|
||||
async runNow(subscriptionId?: string): Promise<void> {
|
||||
if (subscriptionId) {
|
||||
const target = subscriptionManager.getById(subscriptionId)
|
||||
if (target?.enabled) {
|
||||
await this.checkSubscription(target)
|
||||
}
|
||||
return
|
||||
}
|
||||
await this.checkAll()
|
||||
}
|
||||
|
||||
async queueItem(subscriptionId: string, itemId: string): Promise<boolean> {
|
||||
const subscription = subscriptionManager.getById(subscriptionId)
|
||||
if (!subscription) {
|
||||
return false
|
||||
}
|
||||
const item = subscription.items.find((entry) => entry.id === itemId)
|
||||
if (!item || item.addedToQueue) {
|
||||
return false
|
||||
}
|
||||
await this.queueDownload(subscriptionId, itemId, item.url)
|
||||
return true
|
||||
}
|
||||
|
||||
private scheduleNextRun(initialDelay?: number): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
const intervalHours = clampIntervalHours(settingsManager.get('subscriptionCheckIntervalHours'))
|
||||
const delayMs = initialDelay ?? intervalHours * 60 * 60 * 1000
|
||||
this.timer = setTimeout(() => {
|
||||
void this.checkAll().finally(() => this.scheduleNextRun())
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
private async checkAll(): Promise<void> {
|
||||
if (this.checking) {
|
||||
this.pendingRun = true
|
||||
return
|
||||
}
|
||||
this.checking = true
|
||||
try {
|
||||
const subscriptions = subscriptionManager
|
||||
.getAll()
|
||||
.filter((subscription) => subscription.enabled)
|
||||
for (const subscription of subscriptions) {
|
||||
await this.checkSubscription(subscription)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to run subscription sync', error)
|
||||
} finally {
|
||||
this.checking = false
|
||||
if (this.pendingRun) {
|
||||
this.pendingRun = false
|
||||
void this.checkAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async checkSubscription(subscription: SubscriptionRule): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
subscriptionManager.update(subscription.id, {
|
||||
status: 'checking',
|
||||
lastCheckedAt: startedAt,
|
||||
lastError: undefined
|
||||
})
|
||||
|
||||
try {
|
||||
const feed = await parser.parseURL(subscription.feedUrl)
|
||||
const feedItems = Array.isArray(feed.items) ? feed.items : []
|
||||
const normalizedItems = this.normalizeFeedItems(feedItems as ParserItem[])
|
||||
const recentItems = this.filterRecentItems(subscription, normalizedItems)
|
||||
const unseenItems = this.filterNewItems(subscription, recentItems)
|
||||
const keywords = subscription.keywords.map((keyword) => keyword.toLowerCase())
|
||||
const keywordFiltered =
|
||||
keywords.length > 0
|
||||
? unseenItems.filter((item) => {
|
||||
const lowered = item.title.toLowerCase()
|
||||
return keywords.some((keyword) => lowered.includes(keyword))
|
||||
})
|
||||
: unseenItems
|
||||
|
||||
const deduped = keywordFiltered
|
||||
.filter((item) => !historyManager.hasHistoryForUrl(item.url))
|
||||
.sort((a, b) => b.publishedAt - a.publishedAt)
|
||||
|
||||
const itemsToDownload =
|
||||
subscription.onlyDownloadLatest && deduped.length > 0 ? [deduped[0]] : deduped
|
||||
|
||||
subscriptionManager.replaceFeedItems(
|
||||
subscription.id,
|
||||
this.buildFeedItems(normalizedItems, subscription)
|
||||
)
|
||||
|
||||
if (itemsToDownload.length > 0) {
|
||||
for (const item of itemsToDownload) {
|
||||
await this.queueDownload(subscription.id, item.id, item.url)
|
||||
}
|
||||
}
|
||||
|
||||
const latestItem = normalizedItems[0]
|
||||
subscriptionManager.update(subscription.id, {
|
||||
status: 'up-to-date',
|
||||
lastSuccessAt: Date.now(),
|
||||
lastError: undefined,
|
||||
latestVideoTitle: latestItem?.title ?? subscription.latestVideoTitle,
|
||||
latestVideoPublishedAt: latestItem?.publishedAt ?? subscription.latestVideoPublishedAt,
|
||||
coverUrl: (feed.image as { url?: string } | undefined)?.url ?? subscription.coverUrl,
|
||||
title:
|
||||
typeof feed.title === 'string' && feed.title.trim().length > 0
|
||||
? feed.title.trim()
|
||||
: subscription.title,
|
||||
sourceUrl:
|
||||
typeof feed.link === 'string' && feed.link.trim().length > 0
|
||||
? feed.link.trim()
|
||||
: subscription.sourceUrl
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown RSS error'
|
||||
subscriptionManager.update(subscription.id, {
|
||||
status: 'failed',
|
||||
lastError: message,
|
||||
lastCheckedAt: Date.now()
|
||||
})
|
||||
logger.error('Subscription check failed:', { id: subscription.id, error })
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeFeedItems(items: ParserItem[]): FeedItem[] {
|
||||
const normalized: FeedItem[] = []
|
||||
for (const item of items) {
|
||||
const id = this.resolveItemId(item)
|
||||
if (!id || !item.link || !item.title) {
|
||||
continue
|
||||
}
|
||||
normalized.push({
|
||||
id,
|
||||
url: item.link,
|
||||
title: item.title,
|
||||
publishedAt: this.resolvePublishedAt(item),
|
||||
thumbnail: this.resolveThumbnail(item)
|
||||
})
|
||||
}
|
||||
|
||||
return normalized.sort((a, b) => b.publishedAt - a.publishedAt)
|
||||
}
|
||||
|
||||
private buildFeedItems(
|
||||
items: FeedItem[],
|
||||
subscription: SubscriptionRule
|
||||
): SubscriptionFeedItem[] {
|
||||
const existingItems = new Map(subscription.items.map((item) => [item.id, item]))
|
||||
return items.map((item) => {
|
||||
const tracked = this.getTrackedDownloadByUrl(item.url)
|
||||
const existing = existingItems.get(item.id)
|
||||
return {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
publishedAt: item.publishedAt,
|
||||
thumbnail: item.thumbnail,
|
||||
addedToQueue:
|
||||
Boolean(tracked) || existing?.addedToQueue || historyManager.hasHistoryForUrl(item.url),
|
||||
downloadId: tracked?.downloadId ?? existing?.downloadId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private resolveItemId(item: ParserItem): string | null {
|
||||
const idCandidate =
|
||||
item.youtubeId || item.guid || item.id || (typeof item.link === 'string' ? item.link : null)
|
||||
if (!idCandidate) {
|
||||
return null
|
||||
}
|
||||
return idCandidate.trim()
|
||||
}
|
||||
|
||||
private resolvePublishedAt(item: ParserItem): number {
|
||||
const candidates = [item.isoDate, item.pubDate]
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
const timestamp = Date.parse(candidate)
|
||||
if (!Number.isNaN(timestamp)) {
|
||||
return timestamp
|
||||
}
|
||||
}
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
private resolveThumbnail(item: ParserItem): string | undefined {
|
||||
// Try media:thumbnail first
|
||||
const thumbnail = item.mediaThumbnail
|
||||
if (Array.isArray(thumbnail)) {
|
||||
const found = thumbnail.find((entry) => entry?.url)
|
||||
if (found?.url) return found.url
|
||||
}
|
||||
if (thumbnail && typeof thumbnail === 'object' && 'url' in thumbnail) {
|
||||
return thumbnail.url as string | undefined
|
||||
}
|
||||
|
||||
// Try enclosure (for RSS feeds with image/jpeg type)
|
||||
const enclosure = item.enclosure
|
||||
if (Array.isArray(enclosure)) {
|
||||
const imageEnclosure = enclosure.find(
|
||||
(entry) => entry?.url && entry?.type?.startsWith('image/')
|
||||
)
|
||||
if (imageEnclosure?.url) return imageEnclosure.url
|
||||
}
|
||||
if (enclosure && typeof enclosure === 'object' && 'url' in enclosure) {
|
||||
const enc = enclosure as { url?: string; type?: string }
|
||||
if (enc.url && enc.type?.startsWith('image/')) {
|
||||
return enc.url
|
||||
}
|
||||
}
|
||||
|
||||
// Try media:content as fallback
|
||||
const mediaContent = item.mediaContent
|
||||
if (Array.isArray(mediaContent)) {
|
||||
const found = mediaContent.find((entry) => entry?.url)
|
||||
if (found?.url) return found.url
|
||||
}
|
||||
if (mediaContent && typeof mediaContent === 'object' && 'url' in mediaContent) {
|
||||
return mediaContent.url as string | undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private filterRecentItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {
|
||||
const lastKnownPublishedAt = this.getLastKnownPublishedAt(subscription)
|
||||
if (lastKnownPublishedAt === 0) {
|
||||
return subscription.onlyDownloadLatest ? items.slice(0, 1) : items
|
||||
}
|
||||
return items.filter((item) => item.publishedAt > lastKnownPublishedAt)
|
||||
}
|
||||
|
||||
private getLastKnownPublishedAt(subscription: SubscriptionRule): number {
|
||||
const fromItems = subscription.items.reduce((max, item) => Math.max(max, item.publishedAt), 0)
|
||||
return Math.max(subscription.latestVideoPublishedAt ?? 0, fromItems)
|
||||
}
|
||||
|
||||
private filterNewItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {
|
||||
const seenIds = new Set(subscription.items.map((item) => item.id))
|
||||
return items.filter((item) => !seenIds.has(item.id))
|
||||
}
|
||||
|
||||
private async queueDownload(
|
||||
subscriptionId: string,
|
||||
itemId: string,
|
||||
url: string,
|
||||
retryCount = 0
|
||||
): Promise<void> {
|
||||
const downloadId = sanitizeDownloadId(subscriptionId, itemId)
|
||||
const isRetry = retryCount > 0
|
||||
if (this.downloads.has(downloadId) && !isRetry) {
|
||||
return
|
||||
}
|
||||
|
||||
const subscription = subscriptionManager.getById(subscriptionId)
|
||||
if (!subscription) {
|
||||
return
|
||||
}
|
||||
|
||||
const settings = settingsManager.getAll()
|
||||
const downloadDirectory = subscription.downloadDirectory?.trim() || settings.downloadPath
|
||||
const namingTemplate =
|
||||
subscription.namingTemplate?.trim() || settings.subscriptionFilenameTemplate
|
||||
ensureDirectoryExists(downloadDirectory)
|
||||
|
||||
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))
|
||||
|
||||
try {
|
||||
downloadEngine.startDownload(downloadId, {
|
||||
url,
|
||||
type: 'video',
|
||||
customDownloadPath: downloadDirectory,
|
||||
customFilenameTemplate: namingTemplate,
|
||||
tags,
|
||||
origin: 'subscription',
|
||||
subscriptionId
|
||||
})
|
||||
|
||||
this.downloads.set(downloadId, {
|
||||
subscriptionId,
|
||||
itemId,
|
||||
url,
|
||||
retries: retryCount,
|
||||
downloadId
|
||||
})
|
||||
subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, {
|
||||
added: true,
|
||||
downloadId
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to start subscription download', { subscriptionId, itemId, error })
|
||||
subscriptionManager.update(subscriptionId, {
|
||||
status: 'failed'
|
||||
})
|
||||
subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, {
|
||||
added: false,
|
||||
downloadId: null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private getTrackedDownloadByUrl(url: string): TrackedDownload | undefined {
|
||||
for (const tracked of this.downloads.values()) {
|
||||
if (tracked.url === url) {
|
||||
return tracked
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionScheduler = new SubscriptionScheduler()
|
||||
@@ -77,11 +77,14 @@ class SettingsManager {
|
||||
private ensureDownloadDirectory(): void {
|
||||
try {
|
||||
const currentPath: string | undefined = this.store.get('downloadPath')
|
||||
if (!currentPath || currentPath === OLD_DEFAULT_DOWNLOAD_PATH) {
|
||||
this.store.set('downloadPath', DEFAULT_DOWNLOAD_PATH)
|
||||
return
|
||||
const normalizedDownloadPath =
|
||||
!currentPath || currentPath === OLD_DEFAULT_DOWNLOAD_PATH
|
||||
? DEFAULT_DOWNLOAD_PATH
|
||||
: currentPath
|
||||
if (normalizedDownloadPath !== currentPath) {
|
||||
this.store.set('downloadPath', normalizedDownloadPath)
|
||||
}
|
||||
ensureDirectoryExists(currentPath)
|
||||
ensureDirectoryExists(normalizedDownloadPath)
|
||||
} catch (error) {
|
||||
console.error('Failed to verify download directory:', error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user