Files
MoonTVPlus/src/lib/utils.ts
2026-01-11 22:05:32 +08:00

374 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import he from 'he';
import Hls from 'hls.js';
import bs58 from 'bs58';
function getDoubanImageProxyConfig(): {
proxyType:
| 'direct'
| 'server'
| 'img3'
| 'cmliussss-cdn-tencent'
| 'cmliussss-cdn-ali'
| 'custom';
proxyUrl: string;
} {
// 确保在浏览器环境中执行
if (typeof window === 'undefined') {
return {
proxyType: 'cmliussss-cdn-tencent',
proxyUrl: '',
};
}
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 ||
'';
return {
proxyType: doubanImageProxyType,
proxyUrl: doubanImageProxy,
};
}
/**
* 处理图片 URL根据用户设置使用相应的代理
*/
export function processImageUrl(originalUrl: string): string {
if (!originalUrl) return originalUrl;
// 如果已经是代理URL直接返回
if (originalUrl.startsWith('/api/image-proxy')) {
return originalUrl;
}
// 仅处理豆瓣图片代理
if (!originalUrl.includes('doubanio.com')) {
return originalUrl;
}
const { proxyType, proxyUrl } = getDoubanImageProxyConfig();
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 'custom':
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
case 'direct':
default:
return originalUrl;
}
}
/**
* 处理视频 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): Promise<{
quality: string; // 如720p、1080p等
loadSpeed: string; // 自动转换为KB/s或MB/s
pingTime: number; // 网络延迟(毫秒)
bitrate: string; // 视频码率(如 "2.5 Mbps"
}> {
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;
let estimatedBitrate = 0; // 估算的码率bps
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
? '2K' // 2K: 2560x1440
: width >= 1920
? '1080p' // 1080p: 1920x1080
: width >= 1280
? '720p' // 720p: 1280x720
: width >= 854
? '480p'
: 'SD'; // 480p: 854x480
// 格式化码率
const bitrateStr = estimatedBitrate > 0
? estimatedBitrate >= 1000000
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
: `${Math.round(estimatedBitrate / 1000)} Kbps`
: '未知';
resolve({
quality,
loadSpeed: actualLoadSpeed,
pingTime: Math.round(pingTime),
bitrate: bitrateStr,
});
} else {
// webkit 无法获取尺寸,直接返回
const bitrateStr = estimatedBitrate > 0
? estimatedBitrate >= 1000000
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
: `${Math.round(estimatedBitrate / 1000)} Kbps`
: '未知';
resolve({
quality: '未知',
loadSpeed: actualLoadSpeed,
pingTime: Math.round(pingTime),
bitrate: bitrateStr,
});
}
}
};
// 监听片段加载开始
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.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(
`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');
}