diff --git a/migrations/010_add_is_anime_to_play_records.sql b/migrations/010_add_is_anime_to_play_records.sql new file mode 100644 index 0000000..90c8ff2 --- /dev/null +++ b/migrations/010_add_is_anime_to_play_records.sql @@ -0,0 +1,5 @@ +-- ============================================ +-- 播放记录增加 is_anime 字段(追番订阅/继续观看识别) +-- ============================================ + +ALTER TABLE play_records ADD COLUMN is_anime INTEGER DEFAULT 0; diff --git a/migrations/postgres/010_add_is_anime_to_play_records.sql b/migrations/postgres/010_add_is_anime_to_play_records.sql new file mode 100644 index 0000000..90c8ff2 --- /dev/null +++ b/migrations/postgres/010_add_is_anime_to_play_records.sql @@ -0,0 +1,5 @@ +-- ============================================ +-- 播放记录增加 is_anime 字段(追番订阅/继续观看识别) +-- ============================================ + +ALTER TABLE play_records ADD COLUMN is_anime INTEGER DEFAULT 0; diff --git a/src/app/api/admin/anime-subscription/[id]/route.ts b/src/app/api/admin/anime-subscription/[id]/route.ts index 4ee810c..046eba2 100644 --- a/src/app/api/admin/anime-subscription/[id]/route.ts +++ b/src/app/api/admin/anime-subscription/[id]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; +import { validateKeywordExpr } from '@/lib/anime-keyword-expr'; import { getConfig } from '@/lib/config'; import { db } from '@/lib/db'; @@ -35,14 +36,47 @@ export async function PUT( // 更新字段 if (updates.title !== undefined) { - subscription.title = updates.title.trim(); + const normalizedTitle = String(updates.title).trim().replace(/\s+/g, ' '); + if (!normalizedTitle) { + return NextResponse.json({ error: '番剧名称不能为空' }, { status: 400 }); + } + const duplicated = subscriptions.some( + (sub) => + sub.id !== params.id && + sub.title.trim().replace(/\s+/g, ' ').toLowerCase() === + normalizedTitle.toLowerCase() + ); + if (duplicated) { + return NextResponse.json( + { error: `已存在同名追番订阅「${normalizedTitle}」,请勿重复添加` }, + { status: 409 } + ); + } + subscription.title = normalizedTitle; } if (updates.filterText !== undefined) { + const filterCheck = validateKeywordExpr(String(updates.filterText), 'and'); + if (!filterCheck.ok) { + return NextResponse.json( + { error: `过滤关键词表达式无效: ${filterCheck.error}` }, + { status: 400 } + ); + } subscription.filterText = updates.filterText.trim(); } if (updates.excludeText !== undefined) { - subscription.excludeText = + const rawExclude = typeof updates.excludeText === 'string' ? updates.excludeText.trim() : ''; + if (rawExclude) { + const excludeCheck = validateKeywordExpr(rawExclude, 'or'); + if (!excludeCheck.ok) { + return NextResponse.json( + { error: `排除关键词表达式无效: ${excludeCheck.error}` }, + { status: 400 } + ); + } + } + subscription.excludeText = rawExclude; } if (updates.source !== undefined) { if (!['acgrip', 'mikan', 'dmhy', 'nyaa'].includes(updates.source)) { @@ -53,6 +87,14 @@ export async function PUT( if (updates.enabled !== undefined) { subscription.enabled = updates.enabled; } + if (updates.onePerEpisode !== undefined) { + subscription.onePerEpisode = Boolean(updates.onePerEpisode); + } + if (updates.refillMissingEpisodes !== undefined) { + subscription.refillMissingEpisodes = Boolean( + updates.refillMissingEpisodes + ); + } if (updates.lastEpisode !== undefined) { // 验证集数为非负整数 const episode = parseInt(String(updates.lastEpisode), 10); diff --git a/src/app/api/admin/anime-subscription/route.ts b/src/app/api/admin/anime-subscription/route.ts index 61777a2..a029dd3 100644 --- a/src/app/api/admin/anime-subscription/route.ts +++ b/src/app/api/admin/anime-subscription/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; +import { validateKeywordExpr } from '@/lib/anime-keyword-expr'; import { getConfig } from '@/lib/config'; import { db } from '@/lib/db'; import { AnimeSubscription } from '@/types/anime-subscription'; @@ -52,8 +53,16 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '无权限访问' }, { status: 403 }); } - const { title, filterText, excludeText, source, enabled, lastEpisode } = - await req.json(); + const { + title, + filterText, + excludeText, + source, + enabled, + lastEpisode, + onePerEpisode, + refillMissingEpisodes, + } = await req.json(); // 验证必填字段 if (!title || !filterText || !source) { @@ -65,6 +74,23 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '无效的搜索源' }, { status: 400 }); } + const filterCheck = validateKeywordExpr(String(filterText), 'and'); + if (!filterCheck.ok) { + return NextResponse.json( + { error: `过滤关键词表达式无效: ${filterCheck.error}` }, + { status: 400 } + ); + } + if (typeof excludeText === 'string' && excludeText.trim()) { + const excludeCheck = validateKeywordExpr(excludeText, 'or'); + if (!excludeCheck.ok) { + return NextResponse.json( + { error: `排除关键词表达式无效: ${excludeCheck.error}` }, + { status: 400 } + ); + } + } + const config = await getConfig(); if (!config.AnimeSubscriptionConfig) { config.AnimeSubscriptionConfig = { @@ -88,14 +114,34 @@ export async function POST(req: NextRequest) { } } + const normalizedTitle = String(title).trim().replace(/\s+/g, ' '); + if (!normalizedTitle) { + return NextResponse.json({ error: '番剧名称不能为空' }, { status: 400 }); + } + + // 拒绝重复番剧名(忽略大小写与首尾空白) + const exists = (config.AnimeSubscriptionConfig.Subscriptions || []).some( + (sub) => + sub.title.trim().replace(/\s+/g, ' ').toLowerCase() === + normalizedTitle.toLowerCase() + ); + if (exists) { + return NextResponse.json( + { error: `已存在同名追番订阅「${normalizedTitle}」,请勿重复添加` }, + { status: 409 } + ); + } + // 创建新订阅 const newSubscription: AnimeSubscription = { id: crypto.randomUUID(), - title: title.trim(), + title: normalizedTitle, filterText: filterText.trim(), excludeText: typeof excludeText === 'string' ? excludeText.trim() : '', source, enabled: enabled ?? true, + onePerEpisode: Boolean(onePerEpisode), + refillMissingEpisodes: Boolean(refillMissingEpisodes), lastCheckTime: 0, lastEpisode: episodeNum, createdAt: Date.now(), diff --git a/src/app/api/cron/[password]/route.ts b/src/app/api/cron/[password]/route.ts index 1017f5f..866c1a3 100644 --- a/src/app/api/cron/[password]/route.ts +++ b/src/app/api/cron/[password]/route.ts @@ -483,6 +483,7 @@ async function refreshRecordAndFavorites() { save_time: record.save_time, search_title: record.search_title, new_episodes: updatedNewEpisodes > 0 ? updatedNewEpisodes : undefined, + is_anime: record.is_anime, }); console.log( `更新播放记录: ${record.title} (${record.total_episodes} -> ${episodeCount}, 新增 ${newEpisodesCount} 集)` diff --git a/src/app/douban/page.tsx b/src/app/douban/page.tsx index b7d9ee5..b74f074 100644 --- a/src/app/douban/page.tsx +++ b/src/app/douban/page.tsx @@ -830,6 +830,7 @@ function DoubanPageClient() { isBangumi={ type === 'anime' && primarySelection === '每日放送' } + isAnime={type === 'anime'} /> ))} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 62d7108..1e6f16a 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -6,6 +6,7 @@ import { AlertCircle, Cloud, Heart, Keyboard, Loader2, Router, Sparkles, X } fro import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import { isAnimeCategoryText } from '@/lib/anime-keyword-expr'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import { clearDanmakuCacheByTitle, @@ -6580,6 +6581,10 @@ function PlayPageClient() { total_time: Math.floor(duration), save_time: Date.now(), search_title: searchTitle, + is_anime: isAnimeCategoryText( + detailRef.current?.type_name, + detailRef.current?.class + ), }); lastSavedPlayTimeRef.current = playTime; diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index c4798aa..73d88c6 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -23,6 +23,7 @@ import React, { } from 'react'; import { createPortal } from 'react-dom'; +import { isAnimeCategoryText } from '@/lib/anime-keyword-expr'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import { addSearchHistory, @@ -2109,6 +2110,14 @@ function SearchPageClient() { : '' } type={type} + isAnime={group.some((g) => + isAnimeCategoryText(g.type_name, g.class) + )} + typeName={ + group.find((g) => g.type_name || g.class) + ?.type_name || + group.find((g) => g.class)?.class + } /> ); @@ -2116,6 +2125,10 @@ function SearchPageClient() { : filteredAllResults.map((item) => { const type = item.episodes.length > 1 ? 'tv' : 'movie'; + const itemIsAnime = isAnimeCategoryText( + item.type_name, + item.class + ); if (resultDisplayMode === 'list') { return renderListItem({ @@ -2162,6 +2175,8 @@ function SearchPageClient() { year={item.year} from='search' type={type} + isAnime={itemIsAnime} + typeName={item.type_name || item.class} /> ); diff --git a/src/app/source-search/page.tsx b/src/app/source-search/page.tsx index 8eb1741..5f4f15d 100644 --- a/src/app/source-search/page.tsx +++ b/src/app/source-search/page.tsx @@ -4,6 +4,7 @@ import { Loader2, Search } from 'lucide-react'; import { Suspense, useEffect, useRef, useState } from 'react'; +import { isAnimeCategoryText } from '@/lib/anime-keyword-expr'; import { ApiSite } from '@/lib/config'; import { appendSpecialSourceParam } from '@/lib/special-source.client'; import { SearchResult } from '@/lib/types'; @@ -359,6 +360,11 @@ function SourceSearchPageClient() { year={item.year} from='source-search' type={item.episodes.length > 1 ? 'tv' : 'movie'} + isAnime={isAnimeCategoryText( + item.type_name, + item.class + )} + typeName={item.type_name || item.class} cmsData={{ desc: item.desc, episodes: item.episodes, diff --git a/src/app/tv/play/page.tsx b/src/app/tv/play/page.tsx index a2e16ae..46b7f94 100644 --- a/src/app/tv/play/page.tsx +++ b/src/app/tv/play/page.tsx @@ -43,6 +43,7 @@ import { saveDanmakuDisplayState, searchAnime, } from '@/lib/danmaku/api'; +import { isAnimeCategoryText } from '@/lib/anime-keyword-expr'; import { deleteFavorite, generateStorageKey, @@ -832,6 +833,7 @@ function TVPlayClient() { total_time: totalTime, save_time: Date.now(), search_title: title || detail.title, + is_anime: isAnimeCategoryText(detail.type_name, detail.class), }).catch(() => undefined); }; diff --git a/src/components/AnimeSubscribeModal.tsx b/src/components/AnimeSubscribeModal.tsx new file mode 100644 index 0000000..a1c9fab --- /dev/null +++ b/src/components/AnimeSubscribeModal.tsx @@ -0,0 +1,321 @@ +'use client'; + +import { Loader2, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { + ANIME_EXCLUDE_PRESETS, + ANIME_FANSUB_PRESETS, + applyExcludeSingleSelect, + applyFansubSingleSelect, + isExcludePresetActive, + isFansubPresetActive, + type AnimeExcludePreset, + type AnimeFansubPreset, +} from '@/lib/anime-filter-presets'; + +export interface AnimeSubscribeModalProps { + isOpen: boolean; + onClose: () => void; + /** 预填番剧名(搜索词) */ + initialTitle: string; + /** 继续观看时可预填已看集数 */ + initialLastEpisode?: number; + onSuccess?: () => void; +} + +type SourceType = 'acgrip' | 'mikan' | 'dmhy' | 'nyaa'; + +/** + * VideoCard / 管理入口共用的「添加追番订阅」轻量弹层(仅 admin API) + */ +export default function AnimeSubscribeModal({ + isOpen, + onClose, + initialTitle, + initialLastEpisode = 0, + onSuccess, +}: AnimeSubscribeModalProps) { + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [form, setForm] = useState({ + title: '', + filterText: '', + excludeText: '', + source: 'mikan' as SourceType, + lastEpisode: 0, + enabled: true, + onePerEpisode: false, + refillMissingEpisodes: false, + }); + + useEffect(() => { + if (isOpen) { + setVisible(true); + setError(''); + setForm({ + title: initialTitle || '', + filterText: '', + excludeText: '', + source: 'mikan', + lastEpisode: + typeof initialLastEpisode === 'number' && initialLastEpisode > 0 + ? initialLastEpisode + : 0, + enabled: true, + onePerEpisode: false, + refillMissingEpisodes: false, + }); + } else { + setVisible(false); + } + }, [isOpen, initialTitle, initialLastEpisode]); + + if (!isOpen) return null; + + const chipClass = (active: boolean) => + `px-2 py-0.5 text-xs rounded-full border transition-colors ${ + active + ? 'bg-green-600 text-white border-green-600' + : 'bg-gray-50 dark:bg-gray-700/60 text-gray-700 dark:text-gray-200 border-gray-200 dark:border-gray-600' + }`; + + const handleFansubSelect = (preset: AnimeFansubPreset) => { + setForm((prev) => ({ + ...prev, + filterText: applyFansubSingleSelect(prev.filterText, preset), + })); + }; + + const handleExcludeSelect = (preset: AnimeExcludePreset) => { + setForm((prev) => ({ + ...prev, + excludeText: applyExcludeSingleSelect(prev.excludeText, preset), + })); + }; + + const handleSubmit = async () => { + if (!form.title.trim() || !form.filterText.trim()) { + setError('番剧名称和过滤关键词不能为空'); + return; + } + try { + setLoading(true); + setError(''); + const res = await fetch('/api/admin/anime-subscription', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: form.title.trim(), + filterText: form.filterText.trim(), + excludeText: form.excludeText.trim(), + source: form.source, + enabled: form.enabled, + lastEpisode: form.lastEpisode, + onePerEpisode: form.onePerEpisode, + refillMissingEpisodes: form.refillMissingEpisodes, + }), + }); + if (res.status === 403) { + setError('无权限:仅管理员可添加追番订阅'); + return; + } + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data.error || '创建订阅失败'); + return; + } + onSuccess?.(); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : '创建订阅失败'); + } finally { + setLoading(false); + } + }; + + return createPortal( +
+
+
+
+

+ 添加追番订阅 +

+ +
+ +
+

+ 将按番剧名在 ACG 源搜索,过滤后自动离线下载(仅管理员)。 +

+ +
+ + setForm({ ...form, title: e.target.value })} + className='w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm' + placeholder='搜索用的番剧名' + /> +
+ +
+ + setForm({ ...form, filterText: e.target.value })} + className='w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm' + placeholder='喵萌奶茶屋&简日双语' + /> +

