From 56351941eee090804a20e51f36a1b74e5b201d75 Mon Sep 17 00:00:00 2001 From: Nexmoe <16796652+nexmoe@users.noreply.github.com> Date: Sun, 9 Nov 2025 16:00:03 +0800 Subject: [PATCH] feat(subscriptions): add subscription atoms and types for feeds and rules --- package.json | 1 + pnpm-lock.yaml | 31 + src/main/download-engine/args-builder.ts | 13 +- src/main/index.ts | 12 + src/main/ipc/index.ts | 2 + src/main/ipc/services/settings-service.ts | 36 +- src/main/ipc/services/subscription-service.ts | 159 ++++ src/main/lib/download-engine.ts | 44 +- src/main/lib/history-manager.ts | 9 + src/main/lib/subscription-manager.ts | 683 ++++++++++++++++++ src/main/lib/subscription-scheduler.ts | 412 +++++++++++ src/main/settings.ts | 11 +- src/renderer/src/App.tsx | 33 +- .../src/components/download/DownloadItem.tsx | 19 +- src/renderer/src/components/ui/sidebar.tsx | 11 +- src/renderer/src/locales/en.json | 87 ++- src/renderer/src/pages/Settings.tsx | 87 ++- src/renderer/src/pages/Subscriptions.tsx | 556 ++++++++++++++ src/renderer/src/store/subscriptions.ts | 79 ++ src/shared/types/index.ts | 90 ++- 20 files changed, 2345 insertions(+), 30 deletions(-) create mode 100644 src/main/ipc/services/subscription-service.ts create mode 100644 src/main/lib/subscription-manager.ts create mode 100644 src/main/lib/subscription-scheduler.ts create mode 100644 src/renderer/src/pages/Subscriptions.tsx create mode 100644 src/renderer/src/store/subscriptions.ts diff --git a/package.json b/package.json index 7aa71d0..5630e17 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "react-hook-form": "^7.63.0", "react-i18next": "^16.0.0", "react-router": "^7.9.4", + "rss-parser": "^3.13.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cbbcce..65767ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: react-router: specifier: ^7.9.4 version: 7.9.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + rss-parser: + specifier: ^3.13.0 + version: 3.13.0 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -2063,6 +2066,9 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -3088,6 +3094,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rss-parser@3.13.0: + resolution: {integrity: sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -3471,6 +3480,14 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -5407,6 +5424,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@2.2.0: {} + entities@4.5.0: {} env-paths@2.2.1: {} @@ -6457,6 +6476,11 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.52.5 fsevents: 2.3.3 + rss-parser@3.13.0: + dependencies: + entities: 2.2.0 + xml2js: 0.5.0 + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -6794,6 +6818,13 @@ snapshots: wrappy@1.0.2: {} + xml2js@0.5.0: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + xmlbuilder@15.1.1: {} y18n@5.0.8: {} diff --git a/src/main/download-engine/args-builder.ts b/src/main/download-engine/args-builder.ts index 860e4fb..788cee1 100644 --- a/src/main/download-engine/args-builder.ts +++ b/src/main/download-engine/args-builder.ts @@ -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 diff --git a/src/main/index.ts b/src/main/index.ts index 81b9104..7d69106 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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. diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 5a9bc54..4b676a9 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -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 diff --git a/src/main/ipc/services/settings-service.ts b/src/main/ipc/services/settings-service.ts index 6c494e2..b85b2b0 100644 --- a/src/main/ipc/services/settings-service.ts +++ b/src/main/ipc/services/settings-service.ts @@ -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(_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(_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): 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() } } diff --git a/src/main/ipc/services/subscription-service.ts b/src/main/ipc/services/subscription-service.ts new file mode 100644 index 0000000..4da1e22 --- /dev/null +++ b/src/main/ipc/services/subscription-service.ts @@ -0,0 +1,159 @@ +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 { + 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 { + await subscriptionScheduler.runNow(id) + } +} + +export { SubscriptionService } diff --git a/src/main/lib/download-engine.ts b/src/main/lib/download-engine.ts index 3c115e9..0e34b35 100644 --- a/src/main/lib/download-engine.ts +++ b/src/main/lib/download-engine.ts @@ -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,11 @@ class DownloadEngine extends EventEmitter { title: 'Downloading...', type: options.type, status: 'pending' as const, - createdAt + createdAt, + tags: options.tags, + origin, + subscriptionId: options.subscriptionId, + subscriptionTitle: options.subscriptionTitle } this.queue.add(id, options, item) @@ -411,7 +417,11 @@ class DownloadEngine extends EventEmitter { title: item.title, status: 'pending', downloadedAt: createdAt, - downloadPath: settings.downloadPath + downloadPath: targetDownloadPath, + tags: options.tags, + origin, + subscriptionId: options.subscriptionId, + subscriptionTitle: options.subscriptionTitle }) } @@ -419,7 +429,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') { @@ -521,7 +532,7 @@ class DownloadEngine extends EventEmitter { return true } - const args = buildDownloadArgs(options, downloadPath, settings) + const args = buildDownloadArgs(options, resolvedDownloadPath, settings) // Check if format selector contains '+' which means video and audio will be merged const formatSelector = @@ -676,7 +687,7 @@ class DownloadEngine extends EventEmitter { } const fileName = `${sanitizedTitle}.${extension}` - const finalOutputPath = path.join(downloadPath, fileName) + const finalOutputPath = path.join(resolvedDownloadPath, fileName) scopedLoggers.download.info( 'Generated file path for ID:', @@ -699,7 +710,7 @@ class DownloadEngine extends EventEmitter { // 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) + const files = await fs.readdir(resolvedDownloadPath) // Look for files matching the title pattern with the correct extension const matchingFiles = files.filter((file) => { const baseName = file.replace(/\.[^.]+$/, '') @@ -714,7 +725,7 @@ class DownloadEngine extends EventEmitter { // 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 } }) @@ -921,6 +932,9 @@ class DownloadEngine extends EventEmitter { uploader: completedDownload?.item.uploader, viewCount: completedDownload?.item.viewCount, tags: completedDownload?.item.tags, + origin: completedDownload?.item.origin, + subscriptionId: completedDownload?.item.subscriptionId, + subscriptionTitle: completedDownload?.item.subscriptionTitle, playlistId: completedDownload?.item.playlistId, playlistTitle: completedDownload?.item.playlistTitle, playlistIndex: completedDownload?.item.playlistIndex, @@ -934,6 +948,8 @@ class DownloadEngine extends EventEmitter { updates: Partial ): void { const existing = historyManager.getHistoryById(id) + const resolvedDownloadPath = + updates.downloadPath ?? existing?.downloadPath ?? options.customDownloadPath const base: DownloadHistoryItem = existing ?? { id, url: options.url, @@ -941,7 +957,7 @@ 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, @@ -955,7 +971,10 @@ class DownloadEngine extends EventEmitter { 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, + subscriptionTitle: updates.subscriptionTitle ?? options.subscriptionTitle, // Download-specific format info selectedFormat: updates.selectedFormat, playlistId: updates.playlistId, @@ -972,7 +991,12 @@ 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, + subscriptionTitle: updates.subscriptionTitle ?? base.subscriptionTitle } historyManager.addHistoryItem(merged) diff --git a/src/main/lib/history-manager.ts b/src/main/lib/history-manager.ts index 54ed701..b948fe6 100644 --- a/src/main/lib/history-manager.ts +++ b/src/main/lib/history-manager.ts @@ -299,6 +299,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() diff --git a/src/main/lib/subscription-manager.ts b/src/main/lib/subscription-manager.ts new file mode 100644 index 0000000..133389e --- /dev/null +++ b/src/main/lib/subscription-manager.ts @@ -0,0 +1,683 @@ +import { randomUUID } from 'node:crypto' +import { EventEmitter } from 'node:events' +import fs from 'node:fs' +import { join } from 'node:path' +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 { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { app } from 'electron' +import log from 'electron-log/main' +import type { + SubscriptionCreatePayload, + SubscriptionFeedItem, + SubscriptionRule, + SubscriptionStatus, + SubscriptionUpdatePayload +} from '../../shared/types' +import { sanitizeFilenameTemplate } from '../download-engine/args-builder' + +const MAX_SEEN_IDS = 200 + +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)) + +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(), + seenItemIds: text('seen_item_ids').notNull(), + lastItemId: text('last_item_id'), + downloadDirectory: text('download_directory'), + namingTemplate: text('naming_template') +}) + +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'), + status: text('status').notNull(), + 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) + }) +) + +type SubscriptionRow = typeof subscriptionsTable.$inferSelect +type SubscriptionInsert = typeof subscriptionsTable.$inferInsert +type SubscriptionItemRow = typeof subscriptionItemsTable.$inferSelect + +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, + seenItemIds: [], + lastItemId: undefined, + 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 | 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 seenItemIds = this.normalizeSeenItems(updates.seenItemIds ?? existing.seenItemIds) + + const next: SubscriptionRule = { + ...existing, + ...updates, + keywords: keywords ?? existing.keywords, + tags: tags ?? existing.tags, + seenItemIds, + updatedAt: Date.now() + } + + 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 + } + + appendSeenItems(id: string, itemIds: string[]): SubscriptionRule | undefined { + if (itemIds.length === 0) { + return this.getById(id) + } + const existing = this.getById(id) + if (!existing) { + return undefined + } + const merged = this.normalizeSeenItems([...existing.seenItemIds, ...itemIds]) + return this.update(id, { seenItemIds: merged }) + } + + replaceFeedItems( + subscriptionId: string, + items: SubscriptionFeedItem[], + silent: boolean = false + ): void { + const database = this.getDatabase() + const limited = items.slice(0, 20) + const now = Date.now() + database.transaction((tx) => { + tx.delete(subscriptionItemsTable) + .where(eq(subscriptionItemsTable.subscriptionId, subscriptionId)) + .run() + for (const item of limited) { + tx.insert(subscriptionItemsTable) + .values({ + subscriptionId, + itemId: item.id, + title: item.title, + url: item.url, + publishedAt: item.publishedAt, + thumbnail: item.thumbnail ?? null, + status: 'queued', + 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 = { + 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() + 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) + this.sqlite + .prepare( + `CREATE TABLE IF NOT EXISTS subscriptions ( + 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, + seen_item_ids TEXT NOT NULL, + last_item_id TEXT, + download_directory TEXT, + naming_template TEXT + )` + ) + .run() + + this.sqlite + .prepare( + `CREATE TABLE IF NOT EXISTS subscription_items ( + 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, + status TEXT NOT NULL, + added INTEGER NOT NULL, + download_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (subscription_id, item_id) + )` + ) + .run() + this.sqlite + .prepare( + 'CREATE INDEX IF NOT EXISTS subscription_items_subscription_idx ON subscription_items (subscription_id)' + ) + .run() + 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 hasStatus = columns.some((column) => column.name === 'status') + if (!hasStatus) { + this.sqlite + .prepare( + `ALTER TABLE subscription_items ADD COLUMN status TEXT NOT NULL DEFAULT 'queued'` + ) + .run() + } + const hasDownloaded = columns.some((column) => column.name === 'downloaded') + 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, + status TEXT NOT NULL, + 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, + status, + added, + download_id, + created_at, + updated_at + ) + SELECT + subscription_id, + item_id, + title, + url, + published_at, + thumbnail, + status, + 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 downloaded column from subscription_items') + } + } catch (error) { + log.warn('subscriptions: failed to ensure subscription_items schema', error) + } + } + + private getDatabasePath(): string { + return join(app.getPath('userData'), 'subscriptions.sqlite') + } + + 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), + seenItemIds: sanitizeList(legacyItem.seenItemIds), + 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, + seenItemIds: stringifyArray(record.seenItemIds), + lastItemId: record.lastItemId, + 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, + seenItemIds: parseStringArray(row.seenItemIds), + lastItemId: row.lastItemId ?? undefined, + 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 normalizeSeenItems(items: string[]): string[] { + const unique = new Map() + for (const entry of items) { + if (!entry) { + continue + } + const key = entry.trim() + if (!key) { + continue + } + unique.set(key, key) + } + const normalized = Array.from(unique.keys()) + if (normalized.length > MAX_SEEN_IDS) { + return normalized.slice(normalized.length - MAX_SEEN_IDS) + } + return normalized + } + + private emitUpdates(): void { + this.emit('subscriptions:updated', this.getAll()) + } +} + +export const subscriptionManager = new SubscriptionManager() diff --git a/src/main/lib/subscription-scheduler.ts b/src/main/lib/subscription-scheduler.ts new file mode 100644 index 0000000..7a9401c --- /dev/null +++ b/src/main/lib/subscription-scheduler.ts @@ -0,0 +1,412 @@ +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 } + [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 MAX_STORED_FEED_ITEMS = 12 + +const parser = new Parser<{ item: ParserItem }>({ + customFields: { + item: [ + ['yt:videoId', 'youtubeId'], + ['media:thumbnail', 'mediaThumbnail'], + ['media:content', 'mediaContent'] + ] + } +}) + +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 = 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(), + lastError: undefined + }) + 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', + lastError: error.message, + 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 { + if (subscriptionId) { + const target = subscriptionManager.getById(subscriptionId) + if (target?.enabled) { + await this.checkSubscription(target) + } + return + } + await this.checkAll() + } + + 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 { + 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 { + 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 unseenItems = this.filterNewItems(subscription, normalizedItems) + 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 + + if (itemsToDownload.length > 0) { + for (const item of itemsToDownload) { + await this.queueDownload(subscription.id, item.id, item.url) + } + } + + if (keywordFiltered.length > 0) { + subscriptionManager.appendSeenItems( + subscription.id, + keywordFiltered.map((item) => item.id) + ) + } + + 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 + }) + + subscriptionManager.replaceFeedItems(subscription.id, this.buildFeedItems(normalizedItems)) + } 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[]): SubscriptionFeedItem[] { + return items.slice(0, MAX_STORED_FEED_ITEMS).map((item) => { + const tracked = this.getTrackedDownloadByUrl(item.url) + return { + id: item.id, + url: item.url, + title: item.title, + publishedAt: item.publishedAt, + thumbnail: item.thumbnail, + addedToQueue: Boolean(tracked) || historyManager.hasHistoryForUrl(item.url), + downloadId: tracked?.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 { + const thumbnail = item.mediaThumbnail + if (Array.isArray(thumbnail)) { + return thumbnail.find((entry) => entry?.url)?.url + } + if (thumbnail && typeof thumbnail === 'object' && 'url' in thumbnail) { + return thumbnail.url as string | undefined + } + const mediaContent = item.mediaContent + if (Array.isArray(mediaContent)) { + return mediaContent.find((entry) => entry?.url)?.url + } + if (mediaContent && typeof mediaContent === 'object' && 'url' in mediaContent) { + return mediaContent.url as string | undefined + } + return undefined + } + + private filterNewItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] { + const seen = new Set(subscription.seenItemIds) + return items.filter((item) => !seen.has(item.id)) + } + + private async queueDownload( + subscriptionId: string, + itemId: string, + url: string, + retryCount = 0 + ): Promise { + 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, + subscriptionTitle: subscription.title + }) + + 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', + lastError: error instanceof Error ? error.message : String(error) + }) + 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() diff --git a/src/main/settings.ts b/src/main/settings.ts index 4d84a3b..779d4ad 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -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) } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 17fd322..865c149 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -2,7 +2,8 @@ import { ScrollArea } from '@renderer/components/ui/scroll-area' import { Sidebar } from '@renderer/components/ui/sidebar' import { Toaster } from '@renderer/components/ui/sonner' import { TitleBar } from '@renderer/components/ui/title-bar' -import { useAtom } from 'jotai' +import type { SubscriptionRule } from '@shared/types' +import { useSetAtom } from 'jotai' import { ThemeProvider } from 'next-themes' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -11,19 +12,37 @@ import { ipcEvents, ipcServices } from './lib/ipc' import { About } from './pages/About' import { Home } from './pages/Home' import { Settings } from './pages/Settings' +import { Subscriptions } from './pages/Subscriptions' import { SupportedSites } from './pages/SupportedSites' -import { settingsAtom } from './store/settings' +import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions' -type Page = 'home' | 'settings' | 'about' | 'sites' +type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites' function AppContent() { const [currentPage, setCurrentPage] = useState('home') const [platform, setPlatform] = useState('') - const [settings] = useAtom(settingsAtom) + const loadSubscriptions = useSetAtom(loadSubscriptionsAtom) + const setSubscriptions = useSetAtom(setSubscriptionsAtom) const { t } = useTranslation() - const autoUpdateEnabled = settings.autoUpdate const updateDownloadInProgressRef = useRef(false) + useEffect(() => { + loadSubscriptions() + + const handleSubscriptions = (...args: unknown[]) => { + const list = args[0] + if (Array.isArray(list)) { + setSubscriptions(list as SubscriptionRule[]) + } + } + + ipcEvents.on('subscriptions:updated', handleSubscriptions) + + return () => { + ipcEvents.removeListener('subscriptions:updated', handleSubscriptions) + } + }, [loadSubscriptions, setSubscriptions]) + useEffect(() => { // Get platform info to determine if we should show title bar const getPlatform = async () => { @@ -119,7 +138,7 @@ function AppContent() { ipcEvents.removeListener('update:download-progress', handleDownloadProgress) ipcEvents.removeListener('update:show-notification', handleUpdateNotification) } - }, [autoUpdateEnabled, t]) + }, [t]) const renderPage = () => { switch (currentPage) { @@ -132,6 +151,8 @@ function AppContent() { ) case 'settings': return + case 'subscriptions': + return case 'about': return case 'sites': diff --git a/src/renderer/src/components/download/DownloadItem.tsx b/src/renderer/src/components/download/DownloadItem.tsx index f1c8b42..0cabd07 100644 --- a/src/renderer/src/components/download/DownloadItem.tsx +++ b/src/renderer/src/components/download/DownloadItem.tsx @@ -105,6 +105,9 @@ export function DownloadItem({ download }: DownloadItemProps) { const removeDownload = useSetAtom(removeDownloadAtom) const removeHistory = useSetAtom(removeHistoryRecordAtom) const isHistory = download.entryType === 'history' + const isSubscriptionDownload = download.origin === 'subscription' + const subscriptionLabel = + download.subscriptionTitle ?? download.subscriptionId ?? t('subscriptions.labels.unknown') const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt const showActionsWithoutHover = isHistory || download.status === 'completed' const actionsContainerBaseClass = @@ -548,6 +551,13 @@ export function DownloadItem({ download }: DownloadItemProps) { } } + if (isSubscriptionDownload) { + metadataDetails.push({ + label: t('download.metadata.subscription'), + value: subscriptionLabel + }) + } + const hasMetadataDetails = metadataDetails.length > 0 return ( @@ -567,10 +577,15 @@ export function DownloadItem({ download }: DownloadItemProps) {
-
-

