私人影库扫描增加季度支持

This commit is contained in:
mtvpls
2025-12-25 23:34:54 +08:00
parent 636e6042c8
commit 76b3349aa2
11 changed files with 699 additions and 34 deletions

View File

@@ -1,7 +1,7 @@
## [204.0.0] - 2025-12-25
### Added
- ⚠️⚠️⚠️更新此版本前务必进行备份!!!⚠️⚠️⚠️
- ⚠️⚠️⚠️更新此版本前务必进行备份!!!⚠️⚠️⚠️
- 新增私人影视库功能(实验性)
- 增加弹幕热力图
- 增加盘搜搜索资源

View File

@@ -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>

View File

@@ -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 到数据库

View File

@@ -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,
};
}
);
// 按更新时间倒序排序

View File

@@ -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 {
// 记录失败的文件夹

View 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 }
);
}
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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
View 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('');
});
}

View File

@@ -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
*/