diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index de1fa2b..a0fe34b 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -328,6 +328,7 @@ interface SiteConfig { TMDBApiKey?: string; TMDBProxy?: string; BannerDataSource?: string; + RecommendationDataSource?: string; PansouApiUrl?: string; PansouUsername?: string; PansouPassword?: string; @@ -5416,6 +5417,7 @@ const SiteConfigComponent = ({ TMDBApiKey: '', TMDBProxy: '', BannerDataSource: 'TMDB', + RecommendationDataSource: 'Mixed', PansouApiUrl: '', PansouUsername: '', PansouPassword: '', @@ -6002,6 +6004,30 @@ const SiteConfigComponent = ({

+ {/* 更多推荐数据源 */} +
+ + +

+ 选择详情页"更多推荐"的数据来源。混合模式会根据豆瓣ID和评论开关自动切换数据源 +

+
+ {/* 弹幕 API 配置 */}

diff --git a/src/app/api/tmdb-recommendations/route.ts b/src/app/api/tmdb-recommendations/route.ts new file mode 100644 index 0000000..3b1b43d --- /dev/null +++ b/src/app/api/tmdb-recommendations/route.ts @@ -0,0 +1,195 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + searchTMDBMulti, + getTMDBMovieRecommendations, + getTMDBTVRecommendations, + getTMDBImageUrl, +} from '@/lib/tmdb.client'; +import { getConfig } from '@/lib/config'; + +// 服务器端缓存(1天) +const searchCache = new Map(); +const CACHE_TTL = 24 * 60 * 60 * 1000; // 1天 + +// 移除季度信息的辅助函数 +function removeSeasonInfo(title: string): string { + // 移除 "第一季"、"第1季"、"第一(1)季" 等格式 + 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 cleanedTitle = removeSeasonInfo(originalTitle).toLowerCase(); + + // 寻找完全匹配的结果 + for (const result of results) { + const resultTitle = (result.title || result.name || '').toLowerCase(); + const resultOriginalTitle = (result.original_title || result.original_name || '').toLowerCase(); + + if (resultTitle === cleanedTitle || resultOriginalTitle === cleanedTitle) { + 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'); // 浏览器缓存的ID + + if (!title && !cachedId) { + return NextResponse.json( + { error: '缺少必要参数' }, + { 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'; + + // 如果有缓存的ID,直接使用 + if (cachedId) { + const [type, id] = cachedId.split(':'); + mediaType = type as 'movie' | 'tv'; + tmdbId = parseInt(id); + } else { + // 否则搜索 + const cleanedTitle = removeSeasonInfo(title!); + const cacheKey = `search:${cleanedTitle}`; + + // 检查服务器缓存 + const cached = searchCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + tmdbId = cached.data.tmdbId; + mediaType = cached.data.mediaType; + } else { + // 搜索TMDB + const searchResult = await searchTMDBMulti(tmdbApiKey, cleanedTitle, tmdbProxy); + + if (searchResult.code !== 200 || !searchResult.results.length) { + return NextResponse.json( + { recommendations: [], tmdbId: null, mediaType: null }, + { + status: 200, + headers: { + 'Cache-Control': 'public, max-age=86400', // 浏览器缓存1天 + }, + } + ); + } + + // 过滤出电影和电视剧 + const validResults = searchResult.results.filter( + (r: any) => r.media_type === 'movie' || r.media_type === 'tv' + ); + + // 精确匹配 + const matched = findExactMatch(validResults, title!); + + if (!matched) { + return NextResponse.json( + { recommendations: [], tmdbId: null, mediaType: null }, + { + status: 200, + headers: { + 'Cache-Control': 'public, max-age=86400', + }, + } + ); + } + + tmdbId = matched.id; + mediaType = matched.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); + } + }); + } + } + + // 获取推荐 + const recommendationsResult = + mediaType === 'movie' + ? await getTMDBMovieRecommendations(tmdbApiKey, tmdbId, tmdbProxy) + : await getTMDBTVRecommendations(tmdbApiKey, tmdbId, tmdbProxy); + + if (recommendationsResult.code !== 200) { + return NextResponse.json( + { recommendations: [], tmdbId: `${mediaType}:${tmdbId}`, mediaType }, + { + status: 200, + headers: { + 'Cache-Control': 'public, max-age=86400', + }, + } + ); + } + + // 转换为统一格式 + const recommendations = (recommendationsResult.results as any[]) + .filter((r: any) => r.poster_path) // 只保留有海报的 + .slice(0, 20) // 最多20个 + .map((r: any) => ({ + tmdbId: r.id, + title: r.title || r.name, + poster: getTMDBImageUrl(r.poster_path, 'w342'), + rating: r.vote_average ? r.vote_average.toFixed(1) : '', + mediaType, + })); + + return NextResponse.json( + { + recommendations, + tmdbId: `${mediaType}:${tmdbId}`, // 返回给浏览器用于缓存 + mediaType, + }, + { + 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/layout.tsx b/src/app/layout.tsx index 7b1cf68..df5fbd3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -61,6 +61,7 @@ export default async function RootLayout({ process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true'; let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false'; let enableComments = false; + let recommendationDataSource = 'Mixed'; let tmdbApiKey = ''; let openListEnabled = false; let customCategories = [] as { @@ -87,6 +88,7 @@ export default async function RootLayout({ })); fluidSearch = config.SiteConfig.FluidSearch; enableComments = config.SiteConfig.EnableComments; + recommendationDataSource = config.SiteConfig.RecommendationDataSource || 'Mixed'; tmdbApiKey = config.SiteConfig.TMDBApiKey || ''; // 检查是否启用了 OpenList 功能 openListEnabled = !!( @@ -108,6 +110,7 @@ export default async function RootLayout({ CUSTOM_CATEGORIES: customCategories, FLUID_SEARCH: fluidSearch, EnableComments: enableComments, + RecommendationDataSource: recommendationDataSource, ENABLE_TVBOX_SUBSCRIBE: process.env.ENABLE_TVBOX_SUBSCRIBE === 'true', ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true', VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback', diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 71a951a..38c87d3 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -45,7 +45,7 @@ import EpisodeSelector from '@/components/EpisodeSelector'; import DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector'; import PageLayout from '@/components/PageLayout'; import DoubanComments from '@/components/DoubanComments'; -import DoubanRecommendations from '@/components/DoubanRecommendations'; +import SmartRecommendations from '@/components/SmartRecommendations'; import DanmakuFilterSettings from '@/components/DanmakuFilterSettings'; import Toast, { ToastProps } from '@/components/Toast'; import { useEnableComments } from '@/hooks/useEnableComments'; @@ -5212,8 +5212,8 @@ function PlayPageClient() {

- {/* 豆瓣推荐区域 */} - {videoDoubanId !== 0 && enableComments && ( + {/* 推荐区域 */} + {(videoDoubanId !== 0 || videoTitle) && (
{/* 标题 */} @@ -5228,7 +5228,10 @@ function PlayPageClient() { {/* 推荐内容 */}
- +
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 78a6d4a..62476a1 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -52,7 +52,9 @@ declare global { interface Window { __sidebarCollapsed?: boolean; RUNTIME_CONFIG?: { - EnableComments: boolean; + EnableComments?: boolean; + RecommendationDataSource?: string; + [key: string]: any; }; } } diff --git a/src/components/SmartRecommendations.tsx b/src/components/SmartRecommendations.tsx new file mode 100644 index 0000000..92506c2 --- /dev/null +++ b/src/components/SmartRecommendations.tsx @@ -0,0 +1,272 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useEnableComments } from '@/hooks/useEnableComments'; +import { useRecommendationDataSource } from '@/hooks/useRecommendationDataSource'; +import VideoCard from '@/components/VideoCard'; +import ScrollableRow from '@/components/ScrollableRow'; + +interface Recommendation { + doubanId?: string; + tmdbId?: number; + title: string; + poster: string; + rating: string; + mediaType?: 'movie' | 'tv'; +} + +interface SmartRecommendationsProps { + doubanId?: number; + videoTitle: string; +} + +export default function SmartRecommendations({ + doubanId, + videoTitle, +}: SmartRecommendationsProps) { + const [recommendations, setRecommendations] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const enableComments = useEnableComments(); + const recommendationDataSource = useRecommendationDataSource(); + + // 决定使用哪个数据源 + const getDataSource = useCallback(() => { + // 如果没有配置,默认使用混合模式 + const dataSource = recommendationDataSource || 'Mixed'; + + switch (dataSource) { + case 'TMDB': + return 'tmdb'; + case 'Douban': + // 豆瓣类型需要检查开关和豆瓣ID + return enableComments && doubanId ? 'douban' : null; + case 'Mixed': + // 混合模式:优先豆瓣,无豆瓣ID或关闭评论开关时使用TMDB + if (!enableComments || !doubanId) { + return 'tmdb'; + } + return 'douban'; + default: + return doubanId && enableComments ? 'douban' : 'tmdb'; + } + }, [recommendationDataSource, enableComments, doubanId]); + + const fetchDoubanRecommendations = useCallback(async () => { + if (!doubanId) return; + + try { + console.log('正在获取豆瓣推荐'); + setLoading(true); + setError(null); + + // 检查localStorage缓存 + const cacheKey = `douban_recommendations_${doubanId}`; + const cached = localStorage.getItem(cacheKey); + + if (cached) { + try { + const { data, timestamp } = JSON.parse(cached); + const cacheAge = Date.now() - timestamp; + const cacheMaxAge = 7 * 24 * 60 * 60 * 1000; // 7天 + + if (cacheAge < cacheMaxAge) { + console.log('使用缓存的豆瓣推荐数据'); + setRecommendations(data); + setLoading(false); + return; + } + } catch (e) { + console.error('解析缓存失败:', e); + } + } + + const response = await fetch(`/api/douban-recommendations?id=${doubanId}`); + + if (!response.ok) { + throw new Error('获取豆瓣推荐失败'); + } + + const result = await response.json(); + const recommendationsData = result.recommendations || []; + setRecommendations(recommendationsData); + + // 保存到localStorage + try { + localStorage.setItem( + cacheKey, + JSON.stringify({ + data: recommendationsData, + timestamp: Date.now(), + }) + ); + } catch (e) { + console.error('保存缓存失败:', e); + } + } catch (err) { + console.error('获取豆瓣推荐失败:', err); + setError(err instanceof Error ? err.message : '获取推荐失败'); + } finally { + setLoading(false); + } + }, [doubanId]); + + const fetchTMDBRecommendations = useCallback(async () => { + if (!videoTitle) return; + + try { + console.log('正在获取TMDB推荐'); + setLoading(true); + setError(null); + + // 检查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 recommendationsCacheKey = `tmdb_recommendations_${tmdbId}`; + const recommendationsCache = localStorage.getItem(recommendationsCacheKey); + + if (recommendationsCache) { + try { + const { data, timestamp: recTimestamp } = JSON.parse(recommendationsCache); + const recCacheAge = Date.now() - recTimestamp; + const recCacheMaxAge = 24 * 60 * 60 * 1000; // 1天 + + if (recCacheAge < recCacheMaxAge && data) { + console.log('使用缓存的TMDB推荐数据'); + setRecommendations(data); + setLoading(false); + return; + } + } catch (e) { + console.error('解析推荐缓存失败:', e); + } + } + } + } catch (e) { + console.error('解析映射缓存失败:', e); + } + } + + // 构建请求URL + const url = cachedId + ? `/api/tmdb-recommendations?cachedId=${encodeURIComponent(cachedId)}` + : `/api/tmdb-recommendations?title=${encodeURIComponent(videoTitle)}`; + + const response = await fetch(url); + + if (!response.ok) { + throw new Error('获取TMDB推荐失败'); + } + + const result = await response.json(); + const recommendationsData = result.recommendations || []; + setRecommendations(recommendationsData); + + // 保存title到tmdbId的映射到localStorage(1个月) + if (result.tmdbId) { + try { + localStorage.setItem( + mappingCacheKey, + JSON.stringify({ + tmdbId: result.tmdbId, + timestamp: Date.now(), + }) + ); + + // 保存TMDB推荐数据到localStorage(1天) + const recommendationsCacheKey = `tmdb_recommendations_${result.tmdbId}`; + localStorage.setItem( + recommendationsCacheKey, + JSON.stringify({ + data: recommendationsData, + timestamp: Date.now(), + }) + ); + } catch (e) { + console.error('保存缓存失败:', e); + } + } + } catch (err) { + console.error('获取TMDB推荐失败:', err); + setError(err instanceof Error ? err.message : '获取推荐失败'); + } finally { + setLoading(false); + } + }, [videoTitle]); + + useEffect(() => { + const dataSource = getDataSource(); + + if (!dataSource) { + // 不显示推荐 + setRecommendations([]); + return; + } + + if (dataSource === 'douban') { + fetchDoubanRecommendations(); + } else if (dataSource === 'tmdb') { + fetchTMDBRecommendations(); + } + }, [getDataSource, fetchDoubanRecommendations, fetchTMDBRecommendations]); + + // 如果不应该显示推荐,返回null + const dataSource = getDataSource(); + if (!dataSource) { + return null; + } + + if (loading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (recommendations.length === 0) { + return null; + } + + return ( + + {recommendations.map((rec, index) => ( +
+ +
+ ))} +
+ ); +} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index b02d330..6d86ada 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -38,9 +38,10 @@ export interface VideoCardProps { source_names?: string[]; progress?: number; year?: string; - from: 'playrecord' | 'favorite' | 'search' | 'douban'; + from: 'playrecord' | 'favorite' | 'search' | 'douban' | 'tmdb'; currentEpisode?: number; douban_id?: number; + tmdb_id?: number; onDelete?: () => void; rate?: string; type?: string; @@ -77,6 +78,7 @@ const VideoCard = forwardRef(function VideoCard from, currentEpisode, douban_id, + tmdb_id, onDelete, rate, type = '', @@ -246,7 +248,7 @@ const VideoCard = forwardRef(function VideoCard // 直播内容跳转到直播页面 const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; router.push(url); - } else if (from === 'douban' || (isAggregate && !actualSource && !actualId)) { + } else if (from === 'douban' || from === 'tmdb' || (isAggregate && !actualSource && !actualId)) { const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : '' }${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`; router.push(url); @@ -283,7 +285,7 @@ const VideoCard = forwardRef(function VideoCard // 直播内容跳转到直播页面 const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; window.open(url, '_blank'); - } else if (from === 'douban' || (isAggregate && !actualSource && !actualId)) { + } else if (from === 'douban' || from === 'tmdb' || (isAggregate && !actualSource && !actualId)) { const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`; window.open(url, '_blank'); } else if (actualSource && actualId) { @@ -408,6 +410,16 @@ const VideoCard = forwardRef(function VideoCard showRating: !!rate, showYear: false, }, + tmdb: { + showSourceName: false, + showProgress: false, + showPlayButton: !isUpcoming, // 即将上映不显示播放按钮 + showHeart: false, + showCheckCircle: false, + showDoubanLink: false, + showRating: !!rate, + showYear: false, + }, }; return configs[from] || configs.search; }, [from, isAggregate, douban_id, rate, isUpcoming]); @@ -439,7 +451,7 @@ const VideoCard = forwardRef(function VideoCard // 聚合源信息 - 直接在菜单中展示,不需要单独的操作项 // 收藏/取消收藏操作 - if (config.showHeart && from !== 'douban' && actualSource && actualId) { + if (config.showHeart && from !== 'douban' && from !== 'tmdb' && actualSource && actualId) { const currentFavorited = from === 'search' ? searchFavorited : favorited; if (from === 'search') { diff --git a/src/hooks/useRecommendationDataSource.ts b/src/hooks/useRecommendationDataSource.ts new file mode 100644 index 0000000..bdc56e8 --- /dev/null +++ b/src/hooks/useRecommendationDataSource.ts @@ -0,0 +1,21 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * Hook to get the recommendation data source configuration + * @returns The recommendation data source setting (Douban, TMDB, Mixed, MixedSmart) + */ +export function useRecommendationDataSource(): string { + const [dataSource, setDataSource] = useState('Mixed'); + + useEffect(() => { + // 从运行时配置中读取 + if (typeof window !== 'undefined' && window.RUNTIME_CONFIG) { + const configValue = window.RUNTIME_CONFIG.RecommendationDataSource; + setDataSource(configValue || 'Mixed'); + } + }, []); + + return dataSource; +} diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index a1bd60a..e3c4671 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -23,6 +23,7 @@ export interface AdminConfig { TMDBApiKey?: string; TMDBProxy?: string; BannerDataSource?: string; // 轮播图数据源:TMDB 或 TX + RecommendationDataSource?: string; // 更多推荐数据源:Douban、TMDB、Mixed、MixedSmart // Pansou配置 PansouApiUrl?: string; PansouUsername?: string; diff --git a/src/lib/tmdb.client.ts b/src/lib/tmdb.client.ts index 02ab88d..60cdfdc 100644 --- a/src/lib/tmdb.client.ts +++ b/src/lib/tmdb.client.ts @@ -356,3 +356,150 @@ export function getGenreNames(genreIds: number[] = [], limit: number = 2): strin .filter(Boolean) .slice(0, limit); } + +/** + * 搜索多媒体内容 + * @param apiKey - TMDB API Key + * @param query - 搜索关键词 + * @param proxy - 代理服务器地址 + * @returns 搜索结果列表 + */ +export async function searchTMDBMulti( + apiKey: string, + query: string, + proxy?: string +): Promise<{ code: number; results: any[] }> { + try { + if (!apiKey || !query) { + return { code: 400, results: [] }; + } + + const url = `https://api.themoviedb.org/3/search/multi?api_key=${apiKey}&language=zh-CN&query=${encodeURIComponent(query)}&page=1`; + 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 Search API 请求失败:', response.status, response.statusText); + return { code: response.status, results: [] }; + } + + const data: any = await response.json(); + + return { + code: 200, + results: data.results || [], + }; + } catch (error) { + console.error('搜索 TMDB 内容失败:', error); + return { code: 500, results: [] }; + } +} + +/** + * 获取电影推荐 + * @param apiKey - TMDB API Key + * @param movieId - 电影ID + * @param proxy - 代理服务器地址 + * @returns 推荐列表 + */ +export async function getTMDBMovieRecommendations( + apiKey: string, + movieId: number, + proxy?: string +): Promise<{ code: number; results: TMDBMovie[] }> { + try { + if (!apiKey || !movieId) { + return { code: 400, results: [] }; + } + + const url = `https://api.themoviedb.org/3/movie/${movieId}/recommendations?api_key=${apiKey}&language=zh-CN&page=1`; + 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 Movie Recommendations API 请求失败:', response.status, response.statusText); + return { code: response.status, results: [] }; + } + + const data: any = await response.json(); + + return { + code: 200, + results: data.results || [], + }; + } catch (error) { + console.error('获取 TMDB 电影推荐失败:', error); + return { code: 500, results: [] }; + } +} + +/** + * 获取电视剧推荐 + * @param apiKey - TMDB API Key + * @param tvId - 电视剧ID + * @param proxy - 代理服务器地址 + * @returns 推荐列表 + */ +export async function getTMDBTVRecommendations( + apiKey: string, + tvId: number, + proxy?: string +): Promise<{ code: number; results: TMDBTVShow[] }> { + try { + if (!apiKey || !tvId) { + return { code: 400, results: [] }; + } + + const url = `https://api.themoviedb.org/3/tv/${tvId}/recommendations?api_key=${apiKey}&language=zh-CN&page=1`; + 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 TV Recommendations API 请求失败:', response.status, response.statusText); + return { code: response.status, results: [] }; + } + + const data: any = await response.json(); + + return { + code: 200, + results: data.results || [], + }; + } catch (error) { + console.error('获取 TMDB 电视剧推荐失败:', error); + return { code: 500, results: [] }; + } +}