+

+

{download.title}

+ {isSubscriptionDownload && ( + + {t('subscriptions.labels.subscription')} + + )}
{(statusIcon || statusText) && ( diff --git a/src/renderer/src/components/ui/sidebar.tsx b/src/renderer/src/components/ui/sidebar.tsx index 7972159..8312d52 100644 --- a/src/renderer/src/components/ui/sidebar.tsx +++ b/src/renderer/src/components/ui/sidebar.tsx @@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { saveSettingAtom } from '@renderer/store/settings' import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages' import { useSetAtom } from 'jotai' +import { Newspaper } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import '../../assets/title-bar.css' @@ -22,7 +23,7 @@ import MingcuteInformationLine from '~icons/mingcute/information-line' import MingcuteSettingsFill from '~icons/mingcute/settings-3-fill' import MingcuteSettingsLine from '~icons/mingcute/settings-3-line' -type Page = 'home' | 'settings' | 'about' | 'sites' +type Page = 'home' | 'subscriptions' | 'settings' | 'about' | 'sites' interface NavigationItem { id: Page @@ -53,6 +54,14 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { }, label: t('menu.download') }, + { + id: 'subscriptions', + icon: { + active: Newspaper, + inactive: Newspaper + }, + label: t('menu.subscriptions') + }, { id: 'sites', icon: { diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index 0bc0f0f..613edb0 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -190,7 +190,8 @@ "videoCodec": "Video codec", "audioCodec": "Audio codec", "formatNote": "Format note", - "protocol": "Protocol" + "protocol": "Protocol", + "subscription": "Subscription" } }, "errors": { @@ -245,6 +246,7 @@ "about": "About", "download": "Download", "playlist": "Download Playlist", + "subscriptions": "Subscriptions", "preferences": "Preferences", "supportedSites": "Supported Sites", "theme": "Theme:" @@ -356,10 +358,15 @@ "proxy": "Proxy", "proxyDescription": "Proxy server for network requests", "proxyPlaceholder": "http://proxy:port", + "rss": "RSS", "selectConfigFile": "Select config file", "selectPath": "Select", "showMoreFormats": "Show more format options", "showMoreFormatsDescription": "Display additional format options in the interface", + "subscriptionDefaults": { + "filenameDescription": "Pattern used when a subscription does not override its filename.", + "intervalDescription": "How often VidBee checks each subscription feed (1-24 hours)." + }, "system": "System", "theme": "Theme", "themeDescription": "Choose a light, dark, or system theme for VidBee", @@ -370,6 +377,84 @@ }, "video": "Video Preferences" }, + "subscriptions": { + "title": "Subscriptions", + "description": "Automatically monitor RSS feeds and queue new downloads without manual work.", + "defaults": { + "title": "Automation defaults", + "description": "Control where subscription downloads are stored and how often VidBee checks for new videos.", + "downloadDirectory": "Download directory", + "filenameTemplate": "Filename template (file only)", + "checkInterval": "Check interval (hours)", + "onlyLatest": "Download only the latest video", + "onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload." + }, + "add": { + "title": "Add subscription", + "description": "Paste a YouTube, Bilibili, or RSS link. VidBee will detect the feed automatically." + }, + "fields": { + "url": "Source link", + "keywords": "Keyword filter (comma separated)", + "tags": "Auto tags", + "customDirectory": "Custom directory", + "namingTemplate": "Custom filename template (file only)", + "onlyLatest": "Download only the latest video", + "onlyLatestDescription": "Ignore backlog items and fetch just the newest upload from this feed.", + "enabled": "Enabled", + "onlyLatestShort": "Only latest" + }, + "placeholders": { + "url": "https://www.youtube.com/channel/UC..." + }, + "actions": { + "add": "Add", + "refresh": "Refresh", + "edit": "Edit", + "remove": "Remove", + "save": "Save changes", + "selectDirectory": "Browse" + }, + "items": { + "title": "Latest uploads", + "empty": "No recent feed items found.", + "queued": "Queued", + "notQueued": "Not queued", + "actions": { + "open": "Open in browser" + } + }, + "labels": { + "subscription": "Subscription", + "unknown": "Unknown subscription" + }, + "notifications": { + "directoryError": "Failed to open the directory picker.", + "missingUrl": "Please paste a channel link first.", + "created": "Subscription added", + "createError": "Failed to add subscription.", + "refreshStarted": "Refresh started", + "removed": "Subscription removed", + "updated": "Subscription updated", + "openLinkError": "Failed to open the video link." + }, + "detectedFeed": "Detected {{platform}} feed -> {{feed}}", + "detecting": "Detecting feed...", + "latestVideo": "Latest video: {{title}}", + "lastChecked": "Last checked: {{time}}", + "never": "Never", + "empty": "No subscriptions yet. Add your favorite channels to start auto-downloading.", + "edit": { + "title": "Edit {{name}}", + "description": "Tweak filters, tags, and overrides for this feed." + }, + "status": { + "up-to-date": "Up to date", + "checking": "Checking", + "failed": "Failed", + "idle": "Idle" + } + }, "sites": { "homeInlineDescription": "Supports {{sites}} and more.", "moreDescription": "The complete yt-dlp list is updated constantly by the community.", diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index 1153f4b..3716508 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -26,6 +26,15 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings' +const sanitizeTemplateInput = (value: string) => value.replace(/[/\\]+/g, '-') +const clampSubscriptionInterval = (value: string) => { + const parsed = Number.parseInt(value, 10) + if (Number.isNaN(parsed)) { + return 3 + } + return Math.min(24, Math.max(1, parsed)) +} + export function Settings() { const { t } = useTranslation() const { theme, setTheme } = useTheme() @@ -130,9 +139,10 @@ export function Settings() {
- + {t('settings.general')} {t('settings.advanced')} + {t('settings.rss')} @@ -424,6 +434,81 @@ export function Settings() { + + +
+ {t('subscriptions.defaults.description')} +
+ + + + {t('subscriptions.defaults.filenameTemplate')} + + {t('settings.subscriptionDefaults.filenameDescription')} + + + + + handleSettingChange( + 'subscriptionFilenameTemplate', + sanitizeTemplateInput(event.target.value) + ) + } + placeholder="%(uploader)s - %(title)s.%(ext)s" + /> + + + + + + + + {t('subscriptions.defaults.checkInterval')} + + {t('settings.subscriptionDefaults.intervalDescription')} + + + + + void handleSettingChange( + 'subscriptionCheckIntervalHours', + clampSubscriptionInterval(event.target.value) + ) + } + className="w-24" + /> + + + + + + + + {t('subscriptions.defaults.onlyLatest')} + + {t('subscriptions.defaults.onlyLatestDescription')} + + + + + handleSettingChange('subscriptionOnlyLatestDefault', value) + } + /> + + + +
diff --git a/src/renderer/src/pages/Subscriptions.tsx b/src/renderer/src/pages/Subscriptions.tsx new file mode 100644 index 0000000..77c0cda --- /dev/null +++ b/src/renderer/src/pages/Subscriptions.tsx @@ -0,0 +1,556 @@ +import { Badge } from '@renderer/components/ui/badge' +import { Button } from '@renderer/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@renderer/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger +} from '@renderer/components/ui/dialog' +import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder' +import { Input } from '@renderer/components/ui/input' +import { Label } from '@renderer/components/ui/label' +import { Switch } from '@renderer/components/ui/switch' +import { ipcServices } from '@renderer/lib/ipc' +import { settingsAtom } from '@renderer/store/settings' +import { + createSubscriptionAtom, + refreshSubscriptionAtom, + removeSubscriptionAtom, + resolveFeedAtom, + subscriptionsAtom, + updateSubscriptionAtom +} from '@renderer/store/subscriptions' +import type { + SubscriptionFeedItem, + SubscriptionResolvedFeed, + SubscriptionRule +} from '@shared/types' +import dayjs from 'dayjs' +import { useAtom, useSetAtom } from 'jotai' +import { ExternalLink } from 'lucide-react' +import { useEffect, useId, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +const statusStyles: Record< + SubscriptionRule['status'], + { label: string; emoji: string; color: string } +> = { + 'up-to-date': { label: 'Up to date', emoji: '✅', color: 'text-emerald-500' }, + checking: { label: 'Checking', emoji: '🔄', color: 'text-blue-500' }, + failed: { label: 'Failed', emoji: '⚠️', color: 'text-amber-500' }, + idle: { label: 'Idle', emoji: '⏸️', color: 'text-muted-foreground' } +} + +const sanitizeCommaList = (value: string) => + value + .split(',') + .map((entry) => entry.trim()) + .filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index) + +const sanitizeTemplateInput = (value: string) => value.replace(/[/\\]+/g, '-') + +export function Subscriptions() { + const { t } = useTranslation() + const [settings] = useAtom(settingsAtom) + const [subscriptions] = useAtom(subscriptionsAtom) + const createSubscription = useSetAtom(createSubscriptionAtom) + const updateSubscription = useSetAtom(updateSubscriptionAtom) + const removeSubscription = useSetAtom(removeSubscriptionAtom) + const refreshSubscription = useSetAtom(refreshSubscriptionAtom) + const resolveFeed = useSetAtom(resolveFeedAtom) + + const [url, setUrl] = useState('') + const [keywords, setKeywords] = useState('') + const [tags, setTags] = useState('') + const [onlyLatest, setOnlyLatest] = useState(settings.subscriptionOnlyLatestDefault) + const [customDownloadDirectory, setCustomDownloadDirectory] = useState(settings.downloadPath) + const [namingTemplate, setNamingTemplate] = useState(settings.subscriptionFilenameTemplate) + const [detectedFeed, setDetectedFeed] = useState(null) + const [detectingFeed, setDetectingFeed] = useState(false) + + const detectTimeout = useRef(null) + const prevDefaultPathRef = useRef(settings.downloadPath) + const urlInputId = useId() + + useEffect(() => { + const newPath = settings.downloadPath + setCustomDownloadDirectory((prev) => { + if (!prev || prev === prevDefaultPathRef.current) { + return newPath + } + return prev + }) + prevDefaultPathRef.current = newPath + }, [settings.downloadPath]) + + useEffect(() => { + setNamingTemplate(settings.subscriptionFilenameTemplate) + }, [settings.subscriptionFilenameTemplate]) + + useEffect(() => { + setOnlyLatest(settings.subscriptionOnlyLatestDefault) + }, [settings.subscriptionOnlyLatestDefault]) + + useEffect(() => { + if (!url.trim()) { + setDetectedFeed(null) + return + } + + if (detectTimeout.current) { + clearTimeout(detectTimeout.current) + } + + detectTimeout.current = setTimeout(async () => { + setDetectingFeed(true) + try { + const result = await resolveFeed(url.trim()) + setDetectedFeed(result) + } catch (error) { + console.error('Failed to resolve feed:', error) + setDetectedFeed(null) + } finally { + setDetectingFeed(false) + } + }, 500) + + return () => { + if (detectTimeout.current) { + clearTimeout(detectTimeout.current) + } + } + }, [url, resolveFeed]) + + const sortedSubscriptions = useMemo( + () => + [...subscriptions].sort( + (a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0) + ), + [subscriptions] + ) + + const handleSelectDirectory = async () => { + try { + const path = await ipcServices.fs.selectDirectory() + if (path) { + setCustomDownloadDirectory(path) + } + } catch (error) { + console.error('Failed to select directory:', error) + toast.error(t('subscriptions.notifications.directoryError')) + } + } + + const handleCreateSubscription = async () => { + if (!url.trim()) { + toast.error(t('subscriptions.notifications.missingUrl')) + return + } + + try { + await createSubscription({ + url: url.trim(), + keywords, + tags, + onlyDownloadLatest: onlyLatest, + downloadDirectory: customDownloadDirectory, + namingTemplate + }) + toast.success(t('subscriptions.notifications.created')) + setUrl('') + setKeywords('') + setTags('') + setDetectedFeed(null) + } catch (error) { + console.error('Failed to create subscription:', error) + toast.error(t('subscriptions.notifications.createError')) + } + } + + const renderStatus = (subscription: SubscriptionRule) => { + const meta = statusStyles[subscription.status] + return ( +
+ {meta.emoji} + + {t(`subscriptions.status.${subscription.status}`)} + +
+ ) + } + + return ( +
+
+

{t('subscriptions.title')}

+

{t('subscriptions.description')}

+
+ + + + {t('subscriptions.add.title')} + {t('subscriptions.add.description')} + + +
+ + setUrl(event.target.value)} + /> + {detectedFeed && ( + + {t('subscriptions.detectedFeed', { + platform: detectedFeed.platform, + feed: detectedFeed.feedUrl + })} + + )} + {detectingFeed && ( +

{t('subscriptions.detecting')}

+ )} +
+
+
+ + setKeywords(event.target.value)} + placeholder="AI, tutorial" + /> +
+
+ + setTags(event.target.value)} + placeholder="YouTube, AI" + /> +
+
+
+
+ +
+ + +
+
+
+ + setNamingTemplate(sanitizeTemplateInput(event.target.value))} + placeholder="%(uploader)s - %(title)s.%(ext)s" + /> +
+
+
+
+

