更多推荐增加tmdb源
This commit is contained in:
@@ -328,6 +328,7 @@ interface SiteConfig {
|
|||||||
TMDBApiKey?: string;
|
TMDBApiKey?: string;
|
||||||
TMDBProxy?: string;
|
TMDBProxy?: string;
|
||||||
BannerDataSource?: string;
|
BannerDataSource?: string;
|
||||||
|
RecommendationDataSource?: string;
|
||||||
PansouApiUrl?: string;
|
PansouApiUrl?: string;
|
||||||
PansouUsername?: string;
|
PansouUsername?: string;
|
||||||
PansouPassword?: string;
|
PansouPassword?: string;
|
||||||
@@ -5416,6 +5417,7 @@ const SiteConfigComponent = ({
|
|||||||
TMDBApiKey: '',
|
TMDBApiKey: '',
|
||||||
TMDBProxy: '',
|
TMDBProxy: '',
|
||||||
BannerDataSource: 'TMDB',
|
BannerDataSource: 'TMDB',
|
||||||
|
RecommendationDataSource: 'Mixed',
|
||||||
PansouApiUrl: '',
|
PansouApiUrl: '',
|
||||||
PansouUsername: '',
|
PansouUsername: '',
|
||||||
PansouPassword: '',
|
PansouPassword: '',
|
||||||
@@ -6002,6 +6004,30 @@ const SiteConfigComponent = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 配置 */}
|
{/* 弹幕 API 配置 */}
|
||||||
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
<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'>
|
<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';
|
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
|
||||||
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
|
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
|
||||||
let enableComments = false;
|
let enableComments = false;
|
||||||
|
let recommendationDataSource = 'Mixed';
|
||||||
let tmdbApiKey = '';
|
let tmdbApiKey = '';
|
||||||
let openListEnabled = false;
|
let openListEnabled = false;
|
||||||
let customCategories = [] as {
|
let customCategories = [] as {
|
||||||
@@ -87,6 +88,7 @@ export default async function RootLayout({
|
|||||||
}));
|
}));
|
||||||
fluidSearch = config.SiteConfig.FluidSearch;
|
fluidSearch = config.SiteConfig.FluidSearch;
|
||||||
enableComments = config.SiteConfig.EnableComments;
|
enableComments = config.SiteConfig.EnableComments;
|
||||||
|
recommendationDataSource = config.SiteConfig.RecommendationDataSource || 'Mixed';
|
||||||
tmdbApiKey = config.SiteConfig.TMDBApiKey || '';
|
tmdbApiKey = config.SiteConfig.TMDBApiKey || '';
|
||||||
// 检查是否启用了 OpenList 功能
|
// 检查是否启用了 OpenList 功能
|
||||||
openListEnabled = !!(
|
openListEnabled = !!(
|
||||||
@@ -108,6 +110,7 @@ export default async function RootLayout({
|
|||||||
CUSTOM_CATEGORIES: customCategories,
|
CUSTOM_CATEGORIES: customCategories,
|
||||||
FLUID_SEARCH: fluidSearch,
|
FLUID_SEARCH: fluidSearch,
|
||||||
EnableComments: enableComments,
|
EnableComments: enableComments,
|
||||||
|
RecommendationDataSource: recommendationDataSource,
|
||||||
ENABLE_TVBOX_SUBSCRIBE: process.env.ENABLE_TVBOX_SUBSCRIBE === 'true',
|
ENABLE_TVBOX_SUBSCRIBE: process.env.ENABLE_TVBOX_SUBSCRIBE === 'true',
|
||||||
ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
||||||
VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback',
|
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 DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector';
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import DoubanComments from '@/components/DoubanComments';
|
import DoubanComments from '@/components/DoubanComments';
|
||||||
import DoubanRecommendations from '@/components/DoubanRecommendations';
|
import SmartRecommendations from '@/components/SmartRecommendations';
|
||||||
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
|
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
|
||||||
import Toast, { ToastProps } from '@/components/Toast';
|
import Toast, { ToastProps } from '@/components/Toast';
|
||||||
import { useEnableComments } from '@/hooks/useEnableComments';
|
import { useEnableComments } from '@/hooks/useEnableComments';
|
||||||
@@ -5212,8 +5212,8 @@ function PlayPageClient() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 豆瓣推荐区域 */}
|
{/* 推荐区域 */}
|
||||||
{videoDoubanId !== 0 && enableComments && (
|
{(videoDoubanId !== 0 || videoTitle) && (
|
||||||
<div className='mt-6 -mx-3 md:mx-0 md:px-4'>
|
<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'>
|
<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'>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
__sidebarCollapsed?: boolean;
|
__sidebarCollapsed?: boolean;
|
||||||
RUNTIME_CONFIG?: {
|
RUNTIME_CONFIG?: {
|
||||||
EnableComments: boolean;
|
EnableComments?: boolean;
|
||||||
|
RecommendationDataSource?: string;
|
||||||
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Recommendation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className='flex justify-center items-center py-8'>
|
||||||
|
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className='text-center py-8 text-gray-500 dark:text-gray-400'>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recommendations.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollableRow scrollDistance={600} bottomPadding='pb-2'>
|
||||||
|
{recommendations.map((rec, index) => (
|
||||||
|
<div
|
||||||
|
key={rec.doubanId || rec.tmdbId || index}
|
||||||
|
className='min-w-[96px] w-24 sm:min-w-[140px] sm:w-[140px]'
|
||||||
|
>
|
||||||
|
<VideoCard
|
||||||
|
title={rec.title}
|
||||||
|
poster={rec.poster}
|
||||||
|
rate={rec.rating}
|
||||||
|
douban_id={rec.doubanId ? parseInt(rec.doubanId) : undefined}
|
||||||
|
tmdb_id={rec.tmdbId}
|
||||||
|
from={rec.doubanId ? 'douban' : 'tmdb'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,9 +38,10 @@ export interface VideoCardProps {
|
|||||||
source_names?: string[];
|
source_names?: string[];
|
||||||
progress?: number;
|
progress?: number;
|
||||||
year?: string;
|
year?: string;
|
||||||
from: 'playrecord' | 'favorite' | 'search' | 'douban';
|
from: 'playrecord' | 'favorite' | 'search' | 'douban' | 'tmdb';
|
||||||
currentEpisode?: number;
|
currentEpisode?: number;
|
||||||
douban_id?: number;
|
douban_id?: number;
|
||||||
|
tmdb_id?: number;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
rate?: string;
|
rate?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -77,6 +78,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
from,
|
from,
|
||||||
currentEpisode,
|
currentEpisode,
|
||||||
douban_id,
|
douban_id,
|
||||||
|
tmdb_id,
|
||||||
onDelete,
|
onDelete,
|
||||||
rate,
|
rate,
|
||||||
type = '',
|
type = '',
|
||||||
@@ -246,7 +248,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
// 直播内容跳转到直播页面
|
// 直播内容跳转到直播页面
|
||||||
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
||||||
router.push(url);
|
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}` : ''
|
const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''
|
||||||
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`;
|
}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`;
|
||||||
router.push(url);
|
router.push(url);
|
||||||
@@ -283,7 +285,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
// 直播内容跳转到直播页面
|
// 直播内容跳转到直播页面
|
||||||
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`;
|
||||||
window.open(url, '_blank');
|
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())}` : ''}`;
|
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');
|
window.open(url, '_blank');
|
||||||
} else if (actualSource && actualId) {
|
} else if (actualSource && actualId) {
|
||||||
@@ -408,6 +410,16 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
showRating: !!rate,
|
showRating: !!rate,
|
||||||
showYear: false,
|
showYear: false,
|
||||||
},
|
},
|
||||||
|
tmdb: {
|
||||||
|
showSourceName: false,
|
||||||
|
showProgress: false,
|
||||||
|
showPlayButton: !isUpcoming, // 即将上映不显示播放按钮
|
||||||
|
showHeart: false,
|
||||||
|
showCheckCircle: false,
|
||||||
|
showDoubanLink: false,
|
||||||
|
showRating: !!rate,
|
||||||
|
showYear: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return configs[from] || configs.search;
|
return configs[from] || configs.search;
|
||||||
}, [from, isAggregate, douban_id, rate, isUpcoming]);
|
}, [from, isAggregate, douban_id, rate, isUpcoming]);
|
||||||
@@ -439,7 +451,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
// 聚合源信息 - 直接在菜单中展示,不需要单独的操作项
|
// 聚合源信息 - 直接在菜单中展示,不需要单独的操作项
|
||||||
|
|
||||||
// 收藏/取消收藏操作
|
// 收藏/取消收藏操作
|
||||||
if (config.showHeart && from !== 'douban' && actualSource && actualId) {
|
if (config.showHeart && from !== 'douban' && from !== 'tmdb' && actualSource && actualId) {
|
||||||
const currentFavorited = from === 'search' ? searchFavorited : favorited;
|
const currentFavorited = from === 'search' ? searchFavorited : favorited;
|
||||||
|
|
||||||
if (from === 'search') {
|
if (from === 'search') {
|
||||||
|
|||||||
@@ -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<string>('Mixed');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 从运行时配置中读取
|
||||||
|
if (typeof window !== 'undefined' && window.RUNTIME_CONFIG) {
|
||||||
|
const configValue = window.RUNTIME_CONFIG.RecommendationDataSource;
|
||||||
|
setDataSource(configValue || 'Mixed');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return dataSource;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export interface AdminConfig {
|
|||||||
TMDBApiKey?: string;
|
TMDBApiKey?: string;
|
||||||
TMDBProxy?: string;
|
TMDBProxy?: string;
|
||||||
BannerDataSource?: string; // 轮播图数据源:TMDB 或 TX
|
BannerDataSource?: string; // 轮播图数据源:TMDB 或 TX
|
||||||
|
RecommendationDataSource?: string; // 更多推荐数据源:Douban、TMDB、Mixed、MixedSmart
|
||||||
// Pansou配置
|
// Pansou配置
|
||||||
PansouApiUrl?: string;
|
PansouApiUrl?: string;
|
||||||
PansouUsername?: string;
|
PansouUsername?: string;
|
||||||
|
|||||||
@@ -356,3 +356,150 @@ export function getGenreNames(genreIds: number[] = [], limit: number = 2): strin
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.slice(0, limit);
|
.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: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user