字幕组

+
+ {ANIME_FANSUB_PRESETS.map((p) => ( + + ))} +
+
+ +
+ + setForm({ ...form, excludeText: e.target.value })} + className='w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm' + placeholder='先行|预告|PV' + /> +
+ {ANIME_EXCLUDE_PRESETS.map((p) => ( + + ))} +
+
+ +
+
+ + +
+
+ + + setForm({ + ...form, + lastEpisode: parseInt(e.target.value, 10) || 0, + }) + } + className='w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm' + /> +
+
+ + + + + {error ? ( +

{error}

+ ) : null} + +
+ + +
+
+
+
, + document.body + ); +} diff --git a/src/components/AnimeSubscriptionComponent.tsx b/src/components/AnimeSubscriptionComponent.tsx index 3dc9685..50c3b7c 100644 --- a/src/components/AnimeSubscriptionComponent.tsx +++ b/src/components/AnimeSubscriptionComponent.tsx @@ -5,6 +5,16 @@ import { AlertCircle, Loader2, Plus, RefreshCw, Trash2, X } from 'lucide-react'; import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; +import { + ANIME_EXCLUDE_PRESETS, + ANIME_FANSUB_PRESETS, + applyExcludeSingleSelect, + applyFansubSingleSelect, + isExcludePresetActive, + isFansubPresetActive, + type AnimeExcludePreset, + type AnimeFansubPreset, +} from '@/lib/anime-filter-presets'; import { AdminConfig } from '@/lib/admin.types'; import { AnimeSubscription, AnimeSubscriptionDownloadTool } from '@/types/anime-subscription'; @@ -183,6 +193,8 @@ export default function AnimeSubscriptionComponent({ source: 'mikan' as 'acgrip' | 'mikan' | 'dmhy' | 'nyaa', lastEpisode: 0, enabled: true, + onePerEpisode: false, + refillMissingEpisodes: false, }); // 加载配置 @@ -203,6 +215,8 @@ export default function AnimeSubscriptionComponent({ source: 'mikan', lastEpisode: 0, enabled: true, + onePerEpisode: false, + refillMissingEpisodes: false, }); setEditingSubscription(null); setShowAddForm(false); @@ -279,11 +293,34 @@ export default function AnimeSubscriptionComponent({ source: sub.source, lastEpisode: sub.lastEpisode, enabled: sub.enabled, + onePerEpisode: Boolean(sub.onePerEpisode), + refillMissingEpisodes: Boolean(sub.refillMissingEpisodes), }); setEditingSubscription(sub); setShowAddForm(false); }; + const handleFansubSelect = (preset: AnimeFansubPreset) => { + setFormData((prev) => ({ + ...prev, + filterText: applyFansubSingleSelect(prev.filterText, preset), + })); + }; + + const handleExcludeSelect = (preset: AnimeExcludePreset) => { + setFormData((prev) => ({ + ...prev, + excludeText: applyExcludeSingleSelect(prev.excludeText, preset), + })); + }; + + const chipClass = (active: boolean) => + `px-2 py-0.5 text-xs rounded-full border transition-colors ${ + active + ? 'bg-green-600 text-white border-green-600' + : 'bg-gray-50 dark:bg-gray-700/50 text-gray-700 dark:text-gray-200 border-gray-200 dark:border-gray-600 hover:border-green-500 hover:text-green-700 dark:hover:text-green-300' + }`; + // 保存订阅 const handleSave = async () => { if (!formData.title.trim() || !formData.filterText.trim()) { @@ -307,7 +344,8 @@ export default function AnimeSubscriptionComponent({ }); if (!response.ok) { - throw new Error('更新订阅失败'); + const data = await response.json().catch(() => ({})); + throw new Error(data.error || '更新订阅失败'); } } else { // 创建 @@ -318,7 +356,8 @@ export default function AnimeSubscriptionComponent({ }); if (!response.ok) { - throw new Error('创建订阅失败'); + const data = await response.json().catch(() => ({})); + throw new Error(data.error || '创建订阅失败'); } } @@ -491,8 +530,13 @@ export default function AnimeSubscriptionComponent({

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

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

