豆瓣数据源增加备用源

This commit is contained in:
mtvpls
2026-04-09 21:30:07 +08:00
parent 204eec782f
commit 769dbf8310
11 changed files with 778 additions and 202 deletions
+3 -2
View File
@@ -64,6 +64,7 @@ import Drawer from '@/components/Drawer';
import EpisodeSelector from '@/components/EpisodeSelector'; import EpisodeSelector from '@/components/EpisodeSelector';
import PageLayout from '@/components/PageLayout'; import PageLayout from '@/components/PageLayout';
import PansouSearch from '@/components/PansouSearch'; import PansouSearch from '@/components/PansouSearch';
import ProxyImage from '@/components/ProxyImage';
import { useSite } from '@/components/SiteProvider'; import { useSite } from '@/components/SiteProvider';
import SmartRecommendations from '@/components/SmartRecommendations'; import SmartRecommendations from '@/components/SmartRecommendations';
import Toast, { ToastProps } from '@/components/Toast'; import Toast, { ToastProps } from '@/components/Toast';
@@ -9023,8 +9024,8 @@ function PlayPageClient() {
<div className='relative bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'> <div className='relative bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
{videoCover ? ( {videoCover ? (
<> <>
<img <ProxyImage
src={processImageUrl(videoCover)} originalSrc={videoCover}
alt={videoTitle} alt={videoTitle}
className='w-full h-full object-cover' className='w-full h-full object-cover'
/> />
+3 -3
View File
@@ -38,6 +38,7 @@ import CapsuleSwitch from '@/components/CapsuleSwitch';
import ImageViewer from '@/components/ImageViewer'; import ImageViewer from '@/components/ImageViewer';
import PageLayout from '@/components/PageLayout'; import PageLayout from '@/components/PageLayout';
import PansouSearch from '@/components/PansouSearch'; import PansouSearch from '@/components/PansouSearch';
import ProxyImage from '@/components/ProxyImage';
import SearchResultFilter, { import SearchResultFilter, {
SearchFilterCategory, SearchFilterCategory,
} from '@/components/SearchResultFilter'; } from '@/components/SearchResultFilter';
@@ -829,9 +830,8 @@ function SearchPageClient() {
> >
<div className='flex items-start gap-4'> <div className='flex items-start gap-4'>
<div className='relative h-32 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800'> <div className='relative h-32 w-24 shrink-0 overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800'>
{/* eslint-disable-next-line @next/next/no-img-element */} <ProxyImage
<img originalSrc={item.poster}
src={processImageUrl(item.poster)}
alt={item.title} alt={item.title}
className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]' className='h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.04]'
loading='lazy' loading='lazy'
+11 -12
View File
@@ -7,7 +7,8 @@ import { useCallback, useEffect, useRef,useState } from 'react';
import { type TMDBItem,getGenreNames, getTMDBImageUrl } from '@/lib/tmdb.client'; import { type TMDBItem,getGenreNames, getTMDBImageUrl } from '@/lib/tmdb.client';
import { getDoubanDetail } from '@/lib/douban.client'; import { getDoubanDetail } from '@/lib/douban.client';
import { processImageUrl } from '@/lib/utils';
import ProxyImage from '@/components/ProxyImage';
interface BannerCarouselProps { interface BannerCarouselProps {
autoPlayInterval?: number; // 自动播放间隔(毫秒) autoPlayInterval?: number; // 自动播放间隔(毫秒)
@@ -67,15 +68,15 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
} }
}; };
// 获取图片URL(处理TX完整URL和TMDB路径) // 获取图片原始URL(处理TX完整URL和TMDB路径)
const getImageUrl = (path: string | null) => { const getImageUrl = (path: string | null) => {
if (!path) return ''; if (!path) return '';
// 如果是完整URL(TX数据源或豆瓣),使用processImageUrl统一处理 // 如果是完整URL(TX数据源或豆瓣),直接返回原始地址
if (path.startsWith('http://') || path.startsWith('https://')) { if (path.startsWith('http://') || path.startsWith('https://')) {
return processImageUrl(path); return path;
} }
// 否则使用TMDB的URL拼接,并通过processImageUrl处理 // 否则使用TMDB的URL拼接原始地址
return processImageUrl(getTMDBImageUrl(path, 'original')); return getTMDBImageUrl(path, 'original');
}; };
// 获取视频URL(处理豆瓣视频代理) // 获取视频URL(处理豆瓣视频代理)
@@ -454,13 +455,11 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
</div> </div>
) : ( ) : (
/* 显示图片 */ /* 显示图片 */
<Image <ProxyImage
src={getImageUrl(item.backdrop_path || item.poster_path)} originalSrc={getImageUrl(item.backdrop_path || item.poster_path)}
alt={item.title} alt={item.title}
fill className="absolute inset-0 w-full h-full object-cover"
className="object-cover" loading={index === 0 ? 'eager' : 'lazy'}
priority={index === 0}
sizes="100vw"
/> />
)} )}
{/* 渐变遮罩 */} {/* 渐变遮罩 */}
+51 -46
View File
@@ -9,6 +9,7 @@ import { getTMDBImageUrl } from '@/lib/tmdb.client';
import { processImageUrl } from '@/lib/utils'; import { processImageUrl } from '@/lib/utils';
import ImageViewer from '@/components/ImageViewer'; import ImageViewer from '@/components/ImageViewer';
import ProxyImage from '@/components/ProxyImage';
interface DetailPanelProps { interface DetailPanelProps {
isOpen: boolean; isOpen: boolean;
@@ -373,7 +374,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
title: title, title: title,
intro: cmsData.desc, intro: cmsData.desc,
episodesCount: cmsData.episodes?.length, episodesCount: cmsData.episodes?.length,
poster: poster ? processImageUrl(poster) : poster, poster: poster,
}; };
setDetailData(data); setDetailData(data);
setOriginalDetailData(data); setOriginalDetailData(data);
@@ -393,7 +394,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
title: data.title || title, title: data.title || title,
intro: data.desc || '', intro: data.desc || '',
episodesCount: data.episodes?.length || cmsData.episodes?.length, episodesCount: data.episodes?.length || cmsData.episodes?.length,
poster: data.poster ? processImageUrl(data.poster) : poster, poster: data.poster || poster,
year: data.year, year: data.year,
}; };
setDetailData(detailData); setDetailData(detailData);
@@ -423,7 +424,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
title: data.name_cn || data.name, title: data.name_cn || data.name,
originalTitle: data.name, originalTitle: data.name,
year: data.date ? data.date.substring(0, 4) : undefined, year: data.date ? data.date.substring(0, 4) : undefined,
poster: data.images?.large ? processImageUrl(data.images.large) : poster, poster: data.images?.large || poster,
rating: data.rating rating: data.rating
? { ? {
value: data.rating.score, value: data.rating.score,
@@ -454,7 +455,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
title: data.title, title: data.title,
originalTitle: data.original_title, originalTitle: data.original_title,
year: data.year, year: data.year,
poster: (data.pic?.large || data.pic?.normal) ? processImageUrl(data.pic?.large || data.pic?.normal) : poster, poster: data.pic?.large || data.pic?.normal || poster,
rating: data.rating rating: data.rating
? { ? {
value: data.rating.value, value: data.rating.value,
@@ -873,7 +874,7 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
...prev, ...prev,
title: episodesData.name || season?.name || prev.title, title: episodesData.name || season?.name || prev.title,
intro: episodesData.overview || season?.overview || prev.overview, intro: episodesData.overview || season?.overview || prev.overview,
poster: season?.poster_path ? processImageUrl(getTMDBImageUrl(season.poster_path, 'w500')) : prev.poster, poster: season?.poster_path ? getTMDBImageUrl(season.poster_path, 'w500') : prev.poster,
releaseDate: episodesData.air_date || season?.air_date || prev.releaseDate, releaseDate: episodesData.air_date || season?.air_date || prev.releaseDate,
year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year, year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year,
episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount, episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount,
@@ -1090,11 +1091,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
style={{ height: virtualGalleryLayout.totalHeight, width: virtualGalleryLayout.usedWidth || '100%' }} style={{ height: virtualGalleryLayout.totalHeight, width: virtualGalleryLayout.usedWidth || '100%' }}
> >
{virtualGalleryLayout.visibleItems.map((image) => { {virtualGalleryLayout.visibleItems.map((image) => {
const imageUrl = processImageUrl( const imageUrl = getTMDBImageUrl(
getTMDBImageUrl(image.file_path, image.imageType === 'poster' ? 'w500' : 'original') image.file_path,
image.imageType === 'poster' ? 'w500' : 'original'
); );
const thumbUrl = processImageUrl( const thumbUrl = getTMDBImageUrl(
getTMDBImageUrl(image.file_path, image.imageType === 'poster' ? 'w342' : 'w780') image.file_path,
image.imageType === 'poster' ? 'w342' : 'w780'
); );
return ( return (
@@ -1112,12 +1115,10 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
className="relative w-full h-full overflow-hidden rounded-md bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity" className="relative w-full h-full overflow-hidden rounded-md bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(imageUrl)} onClick={() => handleImageClick(imageUrl)}
> >
<Image <ProxyImage
src={thumbUrl} originalSrc={thumbUrl}
alt={`${detailData?.title || title}-gallery-${image.index + 1}`} alt={`${detailData?.title || title}-gallery-${image.index + 1}`}
fill className="absolute inset-0 w-full h-full object-cover"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, (max-width: 1280px) 25vw, 20vw"
className="object-cover"
draggable={false} draggable={false}
/> />
<div className="absolute left-2 top-2 px-2 py-0.5 rounded-full text-xs bg-black/60 text-white"> <div className="absolute left-2 top-2 px-2 py-0.5 rounded-full text-xs bg-black/60 text-white">
@@ -1231,7 +1232,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity" className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(detailData.poster!)} onClick={() => handleImageClick(detailData.poster!)}
> >
<Image src={detailData.poster} alt={detailData.title} fill className="object-cover" draggable={false} /> <ProxyImage
originalSrc={detailData.poster}
alt={detailData.title}
className="absolute inset-0 w-full h-full object-cover"
draggable={false}
/>
</div> </div>
{galleryEntryButton} {galleryEntryButton}
</div> </div>
@@ -1356,13 +1362,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{actor.profile_path ? ( {actor.profile_path ? (
<div <div
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity" className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))} onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
> >
<Image <ProxyImage
src={processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185'))} originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
alt={actor.name} alt={actor.name}
fill className="absolute inset-0 w-full h-full object-cover"
className="object-cover"
draggable={false} draggable={false}
/> />
</div> </div>
@@ -1474,14 +1479,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity" className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500'))); handleImageClick(getTMDBImageUrl(season.poster_path, 'w500'));
}} }}
> >
<Image <ProxyImage
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))} originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
alt={season.name} alt={season.name}
fill className="absolute inset-0 w-full h-full object-cover"
className="object-cover"
draggable={false} draggable={false}
/> />
</div> </div>
@@ -1536,13 +1540,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{episode.still_path && ( {episode.still_path && (
<div <div
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity" className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))} onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
> >
<Image <ProxyImage
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))} originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
alt={episode.name} alt={episode.name}
fill className="absolute inset-0 w-full h-full object-cover"
className="object-cover"
draggable={false} draggable={false}
/> />
</div> </div>
@@ -1742,7 +1745,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity" className="relative w-32 h-48 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(detailData.poster!)} onClick={() => handleImageClick(detailData.poster!)}
> >
<Image src={detailData.poster} alt={detailData.title} fill className="object-cover" draggable={false} /> <ProxyImage
originalSrc={detailData.poster}
alt={detailData.title}
className="absolute inset-0 w-full h-full object-cover"
draggable={false}
/>
</div> </div>
{galleryEntryButton} {galleryEntryButton}
</div> </div>
@@ -1867,13 +1875,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{actor.profile_path ? ( {actor.profile_path ? (
<div <div
className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity" className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))} onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))}
> >
<Image <ProxyImage
src={processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185'))} originalSrc={getTMDBImageUrl(actor.profile_path || null, 'w185')}
alt={actor.name} alt={actor.name}
fill className="absolute inset-0 w-full h-full object-cover"
className="object-cover"
draggable={false} draggable={false}
/> />
</div> </div>
@@ -1985,14 +1992,13 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity" className="relative w-12 h-16 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 flex-shrink-0 hover:opacity-80 transition-opacity"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500'))); handleImageClick(getTMDBImageUrl(season.poster_path, 'w500'));
}} }}
> >
<Image <ProxyImage
src={processImageUrl(getTMDBImageUrl(season.poster_path, 'w92'))} originalSrc={getTMDBImageUrl(season.poster_path, 'w92')}
alt={season.name} alt={season.name}
fill className="absolute inset-0 w-full h-full object-cover"
className="object-cover"
draggable={false} draggable={false}
/> />
</div> </div>
@@ -2047,13 +2053,12 @@ const DetailPanel: React.FC<DetailPanelProps> = ({
{episode.still_path && ( {episode.still_path && (
<div <div
className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity" className="relative w-full h-36 rounded overflow-hidden bg-gray-200 dark:bg-gray-700 mb-2 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))} onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))}
> >
<Image <ProxyImage
src={processImageUrl(getTMDBImageUrl(episode.still_path, 'w300'))} originalSrc={getTMDBImageUrl(episode.still_path, 'w300')}
alt={episode.name} alt={episode.name}
fill className="absolute inset-0 w-full h-full object-cover"
className="object-cover"
draggable={false} draggable={false}
/> />
</div> </div>
+5 -3
View File
@@ -12,10 +12,11 @@ import React, {
import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types'; import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types';
import { EpisodeFilterConfig,SearchResult } from '@/lib/types'; import { EpisodeFilterConfig,SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; import { getVideoResolutionFromM3u8 } from '@/lib/utils';
import DanmakuPanel from '@/components/DanmakuPanel'; import DanmakuPanel from '@/components/DanmakuPanel';
import EpisodeFilterSettings from '@/components/EpisodeFilterSettings'; import EpisodeFilterSettings from '@/components/EpisodeFilterSettings';
import ProxyImage from '@/components/ProxyImage';
// 定义视频信息类型 // 定义视频信息类型
interface VideoInfo { interface VideoInfo {
@@ -870,10 +871,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
{source.source === 'directplay' ? ( {source.source === 'directplay' ? (
<LinkIcon className='w-6 h-6 text-blue-500' /> <LinkIcon className='w-6 h-6 text-blue-500' />
) : source.poster ? ( ) : source.poster ? (
<img <ProxyImage
src={processImageUrl(source.poster)} originalSrc={source.poster}
alt={source.title} alt={source.title}
className='w-full h-full object-cover' className='w-full h-full object-cover'
retryOnError={false}
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
target.style.display = 'none'; target.style.display = 'none';
+5 -7
View File
@@ -1,10 +1,11 @@
'use client'; 'use client';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import Image from 'next/image';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import ProxyImage from '@/components/ProxyImage';
interface ImageViewerProps { interface ImageViewerProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -151,18 +152,15 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<Image <ProxyImage
src={imageUrl} originalSrc={imageUrl}
alt={alt} alt={alt}
width={1200}
height={1800}
className="object-contain max-w-[100vw] max-h-[100vh] sm:max-w-[90vw] sm:max-h-[90vh] w-auto h-auto" className="object-contain max-w-[100vw] max-h-[100vh] sm:max-w-[90vw] sm:max-h-[90vh] w-auto h-auto"
style={{ style={{
maxWidth: '100vw', maxWidth: '100vw',
maxHeight: '100vh', maxHeight: '100vh',
}} }}
priority loading="eager"
quality={100}
/> />
</div> </div>
</div> </div>
+49
View File
@@ -0,0 +1,49 @@
'use client';
import React from 'react';
import { processImageUrl, tryApplyDoubanImageFallback } from '@/lib/utils';
interface ProxyImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
originalSrc: string;
displaySrc?: string;
retryDelay?: number;
retryOnError?: boolean;
}
const ProxyImage: React.FC<ProxyImageProps> = ({
originalSrc,
displaySrc,
retryDelay = 2000,
retryOnError = true,
onError,
src: _src,
...props
}) => {
const handleError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
const img = e.currentTarget;
if (tryApplyDoubanImageFallback(img, originalSrc)) {
return;
}
if (retryOnError && !img.dataset.retried) {
img.dataset.retried = 'true';
window.setTimeout(() => {
img.src = displaySrc || processImageUrl(originalSrc);
}, retryDelay);
}
onError?.(e);
};
return (
<img
{...props}
src={displaySrc || processImageUrl(originalSrc)}
onError={handleError}
/>
);
};
export default ProxyImage;
+279
View File
@@ -118,11 +118,18 @@ export const UserMenu: React.FC = () => {
const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false); const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false);
const [enableTrailers, setEnableTrailers] = useState(false); const [enableTrailers, setEnableTrailers] = useState(false);
const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent'); const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent');
const [doubanDataSourceBackup, setDoubanDataSourceBackup] = useState('direct');
const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent'); const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent');
const [doubanImageProxyTypeBackup, setDoubanImageProxyTypeBackup] = useState('server');
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState(''); const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
const [doubanProxyUrlBackup, setDoubanProxyUrlBackup] = useState('');
const [doubanImageProxyUrlBackup, setDoubanImageProxyUrlBackup] = useState('');
const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false); const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false);
const [isDoubanBackupDropdownOpen, setIsDoubanBackupDropdownOpen] = useState(false);
const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] = const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] =
useState(false); useState(false);
const [isDoubanImageProxyBackupDropdownOpen, setIsDoubanImageProxyBackupDropdownOpen] =
useState(false);
const [bufferStrategy, setBufferStrategy] = useState('medium'); const [bufferStrategy, setBufferStrategy] = useState('medium');
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true); const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true); const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true);
@@ -440,6 +447,16 @@ export const UserMenu: React.FC = () => {
setDoubanProxyUrl(defaultDoubanProxy); setDoubanProxyUrl(defaultDoubanProxy);
} }
const savedDoubanDataSourceBackup = localStorage.getItem(
'doubanDataSourceBackup'
);
setDoubanDataSourceBackup(savedDoubanDataSourceBackup || 'direct');
const savedDoubanProxyUrlBackup = localStorage.getItem(
'doubanProxyUrlBackup'
);
setDoubanProxyUrlBackup(savedDoubanProxyUrlBackup || '');
const savedDoubanImageProxyType = localStorage.getItem( const savedDoubanImageProxyType = localStorage.getItem(
'doubanImageProxyType' 'doubanImageProxyType'
); );
@@ -462,6 +479,16 @@ export const UserMenu: React.FC = () => {
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl); setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
} }
const savedDoubanImageProxyTypeBackup = localStorage.getItem(
'doubanImageProxyTypeBackup'
);
setDoubanImageProxyTypeBackup(savedDoubanImageProxyTypeBackup || 'server');
const savedDoubanImageProxyUrlBackup = localStorage.getItem(
'doubanImageProxyUrlBackup'
);
setDoubanImageProxyUrlBackup(savedDoubanImageProxyUrlBackup || '');
const savedTmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl'); const savedTmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl');
if (savedTmdbImageBaseUrl !== null) { if (savedTmdbImageBaseUrl !== null) {
setTmdbImageBaseUrl(savedTmdbImageBaseUrl); setTmdbImageBaseUrl(savedTmdbImageBaseUrl);
@@ -754,6 +781,23 @@ export const UserMenu: React.FC = () => {
} }
}, [isDoubanDropdownOpen]); }, [isDoubanDropdownOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanBackupDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-datasource-backup"]')) {
setIsDoubanBackupDropdownOpen(false);
}
}
};
if (isDoubanBackupDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanBackupDropdownOpen]);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (isDoubanImageProxyDropdownOpen) { if (isDoubanImageProxyDropdownOpen) {
@@ -771,6 +815,23 @@ export const UserMenu: React.FC = () => {
} }
}, [isDoubanImageProxyDropdownOpen]); }, [isDoubanImageProxyDropdownOpen]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isDoubanImageProxyBackupDropdownOpen) {
const target = event.target as Element;
if (!target.closest('[data-dropdown="douban-image-proxy-backup"]')) {
setIsDoubanImageProxyBackupDropdownOpen(false);
}
}
};
if (isDoubanImageProxyBackupDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isDoubanImageProxyBackupDropdownOpen]);
const handleMenuClick = () => { const handleMenuClick = () => {
setIsOpen(!isOpen); setIsOpen(!isOpen);
}; };
@@ -1049,6 +1110,13 @@ export const UserMenu: React.FC = () => {
} }
}; };
const handleDoubanDataSourceBackupChange = (value: string) => {
setDoubanDataSourceBackup(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanDataSourceBackup', value);
}
};
const handleDoubanImageProxyTypeChange = (value: string) => { const handleDoubanImageProxyTypeChange = (value: string) => {
setDoubanImageProxyType(value); setDoubanImageProxyType(value);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -1056,6 +1124,20 @@ export const UserMenu: React.FC = () => {
} }
}; };
const handleDoubanImageProxyTypeBackupChange = (value: string) => {
setDoubanImageProxyTypeBackup(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanImageProxyTypeBackup', value);
}
};
const handleDoubanProxyUrlBackupChange = (value: string) => {
setDoubanProxyUrlBackup(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanProxyUrlBackup', value);
}
};
const handleDoubanImageProxyUrlChange = (value: string) => { const handleDoubanImageProxyUrlChange = (value: string) => {
setDoubanImageProxyUrl(value); setDoubanImageProxyUrl(value);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -1063,6 +1145,13 @@ export const UserMenu: React.FC = () => {
} }
}; };
const handleDoubanImageProxyUrlBackupChange = (value: string) => {
setDoubanImageProxyUrlBackup(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanImageProxyUrlBackup', value);
}
};
const handleTmdbImageBaseUrlChange = (value: string) => { const handleTmdbImageBaseUrlChange = (value: string) => {
setTmdbImageBaseUrl(value); setTmdbImageBaseUrl(value);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -1240,8 +1329,12 @@ export const UserMenu: React.FC = () => {
setEnableTrailers(false); setEnableTrailers(false);
setDoubanProxyUrl(defaultDoubanProxy); setDoubanProxyUrl(defaultDoubanProxy);
setDoubanDataSource(defaultDoubanProxyType); setDoubanDataSource(defaultDoubanProxyType);
setDoubanDataSourceBackup('direct');
setDoubanProxyUrlBackup('');
setDoubanImageProxyType(defaultDoubanImageProxyType); setDoubanImageProxyType(defaultDoubanImageProxyType);
setDoubanImageProxyUrl(defaultDoubanImageProxyUrl); setDoubanImageProxyUrl(defaultDoubanImageProxyUrl);
setDoubanImageProxyTypeBackup('server');
setDoubanImageProxyUrlBackup('');
setTmdbImageBaseUrl('https://image.tmdb.org'); setTmdbImageBaseUrl('https://image.tmdb.org');
setBufferStrategy('medium'); setBufferStrategy('medium');
setNextEpisodePreCache(true); setNextEpisodePreCache(true);
@@ -1261,8 +1354,12 @@ export const UserMenu: React.FC = () => {
localStorage.setItem('enableTrailers', 'false'); localStorage.setItem('enableTrailers', 'false');
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy); localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
localStorage.setItem('doubanDataSource', defaultDoubanProxyType); localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
localStorage.setItem('doubanDataSourceBackup', 'direct');
localStorage.setItem('doubanProxyUrlBackup', '');
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType); localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl); localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl);
localStorage.setItem('doubanImageProxyTypeBackup', 'server');
localStorage.setItem('doubanImageProxyUrlBackup', '');
localStorage.setItem('tmdbImageBaseUrl', 'https://image.tmdb.org'); localStorage.setItem('tmdbImageBaseUrl', 'https://image.tmdb.org');
localStorage.setItem('bufferStrategy', 'medium'); localStorage.setItem('bufferStrategy', 'medium');
localStorage.setItem('nextEpisodePreCache', 'true'); localStorage.setItem('nextEpisodePreCache', 'true');
@@ -1723,6 +1820,96 @@ export const UserMenu: React.FC = () => {
value={doubanProxyUrl} value={doubanProxyUrl}
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)} onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
/> />
{!doubanProxyUrl.trim() && (
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
</p>
)}
</div>
)}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<div
className='relative'
data-dropdown='douban-datasource-backup'
>
<button
type='button'
onClick={() =>
setIsDoubanBackupDropdownOpen(!isDoubanBackupDropdownOpen)
}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanDataSourceOptions.find(
(option) => option.value === doubanDataSourceBackup
)?.label
}
</button>
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanBackupDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{isDoubanBackupDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanDataSourceOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanDataSourceBackupChange(option.value);
setIsDoubanBackupDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanDataSourceBackup === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{doubanDataSourceBackup === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
</div>
{doubanDataSourceBackup === 'custom' && (
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanProxyUrlBackup}
onChange={(e) =>
handleDoubanProxyUrlBackupChange(e.target.value)
}
/>
{!doubanProxyUrlBackup.trim() && (
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
</p>
)}
</div> </div>
)} )}
@@ -1833,6 +2020,98 @@ export const UserMenu: React.FC = () => {
handleDoubanImageProxyUrlChange(e.target.value) handleDoubanImageProxyUrlChange(e.target.value)
} }
/> />
{!doubanImageProxyUrl.trim() && (
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
</p>
)}
</div>
)}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<div
className='relative'
data-dropdown='douban-image-proxy-backup'
>
<button
type='button'
onClick={() =>
setIsDoubanImageProxyBackupDropdownOpen(
!isDoubanImageProxyBackupDropdownOpen
)
}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanImageProxyTypeOptions.find(
(option) => option.value === doubanImageProxyTypeBackup
)?.label
}
</button>
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanImageProxyBackupDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{isDoubanImageProxyBackupDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanImageProxyTypeOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanImageProxyTypeBackupChange(option.value);
setIsDoubanImageProxyBackupDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanImageProxyTypeBackup === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{doubanImageProxyTypeBackup === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
</div>
{doubanImageProxyTypeBackup === 'custom' && (
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanImageProxyUrlBackup}
onChange={(e) =>
handleDoubanImageProxyUrlBackupChange(e.target.value)
}
/>
{!doubanImageProxyUrlBackup.trim() && (
<p className='text-xs text-amber-600 dark:text-amber-400 mt-1'>
</p>
)}
</div> </div>
)} )}
+7 -3
View File
@@ -21,7 +21,7 @@ import {
saveFavorite, saveFavorite,
subscribeToDataUpdates, subscribeToDataUpdates,
} from '@/lib/db.client'; } from '@/lib/db.client';
import { processImageUrl, base58Decode } from '@/lib/utils'; import { processImageUrl, base58Decode, tryApplyDoubanImageFallback } from '@/lib/utils';
import { useLongPress } from '@/hooks/useLongPress'; import { useLongPress } from '@/hooks/useLongPress';
import AIChatPanel from '@/components/AIChatPanel'; import AIChatPanel from '@/components/AIChatPanel';
@@ -750,8 +750,12 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
setShowImageViewer(true); setShowImageViewer(true);
}} }}
onError={(e) => { onError={(e) => {
const img = e.currentTarget as HTMLImageElement;
if (tryApplyDoubanImageFallback(img, actualPoster)) {
return;
}
// 图片加载失败时的重试机制 // 图片加载失败时的重试机制
const img = e.target as HTMLImageElement;
if (!img.dataset.retried) { if (!img.dataset.retried) {
img.dataset.retried = 'true'; img.dataset.retried = 'true';
setTimeout(() => { setTimeout(() => {
@@ -1598,7 +1602,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
<ImageViewer <ImageViewer
isOpen={showImageViewer} isOpen={showImageViewer}
onClose={() => setShowImageViewer(false)} onClose={() => setShowImageViewer(false)}
imageUrl={processImageUrl(actualPoster)} imageUrl={actualPoster}
alt={actualTitle} alt={actualTitle}
/> />
)} )}
+244 -98
View File
@@ -88,6 +88,36 @@ interface DoubanDetailApiResponse {
[key: string]: any; // 允许其他字段 [key: string]: any; // 允许其他字段
} }
type DoubanProxyType =
| 'direct'
| 'cors-proxy-zwei'
| 'cmliussss-cdn-tencent'
| 'cmliussss-cdn-ali'
| 'cors-anywhere'
| 'custom';
function normalizeDoubanProxyConfig(
proxyType: DoubanProxyType,
proxyUrl: string
): {
proxyType: DoubanProxyType;
proxyUrl: string;
} {
const normalizedProxyUrl = proxyUrl.trim();
if (proxyType === 'custom' && !normalizedProxyUrl) {
return {
proxyType: 'direct',
proxyUrl: '',
};
}
return {
proxyType,
proxyUrl: normalizedProxyUrl,
};
}
/** /**
* 带超时的 fetch 请求 * 带超时的 fetch 请求
*/ */
@@ -135,6 +165,14 @@ function getDoubanProxyConfig(): {
| 'cors-anywhere' | 'cors-anywhere'
| 'custom'; | 'custom';
proxyUrl: string; proxyUrl: string;
backupProxyType:
| 'direct'
| 'cors-proxy-zwei'
| 'cmliussss-cdn-tencent'
| 'cmliussss-cdn-ali'
| 'cors-anywhere'
| 'custom';
backupProxyUrl: string;
} { } {
const doubanProxyType = const doubanProxyType =
localStorage.getItem('doubanDataSource') || localStorage.getItem('doubanDataSource') ||
@@ -144,12 +182,115 @@ function getDoubanProxyConfig(): {
localStorage.getItem('doubanProxyUrl') || localStorage.getItem('doubanProxyUrl') ||
(window as any).RUNTIME_CONFIG?.DOUBAN_PROXY || (window as any).RUNTIME_CONFIG?.DOUBAN_PROXY ||
''; '';
const doubanProxyBackupType =
(localStorage.getItem('doubanDataSourceBackup') as DoubanProxyType | null) ||
'direct';
const doubanProxyBackupUrl =
localStorage.getItem('doubanProxyUrlBackup') || '';
const primaryConfig = normalizeDoubanProxyConfig(doubanProxyType, doubanProxy);
const backupConfig = normalizeDoubanProxyConfig(
doubanProxyBackupType,
doubanProxyBackupUrl
);
return { return {
proxyType: doubanProxyType, proxyType: primaryConfig.proxyType,
proxyUrl: doubanProxy, proxyUrl: primaryConfig.proxyUrl,
backupProxyType: backupConfig.proxyType,
backupProxyUrl: backupConfig.proxyUrl,
}; };
} }
function buildDoubanRequester(
proxyType: DoubanProxyType,
proxyUrl: string
): {
useDirectApi: boolean;
requestProxyUrl: string;
useTencentCDN: boolean;
useAliCDN: boolean;
} {
switch (proxyType) {
case 'cors-proxy-zwei':
return {
useDirectApi: false,
requestProxyUrl: 'https://ciao-cors.is-an.org/',
useTencentCDN: false,
useAliCDN: false,
};
case 'cmliussss-cdn-tencent':
return {
useDirectApi: false,
requestProxyUrl: '',
useTencentCDN: true,
useAliCDN: false,
};
case 'cmliussss-cdn-ali':
return {
useDirectApi: false,
requestProxyUrl: '',
useTencentCDN: false,
useAliCDN: true,
};
case 'cors-anywhere':
return {
useDirectApi: false,
requestProxyUrl: 'https://cors-anywhere.com/',
useTencentCDN: false,
useAliCDN: false,
};
case 'custom':
return {
useDirectApi: false,
requestProxyUrl: proxyUrl,
useTencentCDN: false,
useAliCDN: false,
};
case 'direct':
default:
return {
useDirectApi: true,
requestProxyUrl: '',
useTencentCDN: false,
useAliCDN: false,
};
}
}
async function requestDoubanWithFallback<T>(
primary: { proxyType: DoubanProxyType; proxyUrl: string },
backup: { proxyType: DoubanProxyType; proxyUrl: string },
runner: (requester: ReturnType<typeof buildDoubanRequester>) => Promise<T>
): Promise<T> {
const primaryRequester = buildDoubanRequester(primary.proxyType, primary.proxyUrl);
const backupRequester = buildDoubanRequester(backup.proxyType, backup.proxyUrl);
try {
return await runner(primaryRequester);
} catch (primaryError) {
const sameStrategy =
primary.proxyType === backup.proxyType && primary.proxyUrl === backup.proxyUrl;
if (sameStrategy) {
throw primaryError;
}
console.warn(
`[Douban] 主渠道失败,切换备用渠道: ${primary.proxyType} -> ${backup.proxyType}`,
primaryError
);
return runner(backupRequester);
}
}
function dispatchDoubanGlobalError(message: string) {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message },
})
);
}
}
/** /**
* 浏览器端豆瓣分类数据获取函数 * 浏览器端豆瓣分类数据获取函数
*/ */
@@ -211,14 +352,6 @@ export async function fetchDoubanCategories(
list: list, list: list,
}; };
} catch (error) { } catch (error) {
// 触发全局错误提示
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message: '获取豆瓣分类数据失败' },
})
);
}
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`); throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
} }
} }
@@ -230,25 +363,34 @@ export async function getDoubanCategories(
params: DoubanCategoriesParams params: DoubanCategoriesParams
): Promise<DoubanResult> { ): Promise<DoubanResult> {
const { kind, category, type, pageLimit = 20, pageStart = 0 } = params; const { kind, category, type, pageLimit = 20, pageStart = 0 } = params;
const { proxyType, proxyUrl } = getDoubanProxyConfig(); const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
switch (proxyType) { getDoubanProxyConfig();
case 'cors-proxy-zwei': try {
return fetchDoubanCategories(params, 'https://ciao-cors.is-an.org/'); return await requestDoubanWithFallback(
case 'cmliussss-cdn-tencent': { proxyType, proxyUrl },
return fetchDoubanCategories(params, '', true, false); { proxyType: backupProxyType, proxyUrl: backupProxyUrl },
case 'cmliussss-cdn-ali': async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
return fetchDoubanCategories(params, '', false, true); if (useDirectApi) {
case 'cors-anywhere': const response = await fetch(
return fetchDoubanCategories(params, 'https://cors-anywhere.com/'); `/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`
case 'custom': );
return fetchDoubanCategories(params, proxyUrl); if (!response.ok) {
case 'direct': throw new Error(`HTTP error! Status: ${response.status}`);
default: }
const response = await fetch( return response.json();
`/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}` }
);
return response.json(); return fetchDoubanCategories(
params,
requestProxyUrl,
useTencentCDN,
useAliCDN
);
}
);
} catch (error) {
dispatchDoubanGlobalError('获取豆瓣分类数据失败');
throw error;
} }
} }
@@ -263,25 +405,34 @@ export async function getDoubanList(
params: DoubanListParams params: DoubanListParams
): Promise<DoubanResult> { ): Promise<DoubanResult> {
const { tag, type, pageLimit = 20, pageStart = 0 } = params; const { tag, type, pageLimit = 20, pageStart = 0 } = params;
const { proxyType, proxyUrl } = getDoubanProxyConfig(); const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
switch (proxyType) { getDoubanProxyConfig();
case 'cors-proxy-zwei': try {
return fetchDoubanList(params, 'https://ciao-cors.is-an.org/'); return await requestDoubanWithFallback(
case 'cmliussss-cdn-tencent': { proxyType, proxyUrl },
return fetchDoubanList(params, '', true, false); { proxyType: backupProxyType, proxyUrl: backupProxyUrl },
case 'cmliussss-cdn-ali': async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
return fetchDoubanList(params, '', false, true); if (useDirectApi) {
case 'cors-anywhere': const response = await fetch(
return fetchDoubanList(params, 'https://cors-anywhere.com/'); `/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`
case 'custom': );
return fetchDoubanList(params, proxyUrl); if (!response.ok) {
case 'direct': throw new Error(`HTTP error! Status: ${response.status}`);
default: }
const response = await fetch( return response.json();
`/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}` }
);
return response.json(); return fetchDoubanList(
params,
requestProxyUrl,
useTencentCDN,
useAliCDN
);
}
);
} catch (error) {
dispatchDoubanGlobalError('获取豆瓣列表数据失败');
throw error;
} }
} }
@@ -343,14 +494,6 @@ export async function fetchDoubanList(
list: list, list: list,
}; };
} catch (error) { } catch (error) {
// 触发全局错误提示
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message: '获取豆瓣列表数据失败' },
})
);
}
throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`); throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`);
} }
} }
@@ -383,25 +526,34 @@ export async function getDoubanRecommends(
platform, platform,
sort, sort,
} = params; } = params;
const { proxyType, proxyUrl } = getDoubanProxyConfig(); const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
switch (proxyType) { getDoubanProxyConfig();
case 'cors-proxy-zwei': try {
return fetchDoubanRecommends(params, 'https://ciao-cors.is-an.org/'); return await requestDoubanWithFallback(
case 'cmliussss-cdn-tencent': { proxyType, proxyUrl },
return fetchDoubanRecommends(params, '', true, false); { proxyType: backupProxyType, proxyUrl: backupProxyUrl },
case 'cmliussss-cdn-ali': async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
return fetchDoubanRecommends(params, '', false, true); if (useDirectApi) {
case 'cors-anywhere': const response = await fetch(
return fetchDoubanRecommends(params, 'https://cors-anywhere.com/'); `/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}&region=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`
case 'custom': );
return fetchDoubanRecommends(params, proxyUrl); if (!response.ok) {
case 'direct': throw new Error(`HTTP error! Status: ${response.status}`);
default: }
const response = await fetch( return response.json();
`/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}&region=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}` }
);
return response.json(); return fetchDoubanRecommends(
params,
requestProxyUrl,
useTencentCDN,
useAliCDN
);
}
);
} catch (error) {
dispatchDoubanGlobalError('获取豆瓣推荐数据失败');
throw error;
} }
} }
@@ -544,14 +696,6 @@ export async function fetchDoubanDetail(
const doubanData: DoubanDetailApiResponse = await response.json(); const doubanData: DoubanDetailApiResponse = await response.json();
return doubanData; return doubanData;
} catch (error) { } catch (error) {
// 触发全局错误提示
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('globalError', {
detail: { message: '获取豆瓣详情数据失败' },
})
);
}
throw new Error(`获取豆瓣详情数据失败: ${(error as Error).message}`); throw new Error(`获取豆瓣详情数据失败: ${(error as Error).message}`);
} }
} }
@@ -562,24 +706,26 @@ export async function fetchDoubanDetail(
export async function getDoubanDetail( export async function getDoubanDetail(
id: string id: string
): Promise<DoubanDetailApiResponse> { ): Promise<DoubanDetailApiResponse> {
const { proxyType, proxyUrl } = getDoubanProxyConfig(); const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
switch (proxyType) { getDoubanProxyConfig();
case 'cors-proxy-zwei': try {
return fetchDoubanDetail(id, 'https://ciao-cors.is-an.org/'); return await requestDoubanWithFallback(
case 'cmliussss-cdn-tencent': { proxyType, proxyUrl },
return fetchDoubanDetail(id, '', true, false); { proxyType: backupProxyType, proxyUrl: backupProxyUrl },
case 'cmliussss-cdn-ali': async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => {
return fetchDoubanDetail(id, '', false, true); if (useDirectApi) {
case 'cors-anywhere': const response = await fetch(`/api/douban/detail?id=${id}`);
return fetchDoubanDetail(id, 'https://cors-anywhere.com/'); if (!response.ok) {
case 'custom': throw new Error(`HTTP error! Status: ${response.status}`);
return fetchDoubanDetail(id, proxyUrl); }
case 'direct': return response.json();
default: }
const response = await fetch(`/api/douban/detail?id=${id}`);
if (!response.ok) { return fetchDoubanDetail(id, requestProxyUrl, useTencentCDN, useAliCDN);
throw new Error(`HTTP error! Status: ${response.status}`);
} }
return response.json(); );
} catch (error) {
dispatchDoubanGlobalError('获取豆瓣详情数据失败');
throw error;
} }
} }
+121 -28
View File
@@ -3,8 +3,7 @@ import bs58 from 'bs58';
import he from 'he'; import he from 'he';
import Hls from 'hls.js'; import Hls from 'hls.js';
function getDoubanImageProxyConfig(): { export type DoubanImageProxyType =
proxyType:
| 'direct' | 'direct'
| 'server' | 'server'
| 'img3' | 'img3'
@@ -12,13 +11,72 @@ function getDoubanImageProxyConfig(): {
| 'cmliussss-cdn-ali' | 'cmliussss-cdn-ali'
| 'baidu' | 'baidu'
| 'custom'; | 'custom';
function normalizeDoubanImageProxyConfig(
proxyType: DoubanImageProxyType,
proxyUrl: string
): {
proxyType: DoubanImageProxyType;
proxyUrl: string; proxyUrl: string;
} {
const normalizedProxyUrl = proxyUrl.trim();
if (proxyType === 'custom' && !normalizedProxyUrl) {
return {
proxyType: 'server',
proxyUrl: '',
};
}
return {
proxyType,
proxyUrl: normalizedProxyUrl,
};
}
function buildDoubanImageUrl(
originalUrl: string,
proxyType: DoubanImageProxyType,
proxyUrl: string
): string {
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 'baidu':
return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`;
case 'custom':
return proxyUrl ? `${proxyUrl}${encodeURIComponent(originalUrl)}` : originalUrl;
case 'direct':
default:
return originalUrl;
}
}
function getDoubanImageProxyConfig(): {
proxyType: DoubanImageProxyType;
proxyUrl: string;
backupProxyType: DoubanImageProxyType;
backupProxyUrl: string;
} { } {
// 确保在浏览器环境中执行 // 确保在浏览器环境中执行
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return { return {
proxyType: 'cmliussss-cdn-tencent', proxyType: 'cmliussss-cdn-tencent',
proxyUrl: '', proxyUrl: '',
backupProxyType: 'server',
backupProxyUrl: '',
}; };
} }
@@ -30,12 +88,70 @@ function getDoubanImageProxyConfig(): {
localStorage.getItem('doubanImageProxyUrl') || localStorage.getItem('doubanImageProxyUrl') ||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || (window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
''; '';
const doubanImageProxyBackupType =
(localStorage.getItem('doubanImageProxyTypeBackup') as DoubanImageProxyType | null) ||
'server';
const doubanImageProxyBackupUrl =
localStorage.getItem('doubanImageProxyUrlBackup') || '';
const primaryConfig = normalizeDoubanImageProxyConfig(
doubanImageProxyType,
doubanImageProxy
);
const backupConfig = normalizeDoubanImageProxyConfig(
doubanImageProxyBackupType,
doubanImageProxyBackupUrl
);
return { return {
proxyType: doubanImageProxyType, proxyType: primaryConfig.proxyType,
proxyUrl: doubanImageProxy, proxyUrl: primaryConfig.proxyUrl,
backupProxyType: backupConfig.proxyType,
backupProxyUrl: backupConfig.proxyUrl,
}; };
} }
export function getDoubanImageFallbackUrl(originalUrl: string): string | null {
if (!originalUrl || !originalUrl.includes('doubanio.com')) {
return null;
}
const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } =
getDoubanImageProxyConfig();
const primaryUrl = buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
const backupUrl = buildDoubanImageUrl(
originalUrl,
backupProxyType,
backupProxyUrl
);
if (backupUrl === primaryUrl) {
return null;
}
return backupUrl;
}
export function tryApplyDoubanImageFallback(
target: HTMLImageElement,
originalUrl: string
): boolean {
if (!originalUrl || !originalUrl.includes('doubanio.com')) {
return false;
}
if (target.dataset.doubanBackupTried === 'true') {
return false;
}
const fallbackUrl = getDoubanImageFallbackUrl(originalUrl);
if (!fallbackUrl || fallbackUrl === target.currentSrc || fallbackUrl === target.src) {
return false;
}
target.dataset.doubanBackupTried = 'true';
target.src = fallbackUrl;
return true;
}
/** /**
* 处理图片 URL,根据用户设置使用相应的代理 * 处理图片 URL,根据用户设置使用相应的代理
*/ */
@@ -65,29 +181,7 @@ export function processImageUrl(originalUrl: string): string {
} }
const { proxyType, proxyUrl } = getDoubanImageProxyConfig(); const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
switch (proxyType) { return buildDoubanImageUrl(originalUrl, proxyType, proxyUrl);
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 'baidu':
return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`;
case 'custom':
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
case 'direct':
default:
return originalUrl;
}
} }
/** /**
@@ -406,4 +500,3 @@ export function base58Decode(encoded: string): string {
// 在 Node.js 环境中使用 Buffer // 在 Node.js 环境中使用 Buffer
return Buffer.from(bytes).toString('utf-8'); return Buffer.from(bytes).toString('utf-8');
} }