refactor(subscriptions): remove legacy status fields and migrate items ordering

This commit is contained in:
Nexmoe
2025-11-12 21:47:35 +08:00
parent fe59f1d2bf
commit 5af393ef84
10 changed files with 669 additions and 718 deletions

View File

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

View File

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

View File

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

View File

@@ -10,5 +10,9 @@
body {
@apply text-foreground;
}
input::placeholder,
textarea::placeholder {
opacity: 0.4;
}
}

View File

@@ -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<void>
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<SubscriptionResolvedFeed | null>(null)
const [detectingFeed, setDetectingFeed] = useState(false)
const detectTimeout = useRef<NodeJS.Timeout | null>(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 (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{mode === 'edit' && subscription
? t(titleKey, { name: subscription.title })
: t(titleKey)}
</DialogTitle>
<DialogDescription>{t(descriptionKey)}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<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>
)}
{mode === 'add' && !url.trim() && (
<div className="flex items-center gap-2 rounded-md bg-primary/5 px-3 py-2">
<p className="text-xs text-muted-foreground flex-1">
{t('subscriptions.rssHub.hint')}
</p>
<Button
variant="ghost"
size="sm"
onClick={() => void handleOpenRSSHubDocs()}
className="h-5 w-5 p-0 shrink-0"
title={t('subscriptions.rssHub.openDocs')}
>
<ChevronRight className="h-3 w-3" />
</Button>
</div>
)}
</div>
<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 className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
</div>
</div>
<DialogFooter>
{mode === 'add' && (
<Button variant="outline" onClick={onClose}>
{t('download.cancel')}
</Button>
)}
<Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

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

View File

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

View File

@@ -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() {
</div>
<Tabs defaultValue="general">
<TabsList className="grid w-full grid-cols-3">
<TabsList className="grid w-full grid-cols-2">
<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">
@@ -303,6 +301,33 @@ export function Settings() {
)}
<ItemGroup>
<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('settings.maxConcurrentDownloads')}</ItemTitle>
@@ -456,81 +481,6 @@ 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

@@ -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({
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={handleRefresh}>
<RefreshCw className="mr-2 h-4 w-4" />
<RefreshCw className="h-4 w-4" />
{t('subscriptions.actions.refresh')}
</ContextMenuItem>
<ContextMenuItem onClick={handleEdit}>
<Edit className="mr-2 h-4 w-4" />
<Edit className="h-4 w-4" />
{t('subscriptions.actions.edit')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuCheckboxItem
checked={subscription.enabled}
onCheckedChange={(checked) => void handleToggleEnabled(checked)}
>
<Power className="mr-2 h-4 w-4" />
{t('subscriptions.fields.enabled')}
</ContextMenuCheckboxItem>
<ContextMenuItem onClick={() => void handleToggleEnabled(!subscription.enabled)}>
<Power className="h-4 w-4" />
{subscription.enabled
? t('subscriptions.actions.disable')
: t('subscriptions.actions.enable')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => void handleRemove()} variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
<Trash2 className="h-4 w-4" />
{t('subscriptions.actions.remove')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<SubscriptionEditDialog
subscription={subscription}
onSave={async (data) => {
await onUpdate(data)
toast.success(t('subscriptions.notifications.updated'))
setEditOpen(false)
}}
/>
</Dialog>
<SubscriptionFormDialog
mode="edit"
subscription={subscription}
open={editOpen}
onSave={async (data) => {
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() {
</div>
)}
</section>
{/* RSSHub Info Card */}
<Card className="border-primary/20 bg-primary/5">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{t('subscriptions.rssHub.title')}
</CardTitle>
<CardDescription>{t('subscriptions.rssHub.description')}</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="secondary"
size="sm"
onClick={() => void handleOpenRSSHubDocs()}
className="gap-2"
>
{t('subscriptions.rssHub.openDocs')}
</Button>
</CardContent>
</Card>
</div>
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<SubscriptionAddDialog
open={addDialogOpen}
onCreate={handleCreateSubscription}
onClose={() => setAddDialogOpen(false)}
/>
</Dialog>
<SubscriptionFormDialog
mode="add"
open={addDialogOpen}
onSave={handleCreateSubscription}
onClose={() => setAddDialogOpen(false)}
/>
</div>
)
}
@@ -374,20 +411,15 @@ interface SubscriptionTabProps {
onUpdate: (data: SubscriptionRuleUpdateForm) => Promise<void>
}
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<Record<string, DownloadStatus | null>>(
{}
)
const downloadLookup = useMemo(() => {
const map = new Map<string, DownloadRecord>()
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 (
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{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 (
<article key={`${subscription.id}-${item.id}`} className="group transition-all">
<div className="relative w-full overflow-hidden bg-muted aspect-video overflow-hidden rounded-2xl">
<div className="relative w-full overflow-hidden bg-muted aspect-video rounded-2xl">
{item.thumbnail ? (
<RemoteImage
src={item.thumbnail}
@@ -518,351 +621,3 @@ function SubscriptionCard({ subscription }: { subscription: SubscriptionRule })
</div>
)
}
interface SubscriptionAddDialogProps {
open: boolean
onCreate: () => Promise<void>
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<SubscriptionResolvedFeed | null>(null)
const [detectingFeed, setDetectingFeed] = useState(false)
const detectTimeout = useRef<NodeJS.Timeout | null>(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 (
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('subscriptions.add.title')}</DialogTitle>
<DialogDescription>{t('subscriptions.add.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<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="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={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))}
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t('download.cancel')}
</Button>
<Button onClick={() => void handleCreateSubscription()}>
{t('subscriptions.actions.add')}
</Button>
</DialogFooter>
</DialogContent>
)
}
interface SubscriptionEditDialogProps {
subscription: SubscriptionRule
onSave: (data: SubscriptionRuleUpdateForm) => Promise<void>
}
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<SubscriptionResolvedFeed | null>(null)
const [detectingFeed, setDetectingFeed] = useState(false)
const detectTimeout = useRef<NodeJS.Timeout | null>(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 (
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<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 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="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 className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
<Switch checked={onlyDownloadLatest} onCheckedChange={setOnlyDownloadLatest} />
</div>
</div>
<DialogFooter>
<Button onClick={() => void handleSave()}>{t('subscriptions.actions.save')}</Button>
</DialogFooter>
</DialogContent>
)
}

View File

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