优化动漫数据源图片降级策略
This commit is contained in:
@@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
clearBangumiImageFallbackCacheIfFailed,
|
clearBangumiImageFallbackCacheIfFailed,
|
||||||
|
ensureBangumiImagePrimaryProbed,
|
||||||
processImageUrl,
|
processImageUrl,
|
||||||
tryApplyBangumiImageFallback,
|
tryApplyBangumiImageFallback,
|
||||||
tryApplyDoubanImageFallback,
|
tryApplyDoubanImageFallback,
|
||||||
@@ -38,22 +39,21 @@ const ProxyImage: React.FC<ProxyImageProps> = ({
|
|||||||
setCurrentSrc(initialSrc);
|
setCurrentSrc(initialSrc);
|
||||||
}, [initialSrc]);
|
}, [initialSrc]);
|
||||||
|
|
||||||
|
// 主源图片域主页探测:失败则 sticky 走备源(替代原 5s complete 误判)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (displaySrc) return;
|
if (displaySrc) return;
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
let cancelled = false;
|
||||||
const img = imgRef.current;
|
void (async () => {
|
||||||
if (!img || img.complete || img.dataset.bangumiBackupTried === 'true') {
|
const reachable = await ensureBangumiImagePrimaryProbed();
|
||||||
return;
|
if (cancelled || reachable) return;
|
||||||
}
|
setCurrentSrc(processImageUrl(originalSrc));
|
||||||
|
})();
|
||||||
|
|
||||||
if (tryApplyBangumiImageFallback(img, originalSrc)) {
|
return () => {
|
||||||
setCurrentSrc(img.src);
|
cancelled = true;
|
||||||
}
|
};
|
||||||
}, 5000);
|
}, [displaySrc, originalSrc]);
|
||||||
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [currentSrc, displaySrc, originalSrc]);
|
|
||||||
|
|
||||||
const handleError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
const handleError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
||||||
const img = e.currentTarget;
|
const img = e.currentTarget;
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ import { isNetdiskSource } from '@/lib/netdisk/source';
|
|||||||
import {
|
import {
|
||||||
base58Decode,
|
base58Decode,
|
||||||
clearBangumiImageFallbackCacheIfFailed,
|
clearBangumiImageFallbackCacheIfFailed,
|
||||||
|
ensureBangumiImagePrimaryProbed,
|
||||||
getBangumiImageFallbackUrl,
|
getBangumiImageFallbackUrl,
|
||||||
getDoubanImageFallbackUrl,
|
getDoubanImageFallbackUrl,
|
||||||
markBangumiImageFallbackActive,
|
|
||||||
processImageUrl,
|
processImageUrl,
|
||||||
tryApplyBangumiImageFallback,
|
tryApplyBangumiImageFallback,
|
||||||
tryApplyDoubanImageFallback,
|
tryApplyDoubanImageFallback,
|
||||||
@@ -226,44 +226,24 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
|||||||
setDisplayPoster(processedPoster);
|
setDisplayPoster(processedPoster);
|
||||||
}, [processedPoster]);
|
}, [processedPoster]);
|
||||||
|
|
||||||
const bangumiImageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
// 主源图片域主页探测:失败写 sticky 后切备源海报(替代原 5s 强制降级)
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bangumiImageTimeoutRef.current) {
|
|
||||||
clearTimeout(bangumiImageTimeoutRef.current);
|
|
||||||
bangumiImageTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!actualPoster) return;
|
if (!actualPoster) return;
|
||||||
|
|
||||||
const bangumiFallbackPoster = getBangumiImageFallbackUrl(actualPoster);
|
let cancelled = false;
|
||||||
if (!bangumiFallbackPoster || displayPoster === bangumiFallbackPoster) {
|
void (async () => {
|
||||||
return;
|
const reachable = await ensureBangumiImagePrimaryProbed();
|
||||||
}
|
if (cancelled || reachable) return;
|
||||||
|
const bangumiFallbackPoster = getBangumiImageFallbackUrl(actualPoster);
|
||||||
bangumiImageTimeoutRef.current = setTimeout(() => {
|
if (bangumiFallbackPoster) {
|
||||||
markBangumiImageFallbackActive();
|
setDisplayPoster(bangumiFallbackPoster);
|
||||||
setDisplayPoster((current) =>
|
}
|
||||||
current === bangumiFallbackPoster ? current : bangumiFallbackPoster
|
})();
|
||||||
);
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (bangumiImageTimeoutRef.current) {
|
cancelled = true;
|
||||||
clearTimeout(bangumiImageTimeoutRef.current);
|
|
||||||
bangumiImageTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [actualPoster, displayPoster]);
|
}, [actualPoster]);
|
||||||
|
|
||||||
const clearBangumiImageTimeout = useCallback(() => {
|
|
||||||
if (bangumiImageTimeoutRef.current) {
|
|
||||||
clearTimeout(bangumiImageTimeoutRef.current);
|
|
||||||
bangumiImageTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
setEpisodes: (eps?: number) => setDynamicEpisodes(eps),
|
setEpisodes: (eps?: number) => setDynamicEpisodes(eps),
|
||||||
@@ -1017,7 +997,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
|||||||
loading='lazy'
|
loading='lazy'
|
||||||
onLoadingComplete={() => {
|
onLoadingComplete={() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
clearBangumiImageTimeout();
|
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -1031,7 +1010,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
|||||||
doubanFallbackPoster &&
|
doubanFallbackPoster &&
|
||||||
tryApplyDoubanImageFallback(img, actualPoster)
|
tryApplyDoubanImageFallback(img, actualPoster)
|
||||||
) {
|
) {
|
||||||
clearBangumiImageTimeout();
|
|
||||||
setDisplayPoster(doubanFallbackPoster);
|
setDisplayPoster(doubanFallbackPoster);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1042,7 +1020,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
|||||||
bangumiFallbackPoster &&
|
bangumiFallbackPoster &&
|
||||||
tryApplyBangumiImageFallback(img, actualPoster)
|
tryApplyBangumiImageFallback(img, actualPoster)
|
||||||
) {
|
) {
|
||||||
clearBangumiImageTimeout();
|
|
||||||
setDisplayPoster(bangumiFallbackPoster);
|
setDisplayPoster(bangumiFallbackPoster);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1050,7 +1027,6 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(
|
|||||||
if (
|
if (
|
||||||
clearBangumiImageFallbackCacheIfFailed(img, actualPoster)
|
clearBangumiImageFallbackCacheIfFailed(img, actualPoster)
|
||||||
) {
|
) {
|
||||||
clearBangumiImageTimeout();
|
|
||||||
setDisplayPoster(processedPoster);
|
setDisplayPoster(processedPoster);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,24 @@ type AnimeImageSource =
|
|||||||
const BANGUMI_IMAGE_FALLBACK_UNTIL_KEY = 'bangumiImageFallbackUntil';
|
const BANGUMI_IMAGE_FALLBACK_UNTIL_KEY = 'bangumiImageFallbackUntil';
|
||||||
const BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY = 'bangumiImageFallbackSignature';
|
const BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY = 'bangumiImageFallbackSignature';
|
||||||
const BANGUMI_IMAGE_FALLBACK_DURATION = 60 * 60 * 1000;
|
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 {
|
function normalizeBaseUrl(baseUrl: string): string {
|
||||||
return baseUrl.trim().replace(/\/+$/, '');
|
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 {
|
export function clearBangumiImageFallbackCache(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_UNTIL_KEY);
|
localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_UNTIL_KEY);
|
||||||
localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY);
|
localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY);
|
||||||
|
clearBangumiImageProbeCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markBangumiImageFallbackActive(): void {
|
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 {
|
function normalizeComparableUrl(url: string): string {
|
||||||
if (typeof window === 'undefined') return url;
|
if (typeof window === 'undefined') return url;
|
||||||
|
|
||||||
@@ -441,12 +585,16 @@ export function processImageUrl(originalUrl: string): string {
|
|||||||
|
|
||||||
// 处理 Bangumi 图片代理。直连模式尊重用户选择,不代理图片;
|
// 处理 Bangumi 图片代理。直连模式尊重用户选择,不代理图片;
|
||||||
// 仅在服务器代理 / 自定义 Base URL 模式下使用本站图片代理。
|
// 仅在服务器代理 / 自定义 Base URL 模式下使用本站图片代理。
|
||||||
|
// sticky 降级中走备源;否则用主源,并非阻塞探测主源图片域主页(5s,404 算通)。
|
||||||
if (isBangumiImageUrl(originalUrl)) {
|
if (isBangumiImageUrl(originalUrl)) {
|
||||||
const primary = getPrimaryBangumiImageSource();
|
const primary = getPrimaryBangumiImageSource();
|
||||||
const backup = getBackupBangumiImageSource(primary);
|
const backup = getBackupBangumiImageSource(primary);
|
||||||
if (backup && isBangumiImageFallbackActive()) {
|
if (backup && isBangumiImageFallbackActive()) {
|
||||||
return buildBangumiImageUrl(originalUrl, backup);
|
return buildBangumiImageUrl(originalUrl, backup);
|
||||||
}
|
}
|
||||||
|
if (primary !== 'server-proxy' && backup) {
|
||||||
|
scheduleBangumiImagePrimaryProbe();
|
||||||
|
}
|
||||||
return buildBangumiImageUrl(originalUrl, primary);
|
return buildBangumiImageUrl(originalUrl, primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user