From 182a1b9c1ee88c44e5a882703b036fb40dbf0692 Mon Sep 17 00:00:00 2001 From: Nexmoe <16796652+nexmoe@users.noreply.github.com> Date: Sat, 17 Jan 2026 11:50:08 +0800 Subject: [PATCH] fix(subscriptions): detect duplicate RSS feeds (#134) --- src/main/ipc/services/subscription-service.ts | 15 ++++- src/main/lib/subscription-manager.ts | 35 ++++++++++++ src/renderer/src/locales/ar.json | 3 +- src/renderer/src/locales/de.json | 3 +- src/renderer/src/locales/en.json | 1 + src/renderer/src/locales/es.json | 3 +- src/renderer/src/locales/fr.json | 3 +- src/renderer/src/locales/id.json | 3 +- src/renderer/src/locales/it.json | 3 +- src/renderer/src/locales/ja.json | 3 +- src/renderer/src/locales/ko.json | 3 +- src/renderer/src/locales/pt.json | 3 +- src/renderer/src/locales/ru.json | 3 +- src/renderer/src/locales/zh-TW.json | 3 +- src/renderer/src/locales/zh.json | 3 +- src/renderer/src/pages/Subscriptions.tsx | 55 ++++++++++++++++++- src/shared/types/index.ts | 2 + 17 files changed, 128 insertions(+), 16 deletions(-) diff --git a/src/main/ipc/services/subscription-service.ts b/src/main/ipc/services/subscription-service.ts index e164021..e100fd5 100644 --- a/src/main/ipc/services/subscription-service.ts +++ b/src/main/ipc/services/subscription-service.ts @@ -6,7 +6,10 @@ import type { SubscriptionRule, SubscriptionUpdatePayload } from '../../../shared/types' -import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../../shared/types' +import { + DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE, + SUBSCRIPTION_DUPLICATE_FEED_ERROR +} from '../../../shared/types' import { sanitizeFilenameTemplate } from '../../download-engine/args-builder' import { subscriptionManager } from '../../lib/subscription-manager' import { subscriptionScheduler } from '../../lib/subscription-scheduler' @@ -113,6 +116,10 @@ class SubscriptionService extends IpcService { options: CreateSubscriptionOptions ): Promise { const resolved = resolveFeedFromInput(options.url) + const duplicate = subscriptionManager.findDuplicateFeed(resolved.feedUrl) + if (duplicate) { + throw new Error(SUBSCRIPTION_DUPLICATE_FEED_ERROR) + } const settings = settingsManager.getAll() const defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions') const payload: SubscriptionCreatePayload = { @@ -141,6 +148,12 @@ class SubscriptionService extends IpcService { id: string, updates: SubscriptionUpdatePayload ): SubscriptionRule | undefined { + if (updates.feedUrl) { + const duplicate = subscriptionManager.findDuplicateFeed(updates.feedUrl, id) + if (duplicate) { + throw new Error(SUBSCRIPTION_DUPLICATE_FEED_ERROR) + } + } const normalized: SubscriptionUpdatePayload = { ...updates } if (typeof normalized.namingTemplate === 'string') { normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate) diff --git a/src/main/lib/subscription-manager.ts b/src/main/lib/subscription-manager.ts index f5b65a4..b399d34 100644 --- a/src/main/lib/subscription-manager.ts +++ b/src/main/lib/subscription-manager.ts @@ -99,6 +99,22 @@ export class SubscriptionManager extends EventEmitter { return this.attachFeedItems([this.mapRowToRecord(row)])[0] } + findDuplicateFeed( + feedUrl: string, + ignoreId?: string + ): { id: string; feedUrl: string } | undefined { + const database = this.getDatabase() + const rows = database + .select({ id: subscriptionsTable.id, feedUrl: subscriptionsTable.feedUrl }) + .from(subscriptionsTable) + .all() + const targetKey = this.buildFeedKey(feedUrl) + if (!targetKey) { + return undefined + } + return rows.find((row) => row.id !== ignoreId && this.buildFeedKey(row.feedUrl) === targetKey) + } + add(payload: SubscriptionCreatePayload): SubscriptionRule { const timestamp = Date.now() const keywords = sanitizeList(payload.keywords) @@ -301,6 +317,25 @@ export class SubscriptionManager extends EventEmitter { return this.db } + private buildFeedKey(feedUrl: string): string { + const trimmed = feedUrl.trim() + if (!trimmed) { + return '' + } + const normalized = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}` + try { + const url = new URL(normalized) + let pathname = url.pathname || '/' + pathname = pathname.replace(/\/+$/, '') + if (!pathname) { + pathname = '/' + } + return `${url.host.toLowerCase()}${pathname}${url.search}` + } catch { + return trimmed.toLowerCase() + } + } + private ensureItemsSchema(): void { if (!this.sqlite) { return diff --git a/src/renderer/src/locales/ar.json b/src/renderer/src/locales/ar.json index 8ea117b..dceba9d 100644 --- a/src/renderer/src/locales/ar.json +++ b/src/renderer/src/locales/ar.json @@ -545,7 +545,8 @@ "itemAlreadyQueued": "هذا الفيديو موجود بالفعل في قائمة الانتظار", "queueError": "فشل في الإضافة إلى قائمة انتظار التحميل.", "openLinkError": "فشل في فتح رابط الفيديو.", - "resolveError": "فشل في حل رابط تغذية RSS." + "resolveError": "فشل في حل رابط تغذية RSS.", + "duplicateUrl": "تم الاشتراك في موجز RSS هذا بالفعل." }, "detectedFeed": "تم اكتشاف تغذية {{platform}} -> {{feed}}", "detecting": "جاري اكتشاف التغذية...", diff --git a/src/renderer/src/locales/de.json b/src/renderer/src/locales/de.json index 7e243ff..6e70fc3 100644 --- a/src/renderer/src/locales/de.json +++ b/src/renderer/src/locales/de.json @@ -545,7 +545,8 @@ "itemAlreadyQueued": "Dieses Video ist bereits in der Warteschlange", "queueError": "Hinzufügen zur Download-Warteschlange fehlgeschlagen.", "openLinkError": "Video-Link konnte nicht geöffnet werden.", - "resolveError": "RSS-Feed-URL konnte nicht aufgelöst werden." + "resolveError": "RSS-Feed-URL konnte nicht aufgelöst werden.", + "duplicateUrl": "Dieser RSS-Feed ist bereits abonniert." }, "detectedFeed": "{{platform}} Feed erkannt -> {{feed}}", "detecting": "Feed wird erkannt...", diff --git a/src/renderer/src/locales/en.json b/src/renderer/src/locales/en.json index 3e50ea2..b0b4764 100644 --- a/src/renderer/src/locales/en.json +++ b/src/renderer/src/locales/en.json @@ -538,6 +538,7 @@ "missingUrl": "Please paste a channel link first.", "created": "Subscription added", "createError": "Failed to add subscription.", + "duplicateUrl": "This RSS feed is already subscribed.", "refreshStarted": "Refresh started", "removed": "Subscription removed", "updated": "Subscription updated", diff --git a/src/renderer/src/locales/es.json b/src/renderer/src/locales/es.json index 3041a10..9d428dc 100644 --- a/src/renderer/src/locales/es.json +++ b/src/renderer/src/locales/es.json @@ -545,7 +545,8 @@ "itemAlreadyQueued": "Este video ya está en cola", "queueError": "Error al agregar a la cola de descarga.", "openLinkError": "Error al abrir el enlace del video.", - "resolveError": "Error al resolver la URL del feed RSS." + "resolveError": "Error al resolver la URL del feed RSS.", + "duplicateUrl": "Este feed RSS ya está suscrito." }, "detectedFeed": "Feed {{platform}} detectado -> {{feed}}", "detecting": "Detectando feed...", diff --git a/src/renderer/src/locales/fr.json b/src/renderer/src/locales/fr.json index f9bb59e..203bf74 100644 --- a/src/renderer/src/locales/fr.json +++ b/src/renderer/src/locales/fr.json @@ -545,7 +545,8 @@ "itemAlreadyQueued": "Cette vidéo est déjà en file d'attente", "queueError": "Échec de l'ajout à la file d'attente de téléchargement.", "openLinkError": "Échec de l'ouverture du lien vidéo.", - "resolveError": "Échec de la résolution de l'URL du flux RSS." + "resolveError": "Échec de la résolution de l'URL du flux RSS.", + "duplicateUrl": "Ce flux RSS est déjà abonné." }, "detectedFeed": "Flux {{platform}} détecté -> {{feed}}", "detecting": "Détection du flux...", diff --git a/src/renderer/src/locales/id.json b/src/renderer/src/locales/id.json index d4b776f..65c8195 100644 --- a/src/renderer/src/locales/id.json +++ b/src/renderer/src/locales/id.json @@ -545,7 +545,8 @@ "itemAlreadyQueued": "Video ini sudah diantre", "queueError": "Gagal menambahkan ke antrian unduhan.", "openLinkError": "Gagal membuka tautan video.", - "resolveError": "Gagal menyelesaikan URL feed RSS." + "resolveError": "Gagal menyelesaikan URL feed RSS.", + "duplicateUrl": "Feed RSS ini sudah berlangganan." }, "detectedFeed": "Feed {{platform}} terdeteksi -> {{feed}}", "detecting": "Mendeteksi feed...", diff --git a/src/renderer/src/locales/it.json b/src/renderer/src/locales/it.json index 278ab65..9bba836 100644 --- a/src/renderer/src/locales/it.json +++ b/src/renderer/src/locales/it.json @@ -545,7 +545,8 @@ "itemAlreadyQueued": "Questo video è già in coda", "queueError": "Impossibile aggiungere alla coda di download.", "openLinkError": "Impossibile aprire il collegamento video.", - "resolveError": "Impossibile risolvere l'URL del feed RSS." + "resolveError": "Impossibile risolvere l'URL del feed RSS.", + "duplicateUrl": "Questo feed RSS è già sottoscritto." }, "detectedFeed": "Feed {{platform}} rilevato -> {{feed}}", "detecting": "Rilevamento alimentazione...", diff --git a/src/renderer/src/locales/ja.json b/src/renderer/src/locales/ja.json index c089213..92543a8 100644 --- a/src/renderer/src/locales/ja.json +++ b/src/renderer/src/locales/ja.json @@ -545,7 +545,8 @@ "itemAlreadyQueued": "このビデオはすでにキューに登録されています", "queueError": "ダウンロードキューへの追加に失敗しました。", "openLinkError": "ビデオリンクを開けませんでした。", - "resolveError": "RSS フィード URL を解決できませんでした。" + "resolveError": "RSS フィード URL を解決できませんでした。", + "duplicateUrl": "このRSSフィードはすでに登録されています。" }, "detectedFeed": "{{platform}} フィードが検出されました -> {{feed}}", "detecting": "フィードを検出中...", diff --git a/src/renderer/src/locales/ko.json b/src/renderer/src/locales/ko.json index 23f9e74..5744547 100644 --- a/src/renderer/src/locales/ko.json +++ b/src/renderer/src/locales/ko.json @@ -545,7 +545,8 @@ "itemAlreadyQueued": "이 동영상은 이미 대기열에 있습니다.", "queueError": "다운로드 대기열에 추가하지 못했습니다.", "openLinkError": "동영상 링크를 열지 못했습니다.", - "resolveError": "RSS 피드 URL을 확인하지 못했습니다." + "resolveError": "RSS 피드 URL을 확인하지 못했습니다.", + "duplicateUrl": "이 RSS 피드는 이미 구독되어 있습니다." }, "detectedFeed": "{{platform}} 피드 감지됨 -> {{feed}}", "detecting": "피드 감지 중...", diff --git a/src/renderer/src/locales/pt.json b/src/renderer/src/locales/pt.json index af18436..c295c16 100644 --- a/src/renderer/src/locales/pt.json +++ b/src/renderer/src/locales/pt.json @@ -545,7 +545,8 @@ "itemAlreadyQueued": "Este vídeo já está na fila", "queueError": "Falha ao adicionar à fila de download.", "openLinkError": "Falha ao abrir o link do vídeo.", - "resolveError": "Falha ao resolver o URL do feed RSS." + "resolveError": "Falha ao resolver o URL do feed RSS.", + "duplicateUrl": "Este feed RSS já está inscrito." }, "detectedFeed": "Feed de {{platform}} detectado -> {{feed}}", "detecting": "Detectando feed...", diff --git a/src/renderer/src/locales/ru.json b/src/renderer/src/locales/ru.json index 7fd58e5..9e7b35d 100644 --- a/src/renderer/src/locales/ru.json +++ b/src/renderer/src/locales/ru.json @@ -545,7 +545,8 @@ "itemAlreadyQueued": "Это видео уже в очереди", "queueError": "Не удалось добавить в очередь загрузки.", "openLinkError": "Не удалось открыть ссылку на видео.", - "resolveError": "Не удалось разрешить URL RSS-канала." + "resolveError": "Не удалось разрешить URL RSS-канала.", + "duplicateUrl": "Этот RSS-канал уже добавлен." }, "detectedFeed": "Обнаружен канал {{platform}} -> {{feed}}", "detecting": "Определение канала...", diff --git a/src/renderer/src/locales/zh-TW.json b/src/renderer/src/locales/zh-TW.json index 167b060..ba9a136 100644 --- a/src/renderer/src/locales/zh-TW.json +++ b/src/renderer/src/locales/zh-TW.json @@ -545,7 +545,8 @@ "itemAlreadyQueued": "該視頻已排隊", "queueError": "無法添加到下載隊列。", "openLinkError": "無法打開視頻鏈接。", - "resolveError": "無法解析 RSS 源 URL。" + "resolveError": "無法解析 RSS 源 URL。", + "duplicateUrl": "此 RSS 訂閱已存在。" }, "detectedFeed": "檢測到 {{platform}} feed -> {{feed}}", "detecting": "檢測飼料...", diff --git a/src/renderer/src/locales/zh.json b/src/renderer/src/locales/zh.json index 20f148c..2de639c 100644 --- a/src/renderer/src/locales/zh.json +++ b/src/renderer/src/locales/zh.json @@ -545,7 +545,8 @@ "itemAlreadyQueued": "该视频已排队", "queueError": "无法添加到下载队列。", "openLinkError": "无法打开视频链接。", - "resolveError": "无法解析 RSS 源 URL。" + "resolveError": "无法解析 RSS 源 URL。", + "duplicateUrl": "该 RSS 订阅已存在。" }, "detectedFeed": "检测到 {{platform}} feed -> {{feed}}", "detecting": "检测饲料...", diff --git a/src/renderer/src/pages/Subscriptions.tsx b/src/renderer/src/pages/Subscriptions.tsx index 3df81dd..da0490b 100644 --- a/src/renderer/src/pages/Subscriptions.tsx +++ b/src/renderer/src/pages/Subscriptions.tsx @@ -35,6 +35,7 @@ import { updateSubscriptionAtom } from '@renderer/store/subscriptions' import type { DownloadStatus, SubscriptionFeedItem, SubscriptionRule } from '@shared/types' +import { SUBSCRIPTION_DUPLICATE_FEED_ERROR } from '@shared/types' import dayjs from 'dayjs' import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { Download, Edit, ExternalLink, Plus, Power, RefreshCw, Trash2 } from 'lucide-react' @@ -87,6 +88,41 @@ const subscriptionItemStatusLabels: Record = { cancelled: 'subscriptions.items.status.cancelled' } +const getErrorMessage = (error: unknown): string | undefined => { + if (!error) { + return undefined + } + if (typeof error === 'string') { + return error + } + if (typeof error === 'object') { + if ('message' in error && typeof error.message === 'string') { + return error.message + } + if ('code' in error && typeof error.code === 'string') { + return error.code + } + if ('error' in error) { + const nested = (error as { error?: unknown }).error + if (typeof nested === 'string') { + return nested + } + if (nested && typeof nested === 'object' && 'message' in nested) { + const nestedMessage = (nested as { message?: unknown }).message + if (typeof nestedMessage === 'string') { + return nestedMessage + } + } + } + } + return undefined +} + +const isDuplicateFeedError = (error: unknown) => { + const message = getErrorMessage(error) + return Boolean(message?.includes(SUBSCRIPTION_DUPLICATE_FEED_ERROR)) +} + function SubscriptionTab({ subscription, onRefresh, @@ -252,8 +288,17 @@ export function Subscriptions() { } } - await updateSubscription({ id, data: updatePayload }) - await refreshSubscription(id) + try { + await updateSubscription({ id, data: updatePayload }) + await refreshSubscription(id) + } catch (error) { + console.error('Failed to update subscription:', error) + toast.error( + isDuplicateFeedError(error) + ? t('subscriptions.notifications.duplicateUrl') + : t('subscriptions.notifications.createError') + ) + } }, [refreshSubscription, updateSubscription, resolveFeed, t] ) @@ -281,7 +326,11 @@ export function Subscriptions() { setAddDialogOpen(false) } catch (error) { console.error('Failed to create subscription:', error) - toast.error(t('subscriptions.notifications.createError')) + toast.error( + isDuplicateFeedError(error) + ? t('subscriptions.notifications.duplicateUrl') + : t('subscriptions.notifications.createError') + ) } }, [createSubscription, t] diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index c54f3fa..e4fe123 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -200,6 +200,8 @@ export type SubscriptionPlatform = 'youtube' | 'bilibili' | 'custom' export type SubscriptionStatus = 'idle' | 'checking' | 'up-to-date' | 'failed' +export const SUBSCRIPTION_DUPLICATE_FEED_ERROR = 'SUBSCRIPTION_DUPLICATE_FEED_URL' + export interface SubscriptionRule { id: string title: string