From 96b08dc0bd2ac4ad5609529b175da2b8d503520e Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 18 Feb 2026 17:20:37 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=81=E4=BA=BA=E5=BD=B1=E5=BA=93=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E8=BF=BD=E7=95=AA=E8=AE=A2=E9=98=85=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 15 + .../anime-subscription/[id]/check/route.ts | 51 ++ .../admin/anime-subscription/[id]/route.ts | 112 +++ src/app/api/admin/anime-subscription/route.ts | 106 +++ .../admin/anime-subscription/toggle/route.ts | 47 ++ src/app/api/cron/[password]/route.ts | 2 + src/components/AnimeSubscriptionComponent.tsx | 691 ++++++++++++++++++ src/lib/admin.types.ts | 15 + src/lib/anime-subscription.ts | 397 ++++++++++ src/lib/types.ts | 3 +- src/types/anime-subscription.ts | 17 + 11 files changed, 1455 insertions(+), 1 deletion(-) create mode 100644 src/app/api/admin/anime-subscription/[id]/check/route.ts create mode 100644 src/app/api/admin/anime-subscription/[id]/route.ts create mode 100644 src/app/api/admin/anime-subscription/route.ts create mode 100644 src/app/api/admin/anime-subscription/toggle/route.ts create mode 100644 src/components/AnimeSubscriptionComponent.tsx create mode 100644 src/lib/anime-subscription.ts create mode 100644 src/types/anime-subscription.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 201a5a7..7b80014 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -25,6 +25,7 @@ import { AlertCircle, AlertTriangle, Bot, + Cat, Check, CheckCircle, ChevronDown, @@ -50,6 +51,7 @@ import { createPortal } from 'react-dom'; import { AdminConfig, AdminConfigResult } from '@/lib/admin.types'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; +import AnimeSubscriptionComponent from '@/components/AnimeSubscriptionComponent'; import CorrectDialog from '@/components/CorrectDialog'; import DataMigration from '@/components/DataMigration'; import PageLayout from '@/components/PageLayout'; @@ -11597,6 +11599,7 @@ function AdminPageClient() { openListConfig: false, embyConfig: false, xiaoyaConfig: false, + animeSubscription: false, aiConfig: false, liveSource: false, webLive: false, @@ -12049,6 +12052,18 @@ function AdminPageClient() { > + + {/* 追番订阅子标签 */} + + } + isExpanded={expandedTabs.animeSubscription} + onToggle={() => toggleTab('animeSubscription')} + > + + diff --git a/src/app/api/admin/anime-subscription/[id]/check/route.ts b/src/app/api/admin/anime-subscription/[id]/check/route.ts new file mode 100644 index 0000000..b0f9b9b --- /dev/null +++ b/src/app/api/admin/anime-subscription/[id]/check/route.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server'; + +import { checkSubscription } from '@/lib/anime-subscription'; +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { db } from '@/lib/db'; + +export const runtime = 'nodejs'; + +/** + * POST /api/admin/anime-subscription/[id]/check + * 手动触发检查单个订阅 + */ +export async function POST( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + // 权限检查 + const authInfo = getAuthInfoFromCookie(req); + if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) { + return NextResponse.json({ error: '无权限访问' }, { status: 403 }); + } + + const config = await getConfig(); + const subscriptions = config.AnimeSubscriptionConfig?.Subscriptions || []; + + const subscription = subscriptions.find((sub) => sub.id === params.id); + if (!subscription) { + return NextResponse.json({ error: '订阅不存在' }, { status: 404 }); + } + + // 执行检查逻辑(忽略时间间隔限制) + const result = await checkSubscription(subscription); + + // 保存配置 + await db.saveAdminConfig(config); + + return NextResponse.json({ + success: true, + ...result, + }); + } catch (error: any) { + console.error('检查追番订阅失败:', error); + return NextResponse.json( + { error: error.message || '检查失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/anime-subscription/[id]/route.ts b/src/app/api/admin/anime-subscription/[id]/route.ts new file mode 100644 index 0000000..0c3165f --- /dev/null +++ b/src/app/api/admin/anime-subscription/[id]/route.ts @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { db } from '@/lib/db'; + +export const runtime = 'nodejs'; + +/** + * PUT /api/admin/anime-subscription/[id] + * 更新订阅 + */ +export async function PUT( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + // 权限检查 + const authInfo = getAuthInfoFromCookie(req); + if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) { + return NextResponse.json({ error: '无权限访问' }, { status: 403 }); + } + + const config = await getConfig(); + const subscriptions = config.AnimeSubscriptionConfig?.Subscriptions || []; + + const index = subscriptions.findIndex((sub) => sub.id === params.id); + if (index === -1) { + return NextResponse.json({ error: '订阅不存在' }, { status: 404 }); + } + + const updates = await req.json(); + const subscription = subscriptions[index]; + + // 更新字段 + if (updates.title !== undefined) { + subscription.title = updates.title.trim(); + } + if (updates.filterText !== undefined) { + subscription.filterText = updates.filterText.trim(); + } + if (updates.source !== undefined) { + if (!['acgrip', 'mikan', 'dmhy'].includes(updates.source)) { + return NextResponse.json({ error: '无效的搜索源' }, { status: 400 }); + } + subscription.source = updates.source; + } + if (updates.enabled !== undefined) { + subscription.enabled = updates.enabled; + } + if (updates.lastEpisode !== undefined) { + // 验证集数为非负整数 + const episode = parseInt(String(updates.lastEpisode), 10); + if (isNaN(episode) || episode < 0) { + return NextResponse.json( + { error: '集数必须是非负整数' }, + { status: 400 } + ); + } + subscription.lastEpisode = episode; + } + + subscription.updatedAt = Date.now(); + + await db.saveAdminConfig(config); + + return NextResponse.json(subscription); + } catch (error: any) { + console.error('更新追番订阅失败:', error); + return NextResponse.json( + { error: error.message || '更新订阅失败' }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/admin/anime-subscription/[id] + * 删除订阅 + */ +export async function DELETE( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + // 权限检查 + const authInfo = getAuthInfoFromCookie(req); + if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) { + return NextResponse.json({ error: '无权限访问' }, { status: 403 }); + } + + const config = await getConfig(); + const subscriptions = config.AnimeSubscriptionConfig?.Subscriptions || []; + + const index = subscriptions.findIndex((sub) => sub.id === params.id); + if (index === -1) { + return NextResponse.json({ error: '订阅不存在' }, { status: 404 }); + } + + subscriptions.splice(index, 1); + await db.saveAdminConfig(config); + + return NextResponse.json({ success: true }); + } catch (error: any) { + console.error('删除追番订阅失败:', error); + return NextResponse.json( + { error: error.message || '删除订阅失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/anime-subscription/route.ts b/src/app/api/admin/anime-subscription/route.ts new file mode 100644 index 0000000..501cfe1 --- /dev/null +++ b/src/app/api/admin/anime-subscription/route.ts @@ -0,0 +1,106 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { db } from '@/lib/db'; +import { AnimeSubscription } from '@/types/anime-subscription'; + +export const runtime = 'nodejs'; + +/** + * GET /api/admin/anime-subscription + * 获取订阅列表和配置 + */ +export async function GET(req: NextRequest) { + try { + // 权限检查 + const authInfo = getAuthInfoFromCookie(req); + if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) { + return NextResponse.json({ error: '无权限访问' }, { status: 403 }); + } + + const config = await getConfig(); + const animeConfig = config.AnimeSubscriptionConfig || { + Enabled: false, + Subscriptions: [], + }; + + return NextResponse.json(animeConfig); + } catch (error: any) { + console.error('获取追番订阅配置失败:', error); + return NextResponse.json( + { error: error.message || '获取配置失败' }, + { status: 500 } + ); + } +} + +/** + * POST /api/admin/anime-subscription + * 创建新订阅 + */ +export async function POST(req: NextRequest) { + try { + // 权限检查 + const authInfo = getAuthInfoFromCookie(req); + if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) { + return NextResponse.json({ error: '无权限访问' }, { status: 403 }); + } + + const { title, filterText, source, enabled, lastEpisode } = + await req.json(); + + // 验证必填字段 + if (!title || !filterText || !source) { + return NextResponse.json({ error: '缺少必填字段' }, { status: 400 }); + } + + // 验证 source + if (!['acgrip', 'mikan', 'dmhy'].includes(source)) { + return NextResponse.json({ error: '无效的搜索源' }, { status: 400 }); + } + + const config = await getConfig(); + if (!config.AnimeSubscriptionConfig) { + config.AnimeSubscriptionConfig = { Enabled: false, Subscriptions: [] }; + } + + // 验证集数 + let episodeNum = 0; + if (lastEpisode !== undefined) { + episodeNum = parseInt(String(lastEpisode), 10); + if (isNaN(episodeNum) || episodeNum < 0) { + return NextResponse.json( + { error: '集数必须是非负整数' }, + { status: 400 } + ); + } + } + + // 创建新订阅 + const newSubscription: AnimeSubscription = { + id: crypto.randomUUID(), + title: title.trim(), + filterText: filterText.trim(), + source, + enabled: enabled ?? true, + lastCheckTime: 0, + lastEpisode: episodeNum, + createdAt: Date.now(), + updatedAt: Date.now(), + createdBy: authInfo.username || 'unknown', + }; + + config.AnimeSubscriptionConfig.Subscriptions.push(newSubscription); + await db.saveAdminConfig(config); + + return NextResponse.json(newSubscription); + } catch (error: any) { + console.error('创建追番订阅失败:', error); + return NextResponse.json( + { error: error.message || '创建订阅失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/admin/anime-subscription/toggle/route.ts b/src/app/api/admin/anime-subscription/toggle/route.ts new file mode 100644 index 0000000..6dc2fee --- /dev/null +++ b/src/app/api/admin/anime-subscription/toggle/route.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { db } from '@/lib/db'; + +export const runtime = 'nodejs'; + +/** + * PUT /api/admin/anime-subscription/toggle + * 切换追番功能启用状态 + */ +export async function PUT(req: NextRequest) { + try { + // 权限检查 + const authInfo = getAuthInfoFromCookie(req); + if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) { + return NextResponse.json({ error: '无权限访问' }, { status: 403 }); + } + + const { enabled } = await req.json(); + + if (typeof enabled !== 'boolean') { + return NextResponse.json( + { error: 'enabled 必须是布尔值' }, + { status: 400 } + ); + } + + const config = await getConfig(); + if (!config.AnimeSubscriptionConfig) { + config.AnimeSubscriptionConfig = { Enabled: false, Subscriptions: [] }; + } + + config.AnimeSubscriptionConfig.Enabled = enabled; + await db.saveAdminConfig(config); + + return NextResponse.json({ success: true, enabled }); + } catch (error: any) { + console.error('切换追番功能状态失败:', error); + return NextResponse.json( + { error: error.message || '切换状态失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/cron/[password]/route.ts b/src/app/api/cron/[password]/route.ts index 3ea93d7..c28240c 100644 --- a/src/app/api/cron/[password]/route.ts +++ b/src/app/api/cron/[password]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; +import { checkAnimeSubscriptions } from '@/lib/anime-subscription'; import { getConfig, refineConfig } from '@/lib/config'; import { db, getStorage } from '@/lib/db'; import { EmailService } from '@/lib/email.service'; @@ -57,6 +58,7 @@ async function cronJob() { await refreshAllLiveChannels(); await refreshOpenList(); await refreshRecordAndFavorites(); + await checkAnimeSubscriptions(); } async function refreshAllLiveChannels() { diff --git a/src/components/AnimeSubscriptionComponent.tsx b/src/components/AnimeSubscriptionComponent.tsx new file mode 100644 index 0000000..53444f7 --- /dev/null +++ b/src/components/AnimeSubscriptionComponent.tsx @@ -0,0 +1,691 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +'use client'; + +import { AlertCircle, Loader2, Plus, RefreshCw, Trash2, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { AdminConfig } from '@/lib/admin.types'; +import { AnimeSubscription } from '@/types/anime-subscription'; + +interface AnimeSubscriptionComponentProps { + config: AdminConfig | null; + refreshConfig: () => Promise; +} + +// Switch 组件 +const Switch = ({ checked, onChange, disabled }: { checked: boolean; onChange: (checked: boolean) => void; disabled?: boolean }) => ( + +); + +// AlertModal 组件 +interface AlertModalProps { + isOpen: boolean; + onClose: () => void; + type: 'success' | 'error' | 'warning' | 'info'; + title: string; + message?: string; + confirmText?: string; + onConfirm?: () => void; + showConfirm?: boolean; +} + +const AlertModal = ({ + isOpen, + onClose, + type, + title, + message, + confirmText = '确定', + onConfirm, + showConfirm = false, +}: AlertModalProps) => { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + if (isOpen) { + setIsVisible(true); + } else { + setIsVisible(false); + } + }, [isOpen]); + + if (!isOpen) return null; + + const icons = { + success: , + error: , + warning: , + info: , + }; + + return createPortal( +
+
+
+
+ {icons[type]} +

+ {title} +

+ {message && ( +

+ {message} +

+ )} + +
+ {showConfirm && onConfirm ? ( + <> + + + + ) : ( + + )} +
+
+
+
, + document.body + ); +}; + +export default function AnimeSubscriptionComponent({ + config, + refreshConfig, +}: AnimeSubscriptionComponentProps) { + const [enabled, setEnabled] = useState(false); + const [subscriptions, setSubscriptions] = useState([]); + const [loading, setLoading] = useState(false); + const [showAddForm, setShowAddForm] = useState(false); + const [editingSubscription, setEditingSubscription] = useState(null); + const [checkingId, setCheckingId] = useState(null); + const [alertModal, setAlertModal] = useState<{ + isOpen: boolean; + type: 'success' | 'error' | 'warning' | 'info'; + title: string; + message?: string; + confirmText?: string; + onConfirm?: () => void; + showConfirm?: boolean; + }>({ + isOpen: false, + type: 'success', + title: '', + }); + + const showAlert = (config: Omit) => { + setAlertModal({ ...config, isOpen: true }); + }; + + const hideAlert = () => { + setAlertModal(prev => ({ ...prev, isOpen: false })); + }; + + // 表单状态 + const [formData, setFormData] = useState({ + title: '', + filterText: '', + source: 'mikan' as 'acgrip' | 'mikan' | 'dmhy', + lastEpisode: 0, + enabled: true, + }); + + // 加载配置 + useEffect(() => { + if (config?.AnimeSubscriptionConfig) { + setEnabled(config.AnimeSubscriptionConfig.Enabled || false); + setSubscriptions(config.AnimeSubscriptionConfig.Subscriptions || []); + } + }, [config]); + + // 重置表单 + const resetForm = () => { + setFormData({ + title: '', + filterText: '', + source: 'mikan', + lastEpisode: 0, + enabled: true, + }); + setEditingSubscription(null); + setShowAddForm(false); + }; + + // 切换启用状态 + const handleToggleEnabled = async (newEnabled: boolean) => { + try { + setLoading(true); + const response = await fetch('/api/admin/anime-subscription/toggle', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: newEnabled }), + }); + + if (!response.ok) { + throw new Error('切换状态失败'); + } + + setEnabled(newEnabled); + await refreshConfig(); + } catch (error) { + showAlert({ + type: 'error', + title: '切换状态失败', + message: error instanceof Error ? error.message : '切换状态失败', + }); + } finally { + setLoading(false); + } + }; + + // 开始添加 + const handleAdd = () => { + resetForm(); + setShowAddForm(true); + }; + + // 开始编辑 + const handleEdit = (sub: AnimeSubscription) => { + setFormData({ + title: sub.title, + filterText: sub.filterText, + source: sub.source, + lastEpisode: sub.lastEpisode, + enabled: sub.enabled, + }); + setEditingSubscription(sub); + setShowAddForm(false); + }; + + // 保存订阅 + const handleSave = async () => { + if (!formData.title.trim() || !formData.filterText.trim()) { + showAlert({ + type: 'warning', + title: '请填写必填字段', + message: '番剧名称和过滤关键词不能为空', + }); + return; + } + + try { + setLoading(true); + + if (editingSubscription) { + // 更新 + const response = await fetch(`/api/admin/anime-subscription/${editingSubscription.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + throw new Error('更新订阅失败'); + } + } else { + // 创建 + const response = await fetch('/api/admin/anime-subscription', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + throw new Error('创建订阅失败'); + } + } + + resetForm(); + await refreshConfig(); + showAlert({ + type: 'success', + title: editingSubscription ? '订阅已更新' : '订阅已创建', + }); + } catch (error) { + showAlert({ + type: 'error', + title: '保存失败', + message: error instanceof Error ? error.message : '保存失败', + }); + } finally { + setLoading(false); + } + }; + + // 删除订阅 + const handleDelete = async (id: string, title: string) => { + showAlert({ + type: 'warning', + title: '确认删除', + message: `确定要删除订阅"${title}"吗?`, + confirmText: '删除', + showConfirm: true, + onConfirm: async () => { + try { + setLoading(true); + const response = await fetch(`/api/admin/anime-subscription/${id}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error('删除订阅失败'); + } + + await refreshConfig(); + showAlert({ + type: 'success', + title: '订阅已删除', + }); + } catch (error) { + showAlert({ + type: 'error', + title: '删除失败', + message: error instanceof Error ? error.message : '删除失败', + }); + } finally { + setLoading(false); + } + }, + }); + }; + + // 手动检查更新 + const handleCheckSubscription = async (id: string) => { + try { + setCheckingId(id); + const response = await fetch(`/api/admin/anime-subscription/${id}/check`, { + method: 'POST', + }); + + if (!response.ok) { + throw new Error('检查失败'); + } + + const result = await response.json(); + showAlert({ + type: 'success', + title: '检查完成', + message: `发现 ${result.found} 个新集数,已下载 ${result.downloaded} 个`, + }); + await refreshConfig(); + } catch (error) { + showAlert({ + type: 'error', + title: '检查失败', + message: error instanceof Error ? error.message : '检查失败', + }); + } finally { + setCheckingId(null); + } + }; + + // 切换订阅启用状态 + const handleToggleSubscription = async (sub: AnimeSubscription) => { + try { + setLoading(true); + const response = await fetch(`/api/admin/anime-subscription/${sub.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: !sub.enabled }), + }); + + if (!response.ok) { + throw new Error('切换状态失败'); + } + + await refreshConfig(); + } catch (error) { + showAlert({ + type: 'error', + title: '切换状态失败', + message: error instanceof Error ? error.message : '切换状态失败', + }); + } finally { + setLoading(false); + } + }; + + const formatTime = (timestamp: number) => { + if (!timestamp) return '从未'; + const now = Date.now(); + const diff = now - timestamp; + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return '刚刚'; + if (minutes < 60) return `${minutes}分钟前`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}小时前`; + const days = Math.floor(hours / 24); + return `${days}天前`; + }; + + return ( +
+ {/* 顶部控制 */} +
+
+ + 启用追番功能 + + +
+ +
+ + {/* 说明 */} +
+
+ +
+

• 定时任务会自动检查订阅更新

+

• 下载路径:OpenList离线下载根目录/番剧名称/

+

• 过滤关键词支持多个,用逗号分隔,只会下载包含这些关键字的资源,可以用来过滤字幕组或是字幕种类

+

• 当前集数:已看到第几集,只下载更新的集数

+
+
+
+ + {/* 添加/编辑表单 */} + {(showAddForm || editingSubscription) && ( +
+
+

+ {editingSubscription ? '编辑订阅' : '添加订阅'} +

+ +
+
+
+
+ + setFormData({ ...formData, title: e.target.value })} + placeholder='葬送的芙莉莲' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500' + /> +
+
+ + setFormData({ ...formData, filterText: e.target.value })} + placeholder='简体,喵萌奶茶屋' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500' + /> +

+ 多个关键词用逗号分隔 +

+
+
+
+
+ + +
+
+ + setFormData({ ...formData, lastEpisode: parseInt(e.target.value) || 0 })} + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-green-500' + /> +

+ 已看到第几集 +

+
+
+
+ + 启用此订阅 + + setFormData({ ...formData, enabled: checked })} + /> +
+
+ + +
+
+
+ )} + + {/* 订阅列表 */} + {subscriptions.length === 0 ? ( +
+ 暂无订阅,点击"添加订阅"开始追番 +
+ ) : ( +
+ {subscriptions.map((sub) => ( +
+ {/* 桌面端布局 */} +
+
+
+

+ {sub.title} +

+ + {sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : '动漫花园'} + +
+
+

过滤条件:{sub.filterText}

+

当前集数:第 {sub.lastEpisode} 集

+

上次检查:{formatTime(sub.lastCheckTime)}

+
+
+
+ handleToggleSubscription(sub)} + disabled={loading} + /> + + + +
+
+ + {/* 移动端布局 */} +
+
+
+

+ {sub.title} +

+ + {sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : '动漫花园'} + +
+ handleToggleSubscription(sub)} + disabled={loading} + /> +
+
+

过滤:{sub.filterText}

+

集数:第 {sub.lastEpisode} 集 · {formatTime(sub.lastCheckTime)}

+
+
+ + + +
+
+
+ ))} +
+ )} + + {/* AlertModal */} + +
+ ); +} diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 9e64574..6643a6a 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -249,6 +249,21 @@ export interface AdminConfig { OpenListCachePath?: string; // OpenList缓存目录路径 OpenListCacheProxyEnabled?: boolean; // 启用缓存代理返回(默认开启) }; + AnimeSubscriptionConfig?: { + Enabled: boolean; // 是否启用追番功能 + Subscriptions: Array<{ + id: string; + title: string; + filterText: string; + source: 'acgrip' | 'mikan' | 'dmhy'; + enabled: boolean; + lastCheckTime: number; + lastEpisode: number; + createdAt: number; + updatedAt: number; + createdBy: string; + }>; + }; } export interface AdminConfigResult { diff --git a/src/lib/anime-subscription.ts b/src/lib/anime-subscription.ts new file mode 100644 index 0000000..01c1a69 --- /dev/null +++ b/src/lib/anime-subscription.ts @@ -0,0 +1,397 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import parseTorrentName from 'parse-torrent-name'; +import { parseStringPromise } from 'xml2js'; + +import { getConfig, setCachedConfig } from '@/lib/config'; +import { db, getStorage } from '@/lib/db'; +import { EmailService } from '@/lib/email.service'; +import { OpenListClient } from '@/lib/openlist.client'; +import { AnimeSubscription } from '@/types/anime-subscription'; + +/** + * 从标题中提取集数 + */ +export function extractEpisode(title: string): number | null { + const parsed = parseTorrentName(title); + + if (parsed.episode) { + return parsed.episode; + } + + // 备用正则匹配 + const patterns = [ + /\[(\d+)\]/, // [01] + /第(\d+)[集话]/, // 第01集 + /EP?(\d+)/i, // EP01, E01 + /\s(\d+)\s/, // 空格01空格 + ]; + + for (const pattern of patterns) { + const match = title.match(pattern); + if (match) { + return parseInt(match[1], 10); + } + } + + return null; +} + +/** + * 检查标题是否匹配过滤条件 + */ +export function matchesFilter(title: string, filterText: string): boolean { + if (!filterText) return true; + + // 支持多个关键词,用逗号分隔,必须全部匹配 + const keywords = filterText.split(',').map((k) => k.trim()).filter(Boolean); + + return keywords.every((keyword) => title.includes(keyword)); +} + +/** + * 搜索 ACG 资源(直接调用搜索逻辑,不通过 HTTP) + */ +export async function searchACG( + keyword: string, + source: 'acgrip' | 'mikan' | 'dmhy' +) { + const trimmedKeyword = keyword.trim(); + + let searchUrl: string; + + switch (source) { + case 'mikan': + searchUrl = `https://mikanani.me/RSS/Search?searchstr=${encodeURIComponent(trimmedKeyword)}`; + break; + case 'dmhy': + searchUrl = `http://share.dmhy.org/topics/rss/rss.xml?keyword=${encodeURIComponent(trimmedKeyword)}`; + break; + case 'acgrip': + default: + searchUrl = `https://acg.rip/page/1.xml?term=${encodeURIComponent(trimmedKeyword)}`; + break; + } + + const response = await fetch(searchUrl, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + }); + + if (!response.ok) { + throw new Error(`${source} API 请求失败: ${response.status}`); + } + + const xmlData = await response.text(); + const parsed = await parseStringPromise(xmlData); + + if (!parsed?.rss?.channel?.[0]?.item) { + return []; + } + + const items = parsed.rss.channel[0].item; + + // 统一格式 + return items.map((item: any) => { + const title = item.title?.[0] || ''; + const link = item.link?.[0] || ''; + const guid = item.guid?.[0] || link || `${title}-${item.pubDate?.[0] || ''}`; + const pubDate = item.pubDate?.[0] || ''; + const torrentUrl = item.enclosure?.[0]?.$?.url || ''; + const description = item.description?.[0] || ''; + + return { + title, + link, + guid, + pubDate, + torrentUrl, + description, + }; + }); +} + +/** + * 添加离线下载任务 + */ +export async function addOfflineDownload( + torrentUrl: string, + downloadPath: string +) { + const config = await getConfig(); + const openlistConfig = config.OpenListConfig; + + if (!openlistConfig?.Enabled) { + throw new Error('私人影库功能未启用'); + } + + if ( + !openlistConfig.URL || + !openlistConfig.Username || + !openlistConfig.Password + ) { + throw new Error('OpenList 配置不完整'); + } + + const client = new OpenListClient( + openlistConfig.URL, + openlistConfig.Username, + openlistConfig.Password + ); + + const token = await (client as any).getToken(); + const openlistUrl = `${openlistConfig.URL.replace(/\/$/, '')}/api/fs/add_offline_download`; + + const response = await fetch(openlistUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: token, + }, + body: JSON.stringify({ + path: downloadPath, + urls: [torrentUrl], + tool: 'aria2', + }), + }); + + const data = await response.json(); + + if (!response.ok || data.code !== 200) { + throw new Error(data.message || '添加离线下载任务失败'); + } +} + +/** + * 发送追番更新通知和邮件 + */ +async function sendAnimeUpdateNotifications( + subscription: AnimeSubscription, + episodes: number[] +) { + const config = await getConfig(); + const storage = getStorage(); + + // 获取站长用户名 - 从用户列表中查找 owner 角色 + let ownerUsername: string | null = null; + try { + const allUsers = await db.getAllUsers(); + for (const username of allUsers) { + const userInfo = await db.getUserInfoV2(username); + if (userInfo?.role === 'owner') { + ownerUsername = username; + break; + } + } + } catch (error) { + console.error('[AnimeSubscription] 获取站长用户名失败:', error); + } + + if (!ownerUsername) { + console.warn('[AnimeSubscription] 未找到站长用户,跳过通知'); + return; + } + + // 准备通知内容 + const episodeList = episodes.join('、'); + const notificationTitle = `追番更新:${subscription.title}`; + const notificationMessage = `您订阅的番剧《${subscription.title}》有新集数更新:第 ${episodeList} 集,已下载到私人影库`; + + // 需要通知的用户列表(去重) + const usersToNotify: string[] = [ownerUsername]; + + // 如果创建者不是站长,也通知创建者 + if (subscription.createdBy && subscription.createdBy !== ownerUsername) { + usersToNotify.push(subscription.createdBy); + } + + // 发送站内通知 + for (const username of usersToNotify) { + try { + await storage.addNotification(username, { + id: crypto.randomUUID(), + type: 'anime_subscription_update', + title: notificationTitle, + message: notificationMessage, + timestamp: Date.now(), + read: false, + metadata: { + subscriptionId: subscription.id, + subscriptionTitle: subscription.title, + episodes: episodes, + }, + }); + console.log(`[AnimeSubscription] 已发送站内通知给用户: ${username}`); + } catch (error) { + console.error(`[AnimeSubscription] 发送站内通知失败 (${username}):`, error); + } + } + + // 发送邮件通知(如果已启用) + const emailConfig = config.EmailConfig; + if (!emailConfig?.enabled) { + return; + } + + // 获取需要发送邮件的用户邮箱 + const emailsToSend: Array<{ username: string; email: string }> = []; + + for (const username of usersToNotify) { + try { + const userInfo = await db.getUserInfoV2(username); + // 使用可选的 email 字段 + const email = (userInfo as any)?.email; + if (email) { + emailsToSend.push({ username, email }); + } + } catch (error) { + console.error(`[AnimeSubscription] 获取用户邮箱失败 (${username}):`, error); + } + } + + // 发送邮件 + for (const { username, email } of emailsToSend) { + try { + const emailHtml = ` +
+

追番更新通知

+

您好,${username}!

+

您订阅的番剧有新集数更新:

+
+

${subscription.title}

+

新增集数:第 ${episodeList} 集

+

搜索源:${subscription.source === 'acgrip' ? 'ACG.RIP' : subscription.source === 'mikan' ? '蜜柑' : '动漫花园'}

+
+

这些集数已自动添加到 OpenList 离线下载队列。

+
+

此邮件由系统自动发送,请勿回复。

+
+ `; + + if (emailConfig.provider === 'smtp' && emailConfig.smtp) { + await EmailService.sendViaSMTP(emailConfig.smtp, { + to: email, + subject: notificationTitle, + html: emailHtml, + }); + } else if (emailConfig.provider === 'resend' && emailConfig.resend) { + await EmailService.sendViaResend(emailConfig.resend, { + to: email, + subject: notificationTitle, + html: emailHtml, + }); + } + + console.log(`[AnimeSubscription] 已发送邮件通知给: ${email}`); + } catch (error) { + console.error(`[AnimeSubscription] 发送邮件失败 (${email}):`, error); + } + } +} + +/** + * 检查单个订阅的更新 + */ +export async function checkSubscription(subscription: AnimeSubscription) { + const config = await getConfig(); + const openlistConfig = config.OpenListConfig; + + if (!openlistConfig?.OfflineDownloadPath) { + throw new Error('OpenList 离线下载路径未配置'); + } + + // 1. 搜索资源 + const results = await searchACG(subscription.title, subscription.source); + + // 2. 过滤并解析集数 + const newEpisodes = results + .filter((item: any) => matchesFilter(item.title, subscription.filterText)) + .map((item: any) => ({ + episode: extractEpisode(item.title), + ...item, + })) + .filter((item: any) => item.episode && item.episode > subscription.lastEpisode) + .sort((a: any, b: any) => a.episode! - b.episode!); + + // 3. 下载新集数 + const downloaded = []; + for (const item of newEpisodes) { + try { + const downloadPath = `${openlistConfig.OfflineDownloadPath.replace(/\/$/, '')}/${subscription.title}`; + await addOfflineDownload(item.torrentUrl, downloadPath); + + // 成功后更新 lastEpisode + subscription.lastEpisode = item.episode!; + downloaded.push(item.episode); + + console.log( + `[AnimeSubscription] ${subscription.title}: 已添加第${item.episode}集到下载队列` + ); + } catch (error) { + // 失败则停止,下次继续尝试这一集 + console.error( + `[AnimeSubscription] ${subscription.title}: 下载第${item.episode}集失败`, + error + ); + break; + } + } + + // 4. 更新检查时间 + subscription.lastCheckTime = Date.now(); + + // 5. 发送通知和邮件(如果有下载成功的集数) + if (downloaded.length > 0) { + try { + await sendAnimeUpdateNotifications(subscription, downloaded); + } catch (error) { + console.error(`[AnimeSubscription] ${subscription.title}: 发送通知失败`, error); + } + } + + return { + found: newEpisodes.length, + downloaded: downloaded.length, + episodes: downloaded, + }; +} + +/** + * 检查所有订阅(定时任务调用) + */ +export async function checkAnimeSubscriptions() { + const config = await getConfig(); + const animeConfig = config.AnimeSubscriptionConfig; + + if (!animeConfig?.Enabled) { + return; + } + + const subscriptions = animeConfig.Subscriptions || []; + const now = Date.now(); + const MIN_CHECK_INTERVAL = 30 * 60 * 1000; // 30分钟 + let configChanged = false; + + for (const sub of subscriptions) { + if (!sub.enabled) continue; + + // 检查是否距离上次检查超过30分钟 + if (now - sub.lastCheckTime < MIN_CHECK_INTERVAL) { + continue; + } + + try { + await checkSubscription(sub); + configChanged = true; + } catch (error) { + console.error(`[AnimeSubscription] ${sub.title}: 检查失败`, error); + } + } + + // 5. 保存配置并刷新缓存 + if (configChanged) { + await db.saveAdminConfig(config); + await setCachedConfig(config); + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 26a0f07..34a723a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -240,7 +240,8 @@ export type NotificationType = | 'system' // 系统通知 | 'announcement' // 公告 | 'movie_request' // 新求片通知(给管理员) - | 'request_fulfilled'; // 求片已上架通知(给求片用户) + | 'request_fulfilled' // 求片已上架通知(给求片用户) + | 'anime_subscription_update'; // 追番订阅更新 // 通知数据结构 export interface Notification { diff --git a/src/types/anime-subscription.ts b/src/types/anime-subscription.ts new file mode 100644 index 0000000..c9270d8 --- /dev/null +++ b/src/types/anime-subscription.ts @@ -0,0 +1,17 @@ +export interface AnimeSubscription { + id: string; + title: string; + filterText: string; + source: 'acgrip' | 'mikan' | 'dmhy'; + enabled: boolean; + lastCheckTime: number; + lastEpisode: number; + createdAt: number; + updatedAt: number; + createdBy: string; +} + +export interface AnimeSubscriptionConfig { + Enabled: boolean; + Subscriptions: AnimeSubscription[]; +}