增强追番订阅:关键词表达式、字幕组快捷、右键添加与缺集补搜
- 过滤/排除支持 & | (),兼容旧逗号语义 - 字幕组快捷单选填入;同名订阅拒绝重复 - 单集只下一次、缺集按「番名+补零集数」重搜 - VideoCard 管理员可添加追番;PlayRecord 增加 is_anime 及迁移
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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} 集)`
|
||||
|
||||
@@ -830,6 +830,7 @@ function DoubanPageClient() {
|
||||
isBangumi={
|
||||
type === 'anime' && primarySelection === '每日放送'
|
||||
}
|
||||
isAnime={type === 'anime'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user