From 0cf4db35f9b59389681d7571e401af7f4af49037 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 28 Dec 2025 00:51:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0play=E9=A1=B5=E8=83=8C?= =?UTF-8?q?=E6=99=AF=E5=9B=BE=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/tmdb-details/route.ts | 180 ++++++++++++++++++++++++++++++ src/app/play/page.tsx | 128 +++++++++++++++++++++ src/components/UserMenu.tsx | 37 ++++++ src/lib/tmdb.client.ts | 98 ++++++++++++++++ 4 files changed, 443 insertions(+) create mode 100644 src/app/api/tmdb-details/route.ts diff --git a/src/app/api/tmdb-details/route.ts b/src/app/api/tmdb-details/route.ts new file mode 100644 index 0000000..f82a4f6 --- /dev/null +++ b/src/app/api/tmdb-details/route.ts @@ -0,0 +1,180 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + searchTMDBMulti, + getTMDBMovieDetails, + getTMDBTVDetails, + getTMDBImageUrl, +} from '@/lib/tmdb.client'; +import { getConfig } from '@/lib/config'; + +// 服务器端缓存(内存) +const searchCache = new Map< + string, + { data: { tmdbId: number; mediaType: 'movie' | 'tv' }; timestamp: number } +>(); +const CACHE_TTL = 24 * 60 * 60 * 1000; // 1天 + +// 移除季度信息的辅助函数 +function removeSeasonInfo(title: string): string { + return title + .replace(/第[一二三四五六七八九十\d]+[((]\d+[))][季部]/g, '') + .replace(/第[一二三四五六七八九十\d]+[季部]/g, '') + .replace(/[((]\d+[))]/g, '') + .replace(/\s+season\s+\d+/gi, '') + .replace(/\s+S\d+/gi, '') + .trim(); +} + +// 精确匹配标题 +function findExactMatch(results: any[], originalTitle: string): any | null { + if (!results || results.length === 0) return null; + if (results.length === 1) return results[0]; + + const cleanTitle = originalTitle.toLowerCase().trim(); + + // 尝试精确匹配 + for (const result of results) { + const resultTitle = (result.title || result.name || '').toLowerCase().trim(); + if (resultTitle === cleanTitle) { + return result; + } + } + + // 如果没有精确匹配,返回第一个 + return results[0]; +} + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const title = searchParams.get('title'); + const cachedId = searchParams.get('cachedId'); + + if (!title && !cachedId) { + return NextResponse.json( + { error: '缺少 title 或 cachedId 参数' }, + { 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: 500 } + ); + } + + let tmdbId: number; + let mediaType: 'movie' | 'tv'; + + // 如果提供了cachedId,直接使用 + if (cachedId) { + const [type, id] = cachedId.split(':'); + mediaType = type as 'movie' | 'tv'; + tmdbId = parseInt(id, 10); + } else { + // 否则需要搜索获取ID + const cleanedTitle = removeSeasonInfo(title!); + const cacheKey = `search_${cleanedTitle}`; + + // 检查服务器缓存 + const cached = searchCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + console.log('使用服务器缓存的搜索结果'); + tmdbId = cached.data.tmdbId; + mediaType = cached.data.mediaType; + } else { + // 搜索TMDB + console.log('搜索TMDB:', cleanedTitle); + const searchResult = await searchTMDBMulti( + tmdbApiKey, + cleanedTitle, + tmdbProxy + ); + + if (searchResult.code !== 200 || !searchResult.results.length) { + return NextResponse.json( + { error: '未找到匹配的内容' }, + { status: 404 } + ); + } + + // 精确匹配 + const match = findExactMatch(searchResult.results, cleanedTitle); + if (!match) { + return NextResponse.json( + { error: '未找到匹配的内容' }, + { status: 404 } + ); + } + + tmdbId = match.id; + mediaType = match.media_type; + + // 保存到服务器缓存 + searchCache.set(cacheKey, { + data: { tmdbId, mediaType }, + timestamp: Date.now(), + }); + + // 清理过期缓存 + Array.from(searchCache.entries()).forEach(([key, value]) => { + if (Date.now() - value.timestamp > CACHE_TTL) { + searchCache.delete(key); + } + }); + } + } + + // 获取详情 + let detailsResult; + if (mediaType === 'movie') { + detailsResult = await getTMDBMovieDetails(tmdbApiKey, tmdbId, tmdbProxy); + } else { + detailsResult = await getTMDBTVDetails(tmdbApiKey, tmdbId, tmdbProxy); + } + + if (detailsResult.code !== 200 || !detailsResult.details) { + return NextResponse.json( + { error: '获取详情失败' }, + { status: detailsResult.code } + ); + } + + const details = detailsResult.details; + + // 构建返回数据 + const responseData = { + tmdbId: `${mediaType}:${tmdbId}`, // 用于缓存 + mediaType, + title: details.title || details.name, + backdrop: details.backdrop_path + ? getTMDBImageUrl(details.backdrop_path, 'w1280') + : null, + poster: details.poster_path + ? getTMDBImageUrl(details.poster_path, 'w500') + : null, + overview: details.overview || '', + rating: details.vote_average ? details.vote_average.toFixed(1) : '', + releaseDate: details.release_date || details.first_air_date || '', + }; + + return NextResponse.json(responseData, { + status: 200, + headers: { + 'Cache-Control': 'public, max-age=86400', // 浏览器缓存1天 + }, + }); + } catch (error) { + console.error('获取 TMDB 详情失败:', error); + return NextResponse.json( + { error: '获取详情失败' }, + { status: 500 } + ); + } +} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 38c87d3..5141440 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -95,6 +95,9 @@ function PlayPageClient() { const [error, setError] = useState(null); const [detail, setDetail] = useState(null); + // TMDB背景图 + const [tmdbBackdrop, setTmdbBackdrop] = useState(null); + // 收藏状态 const [favorited, setFavorited] = useState(false); @@ -562,6 +565,118 @@ function PlayPageClient() { fetchDoubanRating(); }, [videoDoubanId]); + // 获取TMDB背景图 + useEffect(() => { + const fetchTMDBBackdrop = async () => { + // 检查是否禁用背景图 + if (typeof window !== 'undefined') { + const disabled = localStorage.getItem('tmdb_backdrop_disabled'); + if (disabled === 'true') { + setTmdbBackdrop(null); + return; + } + } + + if (!videoTitle) { + setTmdbBackdrop(null); + return; + } + + try { + // 检查title到tmdbId的映射缓存(1个月) + const mappingCacheKey = `tmdb_title_mapping_${videoTitle}`; + const mappingCache = localStorage.getItem(mappingCacheKey); + let cachedId: string | null = null; + + if (mappingCache) { + try { + const { tmdbId, timestamp } = JSON.parse(mappingCache); + const cacheAge = Date.now() - timestamp; + const cacheMaxAge = 30 * 24 * 60 * 60 * 1000; // 1个月 + + if (cacheAge < cacheMaxAge && tmdbId) { + console.log('使用缓存的TMDB ID映射'); + cachedId = tmdbId; + + // 检查TMDB详情缓存(1天) + const detailsCacheKey = `tmdb_details_${tmdbId}`; + const detailsCache = localStorage.getItem(detailsCacheKey); + + if (detailsCache) { + try { + const { data, timestamp: detTimestamp } = JSON.parse(detailsCache); + const detCacheAge = Date.now() - detTimestamp; + const detCacheMaxAge = 24 * 60 * 60 * 1000; // 1天 + + if (detCacheAge < detCacheMaxAge && data && data.backdrop) { + console.log('使用缓存的TMDB详情数据'); + setTmdbBackdrop(data.backdrop); + return; + } + } catch (e) { + console.error('解析详情缓存失败:', e); + } + } + } + } catch (e) { + console.error('解析映射缓存失败:', e); + } + } + + // 构建请求URL + const url = cachedId + ? `/api/tmdb-details?cachedId=${encodeURIComponent(cachedId)}` + : `/api/tmdb-details?title=${encodeURIComponent(videoTitle)}`; + + const response = await fetch(url); + + if (!response.ok) { + console.log('获取TMDB详情失败'); + setTmdbBackdrop(null); + return; + } + + const result = await response.json(); + + if (result.backdrop) { + setTmdbBackdrop(result.backdrop); + + // 保存title到tmdbId的映射到localStorage(1个月) + if (result.tmdbId) { + try { + localStorage.setItem( + mappingCacheKey, + JSON.stringify({ + tmdbId: result.tmdbId, + timestamp: Date.now(), + }) + ); + + // 保存TMDB详情数据到localStorage(1天) + const detailsCacheKey = `tmdb_details_${result.tmdbId}`; + localStorage.setItem( + detailsCacheKey, + JSON.stringify({ + data: result, + timestamp: Date.now(), + }) + ); + } catch (e) { + console.error('保存缓存失败:', e); + } + } + } else { + setTmdbBackdrop(null); + } + } catch (error) { + console.error('获取TMDB背景图失败:', error); + setTmdbBackdrop(null); + } + }; + + fetchTMDBBackdrop(); + }, [videoTitle]); + // 视频播放地址 const [videoUrl, setVideoUrl] = useState(''); @@ -4405,6 +4520,19 @@ function PlayPageClient() { return ( + {/* TMDB背景图 */} + {tmdbBackdrop && ( +
+ )} {/* 弹幕源选择对话框 */} {showDanmakuSourceSelector && danmakuMatches.length > 0 && (
diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index 4c8714f..aad359c 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -90,6 +90,7 @@ export const UserMenu: React.FC = () => { const [fluidSearch, setFluidSearch] = useState(true); const [liveDirectConnect, setLiveDirectConnect] = useState(false); const [danmakuHeatmapDisabled, setDanmakuHeatmapDisabled] = useState(false); + const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false); const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent'); const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent'); const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState(''); @@ -309,6 +310,11 @@ export const UserMenu: React.FC = () => { if (savedDanmakuHeatmapDisabled !== null) { setDanmakuHeatmapDisabled(savedDanmakuHeatmapDisabled === 'true'); } + + const savedTmdbBackdropDisabled = localStorage.getItem('tmdb_backdrop_disabled'); + if (savedTmdbBackdropDisabled !== null) { + setTmdbBackdropDisabled(savedTmdbBackdropDisabled === 'true'); + } } }, []); @@ -513,6 +519,13 @@ export const UserMenu: React.FC = () => { } }; + const handleTmdbBackdropDisabledToggle = (value: boolean) => { + setTmdbBackdropDisabled(value); + if (typeof window !== 'undefined') { + localStorage.setItem('tmdb_backdrop_disabled', String(value)); + } + }; + const handleDoubanDataSourceChange = (value: string) => { setDoubanDataSource(value); if (typeof window !== 'undefined') { @@ -1204,6 +1217,30 @@ export const UserMenu: React.FC = () => {
+ {/* 禁用背景图渲染 */} +
+
+

+ 禁用背景图渲染 +

+

+ 关闭播放页面的TMDB背景图显示(需手动刷新页面生效) +

+
+ +
+ {/* 分割线 */}
diff --git a/src/lib/tmdb.client.ts b/src/lib/tmdb.client.ts index 60cdfdc..52f6f03 100644 --- a/src/lib/tmdb.client.ts +++ b/src/lib/tmdb.client.ts @@ -503,3 +503,101 @@ export async function getTMDBTVRecommendations( return { code: 500, results: [] }; } } + +/** + * 获取 TMDB 电影详情 + * @param apiKey - TMDB API Key + * @param movieId - 电影ID + * @param proxy - 代理服务器地址 + * @returns 电影详情 + */ +export async function getTMDBMovieDetails( + apiKey: string, + movieId: number, + proxy?: string +): Promise<{ code: number; details: any }> { + try { + if (!apiKey) { + return { code: 400, details: null }; + } + + const url = `https://api.themoviedb.org/3/movie/${movieId}?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 API 请求失败:', response.status, response.statusText); + return { code: response.status, details: null }; + } + + const data: any = await response.json(); + + return { + code: 200, + details: data, + }; + } catch (error) { + console.error('获取 TMDB 电影详情失败:', error); + return { code: 500, details: null }; + } +} + +/** + * 获取 TMDB 电视剧详情 + * @param apiKey - TMDB API Key + * @param tvId - 电视剧ID + * @param proxy - 代理服务器地址 + * @returns 电视剧详情 + */ +export async function getTMDBTVDetails( + apiKey: string, + tvId: number, + proxy?: string +): Promise<{ code: number; details: any }> { + try { + if (!apiKey) { + return { code: 400, details: 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 API 请求失败:', response.status, response.statusText); + return { code: response.status, details: null }; + } + + const data: any = await response.json(); + + return { + code: 200, + details: data, + }; + } catch (error) { + console.error('获取 TMDB 电视剧详情失败:', error); + return { code: 500, details: null }; + } +}