feat(i18n): add subscriptions UI strings and import downloads types

This commit is contained in:
Nexmoe
2025-11-12 17:25:49 +08:00
parent c2b2c35da6
commit fe59f1d2bf
6 changed files with 448 additions and 193 deletions

View File

@@ -19,8 +19,6 @@ import type {
} from '../../shared/types'
import { sanitizeFilenameTemplate } from '../download-engine/args-builder'
const MAX_SEEN_IDS = 200
const sanitizeList = (values?: string[]): string[] => {
if (!values || values.length === 0) {
return []
@@ -77,8 +75,6 @@ const subscriptionsTable = sqliteTable('subscriptions', {
lastError: text('last_error'),
createdAt: integer('created_at', { mode: 'number' }).notNull(),
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
seenItemIds: text('seen_item_ids').notNull(),
lastItemId: text('last_item_id'),
downloadDirectory: text('download_directory'),
namingTemplate: text('naming_template')
})
@@ -171,8 +167,6 @@ export class SubscriptionManager extends EventEmitter {
lastError: undefined,
createdAt: timestamp,
updatedAt: timestamp,
seenItemIds: [],
lastItemId: undefined,
downloadDirectory: payload.downloadDirectory,
namingTemplate: payload.namingTemplate
? sanitizeFilenameTemplate(payload.namingTemplate)
@@ -197,14 +191,11 @@ export class SubscriptionManager extends EventEmitter {
const keywords = updates.keywords ? sanitizeList(updates.keywords) : undefined
const tags = updates.tags ? sanitizeList(updates.tags) : undefined
const seenItemIds = this.normalizeSeenItems(updates.seenItemIds ?? existing.seenItemIds)
const next: SubscriptionRule = {
...existing,
...updates,
keywords: keywords ?? existing.keywords,
tags: tags ?? existing.tags,
seenItemIds,
updatedAt: Date.now()
}
@@ -237,18 +228,6 @@ export class SubscriptionManager extends EventEmitter {
return false
}
appendSeenItems(id: string, itemIds: string[]): SubscriptionRule | undefined {
if (itemIds.length === 0) {
return this.getById(id)
}
const existing = this.getById(id)
if (!existing) {
return undefined
}
const merged = this.normalizeSeenItems([...existing.seenItemIds, ...itemIds])
return this.update(id, { seenItemIds: merged })
}
replaceFeedItems(
subscriptionId: string,
items: SubscriptionFeedItem[],
@@ -382,8 +361,6 @@ export class SubscriptionManager extends EventEmitter {
last_error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
seen_item_ids TEXT NOT NULL,
last_item_id TEXT,
download_directory TEXT,
naming_template TEXT
)`
@@ -413,6 +390,7 @@ export class SubscriptionManager extends EventEmitter {
'CREATE INDEX IF NOT EXISTS subscription_items_subscription_idx ON subscription_items (subscription_id)'
)
.run()
this.ensureSubscriptionsSchema()
this.ensureItemsSchema()
log.info('subscriptions: database initialized at', databasePath)
return this.db
@@ -510,6 +488,107 @@ export class SubscriptionManager extends EventEmitter {
}
}
private ensureSubscriptionsSchema(): void {
if (!this.sqlite) {
return
}
try {
const columns = this.sqlite.prepare(`PRAGMA table_info(subscriptions)`).all() as Array<{
name: string
}>
const hasLegacyColumns = columns.some((column) =>
['seen_item_ids', 'last_item_id'].includes(column.name)
)
if (!hasLegacyColumns) {
return
}
const sqlite = this.sqlite
const migrate = sqlite.transaction(() => {
sqlite.prepare(`DROP TABLE IF EXISTS subscriptions_new`).run()
sqlite
.prepare(
`CREATE TABLE subscriptions_new (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
source_url TEXT NOT NULL,
feed_url TEXT NOT NULL,
platform TEXT NOT NULL,
keywords TEXT NOT NULL,
tags TEXT NOT NULL,
only_latest INTEGER NOT NULL,
enabled INTEGER NOT NULL,
cover_url TEXT,
latest_video_title TEXT,
latest_video_published_at INTEGER,
last_checked_at INTEGER,
last_success_at INTEGER,
status TEXT NOT NULL,
last_error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
download_directory TEXT,
naming_template TEXT
)`
)
.run()
sqlite
.prepare(
`INSERT INTO subscriptions_new (
id,
title,
source_url,
feed_url,
platform,
keywords,
tags,
only_latest,
enabled,
cover_url,
latest_video_title,
latest_video_published_at,
last_checked_at,
last_success_at,
status,
last_error,
created_at,
updated_at,
download_directory,
naming_template
)
SELECT
id,
title,
source_url,
feed_url,
platform,
keywords,
tags,
only_latest,
enabled,
cover_url,
latest_video_title,
latest_video_published_at,
last_checked_at,
last_success_at,
status,
last_error,
created_at,
updated_at,
download_directory,
naming_template
FROM subscriptions`
)
.run()
sqlite.prepare(`DROP TABLE subscriptions`).run()
sqlite.prepare(`ALTER TABLE subscriptions_new RENAME TO subscriptions`).run()
})
migrate()
log.info('subscriptions: removed legacy seen_item_ids and last_item_id columns')
} catch (error) {
log.warn('subscriptions: failed to ensure subscriptions schema', error)
}
}
private getDatabasePath(): string {
return join(app.getPath('userData'), 'subscriptions.sqlite')
}
@@ -538,7 +617,6 @@ export class SubscriptionManager extends EventEmitter {
...legacyItem,
keywords: sanitizeList(legacyItem.keywords),
tags: sanitizeList(legacyItem.tags),
seenItemIds: sanitizeList(legacyItem.seenItemIds),
items: []
}
this.insertRecord(normalized)
@@ -612,8 +690,6 @@ export class SubscriptionManager extends EventEmitter {
lastError: record.lastError,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
seenItemIds: stringifyArray(record.seenItemIds),
lastItemId: record.lastItemId,
downloadDirectory: record.downloadDirectory,
namingTemplate: record.namingTemplate
? sanitizeFilenameTemplate(record.namingTemplate)
@@ -641,8 +717,6 @@ export class SubscriptionManager extends EventEmitter {
lastError: row.lastError ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
seenItemIds: parseStringArray(row.seenItemIds),
lastItemId: row.lastItemId ?? undefined,
downloadDirectory: row.downloadDirectory ?? undefined,
namingTemplate: row.namingTemplate ? sanitizeFilenameTemplate(row.namingTemplate) : undefined,
items: []
@@ -661,25 +735,6 @@ export class SubscriptionManager extends EventEmitter {
}
}
private normalizeSeenItems(items: string[]): string[] {
const unique = new Map<string, string>()
for (const entry of items) {
if (!entry) {
continue
}
const key = entry.trim()
if (!key) {
continue
}
unique.set(key, key)
}
const normalized = Array.from(unique.keys())
if (normalized.length > MAX_SEEN_IDS) {
return normalized.slice(normalized.length - MAX_SEEN_IDS)
}
return normalized
}
private emitUpdates(): void {
this.emit('subscriptions:updated', this.getAll())
}

View File

@@ -223,13 +223,6 @@ export class SubscriptionScheduler extends EventEmitter {
}
}
if (keywordFiltered.length > 0) {
subscriptionManager.appendSeenItems(
subscription.id,
keywordFiltered.map((item) => item.id)
)
}
const latestItem = normalizedItems[0]
subscriptionManager.update(subscription.id, {
status: 'up-to-date',
@@ -357,8 +350,8 @@ export class SubscriptionScheduler extends EventEmitter {
}
private filterNewItems(subscription: SubscriptionRule, items: FeedItem[]): FeedItem[] {
const seen = new Set(subscription.seenItemIds)
return items.filter((item) => !seen.has(item.id))
const seenIds = new Set(subscription.items.map((item) => item.id))
return items.filter((item) => !seenIds.has(item.id))
}
private async queueDownload(

View File

@@ -328,6 +328,7 @@
},
"configFile": "Use configuration file",
"configFileDescription": "Custom configuration file for yt-dlp",
"clearConfigFile": "Clear",
"dark": "Dark",
"description": "Configure your download preferences and application settings",
"directorySelectError": "Failed to select directory",
@@ -468,6 +469,106 @@
}
}
},
"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 RSS",
"description": "Paste an RSS feed 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.",
"status": {
"queued": "Queued",
"notQueued": "Not queued",
"pending": "Pending",
"downloading": "Downloading",
"processing": "Processing",
"completed": "Completed",
"error": "Failed",
"cancelled": "Cancelled"
},
"fromChannel": "From {{channel}}",
"tooltip": {
"downloadStatus": "Download status: {{status}}",
"notQueued": "Not in the download queue yet"
},
"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}}"
}
}
},
"sites": {
"homeInlineDescription": "Supports {{sites}} and more.",
"moreDescription": "The complete yt-dlp list is updated constantly by the community.",

View File

@@ -359,6 +359,13 @@ export function Settings() {
<div className="flex gap-2 w-full max-w-md">
<Input value={settings.configPath} readOnly className="flex-1" />
<Button onClick={handleSelectConfigFile}>{t('settings.selectPath')}</Button>
<Button
variant="secondary"
onClick={() => void handleSettingChange('configPath', '')}
disabled={!settings.configPath}
>
{t('settings.clearConfigFile')}
</Button>
</div>
</ItemActions>
</Item>

View File

@@ -24,6 +24,7 @@ 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,
@@ -34,12 +35,13 @@ import {
updateSubscriptionAtom
} from '@renderer/store/subscriptions'
import type {
DownloadStatus,
SubscriptionFeedItem,
SubscriptionResolvedFeed,
SubscriptionRule
} from '@shared/types'
import dayjs from 'dayjs'
import { useAtom, useSetAtom } from 'jotai'
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 { useTranslation } from 'react-i18next'
@@ -85,6 +87,19 @@ const disabledStatusStyle = {
label: 'subscriptions.fields.disabled'
}
type SubscriptionItemStatus = DownloadStatus | 'queued' | 'notQueued'
const subscriptionItemStatusLabels: Record<SubscriptionItemStatus, string> = {
notQueued: 'subscriptions.items.status.notQueued',
queued: 'subscriptions.items.status.queued',
pending: 'subscriptions.items.status.pending',
downloading: 'subscriptions.items.status.downloading',
processing: 'subscriptions.items.status.processing',
completed: 'subscriptions.items.status.completed',
error: 'subscriptions.items.status.error',
cancelled: 'subscriptions.items.status.cancelled'
}
function SubscriptionTab({
subscription,
onRefresh,
@@ -131,8 +146,8 @@ function SubscriptionTab({
<TabsTrigger
value={subscription.id}
className={cn(
'flex h-auto w-20 flex-col rounded-sm! items-center gap-1 px-2 py-2 transition-all hover:opacity-80',
isActive && 'bg-neutral-100'
'flex h-auto w-20 flex-col rounded-sm! items-center gap-1 px-2 py-2 transition-all hover:opacity-80 shrink-0 grow-0',
isActive && 'bg-muted/45'
)}
>
<Tooltip>
@@ -284,29 +299,40 @@ export function Subscriptions() {
return (
<div className="relative">
{/* Channel Tabs Header */}
{sortedSubscriptions.length > 0 && (
<div className="sticky top-0 z-10 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
<div className="overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<Tabs value={selectedTab} onValueChange={setSelectedTab} className="w-full">
<TabsList className="h-auto w-full justify-start rounded-none border-none bg-transparent p-0">
<div className="flex gap-0 px-6 pb-6">
{/* Subscription Channel Tabs */}
{sortedSubscriptions.map((subscription) => (
<SubscriptionTab
key={subscription.id}
subscription={subscription}
isActive={subscription.id === selectedTab}
onRefresh={() => refreshSubscription(subscription.id)}
onRemove={() => removeSubscription(subscription.id)}
onUpdate={(data) => handleUpdateSubscription(subscription.id, data)}
/>
))}
<div className="">
<div className="overflow-x-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<Tabs value={selectedTab} onValueChange={setSelectedTab} className="w-auto">
<TabsList className="h-auto w-auto justify-start rounded-none border-none bg-transparent p-0 px-6">
{/* Subscription Channel Tabs */}
{sortedSubscriptions.map((subscription) => (
<SubscriptionTab
key={subscription.id}
subscription={subscription}
isActive={subscription.id === selectedTab}
onRefresh={() => refreshSubscription(subscription.id)}
onRemove={() => removeSubscription(subscription.id)}
onUpdate={(data) => handleUpdateSubscription(subscription.id, data)}
/>
))}
{/* Add RSS Button */}
<Button
className="flex h-auto w-20 flex-col items-center gap-1 rounded-sm! px-2 py-2 transition-all hover:opacity-80 bg-transparent hover:bg-neutral-100 shrink-0 grow-0"
variant="ghost"
onClick={() => setAddDialogOpen(true)}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 border-dashed border-muted-foreground/40 transition-colors">
<Plus className="h-5 w-5 text-muted-foreground" />
</div>
</TabsList>
</Tabs>
</div>
<div className="flex w-full flex-col items-center text-center">
<span className="w-full truncate text-xs font-medium">
{t('subscriptions.add.title')}
</span>
</div>
</Button>
</TabsList>
</Tabs>
</div>
)}
</div>
{/* Content Area */}
<div className="relative space-y-8 p-6">
@@ -329,16 +355,6 @@ export function Subscriptions() {
</section>
</div>
{/* Floating Action Button */}
<Button
className="fixed bottom-8 right-8 h-14 w-14 rounded-full shadow-lg z-50"
size="icon"
onClick={() => setAddDialogOpen(true)}
>
<Plus className="h-6 w-6" />
<span className="sr-only">{t('subscriptions.add.title')}</span>
</Button>
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<SubscriptionAddDialog
open={addDialogOpen}
@@ -371,6 +387,28 @@ interface SubscriptionRuleUpdateForm {
function SubscriptionCard({ subscription }: { subscription: SubscriptionRule }) {
const { t } = useTranslation()
const feedItems: SubscriptionFeedItem[] = subscription.items ?? []
const downloads = useAtomValue(downloadsArrayAtom)
const downloadLookup = useMemo(() => {
const map = new Map<string, DownloadRecord>()
downloads.forEach((record) => {
map.set(record.id, record)
})
return map
}, [downloads])
const resolveItemStatus = (item: SubscriptionFeedItem): SubscriptionItemStatus => {
if (!item.addedToQueue) {
return 'notQueued'
}
if (!item.downloadId) {
return 'queued'
}
const matchedDownload = downloadLookup.get(item.downloadId)
if (!matchedDownload) {
return 'queued'
}
return matchedDownload.status
}
const handleOpenItem = async (url: string) => {
try {
@@ -391,77 +429,92 @@ function SubscriptionCard({ subscription }: { subscription: SubscriptionRule })
return (
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
{feedItems.map((item) => (
<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">
{item.thumbnail ? (
<RemoteImage
src={item.thumbnail}
alt={item.title}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
{t('subscriptions.labels.noThumbnail')}
</div>
)}
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/5 to-transparent" />
<div className="absolute top-3 left-3 flex items-center gap-2 rounded-full bg-black/60 pr-3 pl-1 py-1 text-xs font-medium text-white backdrop-blur">
{subscription.coverUrl ? (
<div className="h-6 w-6 overflow-hidden rounded-full border border-white/40">
<RemoteImage
src={subscription.coverUrl}
alt={subscription.title || t('subscriptions.labels.unknown')}
className="h-full w-full object-cover"
/>
</div>
{feedItems.map((item) => {
const itemStatus = resolveItemStatus(item)
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])
})
: 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">
{item.thumbnail ? (
<RemoteImage
src={item.thumbnail}
alt={item.title}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-white/40 bg-white/10 text-[10px] font-semibold uppercase text-white">
{(subscription.title || t('subscriptions.labels.unknown')).slice(0, 1)}
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
{t('subscriptions.labels.noThumbnail')}
</div>
)}
<span className="max-w-[10rem] truncate text-xs">
{subscription.title || t('subscriptions.labels.unknown')}
</span>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/70 via-black/5 to-transparent" />
<div className="absolute top-3 left-3 flex items-center gap-2 rounded-full bg-black/60 pr-3 pl-1 py-1 text-xs font-medium text-white backdrop-blur">
{subscription.coverUrl ? (
<div className="h-6 w-6 overflow-hidden rounded-full border border-white/40">
<RemoteImage
src={subscription.coverUrl}
alt={subscription.title || t('subscriptions.labels.unknown')}
className="h-full w-full object-cover"
/>
</div>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-white/40 bg-white/10 text-[10px] font-semibold uppercase text-white">
{(subscription.title || t('subscriptions.labels.unknown')).slice(0, 1)}
</div>
)}
<span className="max-w-[10rem] truncate text-xs">
{subscription.title || t('subscriptions.labels.unknown')}
</span>
</div>
<div className="absolute bottom-3 left-3 text-xs font-medium text-white">
{dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="secondary"
className={cn(
'absolute bottom-3 right-3 rounded-full text-xs text-white backdrop-blur',
badgeClass
)}
>
{badgeLabel}
</Badge>
</TooltipTrigger>
<TooltipContent>{tooltipLabel}</TooltipContent>
</Tooltip>
</div>
<div className="absolute bottom-3 left-3 text-xs font-medium text-white">
{dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')}
<div className="flex flex-col gap-4 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p
className="text-base font-semibold leading-snug text-card-foreground"
title={item.title}
>
{item.title}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
className="rounded-full px-4"
onClick={() => void handleOpenItem(item.url)}
title={t('subscriptions.items.actions.open')}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
<Badge
variant={item.addedToQueue ? 'default' : 'secondary'}
className={cn(
'absolute bottom-3 right-3 rounded-full text-xs text-white backdrop-blur',
item.addedToQueue ? 'bg-emerald-500' : 'bg-black/70'
)}
>
{item.addedToQueue
? t('subscriptions.items.queued')
: t('subscriptions.items.notQueued')}
</Badge>
</div>
<div className="flex flex-col gap-4 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p
className="text-base font-semibold leading-snug text-card-foreground"
title={item.title}
>
{item.title}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
className="rounded-full px-4"
onClick={() => void handleOpenItem(item.url)}
title={t('subscriptions.items.actions.open')}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
</article>
))}
</article>
)
})}
</div>
)
}
@@ -624,47 +677,32 @@ function SubscriptionAddDialog({ open, onCreate, onClose }: SubscriptionAddDialo
<p className="text-xs text-muted-foreground">{t('subscriptions.detecting')}</p>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>{t('subscriptions.fields.keywords')}</Label>
<Input
value={keywords}
onChange={(event) => setKeywords(event.target.value)}
placeholder="AI, tutorial"
/>
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.tags')}</Label>
<Input
value={tags}
onChange={(event) => setTags(event.target.value)}
placeholder="YouTube, AI"
/>
<div 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="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>{t('subscriptions.fields.customDirectory')}</Label>
<div className="flex gap-2">
<Input value={customDownloadDirectory} readOnly />
<Button variant="secondary" onClick={() => void handleSelectDirectory()}>
{t('subscriptions.actions.selectDirectory')}
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{t('subscriptions.fields.namingTemplate')}</Label>
<Input
value={namingTemplate}
onChange={(event) => setNamingTemplate(sanitizeTemplateInput(event.target.value))}
placeholder="%(uploader)s - %(title)s.%(ext)s"
/>
</div>
<div 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">
<div>
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
</div>
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
</div>
</div>

View File

@@ -248,6 +248,67 @@ export interface SubscriptionUpdatePayload {
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
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
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[]
}
// Settings types
export type OneClickQualityPreset = 'best' | 'good' | 'normal' | 'bad' | 'worst'