feat(subscriptions): add subscription atoms and types for feeds and rules

This commit is contained in:
Nexmoe
2025-11-09 16:00:03 +08:00
parent e5d8629ba3
commit 56351941ee
20 changed files with 2345 additions and 30 deletions

View File

@@ -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",

31
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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<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)
}
}
export { SubscriptionService }

View File

@@ -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<DownloadHistoryItem>
): 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)

View File

@@ -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()

View File

@@ -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>
): 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<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)
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<string, string>()
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()

View File

@@ -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<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(),
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<void> {
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<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 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<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,
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()

View File

@@ -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)
}

View File

@@ -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<Page>('home')
const [platform, setPlatform] = useState<string>('')
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 <Settings />
case 'subscriptions':
return <Subscriptions />
case 'about':
return <About />
case 'sites':

View File

@@ -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) {
<div className="flex-1 min-w-0 max-w-full space-y-3 overflow-hidden">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between sm:gap-4">
<div className="flex-1 min-w-0 max-w-full space-y-2 overflow-hidden">
<div className="w-full min-w-0 overflow-hidden">
<p className="w-full wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
<div className="w-full min-w-0 overflow-hidden flex flex-wrap items-center gap-2">
<p className="flex-1 wrap-break-word text-sm font-medium sm:text-base line-clamp-2">
{download.title}
</p>
{isSubscriptionDownload && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0.5 shrink-0">
{t('subscriptions.labels.subscription')}
</Badge>
)}
</div>
<div className="flex w-full flex-wrap items-center gap-2 text-xs text-muted-foreground">
{(statusIcon || statusText) && (

View File

@@ -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: {

View File

@@ -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.",

View File

@@ -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() {
</div>
<Tabs defaultValue="general">
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
<TabsTrigger value="advanced">{t('settings.advanced')}</TabsTrigger>
<TabsTrigger value="rss">{t('settings.rss')}</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4 mt-2">
@@ -424,6 +434,81 @@ export function Settings() {
</Item>
</ItemGroup>
</TabsContent>
<TabsContent value="rss" className="space-y-4 mt-2">
<div className="rounded-lg border bg-muted/40 px-4 py-3 text-sm text-muted-foreground">
{t('subscriptions.defaults.description')}
</div>
<ItemGroup>
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('subscriptions.defaults.filenameTemplate')}</ItemTitle>
<ItemDescription>
{t('settings.subscriptionDefaults.filenameDescription')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Input
className="w-full max-w-md"
value={settings.subscriptionFilenameTemplate}
onChange={(event) =>
handleSettingChange(
'subscriptionFilenameTemplate',
sanitizeTemplateInput(event.target.value)
)
}
placeholder="%(uploader)s - %(title)s.%(ext)s"
/>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('subscriptions.defaults.checkInterval')}</ItemTitle>
<ItemDescription>
{t('settings.subscriptionDefaults.intervalDescription')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Input
type="number"
min={1}
max={24}
defaultValue={settings.subscriptionCheckIntervalHours}
key={`subscription-interval-${settings.subscriptionCheckIntervalHours}`}
onBlur={(event) =>
void handleSettingChange(
'subscriptionCheckIntervalHours',
clampSubscriptionInterval(event.target.value)
)
}
className="w-24"
/>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('subscriptions.defaults.onlyLatest')}</ItemTitle>
<ItemDescription>
{t('subscriptions.defaults.onlyLatestDescription')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
checked={settings.subscriptionOnlyLatestDefault}
onCheckedChange={(value) =>
handleSettingChange('subscriptionOnlyLatestDefault', value)
}
/>
</ItemActions>
</Item>
</ItemGroup>
</TabsContent>
</Tabs>
</div>
</div>

View File

@@ -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<SubscriptionResolvedFeed | null>(null)
const [detectingFeed, setDetectingFeed] = useState(false)
const detectTimeout = useRef<NodeJS.Timeout | null>(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 (
<div className="flex items-center gap-2">
<span className={meta.color}>{meta.emoji}</span>
<span className="text-sm text-muted-foreground">
{t(`subscriptions.status.${subscription.status}`)}
</span>
</div>
)
}
return (
<div className="space-y-6 p-6">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{t('subscriptions.title')}</h1>
<p className="text-muted-foreground">{t('subscriptions.description')}</p>
</div>
<Card>
<CardHeader className="space-y-1">
<CardTitle>{t('subscriptions.add.title')}</CardTitle>
<CardDescription>{t('subscriptions.add.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="space-y-2">
<Label htmlFor={urlInputId}>{t('subscriptions.fields.url')}</Label>
<Input
id={urlInputId}
value={url}
placeholder={t('subscriptions.placeholders.url')}
onChange={(event) => setUrl(event.target.value)}
/>
{detectedFeed && (
<Badge variant="outline" className="w-fit text-xs">
{t('subscriptions.detectedFeed', {
platform: detectedFeed.platform,
feed: detectedFeed.feedUrl
})}
</Badge>
)}
{detectingFeed && (
<p className="text-xs text-muted-foreground">{t('subscriptions.detecting')}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>{t('subscriptions.fields.keywords')}</Label>
<Input
value={keywords}
onChange={(event) => setKeywords(event.target.value)}
placeholder="AI, tutorial"
/>
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.tags')}</Label>
<Input
value={tags}
onChange={(event) => setTags(event.target.value)}
placeholder="YouTube, AI"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>{t('subscriptions.fields.customDirectory')}</Label>
<div className="flex gap-2">
<Input value={customDownloadDirectory} readOnly />
<Button variant="secondary" onClick={() => void handleSelectDirectory()}>
{t('subscriptions.actions.selectDirectory')}
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.namingTemplate')}</Label>
<Input
value={namingTemplate}
onChange={(event) => setNamingTemplate(sanitizeTemplateInput(event.target.value))}
placeholder="%(uploader)s - %(title)s.%(ext)s"
/>
</div>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg border px-4 py-3">
<div>
<p className="text-sm font-medium">{t('subscriptions.fields.onlyLatest')}</p>
<p className="text-xs text-muted-foreground">
{t('subscriptions.fields.onlyLatestDescription')}
</p>
</div>
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
</div>
<div className="flex justify-end">
<Button onClick={() => void handleCreateSubscription()}>
{t('subscriptions.actions.add')}
</Button>
</div>
</CardContent>
</Card>
<section className="space-y-4">
<div className="space-y-1">
<h2 className="text-xl font-semibold">{t('subscriptions.title')}</h2>
<p className="text-sm text-muted-foreground">{t('subscriptions.description')}</p>
</div>
{sortedSubscriptions.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">
{t('subscriptions.empty')}
</CardContent>
</Card>
) : (
<div className="space-y-4">
{sortedSubscriptions.map((subscription) => (
<SubscriptionCard
key={subscription.id}
subscription={subscription}
onRefresh={() => refreshSubscription(subscription.id)}
onRemove={() => removeSubscription(subscription.id)}
onUpdate={(data) => updateSubscription({ id: subscription.id, data })}
renderStatus={() => renderStatus(subscription)}
/>
))}
</div>
)}
</section>
</div>
)
}
interface SubscriptionCardProps {
subscription: SubscriptionRule
renderStatus: () => React.ReactNode
onRefresh: () => Promise<void>
onRemove: () => Promise<void>
onUpdate: (data: SubscriptionRuleUpdateForm) => Promise<void>
}
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 (
<Card>
<CardHeader className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex w-full items-start gap-4">
<div className="h-14 w-14 overflow-hidden rounded-md bg-muted">
<ImageWithPlaceholder
src={thumbnail}
alt={subscription.title}
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-lg leading-tight">
{subscription.title || t('subscriptions.labels.unknown')}
</CardTitle>
{(subscription.tags ?? []).map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
{subscription.latestVideoTitle && (
<CardDescription className="text-sm">
{t('subscriptions.latestVideo', { title: subscription.latestVideoTitle })}
</CardDescription>
)}
<p className="text-xs text-muted-foreground">
{t('subscriptions.lastChecked', { time: lastCheckedLabel })}
</p>
<div>{renderStatus()}</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 md:justify-end">
<div className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm text-muted-foreground">
<span>{t('subscriptions.fields.enabled')}</span>
<Switch
checked={subscription.enabled}
onCheckedChange={(checked) => void handleToggleEnabled(checked)}
/>
</div>
<div className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm text-muted-foreground">
<span>{t('subscriptions.fields.onlyLatestShort')}</span>
<Switch
checked={subscription.onlyDownloadLatest}
onCheckedChange={(checked) => void handleToggleMode(checked)}
/>
</div>
<Button variant="secondary" onClick={() => void handleRefresh()}>
{t('subscriptions.actions.refresh')}
</Button>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogTrigger asChild>
<Button variant="outline">{t('subscriptions.actions.edit')}</Button>
</DialogTrigger>
<SubscriptionEditDialog
subscription={subscription}
onSave={async (data) => {
await onUpdate(data)
toast.success(t('subscriptions.notifications.updated'))
setEditOpen(false)
}}
/>
</Dialog>
<Button variant="destructive" onClick={() => void handleRemove()}>
{t('subscriptions.actions.remove')}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold">{t('subscriptions.items.title')}</p>
</div>
{feedItems.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('subscriptions.items.empty')}</p>
) : (
<div className="space-y-2">
{feedItems.map((item) => (
<div
key={`${subscription.id}-${item.id}`}
className="flex flex-col gap-2 rounded-lg border bg-muted/20 px-3 py-2 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium" title={item.title}>
{item.title}
</p>
<p className="text-xs text-muted-foreground">
{dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant={item.addedToQueue ? 'default' : 'outline'}>
{item.addedToQueue
? t('subscriptions.items.queued')
: t('subscriptions.items.notQueued')}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() => void handleOpenItem(item.url)}
title={t('subscriptions.items.actions.open')}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}
interface SubscriptionEditDialogProps {
subscription: SubscriptionRule
onSave: (data: SubscriptionRuleUpdateForm) => Promise<void>
}
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 (
<DialogContent>
<DialogHeader>
<DialogTitle>{t('subscriptions.edit.title', { name: subscription.title })}</DialogTitle>
<DialogDescription>{t('subscriptions.edit.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>{t('subscriptions.fields.keywords')}</Label>
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.tags')}</Label>
<Input value={tags} onChange={(event) => setTags(event.target.value)} />
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.customDirectory')}</Label>
<div className="flex gap-2">
<Input value={downloadDirectory} readOnly />
<Button variant="secondary" onClick={() => void handleSelectDirectory()}>
{t('subscriptions.actions.selectDirectory')}
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.namingTemplate')}</Label>
<Input
value={namingTemplate}
onChange={(event) => setNamingTemplate(sanitizeTemplateInput(event.target.value))}
/>
</div>
</div>
<DialogFooter>
<Button onClick={() => void handleSave()}>{t('subscriptions.actions.save')}</Button>
</DialogFooter>
</DialogContent>
)
}

View File

@@ -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<SubscriptionRule[]>([])
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<SubscriptionResolvedFeed> => {
return ipcServices.subscriptions.resolve(url)
}
)

View File

@@ -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
}