2025-08-12 21:50:58 +08:00
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
|
|
|
|
|
import he from 'he';
|
|
|
|
|
|
import Hls from 'hls.js';
|
2026-01-11 22:05:32 +08:00
|
|
|
|
import bs58 from 'bs58';
|
2025-08-12 21:50:58 +08:00
|
|
|
|
|
|
|
|
|
|
function getDoubanImageProxyConfig(): {
|
|
|
|
|
|
proxyType:
|
2025-08-17 17:48:37 +08:00
|
|
|
|
| 'direct'
|
|
|
|
|
|
| 'server'
|
|
|
|
|
|
| 'img3'
|
|
|
|
|
|
| 'cmliussss-cdn-tencent'
|
|
|
|
|
|
| 'cmliussss-cdn-ali'
|
|
|
|
|
|
| 'custom';
|
2025-08-12 21:50:58 +08:00
|
|
|
|
proxyUrl: string;
|
|
|
|
|
|
} {
|
2026-01-08 01:04:07 +08:00
|
|
|
|
// 确保在浏览器环境中执行
|
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
|
return {
|
|
|
|
|
|
proxyType: 'cmliussss-cdn-tencent',
|
|
|
|
|
|
proxyUrl: '',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-12 21:50:58 +08:00
|
|
|
|
const doubanImageProxyType =
|
|
|
|
|
|
localStorage.getItem('doubanImageProxyType') ||
|
|
|
|
|
|
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE ||
|
2025-08-26 22:53:04 +08:00
|
|
|
|
'cmliussss-cdn-tencent';
|
2025-08-12 21:50:58 +08:00
|
|
|
|
const doubanImageProxy =
|
|
|
|
|
|
localStorage.getItem('doubanImageProxyUrl') ||
|
|
|
|
|
|
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY ||
|
|
|
|
|
|
'';
|
|
|
|
|
|
return {
|
|
|
|
|
|
proxyType: doubanImageProxyType,
|
|
|
|
|
|
proxyUrl: doubanImageProxy,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-08 01:04:07 +08:00
|
|
|
|
* 处理图片 URL,根据用户设置使用相应的代理
|
2025-08-12 21:50:58 +08:00
|
|
|
|
*/
|
|
|
|
|
|
export function processImageUrl(originalUrl: string): string {
|
|
|
|
|
|
if (!originalUrl) return originalUrl;
|
|
|
|
|
|
|
2025-12-27 01:28:35 +08:00
|
|
|
|
// 如果已经是代理URL,直接返回
|
|
|
|
|
|
if (originalUrl.startsWith('/api/image-proxy')) {
|
|
|
|
|
|
return originalUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-12 21:50:58 +08:00
|
|
|
|
// 仅处理豆瓣图片代理
|
|
|
|
|
|
if (!originalUrl.includes('doubanio.com')) {
|
|
|
|
|
|
return originalUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 01:04:07 +08:00
|
|
|
|
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
|
|
|
|
|
|
switch (proxyType) {
|
|
|
|
|
|
case 'server':
|
|
|
|
|
|
return `/api/image-proxy?url=${encodeURIComponent(originalUrl)}`;
|
|
|
|
|
|
case 'img3':
|
2026-01-08 19:25:23 +08:00
|
|
|
|
return originalUrl.replace(/img\d+\.doubanio\.com/g, 'img3.doubanio.com');
|
2026-01-08 01:04:07 +08:00
|
|
|
|
case 'cmliussss-cdn-tencent':
|
|
|
|
|
|
return originalUrl.replace(
|
2026-01-08 19:25:23 +08:00
|
|
|
|
/img\d+\.doubanio\.com/g,
|
|
|
|
|
|
'img.doubanio.cmliussss.net'
|
2026-01-08 01:04:07 +08:00
|
|
|
|
);
|
|
|
|
|
|
case 'cmliussss-cdn-ali':
|
|
|
|
|
|
return originalUrl.replace(
|
2026-01-08 19:25:23 +08:00
|
|
|
|
/img\d+\.doubanio\.com/g,
|
|
|
|
|
|
'img.doubanio.cmliussss.com'
|
2026-01-08 01:04:07 +08:00
|
|
|
|
);
|
|
|
|
|
|
case 'custom':
|
2026-01-08 19:25:23 +08:00
|
|
|
|
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
|
|
|
|
|
|
case 'direct':
|
2026-01-08 01:04:07 +08:00
|
|
|
|
default:
|
2026-01-08 19:25:23 +08:00
|
|
|
|
return originalUrl;
|
2026-01-08 01:04:07 +08:00
|
|
|
|
}
|
2026-01-05 22:14:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-08 01:04:07 +08:00
|
|
|
|
* 处理视频 URL,根据用户设置使用相应的代理
|
2026-01-05 22:14:07 +08:00
|
|
|
|
*/
|
|
|
|
|
|
export function processVideoUrl(originalUrl: string): string {
|
|
|
|
|
|
if (!originalUrl) return originalUrl;
|
|
|
|
|
|
|
|
|
|
|
|
// 仅处理豆瓣视频代理
|
|
|
|
|
|
if (!originalUrl.includes('doubanio.com')) {
|
|
|
|
|
|
return originalUrl;
|
2025-08-12 21:50:58 +08:00
|
|
|
|
}
|
2026-01-05 22:14:07 +08:00
|
|
|
|
|
2026-01-08 01:04:07 +08:00
|
|
|
|
// 获取用户配置的代理设置
|
|
|
|
|
|
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'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-08-12 21:50:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 从m3u8地址获取视频质量等级和网络信息
|
|
|
|
|
|
* @param m3u8Url m3u8播放列表的URL
|
2026-01-11 20:45:33 +08:00
|
|
|
|
* @returns Promise<{quality: string, loadSpeed: string, pingTime: number, bitrate: string}> 视频质量等级和网络信息
|
2025-08-12 21:50:58 +08:00
|
|
|
|
*/
|
|
|
|
|
|
export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
|
|
|
|
|
quality: string; // 如720p、1080p等
|
|
|
|
|
|
loadSpeed: string; // 自动转换为KB/s或MB/s
|
|
|
|
|
|
pingTime: number; // 网络延迟(毫秒)
|
2026-01-11 20:45:33 +08:00
|
|
|
|
bitrate: string; // 视频码率(如 "2.5 Mbps")
|
2025-08-12 21:50:58 +08:00
|
|
|
|
}> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 直接使用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();
|
|
|
|
|
|
|
|
|
|
|
|
// 设置超时处理
|
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
|
hls.destroy();
|
|
|
|
|
|
video.remove();
|
|
|
|
|
|
reject(new Error('Timeout loading video metadata'));
|
|
|
|
|
|
}, 4000);
|
|
|
|
|
|
|
|
|
|
|
|
video.onerror = () => {
|
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
|
hls.destroy();
|
|
|
|
|
|
video.remove();
|
|
|
|
|
|
reject(new Error('Failed to load video metadata'));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
let actualLoadSpeed = '未知';
|
|
|
|
|
|
let hasSpeedCalculated = false;
|
|
|
|
|
|
let hasMetadataLoaded = false;
|
2026-01-11 20:45:33 +08:00
|
|
|
|
let estimatedBitrate = 0; // 估算的码率(bps)
|
2025-08-12 21:50:58 +08:00
|
|
|
|
|
|
|
|
|
|
let fragmentStartTime = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否可以返回结果
|
|
|
|
|
|
const checkAndResolve = () => {
|
|
|
|
|
|
if (
|
|
|
|
|
|
hasMetadataLoaded &&
|
|
|
|
|
|
(hasSpeedCalculated || actualLoadSpeed !== '未知')
|
|
|
|
|
|
) {
|
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
|
const width = video.videoWidth;
|
|
|
|
|
|
if (width && width > 0) {
|
|
|
|
|
|
hls.destroy();
|
|
|
|
|
|
video.remove();
|
|
|
|
|
|
|
|
|
|
|
|
// 根据视频宽度判断视频质量等级,使用经典分辨率的宽度作为分割点
|
|
|
|
|
|
const quality =
|
|
|
|
|
|
width >= 3840
|
|
|
|
|
|
? '4K' // 4K: 3840x2160
|
|
|
|
|
|
: width >= 2560
|
2025-08-17 17:48:37 +08:00
|
|
|
|
? '2K' // 2K: 2560x1440
|
|
|
|
|
|
: width >= 1920
|
|
|
|
|
|
? '1080p' // 1080p: 1920x1080
|
|
|
|
|
|
: width >= 1280
|
|
|
|
|
|
? '720p' // 720p: 1280x720
|
|
|
|
|
|
: width >= 854
|
|
|
|
|
|
? '480p'
|
|
|
|
|
|
: 'SD'; // 480p: 854x480
|
2025-08-12 21:50:58 +08:00
|
|
|
|
|
2026-01-11 20:45:33 +08:00
|
|
|
|
// 格式化码率
|
|
|
|
|
|
const bitrateStr = estimatedBitrate > 0
|
|
|
|
|
|
? estimatedBitrate >= 1000000
|
|
|
|
|
|
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
|
|
|
|
|
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
|
|
|
|
|
: '未知';
|
|
|
|
|
|
|
2025-08-12 21:50:58 +08:00
|
|
|
|
resolve({
|
|
|
|
|
|
quality,
|
|
|
|
|
|
loadSpeed: actualLoadSpeed,
|
|
|
|
|
|
pingTime: Math.round(pingTime),
|
2026-01-11 20:45:33 +08:00
|
|
|
|
bitrate: bitrateStr,
|
2025-08-12 21:50:58 +08:00
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// webkit 无法获取尺寸,直接返回
|
2026-01-11 20:45:33 +08:00
|
|
|
|
const bitrateStr = estimatedBitrate > 0
|
|
|
|
|
|
? estimatedBitrate >= 1000000
|
|
|
|
|
|
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
|
|
|
|
|
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
|
|
|
|
|
: '未知';
|
|
|
|
|
|
|
2025-08-12 21:50:58 +08:00
|
|
|
|
resolve({
|
|
|
|
|
|
quality: '未知',
|
|
|
|
|
|
loadSpeed: actualLoadSpeed,
|
|
|
|
|
|
pingTime: Math.round(pingTime),
|
2026-01-11 20:45:33 +08:00
|
|
|
|
bitrate: bitrateStr,
|
2025-08-12 21:50:58 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 监听片段加载开始
|
|
|
|
|
|
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;
|
2026-01-11 20:45:33 +08:00
|
|
|
|
|
|
|
|
|
|
// 从分片估算码率
|
|
|
|
|
|
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)`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-12 21:50:58 +08:00
|
|
|
|
checkAndResolve(); // 尝试返回结果
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
hls.loadSource(m3u8Url);
|
|
|
|
|
|
hls.attachMedia(video);
|
|
|
|
|
|
|
|
|
|
|
|
// 监听hls.js错误
|
|
|
|
|
|
hls.on(Hls.Events.ERROR, (event: any, data: any) => {
|
|
|
|
|
|
console.error('HLS错误:', data);
|
|
|
|
|
|
if (data.fatal) {
|
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
|
hls.destroy();
|
|
|
|
|
|
video.remove();
|
|
|
|
|
|
reject(new Error(`HLS播放失败: ${data.type}`));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 监听视频元数据加载完成
|
|
|
|
|
|
video.onloadedmetadata = () => {
|
|
|
|
|
|
hasMetadataLoaded = true;
|
|
|
|
|
|
checkAndResolve(); // 尝试返回结果
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
throw new Error(
|
2025-08-17 17:48:37 +08:00
|
|
|
|
`Error getting video resolution: ${error instanceof Error ? error.message : String(error)
|
2025-08-12 21:50:58 +08:00
|
|
|
|
}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-01-11 22:05:32 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 将字符串编码为 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');
|
|
|
|
|
|
}
|