优化动漫数据源图片降级策略

This commit is contained in:
mtvpls
2026-07-21 16:13:00 +08:00
parent d54cf65927
commit 08c2ff36f9
3 changed files with 173 additions and 49 deletions
+12 -12
View File
@@ -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<ProxyImageProps> = ({
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<HTMLImageElement, Event>) => {
const img = e.currentTarget;
+13 -37
View File
@@ -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<VideoCardHandle, VideoCardProps>(
setDisplayPoster(processedPoster);
}, [processedPoster]);
const bangumiImageTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<VideoCardHandle, VideoCardProps>(
loading='lazy'
onLoadingComplete={() => {
setIsLoading(true);
clearBangumiImageTimeout();
}}
onClick={(e) => {
e.stopPropagation();
@@ -1031,7 +1010,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
doubanFallbackPoster &&
tryApplyDoubanImageFallback(img, actualPoster)
) {
clearBangumiImageTimeout();
setDisplayPoster(doubanFallbackPoster);
return;
}
@@ -1042,7 +1020,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
bangumiFallbackPoster &&
tryApplyBangumiImageFallback(img, actualPoster)
) {
clearBangumiImageTimeout();
setDisplayPoster(bangumiFallbackPoster);
return;
}
@@ -1050,7 +1027,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
if (
clearBangumiImageFallbackCacheIfFailed(img, actualPoster)
) {
clearBangumiImageTimeout();
setDisplayPoster(processedPoster);
return;
}
+148
View File
@@ -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<boolean> | 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(),
});
}
/** 当前主源图片域 originserver-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<boolean> {
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<boolean> => {
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);
}