轮播图数据源增加豆瓣

This commit is contained in:
mtvpls
2026-01-05 22:14:07 +08:00
parent f1a295cd75
commit 7a4a8262e1
7 changed files with 326 additions and 38 deletions

View File

@@ -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 = ({
</label>
<select
value={siteSettings.BannerDataSource || 'TMDB'}
value={siteSettings.BannerDataSource || 'Douban'}
onChange={(e) =>
setSiteSettings((prev) => ({
...prev,
@@ -6586,6 +6586,7 @@ const SiteConfigComponent = ({
}
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='Douban'></option>
<option value='TMDB'>TMDB</option>
<option value='TX'>TX</option>
</select>

View File

@@ -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<DoubanHotMoviesResponse>(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<DoubanDetailResponse>(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: [] };
}
}

View File

@@ -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 }
);
}
}

View File

@@ -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<string>(''); // 当前数据源
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 '';
// 如果是完整URLTX数据源),直接返回
// 如果是完整URLTX数据源或豆瓣),需要判断是否需要代理
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 ? (
/* 显示豆瓣直链视频 */
<div className="absolute inset-0 overflow-hidden">
<video
src={getVideoUrl(item.trailer_url) || undefined}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 min-w-full min-h-full w-auto h-auto object-cover"
autoPlay
muted
loop
playsInline
preload="metadata"
/>
</div>
) : item.video_key && isYouTubeAccessible && enableTrailers ? (
/* 显示YouTube视频 */
<div className="absolute inset-0 overflow-hidden">
<iframe
@@ -333,13 +367,20 @@ export default function BannerCarousel({ autoPlayInterval = 5000 }: BannerCarous
{currentItem.vote_average.toFixed(1)}
</span>
)}
{/* 显示TX数据源的标签 */}
{/* 显示标签优先TX的tags其次豆瓣的genres最后TMDB的genre_ids */}
{currentItem.tags && currentItem.tags.length > 0 ? (
currentItem.tags.slice(0, 3).map((tag, index) => (
<span key={index} className="px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm">
{tag}
</span>
))
) : currentItem.genres && Array.isArray(currentItem.genres) && currentItem.genres.length > 0 ? (
/* 显示豆瓣数据源的标签 */
currentItem.genres.slice(0, 3).map((genre, index) => (
<span key={index} className="px-2 py-1 bg-white/20 backdrop-blur-sm rounded text-sm">
{genre}
</span>
))
) : (
/* 显示TMDB数据源的类型标签 */
getGenreNames(currentItem.genre_ids, 3).map(genre => (

View File

@@ -22,7 +22,7 @@ export interface AdminConfig {
// TMDB配置
TMDBApiKey?: string;
TMDBProxy?: string;
BannerDataSource?: string; // 轮播图数据源TMDB 或 TX
BannerDataSource?: string; // 轮播图数据源TMDB、TX 或 Douban
RecommendationDataSource?: string; // 更多推荐数据源Douban、TMDB、Mixed、MixedSmart
// Pansou配置
PansouApiUrl?: string;

View File

@@ -12,7 +12,7 @@ export interface ChangelogEntry {
export const changelog: ChangelogEntry[] = [
{
version: '206.1.0',
date: '2026-01-04',
date: '2026-01-05',
added: [
"新增手动上传弹幕功能"
],

View File

@@ -27,7 +27,7 @@ function getDoubanImageProxyConfig(): {
}
/**
* 处理图片 URL如果设置了图片代理则使用代理
* 处理图片 URL统一使用服务器代理
*/
export function processImageUrl(originalUrl: string): string {
if (!originalUrl) return originalUrl;
@@ -42,28 +42,23 @@ export function processImageUrl(originalUrl: string): string {
return originalUrl;
}
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
switch (proxyType) {
case 'server':
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
case 'img3':
return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
case 'cmliussss-cdn-tencent':
return originalUrl.replace(
/img\d+\.doubanio\.com/g,
'img.doubanio.cmliussss.net'
);
case 'cmliussss-cdn-ali':
return originalUrl.replace(
/img\d+\.doubanio\.com/g,
'img.doubanio.cmliussss.com'
);
case 'custom':
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
case 'direct':
default:
return originalUrl;
// 统一使用服务器代理
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
}
/**
* 处理视频 URL如果<E5A682><E69E9C><EFBFBD>置了代理则使用代理与图片使用相同的代理配置
*/
export function processVideoUrl(originalUrl: string): string {
if (!originalUrl) return originalUrl;
// 仅处理豆瓣视频代理
if (!originalUrl.includes('doubanio.com')) {
return originalUrl;
}
// 统一使用服务器代理
return `/api/video-proxy?url=${encodeURIComponent(originalUrl)}`;
}
/**