diff --git a/src/main/lib/download-engine.ts b/src/main/lib/download-engine.ts index 3128701..c3f3c4f 100644 --- a/src/main/lib/download-engine.ts +++ b/src/main/lib/download-engine.ts @@ -440,6 +440,7 @@ class DownloadEngine extends EventEmitter { let selectedFormat: VideoFormat | undefined let actualFormat: string | null = null let videoInfo: VideoInfo | undefined + let lastKnownOutputPath: string | undefined // First, get detailed video info to capture basic metadata and formats try { @@ -504,6 +505,38 @@ class DownloadEngine extends EventEmitter { const args = buildDownloadArgs(options, resolvedDownloadPath, settings) + const captureOutputPath = (rawPath: string | undefined): void => { + if (!rawPath) { + return + } + const trimmed = rawPath.trim().replace(/^"|"$/g, '') + if (!trimmed) { + return + } + lastKnownOutputPath = path.isAbsolute(trimmed) + ? trimmed + : path.join(resolvedDownloadPath, trimmed) + } + + const extractOutputPathFromLog = (message: string): void => { + const destinationMatch = message.match(/Destination:\s*(.+)$/) + if (destinationMatch) { + captureOutputPath(destinationMatch[1]) + return + } + + const mergingMatch = message.match(/Merging formats into\s+"(.+?)"/) + if (mergingMatch) { + captureOutputPath(mergingMatch[1]) + return + } + + const movingMatch = message.match(/Moving file to\s+"(.+?)"/) + if (movingMatch) { + captureOutputPath(movingMatch[1]) + } + } + // Check if format selector contains '+' which means video and audio will be merged const formatSelector = options.type === 'video' ? resolveVideoFormatSelector(options) : undefined @@ -620,6 +653,10 @@ class DownloadEngine extends EventEmitter { applySelectedFormat(formatMatch[1]) } } + + if (eventType === 'download' || eventType === 'info') { + extractOutputPathFromLog(eventData) + } }) // Handle completion @@ -646,32 +683,44 @@ class DownloadEngine extends EventEmitter { extension = actualFormat || 'mp4' } - const fileName = `${sanitizedTitle}.${extension}` - const finalOutputPath = path.join(resolvedDownloadPath, fileName) + const fallbackFileName = `${sanitizedTitle}.${extension}` + const fallbackOutputPath = path.join(resolvedDownloadPath, fallbackFileName) scopedLoggers.download.info( - 'Generated file path for ID:', + 'Resolved output paths for ID:', id, - 'Path:', - finalOutputPath, + 'Primary:', + lastKnownOutputPath ?? fallbackOutputPath, + 'Fallback:', + fallbackOutputPath, 'Will merge:', willMerge ) let fileSize: number | undefined - let actualFilePath = finalOutputPath + let actualFilePath = lastKnownOutputPath ?? fallbackOutputPath + const candidatePaths = lastKnownOutputPath + ? [lastKnownOutputPath, fallbackOutputPath] + : [fallbackOutputPath] + try { const fs = await import('node:fs/promises') - // Try to find the actual file - yt-dlp may generate files with slightly different names - const stats = await fs.stat(finalOutputPath) - fileSize = stats.size - actualFilePath = finalOutputPath - } catch (error) { - // If the expected file doesn't exist, try to find it by scanning the directory - try { - const fs = await import('node:fs/promises') + let located = false + for (const candidate of candidatePaths) { + if (!candidate) { + continue + } + try { + const stats = await fs.stat(candidate) + fileSize = stats.size + actualFilePath = candidate + located = true + break + } catch {} + } + + if (!located) { const files = await fs.readdir(resolvedDownloadPath) - // Look for files matching the title pattern with the correct extension const matchingFiles = files.filter((file) => { const baseName = file.replace(/\.[^.]+$/, '') const fileExt = file.split('.').pop()?.toLowerCase() @@ -682,30 +731,33 @@ class DownloadEngine extends EventEmitter { }) if (matchingFiles.length > 0) { - // Use the most recently modified file if multiple matches const fileStats = await Promise.all( matchingFiles.map(async (file) => { const filePath = path.join(resolvedDownloadPath, file) const stats = await fs.stat(filePath) - return { file, path: filePath, mtime: stats.mtime, size: stats.size } + return { path: filePath, mtime: stats.mtime, size: stats.size } }) ) const mostRecent = fileStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())[0] actualFilePath = mostRecent.path fileSize = mostRecent.size + located = true scopedLoggers.download.info('Found actual file:', actualFilePath, 'Size:', fileSize) - } else if (latestKnownSizeBytes !== undefined) { - fileSize = latestKnownSizeBytes + } + } + + if (!fileSize && latestKnownSizeBytes !== undefined) { + fileSize = latestKnownSizeBytes + if (!located) { scopedLoggers.download.warn('File not found, using estimated size:', fileSize) - } else { - scopedLoggers.download.warn('Failed to find file for ID:', id, error) - } - } catch (scanError) { - if (latestKnownSizeBytes !== undefined) { - fileSize = latestKnownSizeBytes - } else { - scopedLoggers.download.warn('Failed to get file size for ID:', id, scanError) } + } else if (!fileSize) { + scopedLoggers.download.warn('Failed to find file for ID:', id) + } + } catch (error) { + scopedLoggers.download.warn('Failed to resolve file details for ID:', id, error) + if (latestKnownSizeBytes !== undefined) { + fileSize = latestKnownSizeBytes } } diff --git a/src/main/lib/subscription-manager.ts b/src/main/lib/subscription-manager.ts index a9046e7..bc5c0cd 100644 --- a/src/main/lib/subscription-manager.ts +++ b/src/main/lib/subscription-manager.ts @@ -88,7 +88,6 @@ const subscriptionItemsTable = sqliteTable( 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(), @@ -234,13 +233,13 @@ export class SubscriptionManager extends EventEmitter { silent: boolean = false ): void { const database = this.getDatabase() - const limited = items.slice(0, 20) + const orderedItems = [...items].sort((a, b) => b.publishedAt - a.publishedAt) const now = Date.now() database.transaction((tx) => { tx.delete(subscriptionItemsTable) .where(eq(subscriptionItemsTable.subscriptionId, subscriptionId)) .run() - for (const item of limited) { + for (const item of orderedItems) { tx.insert(subscriptionItemsTable) .values({ subscriptionId, @@ -249,7 +248,6 @@ export class SubscriptionManager extends EventEmitter { url: item.url, publishedAt: item.publishedAt, thumbnail: item.thumbnail ?? null, - status: 'queued', added: booleanToNumber(item.addedToQueue), downloadId: item.downloadId ?? null, createdAt: item.publishedAt, @@ -376,7 +374,6 @@ export class SubscriptionManager extends EventEmitter { 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, @@ -410,17 +407,13 @@ export class SubscriptionManager extends EventEmitter { .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 hasLegacyStatus = columns.some((column) => column.name === 'status') + const needsMigration = hasDownloaded || hasLegacyStatus + if (needsMigration) { + if (hasDownloaded) { + this.sqlite.prepare(`UPDATE subscription_items SET added = 1 WHERE downloaded = 1`).run() + } const sqlite = this.sqlite const migrate = sqlite.transaction(() => { sqlite.prepare(`DROP TABLE IF EXISTS subscription_items_new`).run() @@ -433,7 +426,6 @@ export class SubscriptionManager extends EventEmitter { 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, @@ -451,7 +443,6 @@ export class SubscriptionManager extends EventEmitter { url, published_at, thumbnail, - status, added, download_id, created_at, @@ -464,7 +455,6 @@ export class SubscriptionManager extends EventEmitter { url, published_at, thumbnail, - status, added, download_id, created_at, @@ -481,7 +471,7 @@ export class SubscriptionManager extends EventEmitter { .run() }) migrate() - log.info('subscriptions: removed legacy downloaded column from subscription_items') + log.info('subscriptions: removed legacy columns from subscription_items') } } catch (error) { log.warn('subscriptions: failed to ensure subscription_items schema', error) diff --git a/src/main/lib/subscription-scheduler.ts b/src/main/lib/subscription-scheduler.ts index ef74ce9..a57b8ea 100644 --- a/src/main/lib/subscription-scheduler.ts +++ b/src/main/lib/subscription-scheduler.ts @@ -40,8 +40,6 @@ type FeedItem = { thumbnail?: string } -const MAX_STORED_FEED_ITEMS = 12 - const parser = new Parser<{ item: ParserItem }>({ customFields: { item: [ @@ -92,8 +90,7 @@ export class SubscriptionScheduler extends EventEmitter { this.downloads.delete(id) subscriptionManager.update(tracked.subscriptionId, { status: 'up-to-date', - lastSuccessAt: Date.now(), - lastError: undefined + lastSuccessAt: Date.now() }) subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, { downloadId: id @@ -122,7 +119,6 @@ export class SubscriptionScheduler extends EventEmitter { this.downloads.delete(id) subscriptionManager.update(tracked.subscriptionId, { status: 'failed', - lastError: error.message, lastCheckedAt: Date.now() }) subscriptionManager.updateFeedItemQueueState(tracked.subscriptionId, tracked.itemId, { @@ -200,7 +196,8 @@ export class SubscriptionScheduler extends EventEmitter { 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 recentItems = this.filterRecentItems(subscription, normalizedItems) + const unseenItems = this.filterNewItems(subscription, recentItems) const keywords = subscription.keywords.map((keyword) => keyword.toLowerCase()) const keywordFiltered = keywords.length > 0 @@ -217,6 +214,11 @@ export class SubscriptionScheduler extends EventEmitter { const itemsToDownload = subscription.onlyDownloadLatest && deduped.length > 0 ? [deduped[0]] : deduped + subscriptionManager.replaceFeedItems( + subscription.id, + this.buildFeedItems(normalizedItems, subscription) + ) + if (itemsToDownload.length > 0) { for (const item of itemsToDownload) { await this.queueDownload(subscription.id, item.id, item.url) @@ -240,8 +242,6 @@ export class SubscriptionScheduler extends EventEmitter { ? 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, { @@ -272,17 +272,23 @@ export class SubscriptionScheduler extends EventEmitter { return normalized.sort((a, b) => b.publishedAt - a.publishedAt) } - private buildFeedItems(items: FeedItem[]): SubscriptionFeedItem[] { - return items.slice(0, MAX_STORED_FEED_ITEMS).map((item) => { + private buildFeedItems( + items: FeedItem[], + subscription: SubscriptionRule + ): SubscriptionFeedItem[] { + const existingItems = new Map(subscription.items.map((item) => [item.id, item])) + return items.map((item) => { const tracked = this.getTrackedDownloadByUrl(item.url) + const existing = existingItems.get(item.id) return { id: item.id, url: item.url, title: item.title, publishedAt: item.publishedAt, thumbnail: item.thumbnail, - addedToQueue: Boolean(tracked) || historyManager.hasHistoryForUrl(item.url), - downloadId: tracked?.downloadId + addedToQueue: + Boolean(tracked) || existing?.addedToQueue || historyManager.hasHistoryForUrl(item.url), + downloadId: tracked?.downloadId ?? existing?.downloadId } }) } @@ -349,6 +355,19 @@ export class SubscriptionScheduler extends EventEmitter { return undefined } + private filterRecentItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] { + const lastKnownPublishedAt = this.getLastKnownPublishedAt(subscription) + if (lastKnownPublishedAt === 0) { + return subscription.onlyDownloadLatest ? items.slice(0, 1) : items + } + return items.filter((item) => item.publishedAt > lastKnownPublishedAt) + } + + private getLastKnownPublishedAt(subscription: SubscriptionRule): number { + const fromItems = subscription.items.reduce((max, item) => Math.max(max, item.publishedAt), 0) + return Math.max(subscription.latestVideoPublishedAt ?? 0, fromItems) + } + private filterNewItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] { const seenIds = new Set(subscription.items.map((item) => item.id)) return items.filter((item) => !seenIds.has(item.id)) @@ -404,8 +423,7 @@ export class SubscriptionScheduler extends EventEmitter { } 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) + status: 'failed' }) subscriptionManager.updateFeedItemQueueState(subscriptionId, itemId, { added: false, diff --git a/src/renderer/src/assets/global.css b/src/renderer/src/assets/global.css index 5c28867..eb47861 100644 --- a/src/renderer/src/assets/global.css +++ b/src/renderer/src/assets/global.css @@ -10,5 +10,9 @@ body { @apply text-foreground; } + input::placeholder, + textarea::placeholder { + opacity: 0.4; + } } diff --git a/src/renderer/src/components/subscription/SubscriptionFormDialog.tsx b/src/renderer/src/components/subscription/SubscriptionFormDialog.tsx new file mode 100644 index 0000000..c5234d9 --- /dev/null +++ b/src/renderer/src/components/subscription/SubscriptionFormDialog.tsx @@ -0,0 +1,323 @@ +import { Badge } from '@renderer/components/ui/badge' +import { Button } from '@renderer/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@renderer/components/ui/dialog' +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 { resolveFeedAtom } from '@renderer/store/subscriptions' +import type { SubscriptionResolvedFeed, SubscriptionRule } from '@shared/types' +import { useAtom, useSetAtom } from 'jotai' +import { ChevronRight } from 'lucide-react' +import { useEffect, useId, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +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 interface SubscriptionFormData { + url?: string + keywords?: string[] + tags?: string[] + onlyDownloadLatest?: boolean + downloadDirectory?: string + namingTemplate?: string + enabled?: boolean +} + +interface SubscriptionFormDialogProps { + mode: 'add' | 'edit' + subscription?: SubscriptionRule + open: boolean + onSave: (data: SubscriptionFormData) => Promise + onClose: () => void +} + +export function SubscriptionFormDialog({ + mode, + subscription, + open, + onSave, + onClose +}: SubscriptionFormDialogProps) { + const { t } = useTranslation() + const [settings] = useAtom(settingsAtom) + const resolveFeed = useSetAtom(resolveFeedAtom) + + // Form state + const [url, setUrl] = useState('') + const [keywords, setKeywords] = useState('') + const [tags, setTags] = useState('') + const [onlyLatest, setOnlyLatest] = useState(false) + const [downloadDirectory, setDownloadDirectory] = useState('') + const [namingTemplate, setNamingTemplate] = useState('') + + // Feed detection state + const [detectedFeed, setDetectedFeed] = useState(null) + const [detectingFeed, setDetectingFeed] = useState(false) + + const detectTimeout = useRef(null) + const prevDefaultPathRef = useRef(settings.downloadPath) + const urlInputId = useId() + + // Initialize form values based on mode + useEffect(() => { + if (!open) { + return + } + + if (mode === 'edit' && subscription) { + setUrl(subscription.feedUrl) + setKeywords(subscription.keywords.join(', ')) + setTags(subscription.tags.join(', ')) + setOnlyLatest(subscription.onlyDownloadLatest) + setDownloadDirectory(subscription.downloadDirectory || '') + setNamingTemplate(subscription.namingTemplate || '') + } else { + // Add mode - use defaults from settings + setUrl('') + setKeywords('') + setTags('') + setOnlyLatest(settings.subscriptionOnlyLatestDefault) + setDownloadDirectory(settings.downloadPath) + setNamingTemplate(settings.subscriptionFilenameTemplate) + } + setDetectedFeed(null) + }, [ + open, + mode, + subscription, + settings.subscriptionOnlyLatestDefault, + settings.downloadPath, + settings.subscriptionFilenameTemplate + ]) + + // Sync download directory with settings changes (only in add mode) + useEffect(() => { + if (mode === 'add') { + const newPath = settings.downloadPath + setDownloadDirectory((prev) => { + if (!prev || prev === prevDefaultPathRef.current) { + return newPath + } + return prev + }) + prevDefaultPathRef.current = newPath + } + }, [settings.downloadPath, mode]) + + // Sync naming template with settings changes (only in add mode) + useEffect(() => { + if (mode === 'add') { + setNamingTemplate(settings.subscriptionFilenameTemplate) + } + }, [settings.subscriptionFilenameTemplate, mode]) + + // Sync onlyLatest with settings changes (only in add mode) + useEffect(() => { + if (mode === 'add') { + setOnlyLatest(settings.subscriptionOnlyLatestDefault) + } + }, [settings.subscriptionOnlyLatestDefault, mode]) + + // Feed detection logic + useEffect(() => { + if (!url.trim()) { + setDetectedFeed(null) + return + } + + // In edit mode, don't detect if URL hasn't changed + if (mode === 'edit' && subscription && url.trim() === subscription.feedUrl) { + 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, mode, subscription]) + + const handleSelectDirectory = async () => { + try { + const path = await ipcServices.fs.selectDirectory() + if (path) { + setDownloadDirectory(path) + } + } catch (error) { + console.error('Failed to select directory:', error) + toast.error(t('subscriptions.notifications.directoryError')) + } + } + + const handleOpenRSSHubDocs = async () => { + try { + await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/social-media#youtube') + } catch (error) { + console.error('Failed to open RSSHub documentation:', error) + toast.error(t('subscriptions.notifications.openLinkError')) + } + } + + const handleSave = async () => { + // Validate URL for add mode + if (mode === 'add' && !url.trim()) { + toast.error(t('subscriptions.notifications.missingUrl')) + return + } + + const formData: SubscriptionFormData = { + keywords: sanitizeCommaList(keywords), + tags: sanitizeCommaList(tags), + onlyDownloadLatest: onlyLatest, + downloadDirectory: downloadDirectory || undefined, + namingTemplate: namingTemplate || undefined + } + + // Include URL if it's provided and different from current (for edit mode) + if (url.trim()) { + if ( + mode === 'add' || + (mode === 'edit' && subscription && url.trim() !== subscription.feedUrl) + ) { + try { + await resolveFeed(url.trim()) + formData.url = url.trim() + } catch (error) { + console.error('Failed to resolve feed:', error) + toast.error(t('subscriptions.notifications.resolveError')) + return + } + } + } + + await onSave(formData) + } + + const titleKey = mode === 'add' ? 'subscriptions.add.title' : 'subscriptions.edit.title' + const descriptionKey = + mode === 'add' ? 'subscriptions.add.description' : 'subscriptions.edit.description' + const saveButtonKey = mode === 'add' ? 'subscriptions.actions.add' : 'subscriptions.actions.save' + + return ( + !isOpen && onClose()}> + + + + {mode === 'edit' && subscription + ? t(titleKey, { name: subscription.title }) + : t(titleKey)} + + {t(descriptionKey)} + +
+
+ + setUrl(event.target.value)} + /> + {detectedFeed && ( + + {t('subscriptions.detectedFeed', { + platform: detectedFeed.platform, + feed: detectedFeed.feedUrl + })} + + )} + {detectingFeed && ( +

{t('subscriptions.detecting')}

+ )} + {mode === 'add' && !url.trim() && ( +
+

+ {t('subscriptions.rssHub.hint')} +

+ +
+ )} +
+
+ + setKeywords(event.target.value)} /> +
+
+ + setTags(event.target.value)} /> +
+
+ +
+ + +
+
+
+ + setNamingTemplate(sanitizeTemplateInput(event.target.value))} + /> +
+
+

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

