diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 1394534..6ceff05 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -64,6 +64,7 @@ import Drawer from '@/components/Drawer'; import EpisodeSelector from '@/components/EpisodeSelector'; import PageLayout from '@/components/PageLayout'; import PansouSearch from '@/components/PansouSearch'; +import ProxyImage from '@/components/ProxyImage'; import { useSite } from '@/components/SiteProvider'; import SmartRecommendations from '@/components/SmartRecommendations'; import Toast, { ToastProps } from '@/components/Toast'; @@ -9023,8 +9024,8 @@ function PlayPageClient() {
{videoCover ? ( <> - {videoTitle} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 39bc6ca..2118fbb 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -38,6 +38,7 @@ import CapsuleSwitch from '@/components/CapsuleSwitch'; import ImageViewer from '@/components/ImageViewer'; import PageLayout from '@/components/PageLayout'; import PansouSearch from '@/components/PansouSearch'; +import ProxyImage from '@/components/ProxyImage'; import SearchResultFilter, { SearchFilterCategory, } from '@/components/SearchResultFilter'; @@ -829,9 +830,8 @@ function SearchPageClient() { >
- {/* eslint-disable-next-line @next/next/no-img-element */} - {item.title} { if (!path) return ''; - // 如果是完整URL(TX数据源或豆瓣),使用processImageUrl统一处理 + // 如果是完整URL(TX数据源或豆瓣),直接返回原始地址 if (path.startsWith('http://') || path.startsWith('https://')) { - return processImageUrl(path); + return path; } - // 否则使用TMDB的URL拼接,并通过processImageUrl处理 - return processImageUrl(getTMDBImageUrl(path, 'original')); + // 否则使用TMDB的URL拼接原始地址 + return getTMDBImageUrl(path, 'original'); }; // 获取视频URL(处理豆瓣视频代理) @@ -454,13 +455,11 @@ export default function BannerCarousel({ autoPlayInterval = 5000, delayLoad = fa
) : ( /* 显示图片 */ - {item.title} )} {/* 渐变遮罩 */} diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx index 9c8cbe8..545dcc4 100644 --- a/src/components/DetailPanel.tsx +++ b/src/components/DetailPanel.tsx @@ -9,6 +9,7 @@ import { getTMDBImageUrl } from '@/lib/tmdb.client'; import { processImageUrl } from '@/lib/utils'; import ImageViewer from '@/components/ImageViewer'; +import ProxyImage from '@/components/ProxyImage'; interface DetailPanelProps { isOpen: boolean; @@ -373,7 +374,7 @@ const DetailPanel: React.FC = ({ title: title, intro: cmsData.desc, episodesCount: cmsData.episodes?.length, - poster: poster ? processImageUrl(poster) : poster, + poster: poster, }; setDetailData(data); setOriginalDetailData(data); @@ -393,7 +394,7 @@ const DetailPanel: React.FC = ({ title: data.title || title, intro: data.desc || '', episodesCount: data.episodes?.length || cmsData.episodes?.length, - poster: data.poster ? processImageUrl(data.poster) : poster, + poster: data.poster || poster, year: data.year, }; setDetailData(detailData); @@ -423,7 +424,7 @@ const DetailPanel: React.FC = ({ title: data.name_cn || data.name, originalTitle: data.name, 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 ? { value: data.rating.score, @@ -454,7 +455,7 @@ const DetailPanel: React.FC = ({ title: data.title, originalTitle: data.original_title, 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 ? { value: data.rating.value, @@ -873,7 +874,7 @@ const DetailPanel: React.FC = ({ ...prev, title: episodesData.name || season?.name || prev.title, 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, year: episodesData.air_date?.substring(0, 4) || season?.air_date?.substring(0, 4) || prev.year, episodesCount: episodesData.episodes?.length || season?.episode_count || prev.episodesCount, @@ -1090,11 +1091,13 @@ const DetailPanel: React.FC = ({ style={{ height: virtualGalleryLayout.totalHeight, width: virtualGalleryLayout.usedWidth || '100%' }} > {virtualGalleryLayout.visibleItems.map((image) => { - const imageUrl = processImageUrl( - getTMDBImageUrl(image.file_path, image.imageType === 'poster' ? 'w500' : 'original') + const imageUrl = getTMDBImageUrl( + image.file_path, + image.imageType === 'poster' ? 'w500' : 'original' ); - const thumbUrl = processImageUrl( - getTMDBImageUrl(image.file_path, image.imageType === 'poster' ? 'w342' : 'w780') + const thumbUrl = getTMDBImageUrl( + image.file_path, + image.imageType === 'poster' ? 'w342' : 'w780' ); return ( @@ -1112,12 +1115,10 @@ const DetailPanel: React.FC = ({ 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)} > - {`${detailData?.title
@@ -1231,7 +1232,12 @@ const DetailPanel: React.FC = ({ 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!)} > - {detailData.title} +
{galleryEntryButton}
@@ -1356,13 +1362,12 @@ const DetailPanel: React.FC = ({ {actor.profile_path ? (
handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))} + onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))} > - {actor.name}
@@ -1474,14 +1479,13 @@ const DetailPanel: React.FC = ({ 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) => { e.stopPropagation(); - handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500'))); + handleImageClick(getTMDBImageUrl(season.poster_path, 'w500')); }} > - {season.name}
@@ -1536,13 +1540,12 @@ const DetailPanel: React.FC = ({ {episode.still_path && (
handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))} + onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))} > - {episode.name}
@@ -1742,7 +1745,12 @@ const DetailPanel: React.FC = ({ 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!)} > - {detailData.title} + {galleryEntryButton} @@ -1867,13 +1875,12 @@ const DetailPanel: React.FC = ({ {actor.profile_path ? (
handleImageClick(processImageUrl(getTMDBImageUrl(actor.profile_path || null, 'w185')))} + onClick={() => handleImageClick(getTMDBImageUrl(actor.profile_path || null, 'w185'))} > - {actor.name}
@@ -1985,14 +1992,13 @@ const DetailPanel: React.FC = ({ 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) => { e.stopPropagation(); - handleImageClick(processImageUrl(getTMDBImageUrl(season.poster_path, 'w500'))); + handleImageClick(getTMDBImageUrl(season.poster_path, 'w500')); }} > - {season.name} @@ -2047,13 +2053,12 @@ const DetailPanel: React.FC = ({ {episode.still_path && (
handleImageClick(processImageUrl(getTMDBImageUrl(episode.still_path, 'w500')))} + onClick={() => handleImageClick(getTMDBImageUrl(episode.still_path, 'w500'))} > - {episode.name}
diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index 933ba63..401bb8d 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -12,10 +12,11 @@ import React, { import type { DanmakuComment,DanmakuSelection } from '@/lib/danmaku/types'; import { EpisodeFilterConfig,SearchResult } from '@/lib/types'; -import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; +import { getVideoResolutionFromM3u8 } from '@/lib/utils'; import DanmakuPanel from '@/components/DanmakuPanel'; import EpisodeFilterSettings from '@/components/EpisodeFilterSettings'; +import ProxyImage from '@/components/ProxyImage'; // 定义视频信息类型 interface VideoInfo { @@ -870,10 +871,11 @@ const EpisodeSelector: React.FC = ({ {source.source === 'directplay' ? ( ) : source.poster ? ( - {source.title} { const target = e.target as HTMLImageElement; target.style.display = 'none'; diff --git a/src/components/ImageViewer.tsx b/src/components/ImageViewer.tsx index 4eda739..c4c6fe2 100644 --- a/src/components/ImageViewer.tsx +++ b/src/components/ImageViewer.tsx @@ -1,10 +1,11 @@ 'use client'; import { X } from 'lucide-react'; -import Image from 'next/image'; import React, { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; +import ProxyImage from '@/components/ProxyImage'; + interface ImageViewerProps { isOpen: boolean; onClose: () => void; @@ -151,18 +152,15 @@ const ImageViewer: React.FC = ({ onClick={(e) => e.stopPropagation()} >
- {alt}
diff --git a/src/components/ProxyImage.tsx b/src/components/ProxyImage.tsx new file mode 100644 index 0000000..5684344 --- /dev/null +++ b/src/components/ProxyImage.tsx @@ -0,0 +1,49 @@ +'use client'; + +import React from 'react'; + +import { processImageUrl, tryApplyDoubanImageFallback } from '@/lib/utils'; + +interface ProxyImageProps extends React.ImgHTMLAttributes { + originalSrc: string; + displaySrc?: string; + retryDelay?: number; + retryOnError?: boolean; +} + +const ProxyImage: React.FC = ({ + originalSrc, + displaySrc, + retryDelay = 2000, + retryOnError = true, + onError, + src: _src, + ...props +}) => { + const handleError = (e: React.SyntheticEvent) => { + 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 ( + + ); +}; + +export default ProxyImage; diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index ad182bf..104694a 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -118,11 +118,18 @@ export const UserMenu: React.FC = () => { const [tmdbBackdropDisabled, setTmdbBackdropDisabled] = useState(false); const [enableTrailers, setEnableTrailers] = useState(false); const [doubanDataSource, setDoubanDataSource] = useState('cmliussss-cdn-tencent'); + const [doubanDataSourceBackup, setDoubanDataSourceBackup] = useState('direct'); const [doubanImageProxyType, setDoubanImageProxyType] = useState('cmliussss-cdn-tencent'); + const [doubanImageProxyTypeBackup, setDoubanImageProxyTypeBackup] = useState('server'); const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState(''); + const [doubanProxyUrlBackup, setDoubanProxyUrlBackup] = useState(''); + const [doubanImageProxyUrlBackup, setDoubanImageProxyUrlBackup] = useState(''); const [isDoubanDropdownOpen, setIsDoubanDropdownOpen] = useState(false); + const [isDoubanBackupDropdownOpen, setIsDoubanBackupDropdownOpen] = useState(false); const [isDoubanImageProxyDropdownOpen, setIsDoubanImageProxyDropdownOpen] = useState(false); + const [isDoubanImageProxyBackupDropdownOpen, setIsDoubanImageProxyBackupDropdownOpen] = + useState(false); const [bufferStrategy, setBufferStrategy] = useState('medium'); const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true); const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true); @@ -440,6 +447,16 @@ export const UserMenu: React.FC = () => { setDoubanProxyUrl(defaultDoubanProxy); } + const savedDoubanDataSourceBackup = localStorage.getItem( + 'doubanDataSourceBackup' + ); + setDoubanDataSourceBackup(savedDoubanDataSourceBackup || 'direct'); + + const savedDoubanProxyUrlBackup = localStorage.getItem( + 'doubanProxyUrlBackup' + ); + setDoubanProxyUrlBackup(savedDoubanProxyUrlBackup || ''); + const savedDoubanImageProxyType = localStorage.getItem( 'doubanImageProxyType' ); @@ -462,6 +479,16 @@ export const UserMenu: React.FC = () => { setDoubanImageProxyUrl(defaultDoubanImageProxyUrl); } + const savedDoubanImageProxyTypeBackup = localStorage.getItem( + 'doubanImageProxyTypeBackup' + ); + setDoubanImageProxyTypeBackup(savedDoubanImageProxyTypeBackup || 'server'); + + const savedDoubanImageProxyUrlBackup = localStorage.getItem( + 'doubanImageProxyUrlBackup' + ); + setDoubanImageProxyUrlBackup(savedDoubanImageProxyUrlBackup || ''); + const savedTmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl'); if (savedTmdbImageBaseUrl !== null) { setTmdbImageBaseUrl(savedTmdbImageBaseUrl); @@ -754,6 +781,23 @@ export const UserMenu: React.FC = () => { } }, [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(() => { const handleClickOutside = (event: MouseEvent) => { if (isDoubanImageProxyDropdownOpen) { @@ -771,6 +815,23 @@ export const UserMenu: React.FC = () => { } }, [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 = () => { 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) => { setDoubanImageProxyType(value); 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) => { setDoubanImageProxyUrl(value); 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) => { setTmdbImageBaseUrl(value); if (typeof window !== 'undefined') { @@ -1240,8 +1329,12 @@ export const UserMenu: React.FC = () => { setEnableTrailers(false); setDoubanProxyUrl(defaultDoubanProxy); setDoubanDataSource(defaultDoubanProxyType); + setDoubanDataSourceBackup('direct'); + setDoubanProxyUrlBackup(''); setDoubanImageProxyType(defaultDoubanImageProxyType); setDoubanImageProxyUrl(defaultDoubanImageProxyUrl); + setDoubanImageProxyTypeBackup('server'); + setDoubanImageProxyUrlBackup(''); setTmdbImageBaseUrl('https://image.tmdb.org'); setBufferStrategy('medium'); setNextEpisodePreCache(true); @@ -1261,8 +1354,12 @@ export const UserMenu: React.FC = () => { localStorage.setItem('enableTrailers', 'false'); localStorage.setItem('doubanProxyUrl', defaultDoubanProxy); localStorage.setItem('doubanDataSource', defaultDoubanProxyType); + localStorage.setItem('doubanDataSourceBackup', 'direct'); + localStorage.setItem('doubanProxyUrlBackup', ''); localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType); localStorage.setItem('doubanImageProxyUrl', defaultDoubanImageProxyUrl); + localStorage.setItem('doubanImageProxyTypeBackup', 'server'); + localStorage.setItem('doubanImageProxyUrlBackup', ''); localStorage.setItem('tmdbImageBaseUrl', 'https://image.tmdb.org'); localStorage.setItem('bufferStrategy', 'medium'); localStorage.setItem('nextEpisodePreCache', 'true'); @@ -1723,6 +1820,96 @@ export const UserMenu: React.FC = () => { value={doubanProxyUrl} onChange={(e) => handleDoubanProxyUrlChange(e.target.value)} /> + {!doubanProxyUrl.trim() && ( +

+ 未填写地址时将自动按直连处理 +

+ )} + + )} + +
+
+

+ 豆瓣数据备用渠道 +

+

+ 主渠道失败后自动切换,默认直连 +

+
+
+ +
+ +
+ {isDoubanBackupDropdownOpen && ( +
+ {doubanDataSourceOptions.map((option) => ( + + ))} +
+ )} +
+
+ + {doubanDataSourceBackup === 'custom' && ( +
+
+

+ 豆瓣备用代理地址 +

+

+ 备用渠道为自定义代理时生效 +

+
+ + handleDoubanProxyUrlBackupChange(e.target.value) + } + /> + {!doubanProxyUrlBackup.trim() && ( +

+ 未填写地址时备用渠道将自动按直连处理 +

+ )}
)} @@ -1833,6 +2020,98 @@ export const UserMenu: React.FC = () => { handleDoubanImageProxyUrlChange(e.target.value) } /> + {!doubanImageProxyUrl.trim() && ( +

+ 未填写地址时将自动按服务器代理处理 +

+ )} + + )} + +
+
+

+ 豆瓣图片备用渠道 +

+

+ 主图片渠道失败后自动切换,默认服务器代理 +

+
+
+ +
+ +
+ {isDoubanImageProxyBackupDropdownOpen && ( +
+ {doubanImageProxyTypeOptions.map((option) => ( + + ))} +
+ )} +
+
+ + {doubanImageProxyTypeBackup === 'custom' && ( +
+
+

+ 豆瓣图片备用代理地址 +

+

+ 备用图片渠道为自定义代理时生效 +

+
+ + handleDoubanImageProxyUrlBackupChange(e.target.value) + } + /> + {!doubanImageProxyUrlBackup.trim() && ( +

+ 未填写地址时备用图片渠道将自动按服务器代理处理 +

+ )}
)} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 87a3342..13c6f2f 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -21,7 +21,7 @@ import { saveFavorite, subscribeToDataUpdates, } from '@/lib/db.client'; -import { processImageUrl, base58Decode } from '@/lib/utils'; +import { processImageUrl, base58Decode, tryApplyDoubanImageFallback } from '@/lib/utils'; import { useLongPress } from '@/hooks/useLongPress'; import AIChatPanel from '@/components/AIChatPanel'; @@ -750,8 +750,12 @@ const VideoCard = forwardRef(function VideoCard setShowImageViewer(true); }} onError={(e) => { + const img = e.currentTarget as HTMLImageElement; + if (tryApplyDoubanImageFallback(img, actualPoster)) { + return; + } + // 图片加载失败时的重试机制 - const img = e.target as HTMLImageElement; if (!img.dataset.retried) { img.dataset.retried = 'true'; setTimeout(() => { @@ -1598,7 +1602,7 @@ const VideoCard = forwardRef(function VideoCard setShowImageViewer(false)} - imageUrl={processImageUrl(actualPoster)} + imageUrl={actualPoster} alt={actualTitle} /> )} diff --git a/src/lib/douban.client.ts b/src/lib/douban.client.ts index 399510d..4ff0525 100644 --- a/src/lib/douban.client.ts +++ b/src/lib/douban.client.ts @@ -88,6 +88,36 @@ interface DoubanDetailApiResponse { [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 请求 */ @@ -135,6 +165,14 @@ function getDoubanProxyConfig(): { | 'cors-anywhere' | 'custom'; proxyUrl: string; + backupProxyType: + | 'direct' + | 'cors-proxy-zwei' + | 'cmliussss-cdn-tencent' + | 'cmliussss-cdn-ali' + | 'cors-anywhere' + | 'custom'; + backupProxyUrl: string; } { const doubanProxyType = localStorage.getItem('doubanDataSource') || @@ -144,12 +182,115 @@ function getDoubanProxyConfig(): { localStorage.getItem('doubanProxyUrl') || (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 { - proxyType: doubanProxyType, - proxyUrl: doubanProxy, + proxyType: primaryConfig.proxyType, + 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( + primary: { proxyType: DoubanProxyType; proxyUrl: string }, + backup: { proxyType: DoubanProxyType; proxyUrl: string }, + runner: (requester: ReturnType) => Promise +): Promise { + 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, }; } catch (error) { - // 触发全局错误提示 - if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('globalError', { - detail: { message: '获取豆瓣分类数据失败' }, - }) - ); - } throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`); } } @@ -230,25 +363,34 @@ export async function getDoubanCategories( params: DoubanCategoriesParams ): Promise { const { kind, category, type, pageLimit = 20, pageStart = 0 } = params; - const { proxyType, proxyUrl } = getDoubanProxyConfig(); - switch (proxyType) { - case 'cors-proxy-zwei': - return fetchDoubanCategories(params, 'https://ciao-cors.is-an.org/'); - case 'cmliussss-cdn-tencent': - return fetchDoubanCategories(params, '', true, false); - case 'cmliussss-cdn-ali': - return fetchDoubanCategories(params, '', false, true); - case 'cors-anywhere': - return fetchDoubanCategories(params, 'https://cors-anywhere.com/'); - case 'custom': - return fetchDoubanCategories(params, proxyUrl); - case 'direct': - default: - const response = await fetch( - `/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}` - ); + const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } = + getDoubanProxyConfig(); + try { + return await requestDoubanWithFallback( + { proxyType, proxyUrl }, + { proxyType: backupProxyType, proxyUrl: backupProxyUrl }, + async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => { + if (useDirectApi) { + const response = await fetch( + `/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}` + ); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + return response.json(); + } - return response.json(); + return fetchDoubanCategories( + params, + requestProxyUrl, + useTencentCDN, + useAliCDN + ); + } + ); + } catch (error) { + dispatchDoubanGlobalError('获取豆瓣分类数据失败'); + throw error; } } @@ -263,25 +405,34 @@ export async function getDoubanList( params: DoubanListParams ): Promise { const { tag, type, pageLimit = 20, pageStart = 0 } = params; - const { proxyType, proxyUrl } = getDoubanProxyConfig(); - switch (proxyType) { - case 'cors-proxy-zwei': - return fetchDoubanList(params, 'https://ciao-cors.is-an.org/'); - case 'cmliussss-cdn-tencent': - return fetchDoubanList(params, '', true, false); - case 'cmliussss-cdn-ali': - return fetchDoubanList(params, '', false, true); - case 'cors-anywhere': - return fetchDoubanList(params, 'https://cors-anywhere.com/'); - case 'custom': - return fetchDoubanList(params, proxyUrl); - case 'direct': - default: - const response = await fetch( - `/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}` - ); + const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } = + getDoubanProxyConfig(); + try { + return await requestDoubanWithFallback( + { proxyType, proxyUrl }, + { proxyType: backupProxyType, proxyUrl: backupProxyUrl }, + async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => { + if (useDirectApi) { + const response = await fetch( + `/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}` + ); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + return response.json(); + } - return response.json(); + return fetchDoubanList( + params, + requestProxyUrl, + useTencentCDN, + useAliCDN + ); + } + ); + } catch (error) { + dispatchDoubanGlobalError('获取豆瓣列表数据失败'); + throw error; } } @@ -343,14 +494,6 @@ export async function fetchDoubanList( list: list, }; } catch (error) { - // 触发全局错误提示 - if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('globalError', { - detail: { message: '获取豆瓣列表数据失败' }, - }) - ); - } throw new Error(`获取豆瓣分类数据失败: ${(error as Error).message}`); } } @@ -383,25 +526,34 @@ export async function getDoubanRecommends( platform, sort, } = params; - const { proxyType, proxyUrl } = getDoubanProxyConfig(); - switch (proxyType) { - case 'cors-proxy-zwei': - return fetchDoubanRecommends(params, 'https://ciao-cors.is-an.org/'); - case 'cmliussss-cdn-tencent': - return fetchDoubanRecommends(params, '', true, false); - case 'cmliussss-cdn-ali': - return fetchDoubanRecommends(params, '', false, true); - case 'cors-anywhere': - return fetchDoubanRecommends(params, 'https://cors-anywhere.com/'); - case 'custom': - return fetchDoubanRecommends(params, proxyUrl); - case 'direct': - default: - const response = await fetch( - `/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}` - ); + const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } = + getDoubanProxyConfig(); + try { + return await requestDoubanWithFallback( + { proxyType, proxyUrl }, + { proxyType: backupProxyType, proxyUrl: backupProxyUrl }, + async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => { + if (useDirectApi) { + const response = await fetch( + `/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}` + ); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + return response.json(); + } - 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(); return doubanData; } catch (error) { - // 触发全局错误提示 - if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('globalError', { - detail: { message: '获取豆瓣详情数据失败' }, - }) - ); - } throw new Error(`获取豆瓣详情数据失败: ${(error as Error).message}`); } } @@ -562,24 +706,26 @@ export async function fetchDoubanDetail( export async function getDoubanDetail( id: string ): Promise { - const { proxyType, proxyUrl } = getDoubanProxyConfig(); - switch (proxyType) { - case 'cors-proxy-zwei': - return fetchDoubanDetail(id, 'https://ciao-cors.is-an.org/'); - case 'cmliussss-cdn-tencent': - return fetchDoubanDetail(id, '', true, false); - case 'cmliussss-cdn-ali': - return fetchDoubanDetail(id, '', false, true); - case 'cors-anywhere': - return fetchDoubanDetail(id, 'https://cors-anywhere.com/'); - case 'custom': - return fetchDoubanDetail(id, proxyUrl); - case 'direct': - default: - const response = await fetch(`/api/douban/detail?id=${id}`); - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); + const { proxyType, proxyUrl, backupProxyType, backupProxyUrl } = + getDoubanProxyConfig(); + try { + return await requestDoubanWithFallback( + { proxyType, proxyUrl }, + { proxyType: backupProxyType, proxyUrl: backupProxyUrl }, + async ({ useDirectApi, requestProxyUrl, useTencentCDN, useAliCDN }) => { + if (useDirectApi) { + const response = await fetch(`/api/douban/detail?id=${id}`); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + return response.json(); + } + + return fetchDoubanDetail(id, requestProxyUrl, useTencentCDN, useAliCDN); } - return response.json(); + ); + } catch (error) { + dispatchDoubanGlobalError('获取豆瓣详情数据失败'); + throw error; } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 8035e1f..1b3164e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -3,8 +3,7 @@ import bs58 from 'bs58'; import he from 'he'; import Hls from 'hls.js'; -function getDoubanImageProxyConfig(): { - proxyType: +export type DoubanImageProxyType = | 'direct' | 'server' | 'img3' @@ -12,13 +11,72 @@ function getDoubanImageProxyConfig(): { | 'cmliussss-cdn-ali' | 'baidu' | 'custom'; + +function normalizeDoubanImageProxyConfig( + proxyType: DoubanImageProxyType, + proxyUrl: string +): { + proxyType: DoubanImageProxyType; 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') { return { proxyType: 'cmliussss-cdn-tencent', proxyUrl: '', + backupProxyType: 'server', + backupProxyUrl: '', }; } @@ -30,12 +88,70 @@ function getDoubanImageProxyConfig(): { localStorage.getItem('doubanImageProxyUrl') || (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 { - proxyType: doubanImageProxyType, - proxyUrl: doubanImageProxy, + proxyType: primaryConfig.proxyType, + 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,根据用户设置使用相应的代理 */ @@ -65,29 +181,7 @@ export function processImageUrl(originalUrl: string): string { } 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 'baidu': - return `https://image.baidu.com/search/down?url=${encodeURIComponent(originalUrl)}`; - case 'custom': - return `${proxyUrl}${encodeURIComponent(originalUrl)}`; - case 'direct': - default: - return originalUrl; - } + return buildDoubanImageUrl(originalUrl, proxyType, proxyUrl); } /** @@ -406,4 +500,3 @@ export function base58Decode(encoded: string): string { // 在 Node.js 环境中使用 Buffer return Buffer.from(bytes).toString('utf-8'); } -