更多推荐增加tmdb源
This commit is contained in:
@@ -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 = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 更多推荐数据源 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
更多推荐数据源
|
||||
</label>
|
||||
<select
|
||||
value={siteSettings.RecommendationDataSource || 'Mixed'}
|
||||
onChange={(e) =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
RecommendationDataSource: e.target.value,
|
||||
}))
|
||||
}
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
>
|
||||
<option value='Mixed'>混合</option>
|
||||
<option value='Douban'>豆瓣</option>
|
||||
<option value='TMDB'>TMDB</option>
|
||||
</select>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
选择详情页"更多推荐"的数据来源。混合模式会根据豆瓣ID和评论开关自动切换数据源
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 弹幕 API 配置 */}
|
||||
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
||||
|
||||
@@ -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<string, { data: any; timestamp: number }>();
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 豆瓣推荐区域 */}
|
||||
{videoDoubanId !== 0 && enableComments && (
|
||||
{/* 推荐区域 */}
|
||||
{(videoDoubanId !== 0 || videoTitle) && (
|
||||
<div className='mt-6 -mx-3 md:mx-0 md:px-4'>
|
||||
<div className='bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm rounded-xl border border-gray-200/50 dark:border-gray-700/50 overflow-hidden'>
|
||||
{/* 标题 */}
|
||||
@@ -5228,7 +5228,10 @@ function PlayPageClient() {
|
||||
|
||||
{/* 推荐内容 */}
|
||||
<div className='px-3 pt-3 md:px-6 md:pt-6'>
|
||||
<DoubanRecommendations doubanId={videoDoubanId} />
|
||||
<SmartRecommendations
|
||||
doubanId={videoDoubanId !== 0 ? videoDoubanId : undefined}
|
||||
videoTitle={videoTitle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user