私人影库扫描增加季度支持
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
## [204.0.0] - 2025-12-25
|
||||
|
||||
### Added
|
||||
- ⚠️⚠️⚠️更新此版本前前务必进行备份!!!⚠️⚠️⚠️
|
||||
- ⚠️⚠️⚠️更新此版本前务必进行备份!!!⚠️⚠️⚠️
|
||||
- 新增私人影视库功能(实验性)
|
||||
- 增加弹幕热力图
|
||||
- 增加盘搜搜索资源
|
||||
|
||||
@@ -3226,6 +3226,9 @@ const OpenListConfigComponent = ({
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
类型
|
||||
</th>
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
季度
|
||||
</th>
|
||||
<th className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'>
|
||||
年份
|
||||
</th>
|
||||
@@ -3257,6 +3260,15 @@ const OpenListConfigComponent = ({
|
||||
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
|
||||
{video.mediaType === 'movie' ? '电影' : '剧集'}
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
|
||||
{video.seasonNumber ? (
|
||||
<span className='inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200' title={video.seasonName || `第${video.seasonNumber}季`}>
|
||||
S{video.seasonNumber}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className='px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400'>
|
||||
{video.releaseDate ? video.releaseDate.split('-')[0] : '-'}
|
||||
</td>
|
||||
|
||||
@@ -27,7 +27,18 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { folder, tmdbId, title, posterPath, releaseDate, overview, voteAverage, mediaType } = body;
|
||||
const {
|
||||
folder,
|
||||
tmdbId,
|
||||
title,
|
||||
posterPath,
|
||||
releaseDate,
|
||||
overview,
|
||||
voteAverage,
|
||||
mediaType,
|
||||
seasonNumber,
|
||||
seasonName,
|
||||
} = body;
|
||||
|
||||
if (!folder || !tmdbId) {
|
||||
return NextResponse.json(
|
||||
@@ -97,6 +108,8 @@ export async function POST(request: NextRequest) {
|
||||
media_type: mediaType,
|
||||
last_updated: Date.now(),
|
||||
failed: false, // 纠错后标记为成功
|
||||
season_number: seasonNumber, // 季度编号(可选)
|
||||
season_name: seasonName, // 季度名称(可选)
|
||||
};
|
||||
|
||||
// 保存 metainfo 到数据库
|
||||
|
||||
@@ -125,19 +125,29 @@ export async function GET(request: NextRequest) {
|
||||
const allVideos = Object.entries(metaInfo.folders)
|
||||
.filter(([, info]) => includeFailed || !info.failed) // 根据参数过滤失败的视频
|
||||
.map(
|
||||
([folderName, info]) => ({
|
||||
id: folderName,
|
||||
folder: folderName,
|
||||
tmdbId: info.tmdb_id,
|
||||
title: info.title,
|
||||
poster: getTMDBImageUrl(info.poster_path),
|
||||
releaseDate: info.release_date,
|
||||
overview: info.overview,
|
||||
voteAverage: info.vote_average,
|
||||
mediaType: info.media_type,
|
||||
lastUpdated: info.last_updated,
|
||||
failed: info.failed || false,
|
||||
})
|
||||
([folderName, info]) => {
|
||||
// 构建 id:如果是第二季及以后,id 也要包含季度信息
|
||||
let videoId = folderName;
|
||||
if (info.season_number && info.season_number > 1 && info.season_name) {
|
||||
videoId = `${folderName} ${info.season_name}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: videoId,
|
||||
folder: folderName,
|
||||
tmdbId: info.tmdb_id,
|
||||
title: info.title,
|
||||
poster: getTMDBImageUrl(info.poster_path),
|
||||
releaseDate: info.release_date,
|
||||
overview: info.overview,
|
||||
voteAverage: info.vote_average,
|
||||
mediaType: info.media_type,
|
||||
lastUpdated: info.last_updated,
|
||||
failed: info.failed || false,
|
||||
seasonNumber: info.season_number,
|
||||
seasonName: info.season_name,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// 按更新时间倒序排序
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
failScanTask,
|
||||
updateScanTaskProgress,
|
||||
} from '@/lib/scan-task';
|
||||
import { searchTMDB } from '@/lib/tmdb.search';
|
||||
import { parseSeasonFromTitle } from '@/lib/season-parser';
|
||||
import { searchTMDB, getTVSeasonDetails } from '@/lib/tmdb.search';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -200,17 +201,25 @@ async function performScan(
|
||||
}
|
||||
|
||||
try {
|
||||
// 搜索 TMDB
|
||||
// 解析文件夹名称,提取季度信息
|
||||
const seasonInfo = parseSeasonFromTitle(folder.name);
|
||||
const searchQuery = seasonInfo.cleanTitle || folder.name;
|
||||
|
||||
console.log(`[OpenList Refresh] 处理文件夹: ${folder.name}`);
|
||||
console.log(`[OpenList Refresh] 清理后标题: ${searchQuery}, 季度: ${seasonInfo.seasonNumber}`);
|
||||
|
||||
// 搜索 TMDB(使用清理后的标题)
|
||||
const searchResult = await searchTMDB(
|
||||
tmdbApiKey,
|
||||
folder.name,
|
||||
searchQuery,
|
||||
tmdbProxy
|
||||
);
|
||||
|
||||
if (searchResult.code === 200 && searchResult.result) {
|
||||
const result = searchResult.result;
|
||||
|
||||
metaInfo.folders[folder.name] = {
|
||||
// 基础信息
|
||||
const folderInfo: any = {
|
||||
tmdb_id: result.id,
|
||||
title: result.title || result.name || folder.name,
|
||||
poster_path: result.poster_path,
|
||||
@@ -222,6 +231,51 @@ async function performScan(
|
||||
failed: false,
|
||||
};
|
||||
|
||||
// 如果是电视剧且识别到季度编号,获取该季度的详细信息
|
||||
if (result.media_type === 'tv' && seasonInfo.seasonNumber) {
|
||||
try {
|
||||
const seasonDetails = await getTVSeasonDetails(
|
||||
tmdbApiKey,
|
||||
result.id,
|
||||
seasonInfo.seasonNumber,
|
||||
tmdbProxy
|
||||
);
|
||||
|
||||
if (seasonDetails.code === 200 && seasonDetails.season) {
|
||||
folderInfo.season_number = seasonDetails.season.season_number;
|
||||
folderInfo.season_name = seasonDetails.season.name;
|
||||
|
||||
// 如果是第二季及以后,替换标题和ID
|
||||
if (seasonDetails.season.season_number > 1) {
|
||||
folderInfo.title = `${folderInfo.title} ${seasonDetails.season.name}`;
|
||||
folderInfo.tmdb_id = seasonDetails.season.id; // 使用季度的ID
|
||||
}
|
||||
|
||||
// 使用季度的海报(如果有)
|
||||
if (seasonDetails.season.poster_path) {
|
||||
folderInfo.poster_path = seasonDetails.season.poster_path;
|
||||
}
|
||||
// 使用季度的简介(如果有)
|
||||
if (seasonDetails.season.overview) {
|
||||
folderInfo.overview = seasonDetails.season.overview;
|
||||
}
|
||||
// 使用季度的首播日期(如果有)
|
||||
if (seasonDetails.season.air_date) {
|
||||
folderInfo.release_date = seasonDetails.season.air_date;
|
||||
}
|
||||
} else {
|
||||
console.warn(`[OpenList Refresh] 获取季度 ${seasonInfo.seasonNumber} 详情失败`);
|
||||
// 即使获取季度详情失败,也保存季度编号
|
||||
folderInfo.season_number = seasonInfo.seasonNumber;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[OpenList Refresh] 获取季度详情异常:`, error);
|
||||
// 即使出错,也保存季度编号
|
||||
folderInfo.season_number = seasonInfo.seasonNumber;
|
||||
}
|
||||
}
|
||||
|
||||
metaInfo.folders[folder.name] = folderInfo;
|
||||
newCount++;
|
||||
} else {
|
||||
// 记录失败的文件夹
|
||||
|
||||
65
src/app/api/tmdb/seasons/route.ts
Normal file
65
src/app/api/tmdb/seasons/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { getTVSeasons } from '@/lib/tmdb.search';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/tmdb/seasons?tvId=xxx
|
||||
* 获取电视剧的季度列表
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const tvIdStr = searchParams.get('tvId');
|
||||
|
||||
if (!tvIdStr) {
|
||||
return NextResponse.json({ error: '缺少 tvId 参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const tvId = parseInt(tvIdStr, 10);
|
||||
if (isNaN(tvId)) {
|
||||
return NextResponse.json({ error: 'tvId 必须是数字' }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const tmdbApiKey = config.SiteConfig.TMDBApiKey;
|
||||
const tmdbProxy = config.SiteConfig.TMDBProxy;
|
||||
|
||||
if (!tmdbApiKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'TMDB API Key 未配置' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await getTVSeasons(tmdbApiKey, tvId, tmdbProxy);
|
||||
|
||||
if (result.code === 200 && result.seasons) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
seasons: result.seasons,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: '获取季度列表失败', code: result.code },
|
||||
{ status: result.code }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取季度列表失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取失败', details: (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,16 @@ interface TMDBResult {
|
||||
media_type: 'movie' | 'tv';
|
||||
}
|
||||
|
||||
interface TMDBSeason {
|
||||
id: number;
|
||||
name: string;
|
||||
season_number: number;
|
||||
episode_count: number;
|
||||
air_date: string | null;
|
||||
poster_path: string | null;
|
||||
overview: string;
|
||||
}
|
||||
|
||||
interface CorrectDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -43,11 +53,20 @@ export default function CorrectDialog({
|
||||
const [error, setError] = useState('');
|
||||
const [correcting, setCorrecting] = useState(false);
|
||||
|
||||
// 季度选择相关状态
|
||||
const [selectedResult, setSelectedResult] = useState<TMDBResult | null>(null);
|
||||
const [seasons, setSeasons] = useState<TMDBSeason[]>([]);
|
||||
const [loadingSeasons, setLoadingSeasons] = useState(false);
|
||||
const [showSeasonSelection, setShowSeasonSelection] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchQuery(currentTitle);
|
||||
setResults([]);
|
||||
setError('');
|
||||
setSelectedResult(null);
|
||||
setSeasons([]);
|
||||
setShowSeasonSelection(false);
|
||||
}
|
||||
}, [isOpen, currentTitle]);
|
||||
|
||||
@@ -60,6 +79,8 @@ export default function CorrectDialog({
|
||||
setSearching(true);
|
||||
setError('');
|
||||
setResults([]);
|
||||
setShowSeasonSelection(false);
|
||||
setSelectedResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
@@ -88,22 +109,99 @@ export default function CorrectDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCorrect = async (result: TMDBResult) => {
|
||||
// 获取电视剧的季度列表
|
||||
const fetchSeasons = async (tvId: number) => {
|
||||
setLoadingSeasons(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await fetch(`/api/tmdb/seasons?tvId=${tvId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取季度列表失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.seasons) {
|
||||
return data.seasons as TMDBSeason[];
|
||||
} else {
|
||||
setError('获取季度列表失败');
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取季度列表失败:', err);
|
||||
setError('获取季度列表失败,请重试');
|
||||
return [];
|
||||
} finally {
|
||||
setLoadingSeasons(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理选择结果(电影直接纠错,电视剧显示季度选择)
|
||||
const handleSelectResult = async (result: TMDBResult) => {
|
||||
if (result.media_type === 'tv') {
|
||||
// 电视剧:先获取季度列表
|
||||
setSelectedResult(result);
|
||||
const seasonsList = await fetchSeasons(result.id);
|
||||
|
||||
if (seasonsList.length === 1) {
|
||||
// 只有一季,直接使用该季度进行纠错
|
||||
await handleCorrect(result, seasonsList[0]);
|
||||
} else if (seasonsList.length > 1) {
|
||||
// 多季,显示选择界面
|
||||
setSeasons(seasonsList);
|
||||
setShowSeasonSelection(true);
|
||||
} else {
|
||||
// 没有季度信息,直接使用剧集信息
|
||||
await handleCorrect(result);
|
||||
}
|
||||
} else {
|
||||
// 电影:直接纠错
|
||||
await handleCorrect(result);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理选择季度
|
||||
const handleSelectSeason = async (season: TMDBSeason) => {
|
||||
if (!selectedResult) return;
|
||||
|
||||
await handleCorrect(selectedResult, season);
|
||||
};
|
||||
|
||||
// 执行纠错
|
||||
const handleCorrect = async (result: TMDBResult, season?: TMDBSeason) => {
|
||||
setCorrecting(true);
|
||||
try {
|
||||
// 构建标题和ID:如果是第二季及以后,在标题后加上季度名称,并使用季度ID
|
||||
let finalTitle = result.title || result.name;
|
||||
let finalTmdbId = result.id;
|
||||
|
||||
if (season && season.season_number > 1) {
|
||||
finalTitle = `${finalTitle} ${season.name}`;
|
||||
finalTmdbId = season.id; // 使用季度的ID
|
||||
}
|
||||
|
||||
const body: any = {
|
||||
folder,
|
||||
tmdbId: finalTmdbId,
|
||||
title: finalTitle,
|
||||
posterPath: season?.poster_path || result.poster_path,
|
||||
releaseDate: season?.air_date || result.release_date || result.first_air_date,
|
||||
overview: season?.overview || result.overview,
|
||||
voteAverage: result.vote_average,
|
||||
mediaType: result.media_type,
|
||||
};
|
||||
|
||||
// 如果有季度信息,添加到请求中
|
||||
if (season) {
|
||||
body.seasonNumber = season.season_number;
|
||||
body.seasonName = season.name;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/openlist/correct', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
folder,
|
||||
tmdbId: result.id,
|
||||
title: result.title || result.name,
|
||||
posterPath: result.poster_path,
|
||||
releaseDate: result.release_date || result.first_air_date,
|
||||
overview: result.overview,
|
||||
voteAverage: result.vote_average,
|
||||
mediaType: result.media_type,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -120,6 +218,13 @@ export default function CorrectDialog({
|
||||
}
|
||||
};
|
||||
|
||||
// 返回搜索结果列表
|
||||
const handleBackToResults = () => {
|
||||
setShowSeasonSelection(false);
|
||||
setSelectedResult(null);
|
||||
setSeasons([]);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
@@ -169,11 +274,98 @@ export default function CorrectDialog({
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div className='flex-1 overflow-y-auto p-4'>
|
||||
{results.length === 0 ? (
|
||||
{showSeasonSelection ? (
|
||||
// 季度选择界面
|
||||
<div>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<button
|
||||
onClick={handleBackToResults}
|
||||
className='text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 flex items-center gap-1'
|
||||
>
|
||||
<span>←</span>
|
||||
<span>返回搜索结果</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedResult && (
|
||||
<div className='mb-4 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg'>
|
||||
<h3 className='font-semibold text-gray-900 dark:text-gray-100'>
|
||||
{selectedResult.title || selectedResult.name}
|
||||
</h3>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-400 mt-1'>
|
||||
请选择季度:
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingSeasons ? (
|
||||
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
|
||||
加载季度列表中...
|
||||
</div>
|
||||
) : seasons.length === 0 ? (
|
||||
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
|
||||
未找到季度信息
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{seasons.map((season) => (
|
||||
<div
|
||||
key={season.id}
|
||||
className='flex gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors'
|
||||
>
|
||||
{/* 海报 */}
|
||||
<div className='flex-shrink-0 w-16 h-24 relative rounded overflow-hidden bg-gray-200 dark:bg-gray-700'>
|
||||
{season.poster_path ? (
|
||||
<Image
|
||||
src={processImageUrl(getTMDBImageUrl(season.poster_path))}
|
||||
alt={season.name}
|
||||
fill
|
||||
className='object-cover'
|
||||
referrerPolicy='no-referrer'
|
||||
/>
|
||||
) : (
|
||||
<div className='w-full h-full flex items-center justify-center text-gray-400 text-xs'>
|
||||
无海报
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
<h3 className='font-semibold text-gray-900 dark:text-gray-100'>
|
||||
{season.name}
|
||||
</h3>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-400 mt-1'>
|
||||
{season.episode_count} 集
|
||||
{season.air_date && ` • ${season.air_date.split('-')[0]}`}
|
||||
</p>
|
||||
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1 line-clamp-2'>
|
||||
{season.overview || '暂无简介'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 选择按钮 */}
|
||||
<div className='flex-shrink-0 flex items-center'>
|
||||
<button
|
||||
onClick={() => handleSelectSeason(season)}
|
||||
disabled={correcting}
|
||||
className='px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed'
|
||||
>
|
||||
{correcting ? '处理中...' : '选择'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
// 空状态
|
||||
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
|
||||
{searching ? '搜索中...' : '请输入关键词搜索'}
|
||||
</div>
|
||||
) : (
|
||||
// 搜索结果列表
|
||||
<div className='space-y-3'>
|
||||
{results.map((result) => (
|
||||
<div
|
||||
@@ -217,11 +409,11 @@ export default function CorrectDialog({
|
||||
{/* 选择按钮 */}
|
||||
<div className='flex-shrink-0 flex items-center'>
|
||||
<button
|
||||
onClick={() => handleCorrect(result)}
|
||||
disabled={correcting}
|
||||
onClick={() => handleSelectResult(result)}
|
||||
disabled={correcting || loadingSeasons}
|
||||
className='px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded-lg transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed'
|
||||
>
|
||||
{correcting ? '处理中...' : '选择'}
|
||||
{correcting || loadingSeasons ? '处理中...' : '选择'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface VideoCardProps {
|
||||
origin?: 'vod' | 'live';
|
||||
releaseDate?: string; // 上映日期,格式:YYYY-MM-DD
|
||||
isUpcoming?: boolean; // 是否为即将上映
|
||||
seasonNumber?: number; // 季度编号
|
||||
seasonName?: string; // 季度名称
|
||||
}
|
||||
|
||||
export type VideoCardHandle = {
|
||||
@@ -80,6 +82,8 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
origin = 'vod',
|
||||
releaseDate,
|
||||
isUpcoming = false,
|
||||
seasonNumber,
|
||||
seasonName,
|
||||
}: VideoCardProps,
|
||||
ref
|
||||
) {
|
||||
@@ -775,6 +779,25 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 季度徽章 */}
|
||||
{seasonNumber && (
|
||||
<div
|
||||
className="absolute top-2 left-2 bg-blue-500/80 text-white text-xs font-medium px-2 py-1 rounded backdrop-blur-sm shadow-sm transition-all duration-300 ease-out group-hover:opacity-90"
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
userSelect: 'none',
|
||||
WebkitTouchCallout: 'none',
|
||||
} as React.CSSProperties}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}}
|
||||
title={seasonName || `第${seasonNumber}季`}
|
||||
>
|
||||
S{seasonNumber}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 徽章 */}
|
||||
{config.showRating && rate && (
|
||||
<div
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface MetaInfo {
|
||||
media_type: 'movie' | 'tv';
|
||||
last_updated: number;
|
||||
failed?: boolean; // 标记是否搜索失败
|
||||
season_number?: number; // 季度编号(仅电视剧)
|
||||
season_name?: string; // 季度名称(仅电视剧)
|
||||
};
|
||||
};
|
||||
last_refresh: number;
|
||||
|
||||
171
src/lib/season-parser.ts
Normal file
171
src/lib/season-parser.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* 季度标识解析工具
|
||||
* 用于从文件夹名称中识别和提取季度信息
|
||||
*/
|
||||
|
||||
export interface SeasonInfo {
|
||||
/** 清理后的标题(移除季度标识) */
|
||||
cleanTitle: string;
|
||||
/** 季度编号,如果未识别则为 null */
|
||||
seasonNumber: number | null;
|
||||
/** 原始标题 */
|
||||
originalTitle: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件夹名称中提取季度信息
|
||||
* 支持多种格式:
|
||||
* - S01, S1, s01, s1
|
||||
* - [S01], [S1]
|
||||
* - Season 1, Season 01
|
||||
* - 第一季, 第1季, 第01季
|
||||
* - [第一季], [第1季]
|
||||
* - 第一部, 第1部
|
||||
*/
|
||||
export function parseSeasonFromTitle(title: string): SeasonInfo {
|
||||
const originalTitle = title;
|
||||
let cleanTitle = title;
|
||||
let seasonNumber: number | null = null;
|
||||
|
||||
// 定义季度匹配模式(按优先级排序)
|
||||
const patterns = [
|
||||
// [S01], [S1], [s01], [s1] 格式(方括号包裹)
|
||||
{
|
||||
regex: /\[([Ss]\d{1,2})\]/,
|
||||
extract: (match: RegExpMatchArray) => {
|
||||
const seasonMatch = match[1].match(/[Ss](\d{1,2})/);
|
||||
return seasonMatch ? parseInt(seasonMatch[1], 10) : null;
|
||||
},
|
||||
},
|
||||
// S01, S1, s01, s1 格式
|
||||
{
|
||||
regex: /\b[Ss](\d{1,2})\b/,
|
||||
extract: (match: RegExpMatchArray) => parseInt(match[1], 10),
|
||||
},
|
||||
// [Season 1], [Season 01] 格式(方括号包裹)
|
||||
{
|
||||
regex: /\[Season\s+(\d{1,2})\]/i,
|
||||
extract: (match: RegExpMatchArray) => parseInt(match[1], 10),
|
||||
},
|
||||
// Season 1, Season 01 格式
|
||||
{
|
||||
regex: /\bSeason\s+(\d{1,2})\b/i,
|
||||
extract: (match: RegExpMatchArray) => parseInt(match[1], 10),
|
||||
},
|
||||
// [第一季], [第1季], [第01季] 格式(方括号包裹)
|
||||
{
|
||||
regex: /\[第([一二三四五六七八九十\d]{1,2})季\]/,
|
||||
extract: (match: RegExpMatchArray) => chineseNumberToInt(match[1]),
|
||||
},
|
||||
// 第一季, 第1季, 第01季 格式
|
||||
{
|
||||
regex: /第([一二三四五六七八九十\d]{1,2})季/,
|
||||
extract: (match: RegExpMatchArray) => chineseNumberToInt(match[1]),
|
||||
},
|
||||
// [第一部], [第1部] 格式(方括号包裹)
|
||||
{
|
||||
regex: /\[第([一二三四五六七八九十\d]{1,2})部\]/,
|
||||
extract: (match: RegExpMatchArray) => chineseNumberToInt(match[1]),
|
||||
},
|
||||
// 第一部, 第1部, 第01部 格式
|
||||
{
|
||||
regex: /第([一二三四五六七八九十\d]{1,2})部/,
|
||||
extract: (match: RegExpMatchArray) => chineseNumberToInt(match[1]),
|
||||
},
|
||||
];
|
||||
|
||||
// 尝试匹配每个模式
|
||||
for (const pattern of patterns) {
|
||||
const match = title.match(pattern.regex);
|
||||
if (match) {
|
||||
const extracted = pattern.extract(match);
|
||||
if (extracted !== null) {
|
||||
seasonNumber = extracted;
|
||||
// 移除匹配到的季度标识
|
||||
cleanTitle = title.replace(pattern.regex, '').trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理标题:移除空的方括号和多余的空格
|
||||
cleanTitle = cleanTitle
|
||||
.replace(/\[\s*\]/g, '') // 移除空方括号
|
||||
.replace(/\s+/g, ' ') // 合并多个空格
|
||||
.replace(/[·\-_\s]+$/, '') // 移除末尾的特殊字符
|
||||
.trim();
|
||||
|
||||
return {
|
||||
cleanTitle,
|
||||
seasonNumber,
|
||||
originalTitle,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将中文数字转换为阿拉伯数字
|
||||
*/
|
||||
function chineseNumberToInt(str: string): number {
|
||||
// 如果已经是数字,直接返回
|
||||
if (/^\d+$/.test(str)) {
|
||||
return parseInt(str, 10);
|
||||
}
|
||||
|
||||
const chineseNumbers: Record<string, number> = {
|
||||
'一': 1, '二': 2, '三': 3, '四': 4, '五': 5,
|
||||
'六': 6, '七': 7, '八': 8, '九': 9, '十': 10,
|
||||
};
|
||||
|
||||
// 处理"十"的特殊情况
|
||||
if (str === '十') {
|
||||
return 10;
|
||||
}
|
||||
|
||||
// 处理"十X"的情况(如"十一")
|
||||
if (str.startsWith('十')) {
|
||||
const unit = str.substring(1);
|
||||
return 10 + (chineseNumbers[unit] || 0);
|
||||
}
|
||||
|
||||
// 处理"X十"的情况(如"二十")
|
||||
if (str.endsWith('十')) {
|
||||
const tens = str.substring(0, str.length - 1);
|
||||
return (chineseNumbers[tens] || 0) * 10;
|
||||
}
|
||||
|
||||
// 处理"X十Y"的情况(如"二十一")
|
||||
const tenIndex = str.indexOf('十');
|
||||
if (tenIndex !== -1) {
|
||||
const tens = str.substring(0, tenIndex);
|
||||
const units = str.substring(tenIndex + 1);
|
||||
return (chineseNumbers[tens] || 0) * 10 + (chineseNumbers[units] || 0);
|
||||
}
|
||||
|
||||
// 单个中文数字
|
||||
return chineseNumbers[str] || parseInt(str, 10) || 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试示例
|
||||
*/
|
||||
export function testSeasonParser() {
|
||||
const testCases = [
|
||||
'权力的游戏 第一季',
|
||||
'Breaking Bad S01',
|
||||
'Game of Thrones Season 1',
|
||||
'绝命毒师 第1季',
|
||||
'权力的游戏 S1',
|
||||
'权力的游戏',
|
||||
'绝命毒师 第二部',
|
||||
'Stranger Things S03',
|
||||
];
|
||||
|
||||
console.log('Season Parser Test Results:');
|
||||
testCases.forEach((title) => {
|
||||
const result = parseSeasonFromTitle(title);
|
||||
console.log(`Input: "${title}"`);
|
||||
console.log(` Clean Title: "${result.cleanTitle}"`);
|
||||
console.log(` Season: ${result.seasonNumber}`);
|
||||
console.log('');
|
||||
});
|
||||
}
|
||||
@@ -79,6 +79,129 @@ export async function searchTMDB(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TMDB 季度信息
|
||||
*/
|
||||
export interface TMDBSeasonInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
season_number: number;
|
||||
episode_count: number;
|
||||
air_date: string | null;
|
||||
poster_path: string | null;
|
||||
overview: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TMDB 电视剧详情(包含季度列表)
|
||||
*/
|
||||
interface TMDBTVDetails {
|
||||
id: number;
|
||||
name: string;
|
||||
seasons: TMDBSeasonInfo[];
|
||||
number_of_seasons: number;
|
||||
poster_path: string | null;
|
||||
first_air_date: string;
|
||||
overview: string;
|
||||
vote_average: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取电视剧的季度列表
|
||||
*/
|
||||
export async function getTVSeasons(
|
||||
apiKey: string,
|
||||
tvId: number,
|
||||
proxy?: string
|
||||
): Promise<{ code: number; seasons: TMDBSeasonInfo[] | null }> {
|
||||
try {
|
||||
if (!apiKey) {
|
||||
return { code: 400, seasons: null };
|
||||
}
|
||||
|
||||
const url = `https://api.themoviedb.org/3/tv/${tvId}?api_key=${apiKey}&language=zh-CN`;
|
||||
|
||||
const fetchOptions: any = proxy
|
||||
? {
|
||||
agent: new HttpsProxyAgent(proxy, {
|
||||
timeout: 30000,
|
||||
keepAlive: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
: {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
};
|
||||
|
||||
const response = await nodeFetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('TMDB 获取电视剧详情失败:', response.status, response.statusText);
|
||||
return { code: response.status, seasons: null };
|
||||
}
|
||||
|
||||
const data: TMDBTVDetails = await response.json() as TMDBTVDetails;
|
||||
|
||||
// 过滤掉特殊季度(如 Season 0 通常是特别篇)
|
||||
const validSeasons = data.seasons.filter((season) => season.season_number > 0);
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
seasons: validSeasons,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('TMDB 获取季度列表异常:', error);
|
||||
return { code: 500, seasons: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取电视剧特定季度的详细信息
|
||||
*/
|
||||
export async function getTVSeasonDetails(
|
||||
apiKey: string,
|
||||
tvId: number,
|
||||
seasonNumber: number,
|
||||
proxy?: string
|
||||
): Promise<{ code: number; season: TMDBSeasonInfo | null }> {
|
||||
try {
|
||||
if (!apiKey) {
|
||||
return { code: 400, season: null };
|
||||
}
|
||||
|
||||
const url = `https://api.themoviedb.org/3/tv/${tvId}/season/${seasonNumber}?api_key=${apiKey}&language=zh-CN`;
|
||||
|
||||
const fetchOptions: any = proxy
|
||||
? {
|
||||
agent: new HttpsProxyAgent(proxy, {
|
||||
timeout: 30000,
|
||||
keepAlive: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
: {
|
||||
signal: AbortSignal.timeout(15000),
|
||||
};
|
||||
|
||||
const response = await nodeFetch(url, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('TMDB 获取季度详情失败:', response.status, response.statusText);
|
||||
return { code: response.status, season: null };
|
||||
}
|
||||
|
||||
const data: TMDBSeasonInfo = await response.json() as TMDBSeasonInfo;
|
||||
|
||||
return {
|
||||
code: 200,
|
||||
season: data,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('TMDB 获取季度详情异常:', error);
|
||||
return { code: 500, season: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 TMDB 图片完整 URL
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user