{t('subscriptions.fields.onlyLatest')}

+

+ {t('subscriptions.fields.onlyLatestDescription')} +

+
+ +
+
+ +
+
+
+ +
+
+

{t('subscriptions.title')}

+

{t('subscriptions.description')}

+
+ {sortedSubscriptions.length === 0 ? ( + + + {t('subscriptions.empty')} + + + ) : ( +
+ {sortedSubscriptions.map((subscription) => ( + refreshSubscription(subscription.id)} + onRemove={() => removeSubscription(subscription.id)} + onUpdate={(data) => updateSubscription({ id: subscription.id, data })} + renderStatus={() => renderStatus(subscription)} + /> + ))} +
+ )} +
+
+ ) +} + +interface SubscriptionCardProps { + subscription: SubscriptionRule + renderStatus: () => React.ReactNode + onRefresh: () => Promise + onRemove: () => Promise + onUpdate: (data: SubscriptionRuleUpdateForm) => Promise +} + +interface SubscriptionRuleUpdateForm { + keywords?: string[] + tags?: string[] + onlyDownloadLatest?: boolean + downloadDirectory?: string + namingTemplate?: string + enabled?: boolean +} + +function SubscriptionCard({ + subscription, + renderStatus, + onRefresh, + onRemove, + onUpdate +}: SubscriptionCardProps) { + const { t } = useTranslation() + const [editOpen, setEditOpen] = useState(false) + const feedItems: SubscriptionFeedItem[] = subscription.items ?? [] + + const handleToggleEnabled = async (checked: boolean) => { + await onUpdate({ enabled: checked }) + } + + const handleToggleMode = async (checked: boolean) => { + await onUpdate({ onlyDownloadLatest: checked }) + } + + const handleRefresh = async () => { + await onRefresh() + toast.success(t('subscriptions.notifications.refreshStarted')) + } + + const handleRemove = async () => { + await onRemove() + toast.success(t('subscriptions.notifications.removed')) + } + + const handleOpenItem = async (url: string) => { + try { + await ipcServices.fs.openExternal(url) + } catch (error) { + console.error('Failed to open subscription item link:', error) + toast.error(t('subscriptions.notifications.openLinkError')) + } + } + + const thumbnail = subscription.coverUrl + const lastCheckedLabel = subscription.lastCheckedAt + ? dayjs(subscription.lastCheckedAt).format('YYYY-MM-DD HH:mm') + : t('subscriptions.never') + + return ( + + +
+
+
+ +
+
+
+ + {subscription.title || t('subscriptions.labels.unknown')} + + {(subscription.tags ?? []).map((tag) => ( + + {tag} + + ))} +
+ {subscription.latestVideoTitle && ( + + {t('subscriptions.latestVideo', { title: subscription.latestVideoTitle })} + + )} +

+ {t('subscriptions.lastChecked', { time: lastCheckedLabel })} +

+
{renderStatus()}
+
+
+
+
+ {t('subscriptions.fields.enabled')} + void handleToggleEnabled(checked)} + /> +
+
+ {t('subscriptions.fields.onlyLatestShort')} + void handleToggleMode(checked)} + /> +
+ + + + + + { + await onUpdate(data) + toast.success(t('subscriptions.notifications.updated')) + setEditOpen(false) + }} + /> + + +
+
+
+ +
+

