feat(i18n): add subscriptions UI strings and import downloads types
This commit is contained in:
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user