'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([]); const [selectedIds, setSelectedIds] = useState>(new Set()); const [expandedGroupKeys, setExpandedGroupKeys] = useState>( 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 getStoredDownloadDirHandle = async (): Promise< FileSystemDirectoryHandle | undefined > => { const dbName = 'MoonTVPlus'; const storeName = 'dirHandles'; return new Promise((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 => { 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 => { 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 blobParts: BlobPart[] = []; const failedTitles: string[] = []; for (const task of tasksToExport) { try { const parts = task.downloadMode === 'filesystem' ? await readFilesystemTaskAsBlobParts(task, dirHandle!) : await readIndexedDBTaskAsBlobParts(task); blobParts.push(...parts); } catch (error) { console.error('导出任务失败:', task.title, error); failedTitles.push(`第 ${task.episodeIndex + 1} 集`); } } if (blobParts.length === 0) { alert(`导出失败:${failedTitles.join('、') || '未找到可导出的内容'}`); return; } const selectedGroups = videoGroups.filter((group) => group.tasks.some((task) => selectedIds.has(task.id)) ); const baseName = selectedGroups.length === 1 ? selectedGroups[0].title : `MoonTVPlus导出_${tasksToExport.length}集`; const blob = new Blob(blobParts, { type: 'video/MP2T' }); triggerBlobDownload(blob, `${sanitizeFilename(baseName)}.ts`); 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(() => { const groupMap = new Map(); 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( 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(
{/* Header */}

下载文件管理

{/* Toolbar */}
已选择 {selectedIds.size} / {completedTasks.length} 集 {videoGroups.length > 0 && `,共 ${videoGroups.length} 个视频`}
{/* Content */}
{completedTasks.length === 0 ? (
暂无下载记录
) : (
{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 (
handleToggleGroupExpand(group.key)} >

{group.title}

来源: {group.source} {group.tasks.length} 集 {group.downloadModes .map(getDownloadModeLabel) .join(' / ')}
最近完成: {formatDate(group.lastCompletedAt)}
总大小:{formatFileSize(group.totalSize)}
{selectedCount > 0 && (
已选 {selectedCount} 集
)}
{isExpanded && (
{group.tasks.map((task) => (
handleToggleSelect(task.id)} >
{selectedIds.has(task.id) && ( )}

第 {task.episodeIndex + 1} 集 {task.episodeTitle ? `:${task.episodeTitle}` : ''}

{task.title}

{formatDate(task.completedAt)}
{formatFileSize(task.fileSize)}
第 {task.episodeIndex + 1} 集 {getDownloadModeLabel(task.downloadMode)}
))}
)}
); })}
)}
, document.body )} setShowConfirmDialog(false)} /> ); }