From 08c2ff36f9f78d8a17615c4e9b99c9ed6070e082 Mon Sep 17 00:00:00 2001 From: mtvpls <247332661+mtvpls@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:13:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8A=A8=E6=BC=AB=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90=E5=9B=BE=E7=89=87=E9=99=8D=E7=BA=A7=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ProxyImage.tsx | 24 +++--- src/components/VideoCard.tsx | 50 +++--------- src/lib/utils.ts | 148 ++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 49 deletions(-) diff --git a/src/components/ProxyImage.tsx b/src/components/ProxyImage.tsx index e8a16d2..cd4ac62 100644 --- a/src/components/ProxyImage.tsx +++ b/src/components/ProxyImage.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { clearBangumiImageFallbackCacheIfFailed, + ensureBangumiImagePrimaryProbed, processImageUrl, tryApplyBangumiImageFallback, tryApplyDoubanImageFallback, @@ -38,22 +39,21 @@ const ProxyImage: React.FC = ({ setCurrentSrc(initialSrc); }, [initialSrc]); + // 主源图片域主页探测:失败则 sticky 走备源(替代原 5s complete 误判) useEffect(() => { if (displaySrc) return; - const timer = window.setTimeout(() => { - const img = imgRef.current; - if (!img || img.complete || img.dataset.bangumiBackupTried === 'true') { - return; - } + let cancelled = false; + void (async () => { + const reachable = await ensureBangumiImagePrimaryProbed(); + if (cancelled || reachable) return; + setCurrentSrc(processImageUrl(originalSrc)); + })(); - if (tryApplyBangumiImageFallback(img, originalSrc)) { - setCurrentSrc(img.src); - } - }, 5000); - - return () => window.clearTimeout(timer); - }, [currentSrc, displaySrc, originalSrc]); + return () => { + cancelled = true; + }; + }, [displaySrc, originalSrc]); const handleError = (e: React.SyntheticEvent) => { const img = e.currentTarget; diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index c54692e..f266a11 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -38,9 +38,9 @@ import { isNetdiskSource } from '@/lib/netdisk/source'; import { base58Decode, clearBangumiImageFallbackCacheIfFailed, + ensureBangumiImagePrimaryProbed, getBangumiImageFallbackUrl, getDoubanImageFallbackUrl, - markBangumiImageFallbackActive, processImageUrl, tryApplyBangumiImageFallback, tryApplyDoubanImageFallback, @@ -226,44 +226,24 @@ const VideoCard = forwardRef( setDisplayPoster(processedPoster); }, [processedPoster]); - const bangumiImageTimeoutRef = useRef | null>( - null - ); - + // 主源图片域主页探测:失败写 sticky 后切备源海报(替代原 5s 强制降级) useEffect(() => { - if (bangumiImageTimeoutRef.current) { - clearTimeout(bangumiImageTimeoutRef.current); - bangumiImageTimeoutRef.current = null; - } - if (!actualPoster) return; - const bangumiFallbackPoster = getBangumiImageFallbackUrl(actualPoster); - if (!bangumiFallbackPoster || displayPoster === bangumiFallbackPoster) { - return; - } - - bangumiImageTimeoutRef.current = setTimeout(() => { - markBangumiImageFallbackActive(); - setDisplayPoster((current) => - current === bangumiFallbackPoster ? current : bangumiFallbackPoster - ); - }, 5000); + let cancelled = false; + void (async () => { + const reachable = await ensureBangumiImagePrimaryProbed(); + if (cancelled || reachable) return; + const bangumiFallbackPoster = getBangumiImageFallbackUrl(actualPoster); + if (bangumiFallbackPoster) { + setDisplayPoster(bangumiFallbackPoster); + } + })(); return () => { - if (bangumiImageTimeoutRef.current) { - clearTimeout(bangumiImageTimeoutRef.current); - bangumiImageTimeoutRef.current = null; - } + cancelled = true; }; - }, [actualPoster, displayPoster]); - - const clearBangumiImageTimeout = useCallback(() => { - if (bangumiImageTimeoutRef.current) { - clearTimeout(bangumiImageTimeoutRef.current); - bangumiImageTimeoutRef.current = null; - } - }, []); + }, [actualPoster]); useImperativeHandle(ref, () => ({ setEpisodes: (eps?: number) => setDynamicEpisodes(eps), @@ -1017,7 +997,6 @@ const VideoCard = forwardRef( loading='lazy' onLoadingComplete={() => { setIsLoading(true); - clearBangumiImageTimeout(); }} onClick={(e) => { e.stopPropagation(); @@ -1031,7 +1010,6 @@ const VideoCard = forwardRef( doubanFallbackPoster && tryApplyDoubanImageFallback(img, actualPoster) ) { - clearBangumiImageTimeout(); setDisplayPoster(doubanFallbackPoster); return; } @@ -1042,7 +1020,6 @@ const VideoCard = forwardRef( bangumiFallbackPoster && tryApplyBangumiImageFallback(img, actualPoster) ) { - clearBangumiImageTimeout(); setDisplayPoster(bangumiFallbackPoster); return; } @@ -1050,7 +1027,6 @@ const VideoCard = forwardRef( if ( clearBangumiImageFallbackCacheIfFailed(img, actualPoster) ) { - clearBangumiImageTimeout(); setDisplayPoster(processedPoster); return; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index b67b4be..6bf605a 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -159,6 +159,24 @@ type AnimeImageSource = const BANGUMI_IMAGE_FALLBACK_UNTIL_KEY = 'bangumiImageFallbackUntil'; const BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY = 'bangumiImageFallbackSignature'; const BANGUMI_IMAGE_FALLBACK_DURATION = 60 * 60 * 1000; +/** 探测主源图片域主页超时(404 也算通) */ +const BANGUMI_IMAGE_PROBE_TIMEOUT_MS = 5000; +/** 探测成功后的内存缓存,避免列表刷屏时重复探测 */ +const BANGUMI_IMAGE_PROBE_OK_TTL_MS = 15 * 60 * 1000; + +type BangumiImageProbeCache = { + signature: string; + reachable: boolean | null; + okUntil: number; + inflight: Promise | null; +}; + +let bangumiImageProbeCache: BangumiImageProbeCache = { + signature: '', + reachable: null, + okUntil: 0, + inflight: null, +}; function normalizeBaseUrl(baseUrl: string): string { return baseUrl.trim().replace(/\/+$/, ''); @@ -239,10 +257,50 @@ function getBangumiImageFallbackSignature(): string { }); } +function getBangumiImageProbeSignature(): string { + return JSON.stringify({ + primary: getPrimaryBangumiImageSource(), + imageBaseUrl: getBangumiImageBaseUrl(), + }); +} + +/** 当前主源图片域 origin;server-proxy 无需探测返回 null */ +function getBangumiImageProbeOrigin(): string | null { + const primary = getPrimaryBangumiImageSource(); + switch (primary) { + case 'server-proxy': + return null; + case 'sakura': + return 'https://lain.bangumi.lol'; + case 'custom-baseurl': { + const base = getBangumiImageBaseUrl(); + if (!base) return 'https://lain.bgm.tv'; + try { + return new URL(base).origin; + } catch { + return null; + } + } + case 'direct': + default: + return 'https://lain.bgm.tv'; + } +} + +export function clearBangumiImageProbeCache(): void { + bangumiImageProbeCache = { + signature: '', + reachable: null, + okUntil: 0, + inflight: null, + }; +} + export function clearBangumiImageFallbackCache(): void { if (typeof window === 'undefined') return; localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_UNTIL_KEY); localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY); + clearBangumiImageProbeCache(); } export function markBangumiImageFallbackActive(): void { @@ -257,6 +315,92 @@ export function markBangumiImageFallbackActive(): void { ); } +/** + * 探测主源图片域是否可达:请求 origin 主页(常见 404),5s 超时 + 防缓存。 + * no-cors 下任意完成(含 404)视为可达;超时/DNS/断网视为不可达。 + * 失败时写入 sticky 降级(若有备源)。全局单飞 + 成功缓存 15 分钟。 + * @returns true=主源可用,false=应走备源 + */ +export async function ensureBangumiImagePrimaryProbed(): Promise { + if (typeof window === 'undefined') return true; + + if (isBangumiImageFallbackActive()) { + return false; + } + + const origin = getBangumiImageProbeOrigin(); + if (!origin) { + return true; + } + + const signature = getBangumiImageProbeSignature(); + const now = Date.now(); + + if ( + bangumiImageProbeCache.signature === signature && + bangumiImageProbeCache.reachable === true && + bangumiImageProbeCache.okUntil > now + ) { + return true; + } + + if ( + bangumiImageProbeCache.signature === signature && + bangumiImageProbeCache.inflight + ) { + return bangumiImageProbeCache.inflight; + } + + const probePromise = (async (): Promise => { + const url = `${origin}/?_cb=${Date.now()}_${Math.random() + .toString(36) + .slice(2, 10)}`; + try { + await fetch(url, { + method: 'GET', + mode: 'no-cors', + cache: 'no-store', + credentials: 'omit', + signal: AbortSignal.timeout(BANGUMI_IMAGE_PROBE_TIMEOUT_MS), + }); + bangumiImageProbeCache = { + signature, + reachable: true, + okUntil: Date.now() + BANGUMI_IMAGE_PROBE_OK_TTL_MS, + inflight: null, + }; + return true; + } catch { + bangumiImageProbeCache = { + signature, + reachable: false, + okUntil: 0, + inflight: null, + }; + const primary = getPrimaryBangumiImageSource(); + if (getBackupBangumiImageSource(primary)) { + markBangumiImageFallbackActive(); + } + return false; + } + })(); + + bangumiImageProbeCache = { + signature, + reachable: bangumiImageProbeCache.reachable, + okUntil: bangumiImageProbeCache.okUntil, + inflight: probePromise, + }; + + return probePromise; +} + +/** 非阻塞触发主源图片域探测(processImageUrl / 首屏侧调用) */ +export function scheduleBangumiImagePrimaryProbe(): void { + if (typeof window === 'undefined') return; + void ensureBangumiImagePrimaryProbed(); +} + function normalizeComparableUrl(url: string): string { if (typeof window === 'undefined') return url; @@ -441,12 +585,16 @@ export function processImageUrl(originalUrl: string): string { // 处理 Bangumi 图片代理。直连模式尊重用户选择,不代理图片; // 仅在服务器代理 / 自定义 Base URL 模式下使用本站图片代理。 + // sticky 降级中走备源;否则用主源,并非阻塞探测主源图片域主页(5s,404 算通)。 if (isBangumiImageUrl(originalUrl)) { const primary = getPrimaryBangumiImageSource(); const backup = getBackupBangumiImageSource(primary); if (backup && isBangumiImageFallbackActive()) { return buildBangumiImageUrl(originalUrl, backup); } + if (primary !== 'server-proxy' && backup) { + scheduleBangumiImagePrimaryProbe(); + } return buildBangumiImageUrl(originalUrl, primary); }