/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ import bs58 from 'bs58'; import he from 'he'; export type DoubanImageProxyType = | 'direct' | 'server' | 'img3' | 'cmliussss-cdn-tencent' | '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: '', }; } const doubanImageProxyType = localStorage.getItem('doubanImageProxyType') || (window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'cmliussss-cdn-tencent'; const doubanImageProxy = 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: 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; } function isBangumiImageUrl(url: string): boolean { try { const hostname = new URL(url).hostname.toLowerCase(); return ( hostname === 'lain.bgm.tv' || hostname === 'r.bgm.tv' || hostname.endsWith('.bgm.tv') || hostname.endsWith('.bangumi.tv') ); } catch { return false; } } type AnimeImageSource = 'direct' | 'server-proxy' | 'custom-baseurl'; const BANGUMI_IMAGE_FALLBACK_UNTIL_KEY = 'bangumiImageFallbackUntil'; const BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY = 'bangumiImageFallbackSignature'; const BANGUMI_IMAGE_FALLBACK_DURATION = 60 * 60 * 1000; function normalizeBaseUrl(baseUrl: string): string { return baseUrl.trim().replace(/\/+$/, ''); } function normalizeAnimeImageSource( value: string | null | undefined ): AnimeImageSource { return value === 'server-proxy' || value === 'custom-baseurl' || value === 'direct' ? value : 'direct'; } function getPrimaryBangumiImageSource(): AnimeImageSource { if (typeof window === 'undefined') { return 'direct'; } return normalizeAnimeImageSource( localStorage.getItem('animeDataSource') || (window as any).RUNTIME_CONFIG?.BANGUMI_DATA_SOURCE || 'direct' ); } function getBackupBangumiImageSource( primary: AnimeImageSource ): AnimeImageSource | null { if (typeof window === 'undefined') { return primary === 'server-proxy' ? null : 'server-proxy'; } const backup = normalizeAnimeImageSource( localStorage.getItem('animeDataSourceBackup') || 'server-proxy' ); return backup === primary ? null : backup; } function getBangumiImageBaseUrl(): string { if (typeof window === 'undefined') { return ''; } return normalizeBaseUrl(localStorage.getItem('animeImageBaseUrl') || ''); } function getBangumiImageFallbackSignature(): string { if (typeof window === 'undefined') return ''; return JSON.stringify({ primary: getPrimaryBangumiImageSource(), backup: normalizeAnimeImageSource( localStorage.getItem('animeDataSourceBackup') || 'server-proxy' ), imageBaseUrl: getBangumiImageBaseUrl(), }); } export function clearBangumiImageFallbackCache(): void { if (typeof window === 'undefined') return; localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_UNTIL_KEY); localStorage.removeItem(BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY); } export function markBangumiImageFallbackActive(): void { if (typeof window === 'undefined') return; localStorage.setItem( BANGUMI_IMAGE_FALLBACK_UNTIL_KEY, String(Date.now() + BANGUMI_IMAGE_FALLBACK_DURATION) ); localStorage.setItem( BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY, getBangumiImageFallbackSignature() ); } function normalizeComparableUrl(url: string): string { if (typeof window === 'undefined') return url; try { return new URL(url, window.location.href).href; } catch { return url; } } export function clearBangumiImageFallbackCacheIfFailed( target: HTMLImageElement, originalUrl: string ): boolean { if (!originalUrl || !isBangumiImageUrl(originalUrl)) { return false; } const fallbackUrl = getBangumiImageFallbackUrl(originalUrl); if (!fallbackUrl) { return false; } const normalizedFallbackUrl = normalizeComparableUrl(fallbackUrl); const failedUrls = [target.currentSrc, target.src] .filter(Boolean) .map(normalizeComparableUrl); const isFallbackFailure = target.dataset.bangumiBackupTried === 'true' || failedUrls.includes(normalizedFallbackUrl); if (!isFallbackFailure) { return false; } target.dataset.bangumiBackupFailed = 'true'; delete target.dataset.bangumiBackupTried; clearBangumiImageFallbackCache(); return true; } function isBangumiImageFallbackActive(): boolean { if (typeof window === 'undefined') return false; const until = Number(localStorage.getItem(BANGUMI_IMAGE_FALLBACK_UNTIL_KEY)); if (!until || Date.now() >= until) { clearBangumiImageFallbackCache(); return false; } const signature = localStorage.getItem(BANGUMI_IMAGE_FALLBACK_SIGNATURE_KEY); if (signature !== getBangumiImageFallbackSignature()) { clearBangumiImageFallbackCache(); return false; } return true; } function buildBangumiImageUrl( originalUrl: string, source: AnimeImageSource ): string { switch (source) { case 'server-proxy': return `/api/image-proxy?url=${encodeURIComponent( originalUrl )}&source=bangumi`; case 'custom-baseurl': { const imageBaseUrl = getBangumiImageBaseUrl(); return imageBaseUrl ? `${imageBaseUrl}/${originalUrl}` : originalUrl; } case 'direct': default: return originalUrl; } } export function getBangumiImageFallbackUrl(originalUrl: string): string | null { if (!originalUrl || !isBangumiImageUrl(originalUrl)) { return null; } const primary = getPrimaryBangumiImageSource(); const backup = getBackupBangumiImageSource(primary); if (!backup) { return null; } const primaryUrl = buildBangumiImageUrl(originalUrl, primary); const backupUrl = buildBangumiImageUrl(originalUrl, backup); 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; } export function tryApplyBangumiImageFallback( target: HTMLImageElement, originalUrl: string ): boolean { if (!originalUrl || !isBangumiImageUrl(originalUrl)) { return false; } if (target.dataset.bangumiBackupTried === 'true') { return false; } if (target.dataset.bangumiBackupFailed === 'true') { return false; } const fallbackUrl = getBangumiImageFallbackUrl(originalUrl); if ( !fallbackUrl || fallbackUrl === target.currentSrc || fallbackUrl === target.src ) { return false; } target.dataset.bangumiBackupTried = 'true'; markBangumiImageFallbackActive(); target.src = fallbackUrl; return true; } /** * 处理图片 URL,根据用户设置使用相应的代理 */ export function processImageUrl(originalUrl: string): string { if (!originalUrl) return originalUrl; // 如果已经是代理URL,直接返回 if (originalUrl.startsWith('/api/image-proxy')) { return originalUrl; } // 处理 TMDB 图片 URL 替换 if (originalUrl.includes('image.tmdb.org')) { if (typeof window !== 'undefined') { const tmdbImageBaseUrl = localStorage.getItem('tmdbImageBaseUrl') || 'https://image.tmdb.org'; // 只有当用户设置了不同的 baseUrl 时才进行替换 if (tmdbImageBaseUrl !== 'https://image.tmdb.org') { return originalUrl.replace('https://image.tmdb.org', tmdbImageBaseUrl); } } return originalUrl; } // 处理 Bangumi 图片代理。直连模式尊重用户选择,不代理图片; // 仅在服务器代理 / 自定义 Base URL 模式下使用本站图片代理。 if (isBangumiImageUrl(originalUrl)) { const primary = getPrimaryBangumiImageSource(); const backup = getBackupBangumiImageSource(primary); if (backup && isBangumiImageFallbackActive()) { return buildBangumiImageUrl(originalUrl, backup); } return buildBangumiImageUrl(originalUrl, primary); } // 处理豆瓣图片代理 if (!originalUrl.includes('doubanio.com')) { return originalUrl; } const { proxyType, proxyUrl } = getDoubanImageProxyConfig(); return buildDoubanImageUrl(originalUrl, proxyType, proxyUrl); } /** * 处理视频 URL,根据用户设置使用相应的代理 */ export function processVideoUrl(originalUrl: string): string { if (!originalUrl) return originalUrl; // 仅处理豆瓣视频代理 if (!originalUrl.includes('doubanio.com')) { return originalUrl; } // 获取用户配置的代理设置 const { proxyType, proxyUrl } = getDoubanImageProxyConfig(); // 根据代理类型处理URL switch (proxyType) { case 'direct': // 直连,不使用代理 return originalUrl; case 'server': // 使用服务器代理 return `/api/video-proxy?url=${encodeURIComponent(originalUrl)}`; case 'img3': // 使用 img3.doubanio.com 代理 return originalUrl.replace(/img\d\.doubanio\.com/g, 'img3.doubanio.com'); case 'cmliussss-cdn-tencent': // 使用腾讯云CDN代理 return originalUrl.replace( /https?:\/\/img\d\.doubanio\.com/g, 'https://douban-img.cmliussss.workers.dev' ); case 'cmliussss-cdn-ali': // 使用阿里云CDN代理 return originalUrl.replace( /https?:\/\/img\d\.doubanio\.com/g, 'https://douban-img-ali.cmliussss.workers.dev' ); case 'custom': // 使用自定义代理 if (proxyUrl) { return originalUrl.replace( /https?:\/\/img\d\.doubanio\.com/g, proxyUrl ); } return originalUrl; default: // 默认使用腾讯云CDN代理 return originalUrl.replace( /https?:\/\/img\d\.doubanio\.com/g, 'https://douban-img.cmliussss.workers.dev' ); } } /** * 从m3u8地址获取视频质量等级和网络信息 * @param m3u8Url m3u8播放列表的URL * @returns Promise<{quality: string, loadSpeed: string, pingTime: number, bitrate: string}> 视频质量等级和网络信息 */ export async function getVideoResolutionFromM3u8( m3u8Url: string, timeoutMs = 6000 ): Promise<{ quality: string; // 如720p、1080p等 loadSpeed: string; // 自动转换为KB/s或MB/s pingTime: number; // 网络延迟(毫秒) bitrate: string; // 视频码率(如 "2.5 Mbps") }> { try { const { default: Hls } = await import('hls.js'); // 直接使用m3u8 URL作为视频源,避免CORS问题 return new Promise((resolve, reject) => { const video = document.createElement('video'); video.muted = true; video.preload = 'metadata'; // 测量网络延迟(ping时间) - 使用m3u8 URL而不是ts文件 const pingStart = performance.now(); let pingTime = 0; // 测量ping时间(使用m3u8 URL) fetch(m3u8Url, { method: 'HEAD', mode: 'no-cors' }) .then(() => { pingTime = performance.now() - pingStart; }) .catch(() => { pingTime = performance.now() - pingStart; // 记录到失败为止的时间 }); // 固定使用hls.js加载 const hls = new Hls(); let actualLoadSpeed = '未知'; let hasSpeedCalculated = false; let hasMetadataLoaded = false; let estimatedBitrate = 0; // 估算的码率(bps) // 提取核心返回逻辑供 resolve 和 timeout 共同调用 const resolveCurrentState = () => { const width = video.videoWidth; const quality = width >= 3840 ? '4K' : width >= 2560 ? '2K' : width >= 1920 ? '1080p' : width >= 1280 ? '720p' : width >= 854 ? '480p' : width > 0 ? 'SD' : '未知'; const bitrateStr = estimatedBitrate > 0 ? estimatedBitrate >= 1000000 ? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps` : `${Math.round(estimatedBitrate / 1000)} Kbps` : '未知'; hls.destroy(); video.remove(); resolve({ quality, loadSpeed: actualLoadSpeed, pingTime: Math.round(pingTime), bitrate: bitrateStr, }); }; // 设置超时处理 - 如果部分数据已拿到,则宽容返回 const timeout = setTimeout(() => { if (hasMetadataLoaded || hasSpeedCalculated) { resolveCurrentState(); } else { hls.destroy(); video.remove(); reject(new Error('Timeout loading video metadata')); } }, timeoutMs); video.onerror = () => { clearTimeout(timeout); hls.destroy(); video.remove(); reject(new Error('Failed to load video metadata')); }; let fragmentStartTime = 0; // 检查是否可以相互满足要求 const checkAndResolve = () => { if ( hasMetadataLoaded && (hasSpeedCalculated || actualLoadSpeed !== '未知') ) { clearTimeout(timeout); resolveCurrentState(); } }; // 监听片段加载开始 hls.on(Hls.Events.FRAG_LOADING, () => { fragmentStartTime = performance.now(); }); // 监听片段加载完成,只需首个分片即可计算速度 hls.on(Hls.Events.FRAG_LOADED, (event: any, data: any) => { if ( fragmentStartTime > 0 && data && data.payload && !hasSpeedCalculated ) { const loadTime = performance.now() - fragmentStartTime; const size = data.payload.byteLength || 0; if (loadTime > 0 && size > 0) { const speedKBps = size / 1024 / (loadTime / 1000); // 立即计算速度,无需等待更多分片 const avgSpeedKBps = speedKBps; if (avgSpeedKBps >= 1024) { actualLoadSpeed = `${(avgSpeedKBps / 1024).toFixed(1)} MB/s`; } else { actualLoadSpeed = `${avgSpeedKBps.toFixed(1)} KB/s`; } hasSpeedCalculated = true; // 从分片估算码率 if (data.frag && data.frag.duration > 0) { const fragmentDuration = data.frag.duration; // 分片时长(秒) const fragmentSize = size; // 分片大小(字节) // 码率 = (分片大小 × 8 bits) / 分片时长 estimatedBitrate = Math.round( (fragmentSize * 8) / fragmentDuration ); console.log( `[测速] 估算码率: ${(estimatedBitrate / 1000000).toFixed( 2 )} Mbps (分片: ${(fragmentSize / 1024 / 1024).toFixed( 2 )} MB, 时长: ${fragmentDuration.toFixed(1)}s)` ); } checkAndResolve(); // 尝试返回结果 } } }); // 为分片请求添加时间戳参数破除浏览器缓存 hls.config.xhrSetup = function (xhr: XMLHttpRequest, url: string) { const urlWithTimestamp = url.includes('?') ? `${url}&_t=${Date.now()}` : `${url}?_t=${Date.now()}`; xhr.open('GET', urlWithTimestamp, true); }; hls.loadSource(m3u8Url); hls.attachMedia(video); // 监听hls.js错误 hls.on(Hls.Events.ERROR, (event: any, data: any) => { console.error('HLS错误:', data); if (data.fatal) { const statusCode = data.response?.code || data.response?.status; // 防止 415 代理兜底熔断导致正常的二进制源在优选逻辑中被剔除 if ( statusCode === 415 && (m3u8Url.includes('/api/proxy-m3u8') || m3u8Url.includes('/api/proxy/vod/m3u8')) ) { console.log( '[测速] 测速通道嗅探到这是底层的媒体流文件,免测速通过' ); clearTimeout(timeout); hls.destroy(); video.remove(); resolve({ quality: '原生画质', loadSpeed: '直连', pingTime: 10, bitrate: '未知', }); return; } clearTimeout(timeout); hls.destroy(); video.remove(); reject(new Error(`HLS播放失败: ${data.type}`)); } }); // 监听视频元数据加载完成 video.onloadedmetadata = () => { hasMetadataLoaded = true; checkAndResolve(); // 尝试返回结果 }; }); } catch (error) { throw new Error( `Error getting video resolution: ${ error instanceof Error ? error.message : String(error) }` ); } } export function cleanHtmlTags(text: string): string { if (!text) return ''; const cleanedText = text .replace(/<[^>]+>/g, '\n') // 将 HTML 标签替换为换行 .replace(/\n+/g, '\n') // 将多个连续换行合并为一个 .replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符 .replace(/^\n+|\n+$/g, '') // 去掉首尾换行 .trim(); // 去掉首尾空格 // 使用 he 库解码 HTML 实体 return he.decode(cleanedText); } /** * 将字符串编码为 Base58 * @param str 要编码的字符串 * @returns Base58 编码后的字符串 */ export function base58Encode(str: string): string { if (!str) return ''; // 在浏览器环境中使用 TextEncoder if (typeof window !== 'undefined') { const encoder = new TextEncoder(); const bytes = encoder.encode(str); return bs58.encode(bytes); } // 在 Node.js 环境中使用 Buffer const buffer = Buffer.from(str, 'utf-8'); return bs58.encode(buffer); } /** * 将 Base58 字符串解码为原始字符串 * @param encoded Base58 编码的字符串 * @returns 解码后的原始字符串 */ export function base58Decode(encoded: string): string { if (!encoded) return ''; const bytes = bs58.decode(encoded); // 在浏览器环境中使用 TextDecoder if (typeof window !== 'undefined') { const decoder = new TextDecoder(); return decoder.decode(bytes); } // 在 Node.js 环境中使用 Buffer return Buffer.from(bytes).toString('utf-8'); }