From fe59f1d2bf9b9d04acc25f29cc39bb2d14f6eebc Mon Sep 17 00:00:00 2001 From: Nexmoe <16796652+nexmoe@users.noreply.github.com> Date: Wed, 12 Nov 2025 17:25:49 +0800 Subject: [PATCH] feat(i18n): add subscriptions UI strings and import downloads types --- src/main/lib/subscription-manager.ts | 149 +++++++---- src/main/lib/subscription-scheduler.ts | 11 +- src/renderer/src/locales/en.json | 101 ++++++++ src/renderer/src/pages/Settings.tsx | 7 + src/renderer/src/pages/Subscriptions.tsx | 312 +++++++++++++---------- src/shared/types/index.ts | 61 +++++ 6 files changed, 448 insertions(+), 193 deletions(-) diff --git a/src/main/lib/subscription-manager.ts b/src/main/lib/subscription-manager.ts index ba79d24..a9046e7 100644 --- a/src/main/lib/subscription-manager.ts +++ b/src/main/lib/subscription-manager.ts @@ -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() - 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()) } diff --git a/src/main/lib/subscription-scheduler.ts b/src/main/lib/subscription-scheduler.ts index b9dfa79..ef74ce9 100644 --- a/src/main/lib/subscription-scheduler.ts +++ b/src/main/lib/subscription-scheduler.ts @@ -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( diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index 33dd3c2..e97ca30 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -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.", diff --git a/src/renderer/src/pages/Settings.tsx b/src/renderer/src/pages/Settings.tsx index 7465b71..b11439f 100644 --- a/src/renderer/src/pages/Settings.tsx +++ b/src/renderer/src/pages/Settings.tsx @@ -359,6 +359,13 @@ export function Settings() {
+
diff --git a/src/renderer/src/pages/Subscriptions.tsx b/src/renderer/src/pages/Subscriptions.tsx index 5e61112..f0b8ff8 100644 --- a/src/renderer/src/pages/Subscriptions.tsx +++ b/src/renderer/src/pages/Subscriptions.tsx @@ -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 = { + 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({ @@ -284,29 +299,40 @@ export function Subscriptions() { return (
{/* Channel Tabs Header */} - {sortedSubscriptions.length > 0 && ( -
-
- - -
- {/* Subscription Channel Tabs */} - {sortedSubscriptions.map((subscription) => ( - refreshSubscription(subscription.id)} - onRemove={() => removeSubscription(subscription.id)} - onUpdate={(data) => handleUpdateSubscription(subscription.id, data)} - /> - ))} +
+
+ + + {/* Subscription Channel Tabs */} + {sortedSubscriptions.map((subscription) => ( + refreshSubscription(subscription.id)} + onRemove={() => removeSubscription(subscription.id)} + onUpdate={(data) => handleUpdateSubscription(subscription.id, data)} + /> + ))} + {/* Add RSS Button */} +
+
+ + {t('subscriptions.add.title')} + +
+ + +
- )} +
{/* Content Area */}
@@ -329,16 +355,6 @@ export function Subscriptions() {
- {/* Floating Action Button */} - - { + const map = new Map() + 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 (
- {feedItems.map((item) => ( -
-
- {item.thumbnail ? ( - - ) : ( -
- {t('subscriptions.labels.noThumbnail')} -
- )} -
-
- {subscription.coverUrl ? ( -
- -
+ {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 ( +
+
+ {item.thumbnail ? ( + ) : ( -
- {(subscription.title || t('subscriptions.labels.unknown')).slice(0, 1)} +
+ {t('subscriptions.labels.noThumbnail')}
)} - - {subscription.title || t('subscriptions.labels.unknown')} - +
+
+ {subscription.coverUrl ? ( +
+ +
+ ) : ( +
+ {(subscription.title || t('subscriptions.labels.unknown')).slice(0, 1)} +
+ )} + + {subscription.title || t('subscriptions.labels.unknown')} + +
+
+ {dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')} +
+ + + + {badgeLabel} + + + {tooltipLabel} +
-
- {dayjs(item.publishedAt).format('YYYY-MM-DD HH:mm')} +
+
+

+ {item.title} +

+
+
+ +
- - {item.addedToQueue - ? t('subscriptions.items.queued') - : t('subscriptions.items.notQueued')} - -
-
-
-

- {item.title} -

-
-
- -
-
-
- ))} +
+ ) + })}
) } @@ -624,47 +677,32 @@ function SubscriptionAddDialog({ open, onCreate, onClose }: SubscriptionAddDialo

{t('subscriptions.detecting')}

)}
-
-
- - setKeywords(event.target.value)} - placeholder="AI, tutorial" - /> -
-
- - setTags(event.target.value)} - placeholder="YouTube, AI" - /> +
+ + setKeywords(event.target.value)} /> +
+
+ + setTags(event.target.value)} /> +
+
+ +
+ +
-
-
- -
- - -
-
-
- - setNamingTemplate(sanitizeTemplateInput(event.target.value))} - placeholder="%(uploader)s - %(title)s.%(ext)s" - /> -
+
+ + setNamingTemplate(sanitizeTemplateInput(event.target.value))} + />
-
-

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

-
+

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

diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index b1716c1..8a9ff38 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -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'