fix(subscriptions): detect duplicate RSS feeds (#134)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -545,7 +545,8 @@
|
||||
"itemAlreadyQueued": "هذا الفيديو موجود بالفعل في قائمة الانتظار",
|
||||
"queueError": "فشل في الإضافة إلى قائمة انتظار التحميل.",
|
||||
"openLinkError": "فشل في فتح رابط الفيديو.",
|
||||
"resolveError": "فشل في حل رابط تغذية RSS."
|
||||
"resolveError": "فشل في حل رابط تغذية RSS.",
|
||||
"duplicateUrl": "تم الاشتراك في موجز RSS هذا بالفعل."
|
||||
},
|
||||
"detectedFeed": "تم اكتشاف تغذية {{platform}} -> {{feed}}",
|
||||
"detecting": "جاري اكتشاف التغذية...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -545,7 +545,8 @@
|
||||
"itemAlreadyQueued": "このビデオはすでにキューに登録されています",
|
||||
"queueError": "ダウンロードキューへの追加に失敗しました。",
|
||||
"openLinkError": "ビデオリンクを開けませんでした。",
|
||||
"resolveError": "RSS フィード URL を解決できませんでした。"
|
||||
"resolveError": "RSS フィード URL を解決できませんでした。",
|
||||
"duplicateUrl": "このRSSフィードはすでに登録されています。"
|
||||
},
|
||||
"detectedFeed": "{{platform}} フィードが検出されました -> {{feed}}",
|
||||
"detecting": "フィードを検出中...",
|
||||
|
||||
@@ -545,7 +545,8 @@
|
||||
"itemAlreadyQueued": "이 동영상은 이미 대기열에 있습니다.",
|
||||
"queueError": "다운로드 대기열에 추가하지 못했습니다.",
|
||||
"openLinkError": "동영상 링크를 열지 못했습니다.",
|
||||
"resolveError": "RSS 피드 URL을 확인하지 못했습니다."
|
||||
"resolveError": "RSS 피드 URL을 확인하지 못했습니다.",
|
||||
"duplicateUrl": "이 RSS 피드는 이미 구독되어 있습니다."
|
||||
},
|
||||
"detectedFeed": "{{platform}} 피드 감지됨 -> {{feed}}",
|
||||
"detecting": "피드 감지 중...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -545,7 +545,8 @@
|
||||
"itemAlreadyQueued": "Это видео уже в очереди",
|
||||
"queueError": "Не удалось добавить в очередь загрузки.",
|
||||
"openLinkError": "Не удалось открыть ссылку на видео.",
|
||||
"resolveError": "Не удалось разрешить URL RSS-канала."
|
||||
"resolveError": "Не удалось разрешить URL RSS-канала.",
|
||||
"duplicateUrl": "Этот RSS-канал уже добавлен."
|
||||
},
|
||||
"detectedFeed": "Обнаружен канал {{platform}} -> {{feed}}",
|
||||
"detecting": "Определение канала...",
|
||||
|
||||
@@ -545,7 +545,8 @@
|
||||
"itemAlreadyQueued": "該視頻已排隊",
|
||||
"queueError": "無法添加到下載隊列。",
|
||||
"openLinkError": "無法打開視頻鏈接。",
|
||||
"resolveError": "無法解析 RSS 源 URL。"
|
||||
"resolveError": "無法解析 RSS 源 URL。",
|
||||
"duplicateUrl": "此 RSS 訂閱已存在。"
|
||||
},
|
||||
"detectedFeed": "檢測到 {{platform}} feed -> {{feed}}",
|
||||
"detecting": "檢測飼料...",
|
||||
|
||||
@@ -545,7 +545,8 @@
|
||||
"itemAlreadyQueued": "该视频已排队",
|
||||
"queueError": "无法添加到下载队列。",
|
||||
"openLinkError": "无法打开视频链接。",
|
||||
"resolveError": "无法解析 RSS 源 URL。"
|
||||
"resolveError": "无法解析 RSS 源 URL。",
|
||||
"duplicateUrl": "该 RSS 订阅已存在。"
|
||||
},
|
||||
"detectedFeed": "检测到 {{platform}} feed -> {{feed}}",
|
||||
"detecting": "检测饲料...",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user