From 7a4a8262e17fe8c9e369a64eae4080fa238e466f Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 5 Jan 2026 22:14:07 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BD=AE=E6=92=AD=E5=9B=BE=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=BA=90=E5=A2=9E=E5=8A=A0=E8=B1=86=E7=93=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 7 +- src/app/api/tmdb/trending/route.ts | 166 ++++++++++++++++++++++++++++- src/app/api/video-proxy/route.ts | 91 ++++++++++++++++ src/components/BannerCarousel.tsx | 57 ++++++++-- src/lib/admin.types.ts | 2 +- src/lib/changelog.ts | 2 +- src/lib/utils.ts | 39 +++---- 7 files changed, 326 insertions(+), 38 deletions(-) create mode 100644 src/app/api/video-proxy/route.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 248f9a5..277ef6c 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -6006,7 +6006,7 @@ const SiteConfigComponent = ({ DanmakuApiToken: '87654321', TMDBApiKey: '', TMDBProxy: '', - BannerDataSource: 'TMDB', + BannerDataSource: 'Douban', RecommendationDataSource: 'Mixed', PansouApiUrl: '', PansouUsername: '', @@ -6095,7 +6095,7 @@ const SiteConfigComponent = ({ DanmakuApiToken: config.SiteConfig.DanmakuApiToken || '87654321', TMDBApiKey: config.SiteConfig.TMDBApiKey || '', TMDBProxy: config.SiteConfig.TMDBProxy || '', - BannerDataSource: config.SiteConfig.BannerDataSource || 'TMDB', + BannerDataSource: config.SiteConfig.BannerDataSource || 'Douban', PansouApiUrl: config.SiteConfig.PansouApiUrl || '', PansouUsername: config.SiteConfig.PansouUsername || '', PansouPassword: config.SiteConfig.PansouPassword || '', @@ -6577,7 +6577,7 @@ const SiteConfigComponent = ({ 轮播图数据源 diff --git a/src/app/api/tmdb/trending/route.ts b/src/app/api/tmdb/trending/route.ts index 997a635..cf54406 100644 --- a/src/app/api/tmdb/trending/route.ts +++ b/src/app/api/tmdb/trending/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'; import { getTMDBTrendingContent, getTMDBVideos } from '@/lib/tmdb.client'; import { getConfig } from '@/lib/config'; +import { fetchDoubanData } from '@/lib/douban'; // 缓存配置 - 服务器内存缓存3小时 const CACHE_DURATION = 3 * 60 * 60 * 1000; // 3小时 @@ -10,6 +11,7 @@ const CACHE_DURATION = 3 * 60 * 60 * 1000; // 3小时 // 为不同数据源分别维护缓存 let tmdbCache: { data: any; timestamp: number } | null = null; let txCache: { data: any; timestamp: number } | null = null; +let doubanCache: { data: any; timestamp: number } | null = null; export const dynamic = 'force-dynamic'; @@ -17,10 +19,10 @@ export async function GET() { try { // 获取配置 const config = await getConfig(); - const bannerDataSource = config.SiteConfig?.BannerDataSource || 'TMDB'; + const bannerDataSource = config.SiteConfig?.BannerDataSource || 'Douban'; // 根据数据源选择对应的缓存 - const cache = bannerDataSource === 'TX' ? txCache : tmdbCache; + const cache = bannerDataSource === 'TX' ? txCache : bannerDataSource === 'Douban' ? doubanCache : tmdbCache; // 检查缓存 if (cache && Date.now() - cache.timestamp < CACHE_DURATION) { @@ -30,7 +32,17 @@ export async function GET() { let result: any; // 根据配置的数据源获取数据 - if (bannerDataSource === 'TX') { + if (bannerDataSource === 'Douban') { + // 使用豆瓣数据源 + result = await getDoubanBannerContent(); + // 添加数据源标识 + result.source = 'Douban'; + // 更新豆瓣缓存 + doubanCache = { + data: result, + timestamp: Date.now(), + }; + } else if (bannerDataSource === 'TX') { // 使用TX数据源 result = await getTXBannerContent(); // 添加数据源标识 @@ -234,3 +246,151 @@ function parseTXBannerData(data: any): any[] { return []; } } + +/** + * 获取豆瓣轮播图内容 + */ +async function getDoubanBannerContent(): Promise<{ code: number; list: any[] }> { + try { + // 获取豆瓣热门电影 + const hotMoviesUrl = 'https://m.douban.com/rexxar/api/v2/subject/recent_hot/movie?start=0&limit=10&category=热门&type=全部'; + + interface DoubanHotMovie { + id: string; + title: string; + card_subtitle?: string; + pic?: { + large: string; + normal: string; + }; + rating?: { + value: number; + }; + } + + interface DoubanHotMoviesResponse { + items: DoubanHotMovie[]; + } + + const hotMoviesData = await fetchDoubanData(hotMoviesUrl); + + if (!hotMoviesData.items || hotMoviesData.items.length === 0) { + return { code: 200, list: [] }; + } + + // 取前5个电影 + const topMovies = hotMoviesData.items.slice(0, 5); + + // 为每个电影获取详情信息 + const bannerItems = await Promise.all( + topMovies.map(async (movie) => { + try { + const detailUrl = `https://m.douban.com/rexxar/api/v2/subject/${movie.id}`; + + interface DoubanDetailResponse { + id: string; + title: string; + original_title?: string; + year: string; + rating?: { + value: number; + }; + intro?: string; + genres?: string[]; + cover_url?: string; + trailers?: Array<{ + video_url?: string; + [key: string]: any; + }>; + [key: string]: any; + } + + const detail = await fetchDoubanData(detailUrl); + + // 获取预告片链接(取第一个)- 豆瓣是直链视频URL + const trailerUrl = detail.trailers && detail.trailers.length > 0 + ? detail.trailers[0].video_url + : null; + + // 获取横屏图片 + const backdropPath = detail.cover_url || movie.pic?.large || movie.pic?.normal || ''; + + // 提取年份 + const year = detail.year || movie.card_subtitle?.match(/(\d{4})/)?.[1] || ''; + + // 从card_subtitle提取标签(只读取第二个部分,通过空格分割) + let tags: string[] = []; + if (movie.card_subtitle) { + const parts = movie.card_subtitle.split('/').map(s => s.trim()); + // 过滤掉年份(纯数字)和空字符串 + const filteredParts = parts.filter(part => + part && !/^\d{4}$/.test(part) + ); + // 取第二个部分(类型),通过空格分割 + if (filteredParts.length >= 2) { + tags = filteredParts[1].split(/\s+/).filter(t => t); + } + } + + return { + id: movie.id, + title: detail.title, + backdrop_path: backdropPath, + poster_path: backdropPath, + release_date: year, + overview: detail.intro || '', + vote_average: detail.rating?.value || movie.rating?.value || 0, + media_type: 'movie', + genre_ids: [], + genres: tags, // 使用从card_subtitle提取的标签 + trailer_url: trailerUrl, // 豆瓣预告片直链 + video_key: null, // 豆瓣不使用YouTube key + }; + } catch (error) { + console.error(`获取豆瓣电影 ${movie.id} 详情失败:`, error); + + // 从card_subtitle提取标签(只读取第二个部分,通过空格分割) + let tags: string[] = []; + if (movie.card_subtitle) { + const parts = movie.card_subtitle.split('/').map(s => s.trim()); + // 过滤掉年份(纯数字)和空字符串 + const filteredParts = parts.filter(part => + part && !/^\d{4}$/.test(part) + ); + // 取第二个部分(类型),通过空格分割 + if (filteredParts.length >= 2) { + tags = filteredParts[1].split(/\s+/).filter(t => t); + } + } + + // 如果获取详情失败,使用基本信息 + return { + id: movie.id, + title: movie.title, + backdrop_path: movie.pic?.large || movie.pic?.normal || '', + poster_path: movie.pic?.large || movie.pic?.normal || '', + release_date: movie.card_subtitle?.match(/(\d{4})/)?.[1] || '', + overview: '', + vote_average: movie.rating?.value || 0, + media_type: 'movie', + genre_ids: [], + genres: tags, // 使用从card_subtitle提取的标签 + trailer_url: null, + video_key: null, + }; + } + }) + ); + + // 过滤掉没有图片的项目 + const validBannerItems = bannerItems.filter(item => item.backdrop_path); + + return { + code: 200, + list: validBannerItems, + }; + } catch (error) { + console.error('获取豆瓣轮播图数据失败:', error); + return { code: 500, list: [] }; + } +} diff --git a/src/app/api/video-proxy/route.ts b/src/app/api/video-proxy/route.ts new file mode 100644 index 0000000..ddf85e1 --- /dev/null +++ b/src/app/api/video-proxy/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; + +// 视频代理接口,支持Range请求 +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const videoUrl = searchParams.get('url'); + + if (!videoUrl) { + return NextResponse.json({ error: 'Missing video URL' }, { status: 400 }); + } + + try { + // 获取客户端的Range请求头 + const range = request.headers.get('range'); + + const fetchHeaders: HeadersInit = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', + Accept: 'video/mp4,video/*;q=0.9,*/*;q=0.8', + Referer: 'https://movie.douban.com/', + }; + + // 如果客户端发送了Range请求,转发给源服务器 + if (range) { + fetchHeaders['Range'] = range; + } + + const videoResponse = await fetch(videoUrl, { + headers: fetchHeaders, + }); + + if (!videoResponse.ok) { + return NextResponse.json( + { error: videoResponse.statusText }, + { status: videoResponse.status } + ); + } + + if (!videoResponse.body) { + return NextResponse.json( + { error: 'Video response has no body' }, + { status: 500 } + ); + } + + // 创建响应头 + const headers = new Headers(); + + // 复制重要的响应头 + const contentType = videoResponse.headers.get('content-type'); + if (contentType) { + headers.set('Content-Type', contentType); + } + + const contentLength = videoResponse.headers.get('content-length'); + if (contentLength) { + headers.set('Content-Length', contentLength); + } + + const contentRange = videoResponse.headers.get('content-range'); + if (contentRange) { + headers.set('Content-Range', contentRange); + } + + const acceptRanges = videoResponse.headers.get('accept-ranges'); + if (acceptRanges) { + headers.set('Accept-Ranges', acceptRanges); + } + + // 设置缓存头 + headers.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000'); // 缓存1年 + headers.set('CDN-Cache-Control', 'public, s-maxage=31536000'); + headers.set('Vercel-CDN-Cache-Control', 'public, s-maxage=31536000'); + + // 返回视频流,状态码根据是否有Range请求决定 + const status = range && contentRange ? 206 : 200; + + return new Response(videoResponse.body, { + status, + headers, + }); + } catch (error) { + console.error('Error proxying video:', error); + return NextResponse.json( + { error: 'Error fetching video' }, + { status: 500 } + ); + } +} diff --git a/src/components/BannerCarousel.tsx b/src/components/BannerCarousel.tsx index 6e360a0..aef9473 100644 --- a/src/components/BannerCarousel.tsx +++ b/src/components/BannerCarousel.tsx @@ -5,6 +5,7 @@ import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { getTMDBImageUrl, getGenreNames, type TMDBItem } from '@/lib/tmdb.client'; import { ChevronLeft, ChevronRight, Play } from 'lucide-react'; +import { processImageUrl, processVideoUrl } from '@/lib/utils'; interface BannerCarouselProps { autoPlayInterval?: number; // 自动播放间隔(毫秒) @@ -14,6 +15,8 @@ interface BannerCarouselProps { interface BannerItem extends TMDBItem { subtitle?: string; // TX数据源的子标题 tags?: string[]; // TX数据源的标签 + trailer_url?: string | null; // 豆瓣预告片直链 + genres?: string[]; // 豆瓣数据源的类型标签 } export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarouselProps) { @@ -25,6 +28,7 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous const [skipNextAutoPlay, setSkipNextAutoPlay] = useState(false); // 跳过下一次自动播放 const [isYouTubeAccessible, setIsYouTubeAccessible] = useState(false); // YouTube连通性(默认false,检查后再决定) const [enableTrailers, setEnableTrailers] = useState(false); // 是否启用预告片(默认关闭) + const [dataSource, setDataSource] = useState(''); // 当前数据源 const touchStartX = useRef(0); const touchEndX = useRef(0); const isManualChange = useRef(false); // 标记是否为手动切换 @@ -45,14 +49,29 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous // 获取图片URL(处理TX完整URL和TMDB路径) const getImageUrl = (path: string | null) => { if (!path) return ''; - // 如果是完整URL(TX数据源),直接返回 + // 如果是完整URL(TX数据源或豆瓣),需要判断是否需要代理 if (path.startsWith('http://') || path.startsWith('https://')) { + // 豆瓣图片需要通过代理 + if (path.includes('doubanio.com')) { + return processImageUrl(path); + } + // TX等其他完整URL直接返回 return path; } // 否则使用TMDB的URL拼接 return getTMDBImageUrl(path, 'original'); }; + // 获取视频URL(处理豆瓣视频代理) + const getVideoUrl = (url: string | null) => { + if (!url) return null; + // 豆瓣视频需要通过域名替换代理 + if (url.includes('doubanio.com')) { + return processVideoUrl(url); + } + return url; + }; + // 读取本地设置 useEffect(() => { const setting = localStorage.getItem('enableTrailers'); @@ -61,10 +80,10 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous } }, []); - // 检测YouTube连通性 - 仅在启用预告片时检测 + // 检测YouTube连通性 - 仅在启用预告片且数据源为TMDB时检测 useEffect(() => { - // 如果未启用预告片,不进行检测 - if (!enableTrailers) { + // 如果未启用预告片或数据源不是TMDB,不进行检测 + if (!enableTrailers || dataSource !== 'TMDB') { setIsYouTubeAccessible(false); return; } @@ -91,14 +110,14 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous }; checkYouTubeAccess(); - }, [enableTrailers]); + }, [enableTrailers, dataSource]); // 获取热门内容 useEffect(() => { const fetchTrending = async () => { try { // 先尝试从所有可能的数据源缓存中读取 - const sources = ['TMDB', 'TX']; + const sources = ['TMDB', 'TX', 'Douban']; let cachedData = null; let validSource = null; @@ -126,6 +145,7 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous // 如果有有效的缓存,直接使用,不请求API if (cachedData) { setItems(cachedData); + setDataSource(validSource || ''); // 设置数据源 setIsLoading(false); return; } @@ -139,6 +159,7 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous const cacheKey = getLocalStorageKey(dataSource); setItems(result.list); + setDataSource(dataSource); // 设置数据源 // 保存到 localStorage(使用数据源特定的key) try { @@ -285,7 +306,20 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous index === currentIndex ? 'opacity-100' : 'opacity-0' }`} > - {item.video_key && isYouTubeAccessible && enableTrailers ? ( + {item.trailer_url && enableTrailers ? ( + /* 显示豆瓣直链视频 */ +
+
+ ) : item.video_key && isYouTubeAccessible && enableTrailers ? ( /* 显示YouTube视频 */