Files
MoonTVPlus/src/components/DownloadManagementPanel.tsx
T

798 lines
30 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.
'use client';
import { Check, ChevronDown, Download, Trash2, X } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { downloadDB, CompletedTask } from '@/lib/download-db';
import {
buildIndexedDBVideoCacheKey,
getIndexedDBVideoManifestByEpisode,
getIndexedDBVideoSegments,
deleteIndexedDBVideoCacheByEpisode,
} from '@/lib/indexeddb-video-cache';
import { ConfirmDialog } from './ConfirmDialog';
interface DownloadManagementPanelProps {
isOpen: boolean;
onClose: () => void;
}
interface VideoDownloadGroup {
key: string;
source: string;
videoId: string;
title: string;
tasks: CompletedTask[];
totalSize?: number;
lastCompletedAt: number;
downloadModes: CompletedTask['downloadMode'][];
}
export function DownloadManagementPanel({
isOpen,
onClose,
}: DownloadManagementPanelProps) {
const [completedTasks, setCompletedTasks] = useState<CompletedTask[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [expandedGroupKeys, setExpandedGroupKeys] = useState<Set<string>>(
new Set()
);
const [isDeleting, setIsDeleting] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [mounted, setMounted] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isOpen) {
loadCompletedTasks();
}
}, [isOpen]);
const loadCompletedTasks = async () => {
try {
const tasks = await downloadDB.getCompletedTasks();
setCompletedTasks(tasks);
} catch (error) {
console.error('加载已完成任务失败:', error);
}
};
const handleSelectAll = () => {
if (selectedIds.size === completedTasks.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(completedTasks.map((t) => t.id)));
}
};
const handleToggleSelect = (id: string) => {
const newSet = new Set(selectedIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setSelectedIds(newSet);
};
const handleToggleGroupSelect = (group: VideoDownloadGroup) => {
const newSet = new Set(selectedIds);
const allSelected = group.tasks.every((task) => newSet.has(task.id));
if (allSelected) {
group.tasks.forEach((task) => newSet.delete(task.id));
} else {
group.tasks.forEach((task) => newSet.add(task.id));
}
setSelectedIds(newSet);
};
const handleToggleGroupExpand = (groupKey: string) => {
const newSet = new Set(expandedGroupKeys);
if (newSet.has(groupKey)) {
newSet.delete(groupKey);
} else {
newSet.add(groupKey);
}
setExpandedGroupKeys(newSet);
};
const handleDelete = async () => {
if (selectedIds.size === 0) return;
setShowConfirmDialog(true);
};
const handleConfirmDelete = async () => {
setShowConfirmDialog(false);
setIsDeleting(true);
try {
// 获取要删除的任务
const tasksToDelete = completedTasks.filter((t) => selectedIds.has(t.id));
//禁止SzeMeng76抄袭狗抄袭
// 删除文件系统中的文件
for (const task of tasksToDelete) {
if (task.downloadMode === 'filesystem') {
try {
// 从 IndexedDB 读取目录句柄
const dbName = 'MoonTVPlus';
const storeName = 'dirHandles';
const dirHandle = await new Promise<
FileSystemDirectoryHandle | undefined
>((resolve) => {
const request = indexedDB.open(dbName, 2); // 使用版本 2
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(storeName)) {
db.close();
resolve(undefined);
return;
}
const transaction = db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const getRequest = store.get('downloadDir');
getRequest.onsuccess = () => {
const handle = getRequest.result as
| FileSystemDirectoryHandle
| undefined;
db.close();
resolve(handle);
};
getRequest.onerror = () => {
db.close();
resolve(undefined);
};
};
request.onerror = () => {
resolve(undefined);
};
});
if (dirHandle) {
// 请求写权限
const permission = await (dirHandle as any).requestPermission({
mode: 'readwrite',
});
if (permission !== 'granted') {
console.error('未获得写权限,无法删除文件');
continue;
}
// 删除目录
try {
const sourceDirHandle = await dirHandle.getDirectoryHandle(
task.source,
{ create: false }
);
const videoIdDirHandle =
await sourceDirHandle.getDirectoryHandle(task.videoId, {
create: false,
});
await videoIdDirHandle.removeEntry(
`ep${task.episodeIndex + 1}`,
{ recursive: true }
);
console.log(
'已删除文件:',
task.source,
task.videoId,
`ep${task.episodeIndex + 1}`
);
} catch (deleteError) {
console.error('删除目录失败:', deleteError);
// 如果目录不存在,也算成功
if ((deleteError as Error).name !== 'NotFoundError') {
throw deleteError;
}
}
}
} catch (error) {
console.error('删除文件失败:', task.title, error);
}
} else if (task.downloadMode === 'indexeddb') {
try {
await deleteIndexedDBVideoCacheByEpisode(
task.source,
task.videoId,
task.episodeIndex
);
console.log(
'已删除 IndexedDB 视频缓存:',
task.source,
task.videoId,
task.episodeIndex
);
} catch (error) {
console.error('删除 IndexedDB 视频缓存失败:', task.title, error);
}
}
}
// 从数据库删除记录
await downloadDB.deleteCompletedTasks(Array.from(selectedIds));
await loadCompletedTasks();
setSelectedIds(new Set());
} catch (error) {
console.error('删除任务失败:', error);
alert('删除失败,请重试');
} finally {
setIsDeleting(false);
}
};
const sanitizeFilename = (name: string) => {
return (
name
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120) || 'download'
);
};
const triggerBlobDownload = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const getTaskExportFilename = (
task: CompletedTask,
usedFilenames: Set<string>
) => {
const episodeLabel = `第${task.episodeIndex + 1}集`;
const baseTitle = task.videoTitle || getGroupTitle([task]);
const episodeTitle = task.episodeTitle
? `${episodeLabel}_${task.episodeTitle}`
: episodeLabel;
const baseFilename = sanitizeFilename(`${baseTitle}_${episodeTitle}`);
let filename = `${baseFilename}.ts`;
let duplicateIndex = 2;
while (usedFilenames.has(filename)) {
filename = `${baseFilename}_${duplicateIndex}.ts`;
duplicateIndex += 1;
}
usedFilenames.add(filename);
return filename;
};
const getStoredDownloadDirHandle = async (): Promise<
FileSystemDirectoryHandle | undefined
> => {
const dbName = 'MoonTVPlus';
const storeName = 'dirHandles';
return new Promise<FileSystemDirectoryHandle | undefined>((resolve) => {
const request = indexedDB.open(dbName, 2);
request.onsuccess = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(storeName)) {
db.close();
resolve(undefined);
return;
}
const transaction = db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const getRequest = store.get('downloadDir');
getRequest.onsuccess = () => {
const handle = getRequest.result as
| FileSystemDirectoryHandle
| undefined;
db.close();
resolve(handle);
};
getRequest.onerror = () => {
db.close();
resolve(undefined);
};
};
request.onerror = () => resolve(undefined);
});
};
const readFilesystemTaskAsBlobParts = async (
task: CompletedTask,
dirHandle: FileSystemDirectoryHandle
): Promise<BlobPart[]> => {
const sourceDirHandle = await dirHandle.getDirectoryHandle(task.source, {
create: false,
});
const videoIdDirHandle = await sourceDirHandle.getDirectoryHandle(
task.videoId,
{ create: false }
);
const epDirHandle = await videoIdDirHandle.getDirectoryHandle(
`ep${task.episodeIndex + 1}`,
{ create: false }
);
const segmentFiles: Array<{ name: string; file: File }> = [];
for await (const entry of epDirHandle.values()) {
if (entry.kind === 'file' && /^segment_\d+\.ts$/i.test(entry.name)) {
const fileHandle = entry as FileSystemFileHandle;
segmentFiles.push({
name: entry.name,
file: await fileHandle.getFile(),
});
}
}
segmentFiles.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { numeric: true })
);
if (segmentFiles.length === 0) {
throw new Error('未找到可导出的分片文件');
}
return segmentFiles.map(({ file }) => file);
};
const readIndexedDBTaskAsBlobParts = async (
task: CompletedTask
): Promise<BlobPart[]> => {
const manifest = await getIndexedDBVideoManifestByEpisode(
task.source,
task.videoId,
task.episodeIndex
);
const cacheKey =
manifest?.cacheKey ||
buildIndexedDBVideoCacheKey(task.source, task.videoId, task.episodeIndex);
const segments = await getIndexedDBVideoSegments(cacheKey);
if (segments.length === 0) {
throw new Error('未找到可导出的 IndexedDB 分片');
}
return segments.map((segment) => segment.data);
};
const handleExport = async () => {
if (selectedIds.size === 0 || isExporting) return;
setIsExporting(true);
try {
const tasksToExport = completedTasks
.filter((task) => selectedIds.has(task.id))
.sort((a, b) => {
const groupCompare = `${a.source}::${a.videoId}`.localeCompare(
`${b.source}::${b.videoId}`
);
if (groupCompare !== 0) return groupCompare;
if (a.episodeIndex !== b.episodeIndex)
return a.episodeIndex - b.episodeIndex;
return a.completedAt - b.completedAt;
});
const unsupportedTasks = tasksToExport.filter(
(task) => task.downloadMode === 'browser'
);
if (unsupportedTasks.length > 0) {
alert(
'浏览器下载模式的文件未保存在本地缓存中,无法从下载管理导出。请仅选择 File System API 或 IndexedDB 缓存记录。'
);
return;
}
let dirHandle: FileSystemDirectoryHandle | undefined;
if (tasksToExport.some((task) => task.downloadMode === 'filesystem')) {
dirHandle = await getStoredDownloadDirHandle();
if (!dirHandle) {
alert('无法读取下载目录授权,请先在下载设置中重新选择保存目录。');
return;
}
const permission = await (dirHandle as any).requestPermission({
mode: 'read',
});
if (permission !== 'granted') {
alert('未获得下载目录读取权限,无法导出。');
return;
}
}
const usedFilenames = new Set<string>();
let exportedCount = 0;
const failedTitles: string[] = [];
for (const task of tasksToExport) {
try {
const parts =
task.downloadMode === 'filesystem'
? await readFilesystemTaskAsBlobParts(task, dirHandle!)
: await readIndexedDBTaskAsBlobParts(task);
const blob = new Blob(parts, { type: 'video/MP2T' });
triggerBlobDownload(blob, getTaskExportFilename(task, usedFilenames));
exportedCount += 1;
} catch (error) {
console.error('导出任务失败:', task.title, error);
failedTitles.push(`第 ${task.episodeIndex + 1} 集`);
}
}
if (exportedCount === 0) {
alert(`导出失败:${failedTitles.join('、') || '未找到可导出的内容'}`);
return;
}
if (failedTitles.length > 0) {
alert(`已导出可读取的内容,但以下条目失败:${failedTitles.join('、')}`);
}
} catch (error) {
console.error('导出失败:', error);
alert(
`导出失败:${error instanceof Error ? error.message : String(error)}`
);
} finally {
setIsExporting(false);
}
};
const formatDate = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return '未知';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
if (bytes < 1024 * 1024 * 1024)
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
};
const getDownloadModeLabel = (mode: CompletedTask['downloadMode']) => {
switch (mode) {
case 'filesystem':
return 'File System API';
case 'indexeddb':
return 'IndexedDB 缓存';
case 'browser':
default:
return '浏览器下载';
}
};
const getGroupTitle = (tasks: CompletedTask[]) => {
const taskWithVideoTitle = tasks.find((task) => task.videoTitle);
if (taskWithVideoTitle?.videoTitle) return taskWithVideoTitle.videoTitle;
const firstTitle = tasks[0]?.title || '未知视频';
return (
firstTitle
.replace(/[_\s-]*第\s*\d+\s*集.*$/u, '')
.replace(/[_\s-]*EP?\s*\d+.*$/iu, '')
.trim() || firstTitle
);
};
const videoGroups = useMemo<VideoDownloadGroup[]>(() => {
const groupMap = new Map<string, CompletedTask[]>();
for (const task of completedTasks) {
const key = `${task.source}::${task.videoId}`;
const groupTasks = groupMap.get(key) || [];
groupTasks.push(task);
groupMap.set(key, groupTasks);
}
return Array.from(groupMap.entries())
.map(([key, tasks]) => {
const sortedTasks = [...tasks].sort((a, b) => {
if (a.episodeIndex !== b.episodeIndex) {
return a.episodeIndex - b.episodeIndex;
}
return b.completedAt - a.completedAt;
});
const totalSize = sortedTasks.reduce(
(sum, task) => sum + (task.fileSize || 0),
0
);
const modeSet = new Set<CompletedTask['downloadMode']>(
sortedTasks.map((task) => task.downloadMode)
);
return {
key,
source: sortedTasks[0].source,
videoId: sortedTasks[0].videoId,
title: getGroupTitle(sortedTasks),
tasks: sortedTasks,
totalSize: totalSize > 0 ? totalSize : undefined,
lastCompletedAt: Math.max(
...sortedTasks.map((task) => task.completedAt)
),
downloadModes: Array.from(modeSet),
};
})
.sort((a, b) => b.lastCompletedAt - a.lastCompletedAt);
}, [completedTasks]);
if (!mounted || !isOpen) return null;
return (
<>
{createPortal(
<div className='fixed inset-0 z-[9999] flex items-end justify-center p-0 sm:items-center sm:p-4'>
<div className='absolute inset-0 bg-black/50' onClick={onClose} />
<div className='relative flex h-[92dvh] max-h-[92dvh] w-full max-w-4xl flex-col rounded-t-2xl bg-white shadow-xl dark:bg-gray-900 sm:h-auto sm:max-h-[90vh] sm:rounded-lg'>
{/* Header */}
<div className='flex items-center justify-between border-b border-gray-200 p-3 dark:border-gray-700 sm:p-4'>
<h2 className='text-lg font-semibold text-gray-800 dark:text-gray-200 sm:text-xl'>
下载文件管理
</h2>
<button
onClick={onClose}
className='rounded p-2 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800'
aria-label='关闭下载文件管理'
>
<X className='w-5 h-5 text-gray-600 dark:text-gray-400' />
</button>
</div>
{/* Toolbar */}
<div className='flex items-center justify-between gap-3 border-b border-gray-200 p-3 dark:border-gray-700 sm:p-4'>
<div className='min-w-0 flex-1 flex-col gap-2 sm:flex sm:flex-row sm:items-center sm:gap-4'>
<label className='flex items-center gap-2 cursor-pointer'>
<input
type='checkbox'
checked={
selectedIds.size === completedTasks.length &&
completedTasks.length > 0
}
onChange={handleSelectAll}
className='w-4 h-4'
/>
<span className='text-sm text-gray-700 dark:text-gray-300'>
全选
</span>
</label>
<span className='text-sm text-gray-500 dark:text-gray-400'>
已选择 {selectedIds.size} / {completedTasks.length}
{videoGroups.length > 0 &&
`,共 ${videoGroups.length} 个视频`}
</span>
</div>
<div className='ml-auto flex flex-shrink-0 items-center justify-end gap-2'>
<button
onClick={handleExport}
aria-label={isExporting ? '导出中' : '导出选中'}
disabled={selectedIds.size === 0 || isExporting || isDeleting}
className='flex h-10 w-10 items-center justify-center rounded bg-green-500 text-white transition-colors hover:bg-green-600 disabled:cursor-not-allowed disabled:opacity-50 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2 sm:text-sm'
>
<Download className='h-4 w-4' />
<span className='hidden sm:inline'>
{isExporting ? '导出中...' : '导出选中'}
</span>
</button>
<button
onClick={handleDelete}
aria-label={isDeleting ? '删除中' : '删除选中'}
disabled={selectedIds.size === 0 || isDeleting || isExporting}
className='flex h-10 w-10 items-center justify-center rounded bg-red-500 text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2 sm:text-sm'
>
<Trash2 className='h-4 w-4' />
<span className='hidden sm:inline'>
{isDeleting ? '删除中...' : '删除选中'}
</span>
</button>
</div>
</div>
{/* Content */}
<div className='flex-1 overflow-y-auto p-3 sm:p-4'>
{completedTasks.length === 0 ? (
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
暂无下载记录
</div>
) : (
<div className='space-y-3'>
{videoGroups.map((group) => {
const isExpanded = expandedGroupKeys.has(group.key);
const selectedCount = group.tasks.filter((task) =>
selectedIds.has(task.id)
).length;
const isGroupSelected =
selectedCount === group.tasks.length;
const isGroupPartiallySelected =
selectedCount > 0 && !isGroupSelected;
return (
<div
key={group.key}
className={`border rounded-lg overflow-hidden transition-colors ${
isGroupSelected || isGroupPartiallySelected
? 'border-green-500 bg-green-50/70 dark:bg-green-900/10'
: 'border-gray-200 dark:border-gray-700'
}`}
>
<div
className='flex cursor-pointer items-start gap-2 p-3 hover:bg-gray-50 dark:hover:bg-gray-800/80 sm:gap-3 sm:p-4'
onClick={() => handleToggleGroupExpand(group.key)}
>
<button
type='button'
onClick={(event) => {
event.stopPropagation();
handleToggleGroupSelect(group);
}}
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded border-2 transition-colors sm:mt-1 sm:h-5 sm:w-5 ${
isGroupSelected
? 'border-green-500 bg-green-500'
: isGroupPartiallySelected
? 'border-green-500 bg-green-100 dark:bg-green-900/40'
: 'border-gray-300 dark:border-gray-600'
}`}
aria-label={`选择 ${group.title}`}
>
{isGroupSelected ? (
<Check className='h-4 w-4 text-white sm:h-3 sm:w-3' />
) : isGroupPartiallySelected ? (
<span className='h-0.5 w-3 rounded bg-green-500 sm:w-2.5' />
) : null}
</button>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3'>
<div className='min-w-0 flex-1'>
<h3 className='line-clamp-2 text-sm font-semibold text-gray-800 dark:text-gray-200 sm:truncate'>
{group.title}
</h3>
<div className='mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-gray-500 dark:text-gray-400'>
<span>来源: {group.source}</span>
<span></span>
<span>{group.tasks.length} </span>
<span></span>
<span>
{group.downloadModes
.map(getDownloadModeLabel)
.join(' / ')}
</span>
</div>
</div>
<div className='flex w-full flex-row-reverse items-center justify-between gap-3 text-left sm:w-auto sm:flex-row sm:items-start sm:text-right'>
<div className='min-w-0 sm:min-w-[150px]'>
<div className='text-xs text-gray-500 dark:text-gray-400'>
最近完成:
{formatDate(group.lastCompletedAt)}
</div>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
总大小:{formatFileSize(group.totalSize)}
</div>
{selectedCount > 0 && (
<div className='mt-1 text-xs text-green-600 dark:text-green-400'>
已选 {selectedCount}
</div>
)}
</div>
<ChevronDown
className={`h-5 w-5 flex-shrink-0 text-gray-400 transition-transform sm:mt-1 ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</div>
</div>
</div>
</div>
{isExpanded && (
<div className='border-t border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900'>
{group.tasks.map((task) => (
<div
key={task.id}
className={`flex cursor-pointer items-start gap-2 px-3 py-3.5 transition-colors sm:gap-3 sm:px-4 sm:py-3 ${
selectedIds.has(task.id)
? 'bg-green-50 dark:bg-green-900/20'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
onClick={() => handleToggleSelect(task.id)}
>
<div className='flex-shrink-0 sm:mt-1'>
<div
className={`flex h-8 w-8 items-center justify-center rounded border-2 sm:h-5 sm:w-5 ${
selectedIds.has(task.id)
? 'border-green-500 bg-green-500'
: 'border-gray-300 dark:border-gray-600'
}`}
>
{selectedIds.has(task.id) && (
<Check className='h-4 w-4 text-white sm:h-3 sm:w-3' />
)}
</div>
</div>
<div className='min-w-0 flex-1'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
<div className='min-w-0 flex-1'>
<h4 className='line-clamp-2 text-sm font-medium text-gray-800 dark:text-gray-200 sm:truncate'>
{task.episodeIndex + 1}
{task.episodeTitle
? `${task.episodeTitle}`
: ''}
</h4>
<p className='mt-1 truncate text-xs text-gray-500 dark:text-gray-400'>
{task.title}
</p>
</div>
<div className='flex-shrink-0 text-left sm:text-right'>
<div className='text-xs text-gray-500 dark:text-gray-400'>
{formatDate(task.completedAt)}
</div>
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
{formatFileSize(task.fileSize)}
</div>
</div>
</div>
<div className='mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400'>
<span> {task.episodeIndex + 1} </span>
<span></span>
<span>
{getDownloadModeLabel(task.downloadMode)}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>,
document.body
)}
<ConfirmDialog
isOpen={showConfirmDialog}
title='确认删除'
message={`确定要删除选中的 ${selectedIds.size} 个下载记录吗?\n\n注意:File System API 文件会从磁盘删除,IndexedDB 缓存会从浏览器独立视频缓存库删除。`}
confirmText='删除'
cancelText='取消'
variant='danger'
onConfirm={handleConfirmDelete}
onCancel={() => setShowConfirmDialog(false)}
/>
</>
);
}