增强追番订阅:关键词表达式、字幕组快捷、右键添加与缺集补搜

- 过滤/排除支持 & | (),兼容旧逗号语义
- 字幕组快捷单选填入;同名订阅拒绝重复
- 单集只下一次、缺集按「番名+补零集数」重搜
- VideoCard 管理员可添加追番;PlayRecord 增加 is_anime 及迁移
This commit is contained in:
mtvpls
2026-07-26 14:48:58 +08:00
parent ca798177b9
commit 333c482e19
23 changed files with 1433 additions and 103 deletions
@@ -0,0 +1,5 @@
-- ============================================
-- 播放记录增加 is_anime 字段(追番订阅/继续观看识别)
-- ============================================
ALTER TABLE play_records ADD COLUMN is_anime INTEGER DEFAULT 0;
@@ -0,0 +1,5 @@
-- ============================================
-- 播放记录增加 is_anime 字段(追番订阅/继续观看识别)
-- ============================================
ALTER TABLE play_records ADD COLUMN is_anime INTEGER DEFAULT 0;
@@ -2,6 +2,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { validateKeywordExpr } from '@/lib/anime-keyword-expr';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
@@ -35,14 +36,47 @@ export async function PUT(
// 更新字段 // 更新字段
if (updates.title !== undefined) { 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) { 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(); subscription.filterText = updates.filterText.trim();
} }
if (updates.excludeText !== undefined) { if (updates.excludeText !== undefined) {
subscription.excludeText = const rawExclude =
typeof updates.excludeText === 'string' ? updates.excludeText.trim() : ''; 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 (updates.source !== undefined) {
if (!['acgrip', 'mikan', 'dmhy', 'nyaa'].includes(updates.source)) { if (!['acgrip', 'mikan', 'dmhy', 'nyaa'].includes(updates.source)) {
@@ -53,6 +87,14 @@ export async function PUT(
if (updates.enabled !== undefined) { if (updates.enabled !== undefined) {
subscription.enabled = updates.enabled; 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) { if (updates.lastEpisode !== undefined) {
// 验证集数为非负整数 // 验证集数为非负整数
const episode = parseInt(String(updates.lastEpisode), 10); const episode = parseInt(String(updates.lastEpisode), 10);
+49 -3
View File
@@ -2,6 +2,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { validateKeywordExpr } from '@/lib/anime-keyword-expr';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { AnimeSubscription } from '@/types/anime-subscription'; import { AnimeSubscription } from '@/types/anime-subscription';
@@ -52,8 +53,16 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: '无权限访问' }, { status: 403 }); return NextResponse.json({ error: '无权限访问' }, { status: 403 });
} }
const { title, filterText, excludeText, source, enabled, lastEpisode } = const {
await req.json(); title,
filterText,
excludeText,
source,
enabled,
lastEpisode,
onePerEpisode,
refillMissingEpisodes,
} = await req.json();
// 验证必填字段 // 验证必填字段
if (!title || !filterText || !source) { if (!title || !filterText || !source) {
@@ -65,6 +74,23 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: '无效的搜索源' }, { status: 400 }); 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(); const config = await getConfig();
if (!config.AnimeSubscriptionConfig) { if (!config.AnimeSubscriptionConfig) {
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 = { const newSubscription: AnimeSubscription = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: title.trim(), title: normalizedTitle,
filterText: filterText.trim(), filterText: filterText.trim(),
excludeText: typeof excludeText === 'string' ? excludeText.trim() : '', excludeText: typeof excludeText === 'string' ? excludeText.trim() : '',
source, source,
enabled: enabled ?? true, enabled: enabled ?? true,
onePerEpisode: Boolean(onePerEpisode),
refillMissingEpisodes: Boolean(refillMissingEpisodes),
lastCheckTime: 0, lastCheckTime: 0,
lastEpisode: episodeNum, lastEpisode: episodeNum,
createdAt: Date.now(), createdAt: Date.now(),
+1
View File
@@ -483,6 +483,7 @@ async function refreshRecordAndFavorites() {
save_time: record.save_time, save_time: record.save_time,
search_title: record.search_title, search_title: record.search_title,
new_episodes: updatedNewEpisodes > 0 ? updatedNewEpisodes : undefined, new_episodes: updatedNewEpisodes > 0 ? updatedNewEpisodes : undefined,
is_anime: record.is_anime,
}); });
console.log( console.log(
`更新播放记录: ${record.title} (${record.total_episodes} -> ${episodeCount}, 新增 ${newEpisodesCount} 集)` `更新播放记录: ${record.title} (${record.total_episodes} -> ${episodeCount}, 新增 ${newEpisodesCount} 集)`
+1
View File
@@ -830,6 +830,7 @@ function DoubanPageClient() {
isBangumi={ isBangumi={
type === 'anime' && primarySelection === '每日放送' type === 'anime' && primarySelection === '每日放送'
} }
isAnime={type === 'anime'}
/> />
</div> </div>
))} ))}
+5
View File
@@ -6,6 +6,7 @@ import { AlertCircle, Cloud, Heart, Keyboard, Loader2, Router, Sparkles, X } fro
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useMemo, useRef, useState } from 'react'; import { Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { isAnimeCategoryText } from '@/lib/anime-keyword-expr';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import { import {
clearDanmakuCacheByTitle, clearDanmakuCacheByTitle,
@@ -6580,6 +6581,10 @@ function PlayPageClient() {
total_time: Math.floor(duration), total_time: Math.floor(duration),
save_time: Date.now(), save_time: Date.now(),
search_title: searchTitle, search_title: searchTitle,
is_anime: isAnimeCategoryText(
detailRef.current?.type_name,
detailRef.current?.class
),
}); });
lastSavedPlayTimeRef.current = playTime; lastSavedPlayTimeRef.current = playTime;
+15
View File
@@ -23,6 +23,7 @@ import React, {
} from 'react'; } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { isAnimeCategoryText } from '@/lib/anime-keyword-expr';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import { import {
addSearchHistory, addSearchHistory,
@@ -2109,6 +2110,14 @@ function SearchPageClient() {
: '' : ''
} }
type={type} 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
}
/> />
</div> </div>
); );
@@ -2116,6 +2125,10 @@ function SearchPageClient() {
: filteredAllResults.map((item) => { : filteredAllResults.map((item) => {
const type = const type =
item.episodes.length > 1 ? 'tv' : 'movie'; item.episodes.length > 1 ? 'tv' : 'movie';
const itemIsAnime = isAnimeCategoryText(
item.type_name,
item.class
);
if (resultDisplayMode === 'list') { if (resultDisplayMode === 'list') {
return renderListItem({ return renderListItem({
@@ -2162,6 +2175,8 @@ function SearchPageClient() {
year={item.year} year={item.year}
from='search' from='search'
type={type} type={type}
isAnime={itemIsAnime}
typeName={item.type_name || item.class}
/> />
</div> </div>
); );
+6
View File
@@ -4,6 +4,7 @@
import { Loader2, Search } from 'lucide-react'; import { Loader2, Search } from 'lucide-react';
import { Suspense, useEffect, useRef, useState } from 'react'; import { Suspense, useEffect, useRef, useState } from 'react';
import { isAnimeCategoryText } from '@/lib/anime-keyword-expr';
import { ApiSite } from '@/lib/config'; import { ApiSite } from '@/lib/config';
import { appendSpecialSourceParam } from '@/lib/special-source.client'; import { appendSpecialSourceParam } from '@/lib/special-source.client';
import { SearchResult } from '@/lib/types'; import { SearchResult } from '@/lib/types';
@@ -359,6 +360,11 @@ function SourceSearchPageClient() {
year={item.year} year={item.year}
from='source-search' from='source-search'
type={item.episodes.length > 1 ? 'tv' : 'movie'} type={item.episodes.length > 1 ? 'tv' : 'movie'}
isAnime={isAnimeCategoryText(
item.type_name,
item.class
)}
typeName={item.type_name || item.class}
cmsData={{ cmsData={{
desc: item.desc, desc: item.desc,
episodes: item.episodes, episodes: item.episodes,
+2
View File
@@ -43,6 +43,7 @@ import {
saveDanmakuDisplayState, saveDanmakuDisplayState,
searchAnime, searchAnime,
} from '@/lib/danmaku/api'; } from '@/lib/danmaku/api';
import { isAnimeCategoryText } from '@/lib/anime-keyword-expr';
import { import {
deleteFavorite, deleteFavorite,
generateStorageKey, generateStorageKey,
@@ -832,6 +833,7 @@ function TVPlayClient() {
total_time: totalTime, total_time: totalTime,
save_time: Date.now(), save_time: Date.now(),
search_title: title || detail.title, search_title: title || detail.title,
is_anime: isAnimeCategoryText(detail.type_name, detail.class),
}).catch(() => undefined); }).catch(() => undefined);
}; };
+321
View File
@@ -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(
<div className='fixed inset-0 z-[10000] flex items-center justify-center p-4'>
<div
className={`absolute inset-0 bg-black transition-opacity duration-200 ${
visible ? 'opacity-50' : 'opacity-0'
}`}
onClick={onClose}
/>
<div
className={`relative w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-xl bg-white dark:bg-gray-800 shadow-xl transition-all duration-200 ${
visible ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
}`}
>
<div className='sticky top-0 z-10 flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'>
<h3 className='text-base font-semibold text-gray-900 dark:text-white'>
</h3>
<button
type='button'
onClick={onClose}
className='p-1 text-gray-500 hover:text-gray-800 dark:hover:text-gray-200'
>
<X size={18} />
</button>
</div>
<div className='p-4 space-y-3'>
<p className='text-xs text-gray-500 dark:text-gray-400'>
ACG 线
</p>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
*
</label>
<input
value={form.title}
onChange={(e) => 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='搜索用的番剧名'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
*
</label>
<input
value={form.filterText}
onChange={(e) => 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='喵萌奶茶屋&简日双语'
/>
<p className='mt-1 text-[11px] text-gray-400'></p>
<div className='mt-1.5 flex flex-wrap gap-1.5'>
{ANIME_FANSUB_PRESETS.map((p) => (
<button
key={p.id}
type='button'
title={p.hint ? `${p.insert}\n${p.hint}` : p.insert}
onClick={() => handleFansubSelect(p)}
className={chipClass(isFansubPresetActive(form.filterText, p))}
>
{p.label}
</button>
))}
</div>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
</label>
<input
value={form.excludeText}
onChange={(e) => 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'
/>
<div className='mt-2 flex flex-wrap gap-1.5'>
{ANIME_EXCLUDE_PRESETS.map((p) => (
<button
key={p.id}
type='button'
title={p.insert}
onClick={() => handleExcludeSelect(p)}
className={chipClass(isExcludePresetActive(form.excludeText, p))}
>
{p.label}
</button>
))}
</div>
</div>
<div className='grid grid-cols-2 gap-3'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
</label>
<select
value={form.source}
onChange={(e) =>
setForm({ ...form, source: e.target.value as SourceType })
}
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'
>
<option value='mikan'></option>
<option value='acgrip'>ACG.RIP</option>
<option value='dmhy'></option>
<option value='nyaa'>Nyaa</option>
</select>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
</label>
<input
type='number'
min={0}
value={form.lastEpisode}
onChange={(e) =>
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'
/>
</div>
</div>
<label className='flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300'>
<input
type='checkbox'
checked={form.onePerEpisode}
onChange={(e) =>
setForm({ ...form, onePerEpisode: e.target.checked })
}
className='rounded border-gray-300'
/>
</label>
<label className='flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300'>
<input
type='checkbox'
checked={form.refillMissingEpisodes}
onChange={(e) =>
setForm({ ...form, refillMissingEpisodes: e.target.checked })
}
className='rounded border-gray-300'
/>
+
</label>
{error ? (
<p className='text-sm text-red-600 dark:text-red-400'>{error}</p>
) : null}
<div className='flex justify-end gap-2 pt-1'>
<button
type='button'
onClick={onClose}
disabled={loading}
className='px-4 py-2 rounded-lg text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200'
>
</button>
<button
type='button'
onClick={handleSubmit}
disabled={loading}
className='px-4 py-2 rounded-lg text-sm bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 flex items-center gap-2'
>
{loading ? <Loader2 size={16} className='animate-spin' /> : null}
</button>
</div>
</div>
</div>
</div>,
document.body
);
}
+166 -43
View File
@@ -5,6 +5,16 @@ import { AlertCircle, Loader2, Plus, RefreshCw, Trash2, X } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; 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 { AdminConfig } from '@/lib/admin.types';
import { AnimeSubscription, AnimeSubscriptionDownloadTool } from '@/types/anime-subscription'; import { AnimeSubscription, AnimeSubscriptionDownloadTool } from '@/types/anime-subscription';
@@ -183,6 +193,8 @@ export default function AnimeSubscriptionComponent({
source: 'mikan' as 'acgrip' | 'mikan' | 'dmhy' | 'nyaa', source: 'mikan' as 'acgrip' | 'mikan' | 'dmhy' | 'nyaa',
lastEpisode: 0, lastEpisode: 0,
enabled: true, enabled: true,
onePerEpisode: false,
refillMissingEpisodes: false,
}); });
// 加载配置 // 加载配置
@@ -203,6 +215,8 @@ export default function AnimeSubscriptionComponent({
source: 'mikan', source: 'mikan',
lastEpisode: 0, lastEpisode: 0,
enabled: true, enabled: true,
onePerEpisode: false,
refillMissingEpisodes: false,
}); });
setEditingSubscription(null); setEditingSubscription(null);
setShowAddForm(false); setShowAddForm(false);
@@ -279,11 +293,34 @@ export default function AnimeSubscriptionComponent({
source: sub.source, source: sub.source,
lastEpisode: sub.lastEpisode, lastEpisode: sub.lastEpisode,
enabled: sub.enabled, enabled: sub.enabled,
onePerEpisode: Boolean(sub.onePerEpisode),
refillMissingEpisodes: Boolean(sub.refillMissingEpisodes),
}); });
setEditingSubscription(sub); setEditingSubscription(sub);
setShowAddForm(false); 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 () => { const handleSave = async () => {
if (!formData.title.trim() || !formData.filterText.trim()) { if (!formData.title.trim() || !formData.filterText.trim()) {
@@ -307,7 +344,8 @@ export default function AnimeSubscriptionComponent({
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('更新订阅失败'); const data = await response.json().catch(() => ({}));
throw new Error(data.error || '更新订阅失败');
} }
} else { } else {
// 创建 // 创建
@@ -318,7 +356,8 @@ export default function AnimeSubscriptionComponent({
}); });
if (!response.ok) { 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({
<div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'> <div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'>
<p> </p> <p> </p>
<p> OpenList离线下载根目录//</p> <p> OpenList离线下载根目录//</p>
<p> </p> <p>
<p> ,,PV</p> / <code className='text-xs'>&amp;</code>
<code className='text-xs'>|</code>
<code className='text-xs'>()</code>==
</p>
<p> /</p>
<p> </p>
<p> </p> <p> </p>
</div> </div>
</div> </div>
@@ -513,33 +557,52 @@ export default function AnimeSubscriptionComponent({
</button> </button>
</div> </div>
<div className='space-y-4'> <div className='space-y-4'>
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'> <div>
<div> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'> *
* </label>
</label> <input
<input type='text'
type='text' value={formData.title}
value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })}
onChange={(e) => setFormData({ ...formData, title: e.target.value })} placeholder='葬送的芙莉莲'
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'
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' />
/> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
</div> ACG
<div> </p>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'> </div>
* <div>
</label> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
<input *
type='text' </label>
value={formData.filterText} <input
onChange={(e) => setFormData({ ...formData, filterText: e.target.value })} type='text'
placeholder='简体,喵萌奶茶屋' value={formData.filterText}
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' onChange={(e) => setFormData({ ...formData, filterText: e.target.value })}
/> placeholder='喵萌奶茶屋&(简日双语|简日内嵌)'
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> 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'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
&amp; | ()
</p>
<div className='mt-2'>
<p className='text-[11px] text-gray-400 dark:text-gray-500 mb-1'>
</p> </p>
<div className='flex flex-wrap gap-1.5'>
{ANIME_FANSUB_PRESETS.map((p) => (
<button
key={p.id}
type='button'
title={p.hint ? `${p.insert}\n${p.hint}` : p.insert}
onClick={() => handleFansubSelect(p)}
className={chipClass(isFansubPresetActive(formData.filterText, p))}
>
{p.label}
</button>
))}
</div>
</div> </div>
</div> </div>
<div> <div>
@@ -550,12 +613,25 @@ export default function AnimeSubscriptionComponent({
type='text' type='text'
value={formData.excludeText} value={formData.excludeText}
onChange={(e) => setFormData({ ...formData, excludeText: e.target.value })} 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' 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'
/> />
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'> <p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
&amp; | ()
</p> </p>
<div className='mt-2 flex flex-wrap gap-1.5'>
{ANIME_EXCLUDE_PRESETS.map((p) => (
<button
key={p.id}
type='button'
title={p.insert}
onClick={() => handleExcludeSelect(p)}
className={chipClass(isExcludePresetActive(formData.excludeText, p))}
>
{p.label}
</button>
))}
</div>
</div> </div>
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'> <div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
<div> <div>
@@ -589,15 +665,40 @@ export default function AnimeSubscriptionComponent({
</p> </p>
</div> </div>
</div> </div>
<div className='flex items-center gap-3'> <div className='flex flex-col sm:flex-row sm:items-center gap-4 flex-wrap'>
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'> <div className='flex items-center gap-3'>
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</span>
<Switch </span>
checked={formData.enabled} <Switch
onChange={(checked) => setFormData({ ...formData, enabled: checked })} checked={formData.enabled}
/> onChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
</div>
<div className='flex items-center gap-3'>
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</span>
<Switch
checked={formData.onePerEpisode}
onChange={(checked) => setFormData({ ...formData, onePerEpisode: checked })}
/>
</div>
<div className='flex items-center gap-3'>
<span className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</span>
<Switch
checked={formData.refillMissingEpisodes}
onChange={(checked) =>
setFormData({ ...formData, refillMissingEpisodes: checked })
}
/>
</div>
</div> </div>
<p className='text-xs text-gray-500 dark:text-gray-400 -mt-2'>
+ 02
</p>
<div className='flex gap-2 justify-end pt-2'> <div className='flex gap-2 justify-end pt-2'>
<button <button
onClick={resetForm} onClick={resetForm}
@@ -641,6 +742,16 @@ export default function AnimeSubscriptionComponent({
<span className='px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200'> <span className='px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200'>
{sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : sub.source === 'nyaa' ? 'Nyaa' : '动漫花园'} {sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : sub.source === 'nyaa' ? 'Nyaa' : '动漫花园'}
</span> </span>
{sub.onePerEpisode ? (
<span className='px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'>
×1
</span>
) : null}
{sub.refillMissingEpisodes ? (
<span className='px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200'>
</span>
) : null}
</div> </div>
<div className='text-sm text-gray-600 dark:text-gray-400 space-y-1'> <div className='text-sm text-gray-600 dark:text-gray-400 space-y-1'>
<p>{sub.filterText}</p> <p>{sub.filterText}</p>
@@ -695,9 +806,21 @@ export default function AnimeSubscriptionComponent({
<h3 className='text-base font-medium text-gray-900 dark:text-gray-100 truncate'> <h3 className='text-base font-medium text-gray-900 dark:text-gray-100 truncate'>
{sub.title} {sub.title}
</h3> </h3>
<span className='inline-block mt-1 px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200'> <div className='flex flex-wrap gap-1 mt-1'>
{sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : sub.source === 'nyaa' ? 'Nyaa' : '动漫花园'} <span className='inline-block px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200'>
</span> {sub.source === 'acgrip' ? 'ACG.RIP' : sub.source === 'mikan' ? '蜜柑' : sub.source === 'nyaa' ? 'Nyaa' : '动漫花园'}
</span>
{sub.onePerEpisode ? (
<span className='inline-block px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'>
×1
</span>
) : null}
{sub.refillMissingEpisodes ? (
<span className='inline-block px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200'>
</span>
) : null}
</div>
</div> </div>
<Switch <Switch
checked={sub.enabled} checked={sub.enabled}
+1
View File
@@ -178,6 +178,7 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
orientation='horizontal' orientation='horizontal'
playTime={record.play_time} playTime={record.play_time}
totalTime={record.total_time} totalTime={record.total_time}
isAnime={Boolean(record.is_anime)}
/> />
{record.new_episodes && record.new_episodes > 0 && ( {record.new_episodes && record.new_episodes > 0 && (
<div <div
+63
View File
@@ -6,6 +6,7 @@ import {
Heart, Heart,
Info, Info,
Link, Link,
ListPlus,
PlayCircleIcon, PlayCircleIcon,
Radio, Radio,
Sparkles, Sparkles,
@@ -25,6 +26,8 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { isAnimeCategoryText } from '@/lib/anime-keyword-expr';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import { import {
deleteFavorite, deleteFavorite,
deletePlayRecord, deletePlayRecord,
@@ -48,6 +51,7 @@ import {
import { useLongPress } from '@/hooks/useLongPress'; import { useLongPress } from '@/hooks/useLongPress';
import AIChatPanel from '@/components/AIChatPanel'; import AIChatPanel from '@/components/AIChatPanel';
import AnimeSubscribeModal from '@/components/AnimeSubscribeModal';
import DetailPanel from '@/components/DetailPanel'; import DetailPanel from '@/components/DetailPanel';
import { ImagePlaceholder } from '@/components/ImagePlaceholder'; import { ImagePlaceholder } from '@/components/ImagePlaceholder';
import ImageViewer from '@/components/ImageViewer'; import ImageViewer from '@/components/ImageViewer';
@@ -80,6 +84,10 @@ export interface VideoCardProps {
rate?: string; rate?: string;
type?: string; type?: string;
isBangumi?: boolean; isBangumi?: boolean;
/** 明确标记为动漫(豆瓣动漫页 / CMS 等) */
isAnime?: boolean;
/** CMS 分类名,用于启发式识别动漫 */
typeName?: string;
isAggregate?: boolean; isAggregate?: boolean;
origin?: 'vod' | 'live'; origin?: 'vod' | 'live';
releaseDate?: string; // 上映日期,格式:YYYY-MM-DD releaseDate?: string; // 上映日期,格式:YYYY-MM-DD
@@ -125,6 +133,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
rate, rate,
type = '', type = '',
isBangumi = false, isBangumi = false,
isAnime = false,
typeName,
isAggregate = false, isAggregate = false,
origin = 'vod', origin = 'vod',
releaseDate, releaseDate,
@@ -141,6 +151,17 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
ref ref
) { ) {
const router = useRouter(); 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 actualTitle = title;
const actualPoster = poster; const actualPoster = poster;
const netdiskPosterPlaceholder = useMemo(() => { const netdiskPosterPlaceholder = useMemo(() => {
@@ -870,6 +891,27 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
}); });
} }
// 添加追番订阅(仅管理员 + 判定为动漫)
if (
isAdminUser &&
resolvedIsAnime &&
origin !== 'live' &&
actualTitle
) {
actions.push({
id: 'anime-subscribe',
label: '添加追番订阅',
icon: <ListPlus size={20} />,
onClick: () => {
setShowMobileActions(false);
setTimeout(() => {
setShowAnimeSubscribe(true);
}, 250);
},
color: 'primary' as const,
});
}
return actions; return actions;
}, [ }, [
config, config,
@@ -893,6 +935,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
origin, origin,
tmdb_id, tmdb_id,
openTrailerPicker, openTrailerPicker,
isAdminUser,
resolvedIsAnime,
]); ]);
return ( return (
@@ -2069,6 +2113,25 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
/> />
)} )}
{/* 添加追番订阅(管理员) */}
<AnimeSubscribeModal
isOpen={showAnimeSubscribe}
onClose={() => setShowAnimeSubscribe(false)}
initialTitle={actualTitle}
initialLastEpisode={
from === 'playrecord' && currentEpisode ? currentEpisode : 0
}
onSuccess={() => {
setAnimeSubscribeToast('已添加追番订阅');
window.setTimeout(() => setAnimeSubscribeToast(''), 2500);
}}
/>
{animeSubscribeToast ? (
<div className='fixed bottom-24 left-1/2 z-[10001] -translate-x-1/2 rounded-full bg-green-600 px-4 py-2 text-sm text-white shadow-lg'>
{animeSubscribeToast}
</div>
) : null}
{/* 图片查看器 */} {/* 图片查看器 */}
{showImageViewer && ( {showImageViewer && (
<ImageViewer <ImageViewer
+7 -1
View File
@@ -396,10 +396,16 @@ export interface AdminConfig {
Subscriptions: Array<{ Subscriptions: Array<{
id: string; id: string;
title: string; title: string;
/** 包含关键词:支持 & | ();无运算符时逗号=AND */
filterText: string; filterText: string;
excludeText?: string; // 排除关键词,逗号分隔;标题包含任一则跳过 /** 排除关键词:支持 & | ();无运算符时逗号=OR */
excludeText?: string;
source: 'acgrip' | 'mikan' | 'dmhy' | 'nyaa'; source: 'acgrip' | 'mikan' | 'dmhy' | 'nyaa';
enabled: boolean; enabled: boolean;
/** 单集只下载一次(默认 false) */
onePerEpisode?: boolean;
/** 缺集重新检索(默认 false) */
refillMissingEpisodes?: boolean;
lastCheckTime: number; lastCheckTime: number;
lastEpisode: number; lastEpisode: number;
createdAt: number; createdAt: number;
+169
View File
@@ -0,0 +1,169 @@
/**
* 追番订阅快捷建议(字幕组单选)
* - labelchip 只显示组名
* - insert:选中后整段写入过滤关键词(替换,非追加)
* 偏好:简日双语 > 简中;内嵌 > 内封(网页对 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();
}
+313
View File
@@ -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<string | undefined | null>
): 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 用 andexclude 用 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<T extends EpisodeCandidate>(items: T[]): T[] {
const best = new Map<number, T>();
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);
}
+222 -45
View File
@@ -2,6 +2,11 @@
import parseTorrentName from 'parse-torrent-name'; import parseTorrentName from 'parse-torrent-name';
import { parseStringPromise } from 'xml2js'; import { parseStringPromise } from 'xml2js';
import {
matchesExclude,
matchesFilter,
pickOnePerEpisode,
} from '@/lib/anime-keyword-expr';
import { getConfig, setCachedConfig } from '@/lib/config'; import { getConfig, setCachedConfig } from '@/lib/config';
import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client'; import { getMagnetBaseUrl, universalMagnetFetch } from '@/lib/magnet.client';
import { db, getStorage } from '@/lib/db'; import { db, getStorage } from '@/lib/db';
@@ -13,6 +18,16 @@ import {
} from '@/lib/openlist-offline-download'; } from '@/lib/openlist-offline-download';
import { AnimeSubscription, AnimeSubscriptionDownloadTool } from '@/types/anime-subscription'; 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 downloadTools: AnimeSubscriptionDownloadTool[] = ['aria2', 'qBittorrent', 'Transmission'];
const pickRssText = (value: any): string => { const pickRssText = (value: any): string => {
@@ -25,10 +40,51 @@ const pickRssText = (value: any): string => {
function getAnimeSubscriptionDownloadTool(tool: unknown): AnimeSubscriptionDownloadTool { function getAnimeSubscriptionDownloadTool(tool: unknown): AnimeSubscriptionDownloadTool {
return typeof tool === 'string' && downloadTools.includes(tool as AnimeSubscriptionDownloadTool) return typeof tool === 'string' && downloadTools.includes(tool as AnimeSubscriptionDownloadTool)
? tool as AnimeSubscriptionDownloadTool ? (tool as AnimeSubscriptionDownloadTool)
: 'aria2'; : '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); const parsed = parseTorrentName(title);
if (parsed.episode) { 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 = [ const patterns: Array<[RegExp, number]> = [
/\[(\d+)\]/, // [01] [/\[(\d{1,3})\]/, 1], // [01]
/第(\d+)[集话]/, // 第01集 [/第(\d{1,3})[集话]/, 1], // 第01集
/EP?(\d+)/i, // EP01, E01 [/(?:^|[^A-Za-z0-9])EP?(\d{1,3})(?![0-9])/i, 1], // EP01, E01
/\s(\d+)\s/, // 空格01空格 [/[-–—_]\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); const match = title.match(pattern);
if (match) { 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; return null;
} }
/** type AcgSearchItem = {
* 解析逗号分隔关键词(兼容中文逗号) title: string;
*/ link?: string;
function parseKeywords(text: string): string[] { guid?: string;
return text pubDate?: string;
.replace(//g, ',') torrentUrl?: string;
.split(',') description?: string;
.map((k) => k.trim()) episode?: number | null;
.filter(Boolean); };
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 { async function refillMissingEpisodeResults(
if (!filterText) return true; subscription: AnimeSubscription,
existing: AcgSearchItem[]
): Promise<AcgSearchItem[]> {
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 maxFound = Math.max(...Array.from(foundEps));
const keywords = parseKeywords(filterText); 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}`
);
/** const merged = [...existing];
* 检查标题是否命中排除关键词(OR:任一命中即排除) const haveEp = new Set(foundEps);
*/
export function matchesExclude(title: string, excludeText?: string): boolean {
if (!excludeText) return false;
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. 搜索资源 // 1. 搜索资源
const results = await searchACG(subscription.title, subscription.source); const results = await searchACG(subscription.title, subscription.source);
// 2. 过滤并解析集数(包含关键词 AND,排除关键词 OR // 2. 过滤并解析集数(关键词支持 & | ();旧逗号兼容
const newEpisodes = results let newEpisodes = filterAndParseEpisodes(results, subscription, {
.filter((item: any) => matchesFilter(item.title, subscription.filterText)) minEpisodeExclusive: subscription.lastEpisode,
.filter((item: any) => !matchesExclude(item.title, subscription.excludeText)) });
.map((item: any) => ({
episode: extractEpisode(item.title), // 2a. 缺集重新检索(可选):首搜跳集时按「番名 + 补零集数」补搜中间集
...item, if (subscription.refillMissingEpisodes) {
})) newEpisodes = await refillMissingEpisodeResults(subscription, newEpisodes);
.filter((item: any) => item.episode && item.episode > subscription.lastEpisode) }
.sort((a: any, b: any) => a.episode! - b.episode!);
// 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. 下载新集数 // 3. 下载新集数
const downloaded = []; const downloaded: number[] = [];
for (const item of newEpisodes) { for (const item of newEpisodes) {
if (typeof item.episode !== 'number' || !item.torrentUrl) {
continue;
}
try { try {
const downloadPath = joinOpenListPath( const downloadPath = joinOpenListPath(
getOfflineDownloadBasePath(config), getOfflineDownloadBasePath(config),
@@ -362,7 +539,7 @@ export async function checkSubscription(subscription: AnimeSubscription) {
await addOfflineDownload(item.torrentUrl, downloadPath); await addOfflineDownload(item.torrentUrl, downloadPath);
// 成功后更新 lastEpisode // 成功后更新 lastEpisode
subscription.lastEpisode = item.episode!; subscription.lastEpisode = item.episode;
downloaded.push(item.episode); downloaded.push(item.episode);
console.log( console.log(
+7 -4
View File
@@ -116,9 +116,9 @@ export class D1Storage implements IStorage {
INSERT INTO play_records ( INSERT INTO play_records (
username, key, title, source_name, cover, year, username, key, title, source_name, cover, year,
episode_index, total_episodes, play_time, total_time, 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 ON CONFLICT(username, key) DO UPDATE SET
title = excluded.title, title = excluded.title,
source_name = excluded.source_name, source_name = excluded.source_name,
@@ -130,7 +130,8 @@ export class D1Storage implements IStorage {
total_time = excluded.total_time, total_time = excluded.total_time,
save_time = excluded.save_time, save_time = excluded.save_time,
search_title = excluded.search_title, search_title = excluded.search_title,
new_episodes = excluded.new_episodes new_episodes = excluded.new_episodes,
is_anime = excluded.is_anime
` `
) )
.bind( .bind(
@@ -146,7 +147,8 @@ export class D1Storage implements IStorage {
record.total_time, record.total_time,
record.save_time, record.save_time,
record.search_title || '', record.search_title || '',
record.new_episodes || null record.new_episodes || null,
record.is_anime ? 1 : 0
) )
.run(); .run();
} catch (err) { } catch (err) {
@@ -1239,6 +1241,7 @@ export class D1Storage implements IStorage {
save_time: row.save_time, save_time: row.save_time,
search_title: row.search_title || '', search_title: row.search_title || '',
new_episodes: row.new_episodes || undefined, new_episodes: row.new_episodes || undefined,
is_anime: row.is_anime === 1 || row.is_anime === true,
}; };
} }
+2
View File
@@ -44,6 +44,8 @@ export interface PlayRecord {
search_title?: string; // 搜索时使用的标题 search_title?: string; // 搜索时使用的标题
origin?: 'vod' | 'live'; // 来源类型 origin?: 'vod' | 'live'; // 来源类型
new_episodes?: number; // 新增的剧集数量(用于显示更新提示) new_episodes?: number; // 新增的剧集数量(用于显示更新提示)
/** 是否动漫(写入时根据 CMS type_name/class 判断) */
is_anime?: boolean;
} }
// ---- 收藏类型 ---- // ---- 收藏类型 ----
+7 -4
View File
@@ -109,9 +109,9 @@ export class PostgresStorage implements IStorage {
INSERT INTO play_records ( INSERT INTO play_records (
username, key, title, source_name, cover, year, username, key, title, source_name, cover, year,
episode_index, total_episodes, play_time, total_time, 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 ON CONFLICT (username, key) DO UPDATE SET
title = EXCLUDED.title, title = EXCLUDED.title,
source_name = EXCLUDED.source_name, source_name = EXCLUDED.source_name,
@@ -123,7 +123,8 @@ export class PostgresStorage implements IStorage {
total_time = EXCLUDED.total_time, total_time = EXCLUDED.total_time,
save_time = EXCLUDED.save_time, save_time = EXCLUDED.save_time,
search_title = EXCLUDED.search_title, search_title = EXCLUDED.search_title,
new_episodes = EXCLUDED.new_episodes new_episodes = EXCLUDED.new_episodes,
is_anime = EXCLUDED.is_anime
` `
) )
.bind( .bind(
@@ -139,7 +140,8 @@ export class PostgresStorage implements IStorage {
record.total_time, record.total_time,
record.save_time, record.save_time,
record.search_title || '', record.search_title || '',
record.new_episodes || null record.new_episodes || null,
record.is_anime ? 1 : 0
) )
.run(); .run();
} catch (err) { } catch (err) {
@@ -390,6 +392,7 @@ export class PostgresStorage implements IStorage {
save_time: row.save_time, save_time: row.save_time,
search_title: row.search_title || '', search_title: row.search_title || '',
new_episodes: row.new_episodes || undefined, new_episodes: row.new_episodes || undefined,
is_anime: row.is_anime === 1 || row.is_anime === true,
}; };
} }
+3
View File
@@ -15,6 +15,9 @@ export interface PlayRecord {
save_time: number; // 记录保存时间(时间戳) save_time: number; // 记录保存时间(时间戳)
search_title: string; // 搜索时使用的标题 search_title: string; // 搜索时使用的标题
new_episodes?: number; // 新增的剧集数量(用于显示更新提示) new_episodes?: number; // 新增的剧集数量(用于显示更新提示)
origin?: 'vod' | 'live';
/** 是否动漫(写入时根据 CMS type_name/class 判断) */
is_anime?: boolean;
} }
// 收藏数据结构 // 收藏数据结构
+19 -1
View File
@@ -1,11 +1,29 @@
export interface AnimeSubscription { export interface AnimeSubscription {
id: string; id: string;
title: string; title: string;
/**
* 包含关键词表达式。
* 支持 &(且)|(或)();无运算符时逗号为 AND(兼容旧数据)。
* 例:喵萌奶茶屋&(简日双语|简日内嵌)
*/
filterText: string; filterText: string;
/** 排除关键词,逗号分隔;标题包含任一关键词则跳过,例如:先行版,预告 */ /**
* 排除关键词表达式。
* 支持 & | ();无运算符时逗号为 OR(兼容旧数据)。
* 例:先行|预告|PV
*/
excludeText?: string; excludeText?: string;
source: 'acgrip' | 'mikan' | 'dmhy' | 'nyaa'; source: 'acgrip' | 'mikan' | 'dmhy' | 'nyaa';
enabled: boolean; enabled: boolean;
/**
* 单集只下载一次:同一集匹配到多个种子时只入队一条(可选,默认 false)
*/
onePerEpisode?: boolean;
/**
* 缺集重新检索:首搜若跳集(如已看到 1,结果只有 11/12),
* 则对中间缺集按「番名 + 补零集数」再搜(可选,默认 false)
*/
refillMissingEpisodes?: boolean;
lastCheckTime: number; lastCheckTime: number;
lastEpisode: number; lastEpisode: number;
createdAt: number; createdAt: number;