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

- 过滤/排除支持 & | (),兼容旧逗号语义
- 字幕组快捷单选填入;同名订阅拒绝重复
- 单集只下一次、缺集按「番名+补零集数」重搜
- 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
@@ -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);
+49 -3
View File
@@ -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(),
+1
View File
@@ -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} 集)`
+1
View File
@@ -830,6 +830,7 @@ function DoubanPageClient() {
isBangumi={
type === 'anime' && primarySelection === '每日放送'
}
isAnime={type === 'anime'}
/>
</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 { 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;
+15
View File
@@ -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
}
/>
</div>
);
@@ -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}
/>
</div>
);
+6
View File
@@ -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,
+2
View File
@@ -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);
};