+ +
+
+ + {mode === 'add' && ( + + )} + + +
+
+ ) +} diff --git a/src/renderer/src/components/ui/sidebar.tsx b/src/renderer/src/components/ui/sidebar.tsx index d5d2319..5992bae 100644 --- a/src/renderer/src/components/ui/sidebar.tsx +++ b/src/renderer/src/components/ui/sidebar.tsx @@ -9,7 +9,6 @@ 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' @@ -20,6 +19,8 @@ import MingcuteDownload3Line from '~icons/mingcute/download-3-line' import MingcuteGlobeLine from '~icons/mingcute/globe-2-line' import MingcuteInformationFill from '~icons/mingcute/information-fill' import MingcuteInformationLine from '~icons/mingcute/information-line' +import MingcuteRssFill from '~icons/mingcute/rss-fill' +import MingcuteRssLine from '~icons/mingcute/rss-line' import MingcuteSettingsFill from '~icons/mingcute/settings-3-fill' import MingcuteSettingsLine from '~icons/mingcute/settings-3-line' @@ -57,8 +58,8 @@ export function Sidebar({ currentPage, onPageChange }: SidebarProps) { { id: 'subscriptions', icon: { - active: Newspaper, - inactive: Newspaper + active: MingcuteRssFill, + inactive: MingcuteRssLine }, label: t('menu.rss') }, diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index e97ca30..16e4acd 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -362,7 +362,6 @@ "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", @@ -381,94 +380,6 @@ }, "video": "Video Preferences" }, - "subscriptions": { - "title": "Subscriptions", - "subtitle": "{{count}} subscription{{count, plural, one {} other {s}}}", - "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", - "disabled": "Disabled", - "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 ({{count}})", - "count": "{{count}} items", - "empty": "No recent feed items found.", - "queued": "Queued", - "notQueued": "Not queued", - "fromChannel": "From {{channel}}", - "actions": { - "open": "Open in browser" - } - }, - "labels": { - "subscription": "Subscription", - "unknown": "Unknown subscription", - "noThumbnail": "No thumbnail" - }, - "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.", - "resolveError": "Failed to resolve RSS feed URL." - }, - "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": { - "title": "Status", - "up-to-date": "Up to date", - "checking": "Checking", - "failed": "Failed", - "idle": "Idle", - "tooltip": { - "updatedAt": "Updated: {{time}}" - } - } - }, "subscriptions": { "title": "Subscriptions", "subtitle": "{{count}} subscription{{count, plural, one {} other {s}}}", @@ -487,7 +398,7 @@ "description": "Paste an RSS feed link. VidBee will detect the feed automatically." }, "fields": { - "url": "Source link", + "url": "Feed URL", "keywords": "Keyword filter (comma separated)", "tags": "Auto tags", "customDirectory": "Custom directory", @@ -499,7 +410,7 @@ "onlyLatestShort": "Only latest" }, "placeholders": { - "url": "https://www.youtube.com/channel/UC..." + "url": "https://rsshub.app/youtube/user/@FKJ" }, "actions": { "add": "Add", @@ -507,7 +418,9 @@ "edit": "Edit", "remove": "Remove", "save": "Save changes", - "selectDirectory": "Browse" + "selectDirectory": "Browse", + "enable": "Enable", + "disable": "Disable" }, "items": { "title": "Latest uploads ({{count}})", @@ -526,6 +439,7 @@ "fromChannel": "From {{channel}}", "tooltip": { "downloadStatus": "Download status: {{status}}", + "downloadPending": "Waiting for download details...", "notQueued": "Not in the download queue yet" }, "actions": { @@ -567,6 +481,13 @@ "tooltip": { "updatedAt": "Updated: {{time}}" } + }, + "rssHub": { + "title": "Automated Subscriptions with RSSHub", + "description": "Combine VidBee with RSSHub to enable automated subscriptions and downloads from various platforms. Once set up, VidBee runs in the background and automatically downloads the latest videos and content.", + "learnMore": "Learn more about RSSHub", + "openDocs": "Open RSSHub Documentation", + "hint": "Don't have an RSS feed URL? Use RSSHub to generate RSS feeds for YouTube, Twitter, and thousands of other platforms." } }, "sites": { diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index b11439f..a6f67c4 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -26,7 +26,6 @@ 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)) { @@ -139,10 +138,9 @@ export function Settings() { - + {t('settings.general')} {t('settings.advanced')} - {t('settings.rss')} @@ -303,6 +301,33 @@ export function Settings() { )} + + + {t('subscriptions.defaults.checkInterval')} + + {t('settings.subscriptionDefaults.intervalDescription')} + + + + + void handleSettingChange( + 'subscriptionCheckIntervalHours', + clampSubscriptionInterval(event.target.value) + ) + } + className="w-24" + /> + + + + + {t('settings.maxConcurrentDownloads')} @@ -456,81 +481,6 @@ 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 index f0b8ff8..7017286 100644 --- a/src/renderer/src/pages/Subscriptions.tsx +++ b/src/renderer/src/pages/Subscriptions.tsx @@ -1,31 +1,29 @@ +import { + type SubscriptionFormData, + SubscriptionFormDialog +} from '@renderer/components/subscription/SubscriptionFormDialog' 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 { ContextMenu, - ContextMenuCheckboxItem, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '@renderer/components/ui/context-menu' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@renderer/components/ui/dialog' -import { Input } from '@renderer/components/ui/input' -import { Label } from '@renderer/components/ui/label' import { RemoteImage } from '@renderer/components/ui/remote-image' -import { Switch } from '@renderer/components/ui/switch' import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip' import { ipcServices } from '@renderer/lib/ipc' import { cn } from '@renderer/lib/utils' import { type DownloadRecord, downloadsArrayAtom } from '@renderer/store/downloads' -import { settingsAtom } from '@renderer/store/settings' import { createSubscriptionAtom, refreshSubscriptionAtom, @@ -34,27 +32,14 @@ import { subscriptionsAtom, updateSubscriptionAtom } from '@renderer/store/subscriptions' -import type { - DownloadStatus, - SubscriptionFeedItem, - SubscriptionResolvedFeed, - SubscriptionRule -} from '@shared/types' +import type { DownloadStatus, SubscriptionFeedItem, SubscriptionRule } from '@shared/types' import dayjs from 'dayjs' import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { Edit, ExternalLink, Plus, Power, RefreshCw, Trash2 } from 'lucide-react' -import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -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, '-') - const statusStyles: Record< SubscriptionRule['status'], { dotClass: string; textClass: string; label: string } @@ -182,39 +167,38 @@ function SubscriptionTab({ - + {t('subscriptions.actions.refresh')} - + {t('subscriptions.actions.edit')} - - void handleToggleEnabled(checked)} - > - - {t('subscriptions.fields.enabled')} - + void handleToggleEnabled(!subscription.enabled)}> + + {subscription.enabled + ? t('subscriptions.actions.disable') + : t('subscriptions.actions.enable')} + void handleRemove()} variant="destructive"> - + {t('subscriptions.actions.remove')} - - { - await onUpdate(data) - toast.success(t('subscriptions.notifications.updated')) - setEditOpen(false) - }} - /> - + { + await onUpdate(data) + toast.success(t('subscriptions.notifications.updated')) + setEditOpen(false) + }} + onClose={() => setEditOpen(false)} + /> ) } @@ -249,7 +233,7 @@ export function Subscriptions() { enabled: data.enabled } - // If URL is provided, resolve it and include sourceUrl, feedUrl, and platform + // If feed URL is provided, resolve it and include sourceUrl, feedUrl, and platform if (data.url) { try { const resolved = await resolveFeed(data.url) @@ -269,9 +253,43 @@ export function Subscriptions() { [refreshSubscription, updateSubscription, resolveFeed, t] ) - const handleCreateSubscription = useCallback(async () => { - setAddDialogOpen(false) - }, []) + const createSubscription = useSetAtom(createSubscriptionAtom) + + const handleCreateSubscription = useCallback( + async (data: SubscriptionFormData) => { + if (!data.url) { + toast.error(t('subscriptions.notifications.missingUrl')) + return + } + + try { + await createSubscription({ + url: data.url, + keywords: data.keywords?.join(', '), + tags: data.tags?.join(', '), + onlyDownloadLatest: data.onlyDownloadLatest, + downloadDirectory: data.downloadDirectory, + namingTemplate: data.namingTemplate, + enabled: data.enabled + }) + toast.success(t('subscriptions.notifications.created')) + setAddDialogOpen(false) + } catch (error) { + console.error('Failed to create subscription:', error) + toast.error(t('subscriptions.notifications.createError')) + } + }, + [createSubscription, t] + ) + + const handleOpenRSSHubDocs = useCallback(async () => { + try { + await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/social-media#youtube') + } catch (error) { + console.error('Failed to open RSSHub documentation:', error) + toast.error(t('subscriptions.notifications.openLinkError')) + } + }, [t]) // Filter subscriptions based on selected tab const displayedSubscriptions = useMemo(() => { @@ -353,15 +371,34 @@ export function Subscriptions() { )} + + {/* RSSHub Info Card */} + + + + {t('subscriptions.rssHub.title')} + + {t('subscriptions.rssHub.description')} + + + + + - - setAddDialogOpen(false)} - /> - + setAddDialogOpen(false)} + /> ) } @@ -374,20 +411,15 @@ interface SubscriptionTabProps { onUpdate: (data: SubscriptionRuleUpdateForm) => Promise } -interface SubscriptionRuleUpdateForm { - url?: string - keywords?: string[] - tags?: string[] - onlyDownloadLatest?: boolean - downloadDirectory?: string - namingTemplate?: string - enabled?: boolean -} +type SubscriptionRuleUpdateForm = SubscriptionFormData function SubscriptionCard({ subscription }: { subscription: SubscriptionRule }) { const { t } = useTranslation() const feedItems: SubscriptionFeedItem[] = subscription.items ?? [] const downloads = useAtomValue(downloadsArrayAtom) + const [historyStatusMap, setHistoryStatusMap] = useState>( + {} + ) const downloadLookup = useMemo(() => { const map = new Map() downloads.forEach((record) => { @@ -396,6 +428,69 @@ function SubscriptionCard({ subscription }: { subscription: SubscriptionRule }) return map }, [downloads]) + useEffect(() => { + const queuedDownloadIds = Array.from( + new Set( + feedItems + .filter((item) => item.addedToQueue && item.downloadId) + .map((item) => item.downloadId as string) + ) + ) + + const missingIds = queuedDownloadIds.filter( + (downloadId) => !downloadLookup.has(downloadId) && historyStatusMap[downloadId] === undefined + ) + + if (missingIds.length === 0) { + return + } + + let cancelled = false + + const fetchHistoryStatuses = async () => { + try { + const results = await Promise.all( + missingIds.map(async (downloadId) => { + try { + const historyItem = await ipcServices.history.getHistoryById(downloadId) + return { downloadId, status: historyItem?.status ?? null } + } catch (error) { + console.error('Failed to fetch download history entry:', error) + return { downloadId, status: null } + } + }) + ) + + if (cancelled) { + return + } + + setHistoryStatusMap((prev) => { + let changed = false + const next = { ...prev } + + for (const { downloadId, status } of results) { + if (next[downloadId] === status) { + continue + } + next[downloadId] = status + changed = true + } + + return changed ? next : prev + }) + } catch (error) { + console.error('Failed to resolve download history statuses:', error) + } + } + + void fetchHistoryStatuses() + + return () => { + cancelled = true + } + }, [feedItems, downloadLookup, historyStatusMap]) + const resolveItemStatus = (item: SubscriptionFeedItem): SubscriptionItemStatus => { if (!item.addedToQueue) { return 'notQueued' @@ -405,6 +500,10 @@ function SubscriptionCard({ subscription }: { subscription: SubscriptionRule }) } const matchedDownload = downloadLookup.get(item.downloadId) if (!matchedDownload) { + const cachedHistoryStatus = historyStatusMap[item.downloadId] + if (cachedHistoryStatus) { + return cachedHistoryStatus + } return 'queued' } return matchedDownload.status @@ -428,21 +527,25 @@ function SubscriptionCard({ subscription }: { subscription: SubscriptionRule }) } return ( -
+
{feedItems.map((item) => { const itemStatus = resolveItemStatus(item) + const hasResolvedDownloadStatus = + item.addedToQueue && itemStatus !== 'queued' && itemStatus !== 'notQueued' const badgeLabel = item.addedToQueue ? t('subscriptions.items.status.queued') : t('subscriptions.items.status.notQueued') const tooltipLabel = item.addedToQueue - ? t('subscriptions.items.tooltip.downloadStatus', { - status: t(subscriptionItemStatusLabels[itemStatus]) - }) + ? hasResolvedDownloadStatus + ? t('subscriptions.items.tooltip.downloadStatus', { + status: t(subscriptionItemStatusLabels[itemStatus]) + }) + : t('subscriptions.items.tooltip.downloadPending') : t('subscriptions.items.tooltip.notQueued') const badgeClass = item.addedToQueue ? 'bg-emerald-500' : 'bg-black/70' return (
-
+
{item.thumbnail ? ( ) } - -interface SubscriptionAddDialogProps { - open: boolean - onCreate: () => Promise - onClose: () => void -} - -function SubscriptionAddDialog({ open, onCreate, onClose }: SubscriptionAddDialogProps) { - const { t } = useTranslation() - const [settings] = useAtom(settingsAtom) - const createSubscription = useSetAtom(createSubscriptionAtom) - 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() - - // Reset form when dialog closes - useEffect(() => { - if (!open) { - setUrl('') - setKeywords('') - setTags('') - setDetectedFeed(null) - setOnlyLatest(settings.subscriptionOnlyLatestDefault) - setCustomDownloadDirectory(settings.downloadPath) - setNamingTemplate(settings.subscriptionFilenameTemplate) - } - }, [ - open, - settings.subscriptionOnlyLatestDefault, - settings.downloadPath, - settings.subscriptionFilenameTemplate - ]) - - 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 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) - await onCreate() - } catch (error) { - console.error('Failed to create subscription:', error) - toast.error(t('subscriptions.notifications.createError')) - } - } - - return ( - - - {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)} /> -
-
- - setTags(event.target.value)} /> -
-
- -
- - -
-
-
- - setNamingTemplate(sanitizeTemplateInput(event.target.value))} - /> -
-
-

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

- -
-
- - - - -
- ) -} - -interface SubscriptionEditDialogProps { - subscription: SubscriptionRule - onSave: (data: SubscriptionRuleUpdateForm) => Promise -} - -function SubscriptionEditDialog({ subscription, onSave }: SubscriptionEditDialogProps) { - const { t } = useTranslation() - const resolveFeed = useSetAtom(resolveFeedAtom) - const [url, setUrl] = useState(subscription.feedUrl) - 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 [onlyDownloadLatest, setOnlyDownloadLatest] = useState(subscription.onlyDownloadLatest) - const [detectedFeed, setDetectedFeed] = useState(null) - const [detectingFeed, setDetectingFeed] = useState(false) - - const detectTimeout = useRef(null) - const urlInputId = useId() - - useEffect(() => { - if (!url.trim() || url.trim() === subscription.feedUrl) { - 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, subscription.feedUrl]) - - 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 () => { - const updateData: SubscriptionRuleUpdateForm = { - keywords: sanitizeCommaList(keywords), - tags: sanitizeCommaList(tags), - downloadDirectory: downloadDirectory || undefined, - namingTemplate: namingTemplate || undefined, - onlyDownloadLatest - } - - // If feed URL changed, resolve it to validate and include in update - if (url.trim() && url.trim() !== subscription.feedUrl) { - try { - await resolveFeed(url.trim()) - updateData.url = url.trim() - } catch (error) { - console.error('Failed to resolve feed:', error) - toast.error(t('subscriptions.notifications.resolveError')) - return - } - } - - await onSave(updateData) - } - - return ( - - - {t('subscriptions.edit.title', { name: subscription.title })} - {t('subscriptions.edit.description')} - -
-
- - setUrl(event.target.value)} - /> - {detectedFeed && ( - - {t('subscriptions.detectedFeed', { - platform: detectedFeed.platform, - feed: detectedFeed.feedUrl - })} - - )} - {detectingFeed && ( -

{t('subscriptions.detecting')}

- )} -
-
- - setKeywords(event.target.value)} /> -
-
- - setTags(event.target.value)} /> -
-
- -
- - -
-
-
- - setNamingTemplate(sanitizeTemplateInput(event.target.value))} - /> -
-
-

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

- -
-
- - - -
- ) -} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 8a9ff38..3445150 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -190,69 +190,6 @@ 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 - sourceUrl?: string - feedUrl?: string - platform?: SubscriptionPlatform - keywords?: string[] - tags?: string[] - onlyDownloadLatest?: boolean - enabled?: boolean - downloadDirectory?: string - namingTemplate?: string - items?: SubscriptionFeedItem[] -} - -// Subscription types -export type SubscriptionPlatform = 'youtube' | 'bilibili' | 'custom' - -export type SubscriptionStatus = 'idle' | 'checking' | 'up-to-date' | 'failed' - export interface SubscriptionRule { id: string title: string