{t('subscriptions.items.title')}

+
+ {feedItems.length === 0 ? ( +

{t('subscriptions.items.empty')}

+ ) : ( +
+ {feedItems.map((item) => ( +
+
+

+ {item.title} +

+

+ {dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')} +

+
+
+ + {item.addedToQueue + ? t('subscriptions.items.queued') + : t('subscriptions.items.notQueued')} + + +
+
+ ))} +
+ )} +
+
+ ) +} + +interface SubscriptionEditDialogProps { + subscription: SubscriptionRule + onSave: (data: SubscriptionRuleUpdateForm) => Promise +} + +function SubscriptionEditDialog({ subscription, onSave }: SubscriptionEditDialogProps) { + const { t } = useTranslation() + const [keywords, setKeywords] = useState(subscription.keywords.join(', ')) + const [tags, setTags] = useState(subscription.tags.join(', ')) + const [downloadDirectory, setDownloadDirectory] = useState(subscription.downloadDirectory || '') + const [namingTemplate, setNamingTemplate] = useState(subscription.namingTemplate || '') + + const handleSelectDirectory = async () => { + try { + const path = await ipcServices.fs.selectDirectory() + if (path) { + setDownloadDirectory(path) + } + } catch (error) { + console.error('Failed to update directory:', error) + toast.error(t('subscriptions.notifications.directoryError')) + } + } + + const handleSave = async () => { + await onSave({ + keywords: sanitizeCommaList(keywords), + tags: sanitizeCommaList(tags), + downloadDirectory: downloadDirectory || undefined, + namingTemplate: namingTemplate || undefined + }) + } + + return ( + + + {t('subscriptions.edit.title', { name: subscription.title })} + {t('subscriptions.edit.description')} + +
+
+ + setKeywords(event.target.value)} /> +
+
+ + setTags(event.target.value)} /> +
+
+ +
+ + +
+
+
+ + setNamingTemplate(sanitizeTemplateInput(event.target.value))} + /> +
+
+ + + +
+ ) +} diff --git a/src/renderer/src/store/subscriptions.ts b/src/renderer/src/store/subscriptions.ts new file mode 100644 index 0000000..9a5a052 --- /dev/null +++ b/src/renderer/src/store/subscriptions.ts @@ -0,0 +1,79 @@ +import type { + SubscriptionResolvedFeed, + SubscriptionRule, + SubscriptionUpdatePayload +} from '@shared/types' +import { atom } from 'jotai' +import { ipcServices } from '../lib/ipc' + +const normalizeCommaList = (value?: string): string[] => { + if (!value) { + return [] + } + return value + .split(',') + .map((entry) => entry.trim()) + .filter((entry, index, array) => entry.length > 0 && array.indexOf(entry) === index) +} + +export const subscriptionsAtom = atom([]) + +export const setSubscriptionsAtom = atom(null, (_get, set, subscriptions: SubscriptionRule[]) => { + set(subscriptionsAtom, subscriptions) +}) + +export const loadSubscriptionsAtom = atom(null, async (_get, set) => { + try { + const subscriptions = await ipcServices.subscriptions.list() + set(subscriptionsAtom, subscriptions) + } catch (error) { + console.error('Failed to load subscriptions:', error) + } +}) + +export interface CreateSubscriptionForm { + url: string + keywords?: string + tags?: string + onlyDownloadLatest?: boolean + downloadDirectory?: string + namingTemplate?: string + enabled?: boolean +} + +export const createSubscriptionAtom = atom( + null, + async (_get, _set, payload: CreateSubscriptionForm) => { + await ipcServices.subscriptions.create({ + url: payload.url, + keywords: normalizeCommaList(payload.keywords), + tags: normalizeCommaList(payload.tags), + onlyDownloadLatest: payload.onlyDownloadLatest, + downloadDirectory: payload.downloadDirectory, + namingTemplate: payload.namingTemplate, + enabled: payload.enabled + }) + } +) + +export const updateSubscriptionAtom = atom( + null, + async (_get, _set, update: { id: string; data: SubscriptionUpdatePayload }) => { + await ipcServices.subscriptions.update(update.id, update.data) + } +) + +export const removeSubscriptionAtom = atom(null, async (_get, _set, id: string) => { + await ipcServices.subscriptions.remove(id) +}) + +export const refreshSubscriptionAtom = atom(null, async (_get, _set, id?: string) => { + await ipcServices.subscriptions.refresh(id) +}) + +export const resolveFeedAtom = atom( + null, + async (_get, _set, url: string): Promise => { + return ipcServices.subscriptions.resolve(url) + } +) diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 678edf1..d4a6d65 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -76,6 +76,9 @@ export interface DownloadItem { uploader?: string viewCount?: number tags?: string[] + origin?: 'manual' | 'subscription' + subscriptionId?: string + subscriptionTitle?: string // Download-specific format info selectedFormat?: VideoFormat // Playlist context (optional) @@ -85,6 +88,16 @@ export interface DownloadItem { playlistSize?: number } +export interface SubscriptionFeedItem { + id: string + url: string + title: string + publishedAt: number + thumbnail?: string + addedToQueue: boolean + downloadId?: string +} + export interface DownloadHistoryItem { id: string url: string @@ -109,6 +122,9 @@ export interface DownloadHistoryItem { uploader?: string viewCount?: number tags?: string[] + origin?: 'manual' | 'subscription' + subscriptionId?: string + subscriptionTitle?: string // Download-specific format info selectedFormat?: VideoFormat // Playlist context (optional) @@ -128,6 +144,12 @@ export interface DownloadOptions { startTime?: string endTime?: string downloadSubs?: boolean + customDownloadPath?: string + customFilenameTemplate?: string + tags?: string[] + origin?: 'manual' | 'subscription' + subscriptionId?: string + subscriptionTitle?: string } export interface PlaylistEntry { @@ -173,6 +195,66 @@ export interface PlaylistDownloadResult { entries: PlaylistDownloadEntry[] } +// Subscription types +export type SubscriptionPlatform = 'youtube' | 'bilibili' | 'custom' + +export type SubscriptionStatus = 'idle' | 'checking' | 'up-to-date' | 'failed' + +export interface SubscriptionRule { + id: string + title: string + sourceUrl: string + feedUrl: string + platform: SubscriptionPlatform + keywords: string[] + tags: string[] + onlyDownloadLatest: boolean + enabled: boolean + coverUrl?: string + latestVideoTitle?: string + latestVideoPublishedAt?: number + lastCheckedAt?: number + lastSuccessAt?: number + status: SubscriptionStatus + lastError?: string + createdAt: number + updatedAt: number + seenItemIds: string[] + lastItemId?: string + downloadDirectory?: string + namingTemplate?: string + items: SubscriptionFeedItem[] +} + +export interface SubscriptionResolvedFeed { + sourceUrl: string + feedUrl: string + platform: SubscriptionPlatform +} + +export interface SubscriptionCreatePayload { + sourceUrl: string + feedUrl: string + platform: SubscriptionPlatform + keywords?: string[] + tags?: string[] + onlyDownloadLatest?: boolean + downloadDirectory?: string + namingTemplate?: string + enabled?: boolean +} + +export interface SubscriptionUpdatePayload { + title?: string + keywords?: string[] + tags?: string[] + onlyDownloadLatest?: boolean + enabled?: boolean + downloadDirectory?: string + namingTemplate?: string + items?: SubscriptionFeedItem[] +} + // Settings types export type OneClickQualityPreset = 'best' | 'good' | 'normal' | 'bad' | 'worst' @@ -193,6 +275,9 @@ export interface AppSettings { closeToTray: boolean hideDockIcon: boolean autoUpdate: boolean + subscriptionFilenameTemplate: string + subscriptionOnlyLatestDefault: boolean + subscriptionCheckIntervalHours: number } export const defaultSettings: AppSettings = { @@ -211,5 +296,8 @@ export const defaultSettings: AppSettings = { oneClickQuality: 'best', closeToTray: false, hideDockIcon: false, - autoUpdate: true + autoUpdate: true, + subscriptionFilenameTemplate: '%(uploader)s - %(title)s.%(ext)s', + subscriptionOnlyLatestDefault: true, + subscriptionCheckIntervalHours: 3 }