fix(subscriptions): detect duplicate RSS feeds (#134)

This commit is contained in:
Nexmoe
2026-01-17 11:50:08 +08:00
committed by GitHub
parent 59aee07913
commit 182a1b9c1e
17 changed files with 128 additions and 16 deletions

View File

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

View File

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

View File

@@ -545,7 +545,8 @@
"itemAlreadyQueued": "هذا الفيديو موجود بالفعل في قائمة الانتظار",
"queueError": "فشل في الإضافة إلى قائمة انتظار التحميل.",
"openLinkError": "فشل في فتح رابط الفيديو.",
"resolveError": "فشل في حل رابط تغذية RSS."
"resolveError": "فشل في حل رابط تغذية RSS.",
"duplicateUrl": "تم الاشتراك في موجز RSS هذا بالفعل."
},
"detectedFeed": "تم اكتشاف تغذية {{platform}} -> {{feed}}",
"detecting": "جاري اكتشاف التغذية...",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -545,7 +545,8 @@
"itemAlreadyQueued": "このビデオはすでにキューに登録されています",
"queueError": "ダウンロードキューへの追加に失敗しました。",
"openLinkError": "ビデオリンクを開けませんでした。",
"resolveError": "RSS フィード URL を解決できませんでした。"
"resolveError": "RSS フィード URL を解決できませんでした。",
"duplicateUrl": "このRSSフィードはすでに登録されています。"
},
"detectedFeed": "{{platform}} フィードが検出されました -> {{feed}}",
"detecting": "フィードを検出中...",

View File

@@ -545,7 +545,8 @@
"itemAlreadyQueued": "이 동영상은 이미 대기열에 있습니다.",
"queueError": "다운로드 대기열에 추가하지 못했습니다.",
"openLinkError": "동영상 링크를 열지 못했습니다.",
"resolveError": "RSS 피드 URL을 확인하지 못했습니다."
"resolveError": "RSS 피드 URL을 확인하지 못했습니다.",
"duplicateUrl": "이 RSS 피드는 이미 구독되어 있습니다."
},
"detectedFeed": "{{platform}} 피드 감지됨 -> {{feed}}",
"detecting": "피드 감지 중...",

View File

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

View File

@@ -545,7 +545,8 @@
"itemAlreadyQueued": "Это видео уже в очереди",
"queueError": "Не удалось добавить в очередь загрузки.",
"openLinkError": "Не удалось открыть ссылку на видео.",
"resolveError": "Не удалось разрешить URL RSS-канала."
"resolveError": "Не удалось разрешить URL RSS-канала.",
"duplicateUrl": "Этот RSS-канал уже добавлен."
},
"detectedFeed": "Обнаружен канал {{platform}} -> {{feed}}",
"detecting": "Определение канала...",

View File

@@ -545,7 +545,8 @@
"itemAlreadyQueued": "該視頻已排隊",
"queueError": "無法添加到下載隊列。",
"openLinkError": "無法打開視頻鏈接。",
"resolveError": "無法解析 RSS 源 URL。"
"resolveError": "無法解析 RSS 源 URL。",
"duplicateUrl": "此 RSS 訂閱已存在。"
},
"detectedFeed": "檢測到 {{platform}} feed -> {{feed}}",
"detecting": "檢測飼料...",

View File

@@ -545,7 +545,8 @@
"itemAlreadyQueued": "该视频已排队",
"queueError": "无法添加到下载队列。",
"openLinkError": "无法打开视频链接。",
"resolveError": "无法解析 RSS 源 URL。"
"resolveError": "无法解析 RSS 源 URL。",
"duplicateUrl": "该 RSS 订阅已存在。"
},
"detectedFeed": "检测到 {{platform}} feed -> {{feed}}",
"detecting": "检测饲料...",

View File

@@ -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<SubscriptionItemStatus, string> = {
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]

View File

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