From cc6d1e95f3fb0e8f7661b6fcc89c88d797b6e52e Mon Sep 17 00:00:00 2001
From: mtvpls
Date: Sat, 27 Dec 2025 23:45:34 +0800
Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E5=A4=9A=E6=8E=A8=E8=8D=90=E5=A2=9E?=
=?UTF-8?q?=E5=8A=A0tmdb=E6=BA=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/admin/page.tsx | 26 +++
src/app/api/tmdb-recommendations/route.ts | 195 ++++++++++++++++
src/app/layout.tsx | 3 +
src/app/play/page.tsx | 11 +-
src/components/Sidebar.tsx | 4 +-
src/components/SmartRecommendations.tsx | 272 ++++++++++++++++++++++
src/components/VideoCard.tsx | 20 +-
src/hooks/useRecommendationDataSource.ts | 21 ++
src/lib/admin.types.ts | 1 +
src/lib/tmdb.client.ts | 147 ++++++++++++
10 files changed, 691 insertions(+), 9 deletions(-)
create mode 100644 src/app/api/tmdb-recommendations/route.ts
create mode 100644 src/components/SmartRecommendations.tsx
create mode 100644 src/hooks/useRecommendationDataSource.ts
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: [] };
+ }
+}