-

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

-

• 排除关键词支持多个,用逗号分隔,标题包含任一关键词则跳过,例如:先行版,预告,PV

+

+ • 过滤/排除支持 &(且)、 + |(或)、 + ();无运算符时逗号仍可用(过滤=且,排除=或) +

+

• 快捷建议按字幕组填入(显示组名,写入已带语种/封装偏好;可再手改)

+

• 「单集只下载一次」为每条可选保险:同集多个种子只入队一条

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

@@ -513,33 +557,52 @@ export default function AnimeSubscriptionComponent({
-
-
- - 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, 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' + /> +

+ 用作 ACG 源搜索词 +

+
+
+ + 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' + /> +

+ 支持 & | () +

+
+

+ 字幕组

+
+ {ANIME_FANSUB_PRESETS.map((p) => ( + + ))} +
@@ -550,12 +613,25 @@ export default function AnimeSubscriptionComponent({ type='text' value={formData.excludeText} onChange={(e) => setFormData({ ...formData, excludeText: e.target.value })} - placeholder='先行版,预告,PV' + placeholder='先行|预告|PV' 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' />

- 可选;多个关键词用逗号分隔,标题包含任一则跳过 + 可选;支持 & | ()

+
+ {ANIME_EXCLUDE_PRESETS.map((p) => ( + + ))} +
@@ -589,15 +665,40 @@ export default function AnimeSubscriptionComponent({

-
- - 启用此订阅 - - setFormData({ ...formData, enabled: checked })} - /> +
+
+ + 启用此订阅 + + setFormData({ ...formData, enabled: checked })} + /> +
+
+ + 单集只下载一次 + + setFormData({ ...formData, onePerEpisode: checked })} + /> +
+
+ + 缺集重新检索 + + + setFormData({ ...formData, refillMissingEpisodes: checked }) + } + /> +
+

+ 单集只下一次:同集多种子只入队一条。缺集重新检索:首搜跳集时按「番名+集数」补搜中间集(如 02) +

过滤条件:{sub.filterText}

@@ -695,9 +806,21 @@ export default function AnimeSubscriptionComponent({

{sub.title}

- - {sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : sub.source === 'nyaa' ? 'Nyaa' : '动漫花园'} - +
+ + {sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : sub.source === 'nyaa' ? 'Nyaa' : '动漫花园'} + + {sub.onePerEpisode ? ( + + 单集×1 + + ) : null} + {sub.refillMissingEpisodes ? ( + + 缺集补搜 + + ) : null} +
{record.new_episodes && record.new_episodes > 0 && (
( rate, type = '', isBangumi = false, + isAnime = false, + typeName, isAggregate = false, origin = 'vod', releaseDate, @@ -141,6 +151,17 @@ const VideoCard = forwardRef( ref ) { const router = useRouter(); + const [showAnimeSubscribe, setShowAnimeSubscribe] = useState(false); + const [animeSubscribeToast, setAnimeSubscribeToast] = useState(''); + const isAdminUser = useMemo(() => { + const auth = getAuthInfoFromBrowserCookie(); + return auth?.role === 'admin' || auth?.role === 'owner'; + }, []); + const resolvedIsAnime = useMemo( + () => + Boolean(isBangumi || isAnime || isAnimeCategoryText(typeName)), + [isBangumi, isAnime, typeName] + ); const actualTitle = title; const actualPoster = poster; const netdiskPosterPlaceholder = useMemo(() => { @@ -870,6 +891,27 @@ const VideoCard = forwardRef( }); } + // 添加追番订阅(仅管理员 + 判定为动漫) + if ( + isAdminUser && + resolvedIsAnime && + origin !== 'live' && + actualTitle + ) { + actions.push({ + id: 'anime-subscribe', + label: '添加追番订阅', + icon: , + onClick: () => { + setShowMobileActions(false); + setTimeout(() => { + setShowAnimeSubscribe(true); + }, 250); + }, + color: 'primary' as const, + }); + } + return actions; }, [ config, @@ -893,6 +935,8 @@ const VideoCard = forwardRef( origin, tmdb_id, openTrailerPicker, + isAdminUser, + resolvedIsAnime, ]); return ( @@ -2069,6 +2113,25 @@ const VideoCard = forwardRef( /> )} + {/* 添加追番订阅(管理员) */} + setShowAnimeSubscribe(false)} + initialTitle={actualTitle} + initialLastEpisode={ + from === 'playrecord' && currentEpisode ? currentEpisode : 0 + } + onSuccess={() => { + setAnimeSubscribeToast('已添加追番订阅'); + window.setTimeout(() => setAnimeSubscribeToast(''), 2500); + }} + /> + {animeSubscribeToast ? ( +
+ {animeSubscribeToast} +
+ ) : null} + {/* 图片查看器 */} {showImageViewer && ( 简中;内嵌 > 内封(网页对 MKV/内封不友好) + */ + +export interface AnimeFansubPreset { + id: string; + /** chip 显示的短名 */ + label: string; + /** 选中后写入 filterText 的完整表达式 */ + insert: string; + hint?: string; +} + +/** 排除关键词快捷(同样单选替换) */ +export interface AnimeExcludePreset { + id: string; + label: string; + insert: string; + hint?: string; +} + +/** 字幕组快捷列表:单选,一次只选一个组 */ +export const ANIME_FANSUB_PRESETS: AnimeFansubPreset[] = [ + { + id: 'miao', + label: '喵萌奶茶屋', + insert: '喵萌奶茶屋&简日双语', + hint: '简日双语优先', + }, + { + id: 'kitauji', + label: '北宇治', + insert: '北宇治&简日内嵌', + }, + { + id: 'lvcha', + label: '绿茶字幕组', + insert: '绿茶&简日内嵌', + }, + { + id: 'boxue', + label: '拨雪寻春', + insert: '拨雪寻春&简日内嵌', + }, + { + id: 'sandwich', + label: '三明治摆烂组', + insert: '三明治摆烂组&简日内嵌', + }, + { + id: 'sakurato', + label: '桜都', + insert: '桜都&简日内嵌', + }, + { + id: 'qianxia', + label: '千夏', + insert: '千夏&简日内嵌', + }, + { + id: 'ailian', + label: '爱恋', + insert: '爱恋&简日内嵌', + }, + { + id: 'zhushen', + label: '诸神', + insert: '诸神&简中', + }, + { + id: 'youha', + label: '悠哈璃羽', + insert: '悠哈璃羽&简中', + }, + { + id: 'jiying', + label: '极影', + insert: '极影&简中', + }, + { + id: 'wandou', + label: '豌豆', + insert: '豌豆&简体', + hint: '多为简体 MP4', + }, + { + id: 'ani', + label: 'ANi', + insert: 'ANi&CHS', + hint: '默认多为繁中,已锁 CHS', + }, + { + id: 'skymoon', + label: 'Skymoon', + insert: 'Skymoon&CHS', + }, + { + id: 'lilith', + label: 'Lilith-Raws', + insert: 'Lilith-Raws&CHS', + }, + { + id: 'lolihouse', + label: 'LoliHouse', + insert: 'LoliHouse&简繁内封', + hint: '多为 MKV 内封,网页不友好', + }, +]; + +export const ANIME_EXCLUDE_PRESETS: AnimeExcludePreset[] = [ + { + id: 'preview', + label: '预告/PV', + insert: '先行|预告|PV|CM|特报|预览', + }, + { + id: 'raw', + label: '生肉', + insert: '生肉|RAW|raw', + }, + { + id: '720', + label: '720p', + insert: '720', + }, +]; + +/** + * 字幕组单选: + * - 点未选中的组 → 整段替换为该 insert + * - 再点同一组 → 清空 + */ +export function applyFansubSingleSelect( + current: string, + preset: AnimeFansubPreset +): string { + const cur = (current || '').trim(); + const ins = preset.insert.trim(); + if (cur === ins) return ''; + return ins; +} + +/** 排除快捷单选(同上) */ +export function applyExcludeSingleSelect( + current: string, + preset: AnimeExcludePreset +): string { + const cur = (current || '').trim(); + const ins = preset.insert.trim(); + if (cur === ins) return ''; + return ins; +} + +export function isFansubPresetActive( + current: string, + preset: AnimeFansubPreset +): boolean { + return (current || '').trim() === preset.insert.trim(); +} + +export function isExcludePresetActive( + current: string, + preset: AnimeExcludePreset +): boolean { + return (current || '').trim() === preset.insert.trim(); +} diff --git a/src/lib/anime-keyword-expr.ts b/src/lib/anime-keyword-expr.ts new file mode 100644 index 0000000..5880d40 --- /dev/null +++ b/src/lib/anime-keyword-expr.ts @@ -0,0 +1,313 @@ +/** + * 追番订阅关键词表达式(纯函数,可被客户端安全引用) + * + * 语法(优先级:() > & > |): + * expr := or_expr + * or_expr := and_expr ( '|' and_expr )* + * and_expr := primary ( '&' primary )* + * primary := '(' expr ')' | keyword + * + * 兼容旧数据:字符串中不含 & | ( ) 时 + * - mode 'and'(filter):逗号 = AND + * - mode 'or'(exclude):逗号 = OR + * + * 全角 &|() 会归一化为半角。 + */ + +export type KeywordExprMode = 'and' | 'or'; + +/** 判断 CMS / 分类文案是否为动漫(客户端/服务端均可) */ +export function isAnimeCategoryText( + ...parts: Array +): boolean { + const text = parts.filter(Boolean).join(' '); + if (!text) return false; + return /动画|動漫|动漫|anime|アニメ/i.test(text); +} + +type ExprNode = + | { type: 'and'; children: ExprNode[] } + | { type: 'or'; children: ExprNode[] } + | { type: 'kw'; value: string }; + +const OP_CHARS = new Set(['&', '|', '(', ')']); + +/** 是否含有表达式运算符(半角或全角) */ +export function hasExprOperators(text: string): boolean { + return /[&|()()]|&||/.test(text); +} + +function normalizeOps(text: string): string { + return text + .replace(/&/g, '&') + .replace(/|/g, '|') + .replace(/(/g, '(') + .replace(/)/g, ')'); +} + +function normalizeCommas(text: string): string { + return text.replace(/,/g, ','); +} + +/** 旧式逗号分隔关键词 */ +export function parseCommaKeywords(text: string): string[] { + return normalizeCommas(text) + .split(',') + .map((k) => k.trim()) + .filter(Boolean); +} + +type Token = + | { kind: 'op'; value: '&' | '|' | '(' | ')' } + | { kind: 'kw'; value: string }; + +function tokenize(input: string): Token[] { + const s = normalizeOps(input); + const tokens: Token[] = []; + let i = 0; + + while (i < s.length) { + const ch = s[i]; + if (/\s/.test(ch)) { + i += 1; + continue; + } + if (ch === '&' || ch === '|' || ch === '(' || ch === ')') { + tokens.push({ kind: 'op', value: ch }); + i += 1; + continue; + } + // 关键词可含空格,直到运算符为止 + let j = i; + while (j < s.length && !OP_CHARS.has(s[j])) { + j += 1; + } + const raw = s.slice(i, j).trim(); + if (raw) { + tokens.push({ kind: 'kw', value: raw }); + } + i = j; + } + + return tokens; +} + +class ParseError extends Error { + constructor(message: string) { + super(message); + this.name = 'KeywordExprParseError'; + } +} + +function parseTokens(tokens: Token[]): ExprNode { + let pos = 0; + + const peek = () => tokens[pos]; + const consume = () => { + const t = tokens[pos]; + pos += 1; + return t; + }; + + function parseOr(): ExprNode { + const parts: ExprNode[] = [parseAnd()]; + while (peek()?.kind === 'op' && peek().value === '|') { + consume(); + parts.push(parseAnd()); + } + if (parts.length === 1) return parts[0]; + return { type: 'or', children: parts }; + } + + function parseAnd(): ExprNode { + const parts: ExprNode[] = [parsePrimary()]; + while (peek()?.kind === 'op' && peek().value === '&') { + consume(); + parts.push(parsePrimary()); + } + if (parts.length === 1) return parts[0]; + return { type: 'and', children: parts }; + } + + function parsePrimary(): ExprNode { + const t = peek(); + if (!t) { + throw new ParseError('表达式不完整'); + } + if (t.kind === 'op' && t.value === '(') { + consume(); + const inner = parseOr(); + const close = consume(); + if (!close || close.kind !== 'op' || close.value !== ')') { + throw new ParseError('缺少右括号 )'); + } + return inner; + } + if (t.kind === 'kw') { + consume(); + return { type: 'kw', value: t.value }; + } + throw new ParseError(`意外的符号: ${t.value}`); + } + + if (tokens.length === 0) { + throw new ParseError('空表达式'); + } + + const root = parseOr(); + if (pos < tokens.length) { + throw new ParseError('表达式存在多余内容'); + } + return root; +} + +function evalNode(title: string, node: ExprNode): boolean { + switch (node.type) { + case 'kw': + return title.includes(node.value); + case 'and': + return node.children.every((c) => evalNode(title, c)); + case 'or': + return node.children.some((c) => evalNode(title, c)); + default: + return false; + } +} + +/** + * 解析并匹配关键词表达式。 + * @param mode 无运算符时的逗号语义:filter 用 and,exclude 用 or + * @returns 匹配结果;非法表达式时 match=false 且带 error + */ +export function matchKeywordExpr( + title: string, + exprText: string | undefined | null, + mode: KeywordExprMode +): { match: boolean; error?: string } { + if (exprText == null || !String(exprText).trim()) { + // filter 空 = 全过;exclude 空 = 不排除 + return { match: mode === 'and' }; + } + + const text = String(exprText).trim(); + + try { + if (!hasExprOperators(text)) { + const keywords = parseCommaKeywords(text); + if (keywords.length === 0) { + return { match: mode === 'and' }; + } + if (mode === 'and') { + return { match: keywords.every((k) => title.includes(k)) }; + } + return { match: keywords.some((k) => title.includes(k)) }; + } + + const tokens = tokenize(text); + if (tokens.length === 0) { + return { match: mode === 'and' }; + } + const ast = parseTokens(tokens); + return { match: evalNode(title, ast) }; + } catch (e) { + const message = e instanceof Error ? e.message : '表达式解析失败'; + return { match: false, error: message }; + } +} + +/** 包含关键词(filter):空=通过;非法表达式=不通过 */ +export function matchesFilter(title: string, filterText: string): boolean { + if (!filterText) return true; + const result = matchKeywordExpr(title, filterText, 'and'); + if (result.error) { + console.warn(`[AnimeSubscription] 过滤表达式无效: ${result.error} | ${filterText}`); + } + return result.match; +} + +/** 排除关键词(exclude):空=不排除;命中=true 表示应跳过 */ +export function matchesExclude(title: string, excludeText?: string): boolean { + if (!excludeText) return false; + const result = matchKeywordExpr(title, excludeText, 'or'); + if (result.error) { + console.warn(`[AnimeSubscription] 排除表达式无效: ${result.error} | ${excludeText}`); + // 非法排除式:保守起见不排除(避免误杀全部),但已打日志 + return false; + } + return result.match; +} + +/** 校验表达式是否可解析(供 API/UI) */ +export function validateKeywordExpr( + exprText: string | undefined | null, + mode: KeywordExprMode = 'and' +): { ok: boolean; error?: string } { + if (exprText == null || !String(exprText).trim()) { + return { ok: true }; + } + const text = String(exprText).trim(); + if (!hasExprOperators(text)) { + return { ok: true }; + } + try { + const tokens = tokenize(text); + if (tokens.length === 0) return { ok: true }; + parseTokens(tokens); + return { ok: true }; + } catch (e) { + return { + ok: false, + error: e instanceof Error ? e.message : '表达式解析失败', + }; + } +} + +// --------------------------------------------------------------------------- +// 单集只下一次:同集择优 +// --------------------------------------------------------------------------- + +/** 网页友好打分:内嵌 > 内封,简日双语 > 简中 */ +export function scoreTorrentTitle(title: string): number { + let score = 0; + if (/简日双语|简日雙語/.test(title)) score += 5; + else if (/简日内嵌|簡日內嵌/.test(title)) score += 4; + else if (/简中|简体|CHS|GB/i.test(title)) score += 3; + else if (/简日/.test(title)) score += 2; + + if (/内嵌|內嵌/.test(title)) score += 4; + else if (/内封|內封/.test(title)) score += 1; + + if (/1080/.test(title)) score += 2; + else if (/720/.test(title)) score -= 1; + + // MP4 略优于默认(网页更友好) + if (/MP4|mp4/.test(title)) score += 1; + + return score; +} + +export interface EpisodeCandidate { + episode: number; + title: string; + [key: string]: unknown; +} + +/** + * 每个集数只保留打分最高的一条(同分保留先出现的) + */ +export function pickOnePerEpisode(items: T[]): T[] { + const best = new Map(); + for (const item of items) { + const prev = best.get(item.episode); + if (!prev) { + best.set(item.episode, item); + continue; + } + const sNew = scoreTorrentTitle(item.title); + const sOld = scoreTorrentTitle(prev.title); + if (sNew > sOld) { + best.set(item.episode, item); + } + } + return Array.from(best.values()).sort((a, b) => a.episode - b.episode); +} diff --git a/src/lib/anime-subscription.ts b/src/lib/anime-subscription.ts index fc7e5b2..0d37343 100644 --- a/src/lib/anime-subscription.ts +++ b/src/lib/anime-subscription.ts @@ -2,6 +2,11 @@ import parseTorrentName from 'parse-torrent-name'; import { parseStringPromise } from 'xml2js'; +import { + matchesExclude, + matchesFilter, + pickOnePerEpisode, +} from '@/lib/anime-keyword-expr'; import { getConfig, setCachedConfig } from '@/lib/config'; import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client'; import { db, getStorage } from '@/lib/db'; @@ -13,6 +18,16 @@ import { } from '@/lib/openlist-offline-download'; import { AnimeSubscription, AnimeSubscriptionDownloadTool } from '@/types/anime-subscription'; +// 兼容外部从本模块引用匹配工具(仅服务端使用本文件;客户端请直接 import anime-keyword-expr) +export { + isAnimeCategoryText, + matchesExclude, + matchesFilter, + pickOnePerEpisode, + scoreTorrentTitle, + validateKeywordExpr, +} from '@/lib/anime-keyword-expr'; + const downloadTools: AnimeSubscriptionDownloadTool[] = ['aria2', 'qBittorrent', 'Transmission']; const pickRssText = (value: any): string => { @@ -25,10 +40,51 @@ const pickRssText = (value: any): string => { function getAnimeSubscriptionDownloadTool(tool: unknown): AnimeSubscriptionDownloadTool { return typeof tool === 'string' && downloadTools.includes(tool as AnimeSubscriptionDownloadTool) - ? tool as AnimeSubscriptionDownloadTool + ? (tool as AnimeSubscriptionDownloadTool) : 'aria2'; } +/** + * 搜索用集数 token:个位数补零(2 → 02),≥10 原样 + */ +export function formatEpisodeSearchToken(episode: number): string { + if (!Number.isFinite(episode) || episode < 0) return ''; + const n = Math.floor(episode); + return n < 10 ? String(n).padStart(2, '0') : String(n); +} + +/** + * 标题是否明确包含目标集数(避免 1080/720/年份等误命中) + * 认可形态示例:[02]、[2]、第02集、EP02、E02、 - 02 [ + */ +export function titleContainsEpisode(title: string, episode: number): boolean { + if (!title || !Number.isFinite(episode) || episode <= 0) return false; + const ep = Math.floor(episode); + const padded = formatEpisodeSearchToken(ep); + const raw = String(ep); + + // 先挖掉分辨率/常见非集数数字,降低误判 + const cleaned = title + .replace(/(?:^|[^0-9])(?:240|360|480|720|1080|1440|2160|4k|8k)(?:p|P|i|I)?(?![0-9])/g, ' ') + .replace(/(?:19|20)\d{2}/g, ' '); // 年份 + + const patterns: RegExp[] = [ + new RegExp(`\\[0*${ep}\\]`), // [02] [2] + new RegExp(`第0*${ep}[集话話]`), + new RegExp(`(?:^|[^A-Za-z0-9])EP?0*${ep}(?![0-9])`, 'i'), // EP02 E02 + new RegExp(`(?:^|[^0-9])0*${ep}(?=\\s*[\\]\\-–—_]|\\s+\\[)`), // 02] / 02 - / 02 [ + new RegExp(`[-–—_]\\s*0*${ep}(?![0-9])`), // - 02 + new RegExp(`\\s0*${ep}\\s`), // 空格02空格 + ]; + + // padded 与 raw 在部分形态下等价(上面已用 0*ep);额外允许字面 [02] + if (padded !== raw) { + patterns.push(new RegExp(`\\[${padded}\\]`)); + } + + return patterns.some((re) => re.test(cleaned) || re.test(title)); +} + /** * 从标题中提取集数 */ @@ -36,59 +92,157 @@ export function extractEpisode(title: string): number | null { const parsed = parseTorrentName(title); if (parsed.episode) { - return parsed.episode; + const ep = Number(parsed.episode); + // 过滤明显非集数(分辨率等) + if (ep > 0 && ep < 1000 && ![480, 720, 1080, 1440, 2160].includes(ep)) { + if (titleContainsEpisode(title, ep) || ep < 100) { + return ep; + } + } } - // 备用正则匹配 - const patterns = [ - /\[(\d+)\]/, // [01] - /第(\d+)[集话]/, // 第01集 - /EP?(\d+)/i, // EP01, E01 - /\s(\d+)\s/, // 空格01空格 + // 备用正则匹配(带集数语义,避免裸数字) + const patterns: Array<[RegExp, number]> = [ + [/\[(\d{1,3})\]/, 1], // [01] + [/第(\d{1,3})[集话話]/, 1], // 第01集 + [/(?:^|[^A-Za-z0-9])EP?(\d{1,3})(?![0-9])/i, 1], // EP01, E01 + [/[-–—_]\s*(\d{1,3})(?![0-9])/, 1], // - 01 + [/\s(\d{1,3})\s/, 1], // 空格01空格(最后兜底) ]; - for (const pattern of patterns) { + for (const [pattern] of patterns) { const match = title.match(pattern); if (match) { - return parseInt(match[1], 10); + const ep = parseInt(match[1], 10); + if ( + !Number.isFinite(ep) || + ep <= 0 || + ep >= 1000 || + [480, 720, 1080, 1440, 2160].includes(ep) + ) { + continue; + } + // 空格数字兜底时必须再过 titleContainsEpisode,降低误伤 + if (pattern.source.includes('\\s') && !titleContainsEpisode(title, ep)) { + continue; + } + return ep; } } return null; } -/** - * 解析逗号分隔关键词(兼容中文逗号) - */ -function parseKeywords(text: string): string[] { - return text - .replace(/,/g, ',') - .split(',') - .map((k) => k.trim()) - .filter(Boolean); +type AcgSearchItem = { + title: string; + link?: string; + guid?: string; + pubDate?: string; + torrentUrl?: string; + description?: string; + episode?: number | null; +}; + +function filterAndParseEpisodes( + results: AcgSearchItem[], + subscription: AnimeSubscription, + opts?: { onlyEpisode?: number; minEpisodeExclusive?: number } +): AcgSearchItem[] { + const only = opts?.onlyEpisode; + const minExclusive = opts?.minEpisodeExclusive ?? -Infinity; + + return results + .filter((item) => matchesFilter(item.title, subscription.filterText)) + .filter((item) => !matchesExclude(item.title, subscription.excludeText)) + .map((item) => { + const episode = extractEpisode(item.title); + return { ...item, episode }; + }) + .filter((item) => { + if (!item.episode) return false; + if (only != null) { + return ( + item.episode === only && titleContainsEpisode(item.title, only) + ); + } + return item.episode > minExclusive; + }) + .sort((a, b) => (a.episode || 0) - (b.episode || 0)); } /** - * 检查标题是否匹配过滤条件(包含关键词,AND:必须全部命中) + * 缺集补搜:在 (lastEpisode, maxFound] 内对未命中集按「番名 + 补零集数」再搜 */ -export function matchesFilter(title: string, filterText: string): boolean { - if (!filterText) return true; +async function refillMissingEpisodeResults( + subscription: AnimeSubscription, + existing: AcgSearchItem[] +): Promise { + const last = subscription.lastEpisode || 0; + const foundEps = new Set( + existing + .map((i) => i.episode) + .filter((ep): ep is number => typeof ep === 'number' && ep > last) + ); + if (foundEps.size === 0) return existing; - // 支持多个关键词,用逗号分隔,必须全部匹配 - const keywords = parseKeywords(filterText); + const maxFound = Math.max(...Array.from(foundEps)); + const missing: number[] = []; + for (let ep = last + 1; ep <= maxFound; ep += 1) { + if (!foundEps.has(ep)) missing.push(ep); + } + if (missing.length === 0) return existing; - return keywords.every((keyword) => title.includes(keyword)); -} + // 单次检查最多补搜 24 集,避免源站压力过大 + const toSearch = missing.slice(0, 24); + console.log( + `[AnimeSubscription] ${subscription.title}: 缺集重新检索 ${toSearch.join( + ',' + )}(上限内;总缺 ${missing.length})` + ); -/** - * 检查标题是否命中排除关键词(OR:任一命中即排除) - */ -export function matchesExclude(title: string, excludeText?: string): boolean { - if (!excludeText) return false; + const merged = [...existing]; + const haveEp = new Set(foundEps); - const keywords = parseKeywords(excludeText); + for (const ep of toSearch) { + const token = formatEpisodeSearchToken(ep); + const keyword = `${subscription.title} ${token}`.trim(); + try { + const results = await searchACG(keyword, subscription.source); + const matched = filterAndParseEpisodes(results, subscription, { + onlyEpisode: ep, + }); + if (matched.length === 0) { + console.log( + `[AnimeSubscription] ${subscription.title}: 补搜「${keyword}」未命中第${ep}集` + ); + continue; + } + for (const item of matched) { + if (item.episode && !haveEp.has(item.episode)) { + // 同集先都放进池子,后续 onePerEpisode 再择优 + } + merged.push(item); + } + haveEp.add(ep); + console.log( + `[AnimeSubscription] ${subscription.title}: 补搜第${ep}集命中 ${matched.length} 条` + ); + } catch (err) { + console.error( + `[AnimeSubscription] ${subscription.title}: 补搜第${ep}集失败`, + err + ); + } + } - return keywords.some((keyword) => title.includes(keyword)); + return merged + .filter( + (item) => + item.episode && + item.episode > last && + titleContainsEpisode(item.title, item.episode) + ) + .sort((a, b) => (a.episode || 0) - (b.episode || 0)); } /** @@ -340,20 +494,43 @@ export async function checkSubscription(subscription: AnimeSubscription) { // 1. 搜索资源 const results = await searchACG(subscription.title, subscription.source); - // 2. 过滤并解析集数(包含关键词 AND,排除关键词 OR) - const newEpisodes = results - .filter((item: any) => matchesFilter(item.title, subscription.filterText)) - .filter((item: any) => !matchesExclude(item.title, subscription.excludeText)) - .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!); + // 2. 过滤并解析集数(关键词支持 & | ();旧逗号兼容) + let newEpisodes = filterAndParseEpisodes(results, subscription, { + minEpisodeExclusive: subscription.lastEpisode, + }); + + // 2a. 缺集重新检索(可选):首搜跳集时按「番名 + 补零集数」补搜中间集 + if (subscription.refillMissingEpisodes) { + newEpisodes = await refillMissingEpisodeResults(subscription, newEpisodes); + } + + // 2b. 单集只下载一次(每条订阅可选,默认关) + if (subscription.onePerEpisode) { + const before = newEpisodes.length; + newEpisodes = pickOnePerEpisode( + newEpisodes.filter( + (item): item is AcgSearchItem & { episode: number; title: string } => + typeof item.episode === 'number' && !!item.title + ) + ); + if (before > newEpisodes.length) { + console.log( + `[AnimeSubscription] ${subscription.title}: 单集只下一次,${before} → ${newEpisodes.length} 条` + ); + for (const item of newEpisodes) { + console.log( + `[AnimeSubscription] ${subscription.title}: 第${item.episode}集选用「${item.title}」` + ); + } + } + } // 3. 下载新集数 - const downloaded = []; + const downloaded: number[] = []; for (const item of newEpisodes) { + if (typeof item.episode !== 'number' || !item.torrentUrl) { + continue; + } try { const downloadPath = joinOpenListPath( getOfflineDownloadBasePath(config), @@ -362,7 +539,7 @@ export async function checkSubscription(subscription: AnimeSubscription) { await addOfflineDownload(item.torrentUrl, downloadPath); // 成功后更新 lastEpisode - subscription.lastEpisode = item.episode!; + subscription.lastEpisode = item.episode; downloaded.push(item.episode); console.log( diff --git a/src/lib/d1.db.ts b/src/lib/d1.db.ts index d5f2187..78a0485 100644 --- a/src/lib/d1.db.ts +++ b/src/lib/d1.db.ts @@ -116,9 +116,9 @@ export class D1Storage implements IStorage { INSERT INTO play_records ( username, key, title, source_name, cover, year, episode_index, total_episodes, play_time, total_time, - save_time, search_title, new_episodes + save_time, search_title, new_episodes, is_anime ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(username, key) DO UPDATE SET title = excluded.title, source_name = excluded.source_name, @@ -130,7 +130,8 @@ export class D1Storage implements IStorage { total_time = excluded.total_time, save_time = excluded.save_time, search_title = excluded.search_title, - new_episodes = excluded.new_episodes + new_episodes = excluded.new_episodes, + is_anime = excluded.is_anime ` ) .bind( @@ -146,7 +147,8 @@ export class D1Storage implements IStorage { record.total_time, record.save_time, record.search_title || '', - record.new_episodes || null + record.new_episodes || null, + record.is_anime ? 1 : 0 ) .run(); } catch (err) { @@ -1239,6 +1241,7 @@ export class D1Storage implements IStorage { save_time: row.save_time, search_title: row.search_title || '', new_episodes: row.new_episodes || undefined, + is_anime: row.is_anime === 1 || row.is_anime === true, }; } diff --git a/src/lib/db.client.ts b/src/lib/db.client.ts index caab615..fdd3e62 100644 --- a/src/lib/db.client.ts +++ b/src/lib/db.client.ts @@ -44,6 +44,8 @@ export interface PlayRecord { search_title?: string; // 搜索时使用的标题 origin?: 'vod' | 'live'; // 来源类型 new_episodes?: number; // 新增的剧集数量(用于显示更新提示) + /** 是否动漫(写入时根据 CMS type_name/class 判断) */ + is_anime?: boolean; } // ---- 收藏类型 ---- diff --git a/src/lib/postgres.db.ts b/src/lib/postgres.db.ts index 2f60f39..b6e9e66 100644 --- a/src/lib/postgres.db.ts +++ b/src/lib/postgres.db.ts @@ -109,9 +109,9 @@ export class PostgresStorage implements IStorage { INSERT INTO play_records ( username, key, title, source_name, cover, year, episode_index, total_episodes, play_time, total_time, - save_time, search_title, new_episodes + save_time, search_title, new_episodes, is_anime ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (username, key) DO UPDATE SET title = EXCLUDED.title, source_name = EXCLUDED.source_name, @@ -123,7 +123,8 @@ export class PostgresStorage implements IStorage { total_time = EXCLUDED.total_time, save_time = EXCLUDED.save_time, search_title = EXCLUDED.search_title, - new_episodes = EXCLUDED.new_episodes + new_episodes = EXCLUDED.new_episodes, + is_anime = EXCLUDED.is_anime ` ) .bind( @@ -139,7 +140,8 @@ export class PostgresStorage implements IStorage { record.total_time, record.save_time, record.search_title || '', - record.new_episodes || null + record.new_episodes || null, + record.is_anime ? 1 : 0 ) .run(); } catch (err) { @@ -390,6 +392,7 @@ export class PostgresStorage implements IStorage { save_time: row.save_time, search_title: row.search_title || '', new_episodes: row.new_episodes || undefined, + is_anime: row.is_anime === 1 || row.is_anime === true, }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index 28700a0..06cc49d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -15,6 +15,9 @@ export interface PlayRecord { save_time: number; // 记录保存时间(时间戳) search_title: string; // 搜索时使用的标题 new_episodes?: number; // 新增的剧集数量(用于显示更新提示) + origin?: 'vod' | 'live'; + /** 是否动漫(写入时根据 CMS type_name/class 判断) */ + is_anime?: boolean; } // 收藏数据结构 diff --git a/src/types/anime-subscription.ts b/src/types/anime-subscription.ts index 7a0059c..d843db3 100644 --- a/src/types/anime-subscription.ts +++ b/src/types/anime-subscription.ts @@ -1,11 +1,29 @@ export interface AnimeSubscription { id: string; title: string; + /** + * 包含关键词表达式。 + * 支持 &(且)|(或)();无运算符时逗号为 AND(兼容旧数据)。 + * 例:喵萌奶茶屋&(简日双语|简日内嵌) + */ filterText: string; - /** 排除关键词,逗号分隔;标题包含任一关键词则跳过,例如:先行版,预告 */ + /** + * 排除关键词表达式。 + * 支持 & | ();无运算符时逗号为 OR(兼容旧数据)。 + * 例:先行|预告|PV + */ excludeText?: string; source: 'acgrip' | 'mikan' | 'dmhy' | 'nyaa'; enabled: boolean; + /** + * 单集只下载一次:同一集匹配到多个种子时只入队一条(可选,默认 false) + */ + onePerEpisode?: boolean; + /** + * 缺集重新检索:首搜若跳集(如已看到 1,结果只有 11/12), + * 则对中间缺集按「番名 + 补零集数」再搜(可选,默认 false) + */ + refillMissingEpisodes?: boolean; lastCheckTime: number; lastEpisode: number; createdAt: number;