更多推荐增加tmdb源
This commit is contained in:
@@ -52,7 +52,9 @@ declare global {
|
||||
interface Window {
|
||||
__sidebarCollapsed?: boolean;
|
||||
RUNTIME_CONFIG?: {
|
||||
EnableComments: boolean;
|
||||
EnableComments?: boolean;
|
||||
RecommendationDataSource?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
272
src/components/SmartRecommendations.tsx
Normal file
272
src/components/SmartRecommendations.tsx
Normal file
@@ -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[];
|
||||
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<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
from,
|
||||
currentEpisode,
|
||||
douban_id,
|
||||
tmdb_id,
|
||||
onDelete,
|
||||
rate,
|
||||
type = '',
|
||||
@@ -246,7 +248,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(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<VideoCardHandle, VideoCardProps>(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<VideoCardHandle, VideoCardProps>(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<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;
|
||||
|
||||
if (from === 'search') {
|
||||
|
||||
Reference in New Issue
Block a user