完善下载重试机制

This commit is contained in:
mtvpls
2025-12-09 23:30:12 +08:00
parent 777e110bf1
commit af12da4263
4 changed files with 139 additions and 39 deletions

View File

@@ -3581,11 +3581,15 @@ function PlayPageClient() {
<button
onClick={async (e) => {
e.preventDefault();
// 使用代理 URL
// 获取正确的代理 URL - 使用浏览器实际访问的地址
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
const origin = `${window.location.protocol}//${window.location.host}`;
console.log('下载按钮 - origin:', origin);
console.log('下载按钮 - window.location:', window.location.href);
const proxyUrl = externalPlayerAdBlock
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(videoUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
? `${origin}/api/proxy-m3u8?url=${encodeURIComponent(videoUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
: videoUrl;
console.log('下载按钮 - proxyUrl:', proxyUrl);
const isM3u8 = videoUrl.toLowerCase().includes('.m3u8') || videoUrl.toLowerCase().includes('/m3u8/');
if (isM3u8) {
@@ -3594,7 +3598,7 @@ function PlayPageClient() {
const downloadTitle = `${videoTitle}_第${currentEpisodeIndex + 1}`;
await addDownloadTask(proxyUrl, downloadTitle, 'TS');
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '已添加到下载队列!下载完成后可重命名为 .mp4';
artPlayerRef.current.notice.show = '已添加到下载队列!';
}
} catch (error) {
console.error('添加下载任务失败:', error);
@@ -3638,44 +3642,39 @@ function PlayPageClient() {
</span>
</button>
{/* IDM 下载按钮 */}
{/* IDM */}
<button
onClick={(e) => {
e.preventDefault();
// 使用代理 URL
// 获取正确的代理 URL
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
const origin = `${window.location.protocol}//${window.location.host}`;
const proxyUrl = externalPlayerAdBlock
? `${window.location.origin}/api/proxy-m3u8?url=${encodeURIComponent(videoUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
? `${origin}/api/proxy-m3u8?url=${encodeURIComponent(videoUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
: videoUrl;
// 复制链接到剪贴板
navigator.clipboard.writeText(proxyUrl).then(() => {
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '链接已复制!请在 IDM 中粘贴下载';
}
}).catch(() => {
if (artPlayerRef.current) {
artPlayerRef.current.notice.show = '复制失败,请手动复制链接';
}
});
// 唤起 IDM 下载器
window.open(`idm://${encodeURIComponent(proxyUrl)}`, '_blank');
}}
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-orange-400 flex-shrink-0'
title='IDM 下载'
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-white hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-gray-300 dark:border-gray-600 flex-shrink-0'
title='IDM'
>
<svg
className='w-4 h-4 flex-shrink-0 text-white'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
className='w-4 h-4 flex-shrink-0'
viewBox='0 0 48 48'
xmlns='http://www.w3.org/2000/svg'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M13 10V3L4 14h7v7l9-11h-7z'
/>
<path fill='#1976d2' d='M20,13c-8.837,0-16,6.044-16,13.5S11.163,40,20,40s16-6.044,16-13.5S28.837,13,20,13z M20,30 c-4.418,0-8-2.91-8-6.5s3.582-6.5,8-6.5s8,2.91,8,6.5S24.418,30,20,30z'/>
<path fill='#4caf50' d='M20,13c-6.879,0-12.726,3.669-14.987,8.809C5.011,21.874,5,21.936,5,22c0,7.18,4,14,17,15 c5.472,0.421,10.355-3.997,13.463-7.083C35.801,28.823,36,27.683,36,26.5C36,19.044,28.837,13,20,13z M20,30c-4.418,0-8-2.91-8-6.5 s3.582-6.5,8-6.5s8,2.91,8,6.5S24.418,30,20,30z'/>
<path fill='#ffeb3b' d='M31,33l-1.382-17.27C26.939,14.024,23.615,13,20,13c-5.319,0-10.014,2.2-12.918,5.572 C6.461,21.613,6.806,25.806,11,30C19,38,31,33,31,33z M20,17c4.418,0,8,2.91,8,6.5S24.418,30,20,30s-8-2.91-8-6.5S15.582,17,20,17z'/>
<path fill='#ff1744' d='M24.563,28.835C23.268,29.568,21.697,30,20,30c-4.418,0-8-2.91-8-6.5s3.582-6.5,8-6.5 c0.043,0,0.084,0.005,0.127,0.005L19,14c0,0-12,2-9,10s15,6,15,6L24.563,28.835z'/>
<circle cx='30' cy='20' r='14' fill='#29b6f6'/>
<path fill='#4caf50' d='M42,24c0,0,0,2-5,1s-6,2-6,2l-1.986,6.95C29.341,33.973,29.667,34,30,34 c7.459,0,13.538-5.838,13.959-13.192C42.832,21.508,42,24,42,24z'/>
<path fill='#4caf50' d='M32.208,10.347C31.719,10.487,31.302,10.698,31,11c-2,2-4,0-4,0s1.373-1.648,3.256-3.072 c0.959-0.726,0.043-2.255-1.026-1.702C28.823,6.436,28.411,6.691,28,7c-1.684,1.263-2.301,0.749-2.455-0.264 C20,8.598,16,13.827,16,20c0,0.425,0.04,0.839,0.077,1.254C17.042,21.932,18.81,22.532,22,22c6-1,4,7,4,7 s-0.196,0.458-0.503,1.135c0.612-0.375,1.239-0.832,1.884-1.4C28.332,28.507,29,27.469,29,25c0-1.716-0.52-3.043-1.127-4.008 c-0.839-1.333,0.716-2.908,2.037-2.051C29.94,18.96,29.97,18.98,30,19c3,2,1-2,1-2s-1.531-3.061,1.968-4.811 C34.048,11.65,33.368,10.014,32.208,10.347z'/>
<polygon fill='#8bc34a' points='20,23 24,44 44,29'/>
<polygon fill='#4caf50' points='24,44 29,34 20,23'/>
<polygon fill='#33691e' points='24,44 29,34 44,29'/>
</svg>
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-white'>
<span className='hidden lg:inline max-w-0 group-hover:max-w-[100px] overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out text-gray-700 dark:text-gray-200'>
IDM
</span>
</button>

View File

@@ -5,7 +5,7 @@ import { useDownload } from '@/contexts/DownloadContext';
import { M3U8DownloadTask } from '@/lib/m3u8-downloader';
export function DownloadPanel() {
const { tasks, showDownloadPanel, setShowDownloadPanel, startTask, pauseTask, cancelTask, getProgress } = useDownload();
const { tasks, showDownloadPanel, setShowDownloadPanel, startTask, pauseTask, cancelTask, retryFailedSegments, getProgress } = useDownload();
if (!showDownloadPanel) {
return null;
@@ -127,8 +127,16 @@ export function DownloadPanel() {
{/* 错误信息 */}
{task.errorNum > 0 && (
<div className='mb-3 text-xs text-red-500 dark:text-red-400'>
{task.errorNum}
<div className='mb-3 flex items-center justify-between'>
<div className='text-xs text-red-500 dark:text-red-400'>
{task.errorNum}
</div>
<button
onClick={() => retryFailedSegments(task.id)}
className='text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 underline'
>
</button>
</div>
)}

View File

@@ -10,6 +10,7 @@ interface DownloadContextType {
startTask: (taskId: string) => void;
pauseTask: (taskId: string) => void;
cancelTask: (taskId: string) => void;
retryFailedSegments: (taskId: string) => void;
getProgress: (taskId: string) => number;
downloadingCount: number;
showDownloadPanel: boolean;
@@ -62,6 +63,11 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
setTasks(downloader.getAllTasks());
}, [downloader]);
const retryFailedSegments = useCallback((taskId: string) => {
downloader.retryFailedSegments(taskId);
setTasks(downloader.getAllTasks());
}, [downloader]);
const getProgress = useCallback((taskId: string) => {
return downloader.getProgress(taskId);
}, [downloader]);
@@ -77,6 +83,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
startTask,
pauseTask,
cancelTask,
retryFailedSegments,
getProgress,
downloadingCount,
showDownloadPanel,

View File

@@ -13,7 +13,11 @@ export interface M3U8DownloadTask {
title: string;
type: 'TS' | 'MP4';
status: 'ready' | 'downloading' | 'pause' | 'done' | 'error';
finishList: Array<{ title: string; status: '' | 'is-downloading' | 'is-success' | 'is-error' }>;
finishList: Array<{
title: string;
status: '' | 'is-downloading' | 'is-success' | 'is-error';
retryCount?: number; // 重试次数
}>;
tsUrlList: string[];
requests: XMLHttpRequest[];
mediaFileList: ArrayBuffer[];
@@ -231,6 +235,40 @@ export class M3U8Downloader {
return (task.finishNum / task.rangeDownload.targetSegment) * 100;
}
/**
* 重试所有失败的片段
*/
retryFailedSegments(taskId: string): void {
const task = this.tasks.get(taskId);
if (!task) return;
// 重置所有失败片段的状态
let hasError = false;
task.finishList.forEach((item) => {
if (item.status === 'is-error') {
item.status = '';
item.retryCount = 0;
hasError = true;
}
});
if (hasError) {
task.errorNum = 0;
task.status = 'downloading';
// 找到第一个失败的片段索引
let firstErrorIndex = task.rangeDownload.endSegment;
for (let i = task.rangeDownload.startSegment - 1; i < task.rangeDownload.endSegment; i++) {
if (task.finishList[i] && task.finishList[i].status === '') {
firstErrorIndex = Math.min(firstErrorIndex, i);
}
}
task.downloadIndex = firstErrorIndex;
this.downloadTS(task);
}
}
/**
* 下载 TS 片段
*/
@@ -247,6 +285,9 @@ export class M3U8Downloader {
if (task.finishList[index] && task.finishList[index].status === '') {
task.finishList[index].status = 'is-downloading';
if (!task.finishList[index].retryCount) {
task.finishList[index].retryCount = 0;
}
const xhr = new XMLHttpRequest();
xhr.responseType = 'arraybuffer';
@@ -259,9 +300,36 @@ export class M3U8Downloader {
}
});
} else {
task.errorNum++;
task.finishList[index].status = 'is-error';
this.options.onError?.(task, `片段 ${index} 下载失败`);
// 下载失败,检查是否需要重试
const maxRetries = 3;
const currentRetry = task.finishList[index].retryCount || 0;
if (currentRetry < maxRetries) {
// 重试
task.finishList[index].retryCount = currentRetry + 1;
task.finishList[index].status = '';
console.log(`片段 ${index} 下载失败,正在重试 (${currentRetry + 1}/${maxRetries})...`);
// 延迟重试,避免立即重试
setTimeout(() => {
if (task.status !== 'pause') {
download();
}
}, 1000 * (currentRetry + 1)); // 递增延迟
} else {
// 重试次数用完,标记为最终失败
task.errorNum++;
task.finishList[index].status = 'is-error';
this.options.onError?.(task, `片段 ${index} 下载失败(已重试 ${maxRetries} 次)`);
// 检查是否所有片段都已处理完成
if (task.finishNum + task.errorNum === task.rangeDownload.targetSegment) {
if (task.errorNum > 0) {
task.status = 'pause';
this.options.onError?.(task, `下载完成,但有 ${task.errorNum} 个片段失败`);
}
}
}
if (task.downloadIndex < task.rangeDownload.endSegment) {
!isPause && download();
@@ -357,6 +425,7 @@ export class M3U8Downloader {
* 获取 M3U8 文件
*/
private async fetchM3U8(url: string): Promise<string> {
console.log('fetchM3U8 - 请求 URL:', url);
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
@@ -364,6 +433,7 @@ export class M3U8Downloader {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.responseText);
} else {
console.error('fetchM3U8 失败 - URL:', url, 'Status:', xhr.status);
reject(new Error(`HTTP ${xhr.status}`));
}
}
@@ -428,14 +498,30 @@ export class M3U8Downloader {
*/
private applyURL(targetURL: string, baseURL: string): string {
if (targetURL.indexOf('http') === 0) {
// 如果目标 URL 包含 0.0.0.0,替换为当前浏览器的 host
if (targetURL.includes('0.0.0.0')) {
const currentOrigin = `${window.location.protocol}//${window.location.host}`;
return targetURL.replace(/https?:\/\/0\.0\.0\.0:\d+/, currentOrigin);
}
return targetURL;
} else if (targetURL[0] === '/') {
const domain = baseURL.split('/');
return domain[0] + '//' + domain[2] + targetURL;
let origin = domain[0] + '//' + domain[2];
// 如果 origin 包含 0.0.0.0,替换为当前浏览器的 host
if (origin.includes('0.0.0.0')) {
origin = `${window.location.protocol}//${window.location.host}`;
}
return origin + targetURL;
} else {
const domain = baseURL.split('/');
domain.pop();
return domain.join('/') + '/' + targetURL;
let result = domain.join('/') + '/' + targetURL;
// 如果结果包含 0.0.0.0,替换为当前浏览器的 host
if (result.includes('0.0.0.0')) {
const currentOrigin = `${window.location.protocol}//${window.location.host}`;
result = result.replace(/https?:\/\/0\.0\.0\.0:\d+/, currentOrigin);
}
return result;
}
}