From b3a460e300be9e6fb41a9a424399c75f67c8c55c Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 26 Feb 2026 22:47:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=86=85=E5=AE=B9=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/play/page.tsx | 32 ++- src/components/ConfirmDialog.tsx | 95 +++++++ src/components/DownloadManagementPanel.tsx | 314 +++++++++++++++++++++ src/components/UserMenu.tsx | 92 ++++-- src/contexts/DownloadContext.tsx | 157 ++++++++--- src/lib/download-db.ts | 256 +++++++++++++++++ src/lib/m3u8-downloader.ts | 2 + 7 files changed, 894 insertions(+), 54 deletions(-) create mode 100644 src/components/ConfirmDialog.tsx create mode 100644 src/components/DownloadManagementPanel.tsx create mode 100644 src/lib/download-db.ts diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index dfc7668..17b689e 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1625,13 +1625,31 @@ function PlayPageClient() { const storeName = 'dirHandles'; return new Promise((resolve) => { - const request = indexedDB.open(dbName, 1); + const request = indexedDB.open(dbName, 2); // 使用版本 2 request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; + + // 创建 dirHandles 表(如果不存在) if (!db.objectStoreNames.contains(storeName)) { db.createObjectStore(storeName); } + + // 创建 activeTasks 表(如果不存在) + if (!db.objectStoreNames.contains('activeTasks')) { + const activeStore = db.createObjectStore('activeTasks', { keyPath: 'id' }); + activeStore.createIndex('status', 'status', { unique: false }); + activeStore.createIndex('createdAt', 'createdAt', { unique: false }); + } + + // 创建 completedTasks 表(如果不存在) + if (!db.objectStoreNames.contains('completedTasks')) { + const completedStore = db.createObjectStore('completedTasks', { keyPath: 'id' }); + completedStore.createIndex('source', 'source', { unique: false }); + completedStore.createIndex('videoId', 'videoId', { unique: false }); + completedStore.createIndex('completedAt', 'completedAt', { unique: false }); + completedStore.createIndex('sourceVideoId', ['source', 'videoId'], { unique: false }); + } }; request.onsuccess = async (event) => { @@ -1656,6 +1674,17 @@ function PlayPageClient() { } try { + // 请求读权限 + const permission = await (dirHandle as any).queryPermission({ mode: 'read' }); + if (permission !== 'granted') { + const requestPermission = await (dirHandle as any).requestPermission({ mode: 'read' }); + if (requestPermission !== 'granted') { + console.warn('未获得读权限'); + resolve({ hasLocal: false }); + return; + } + } + // 如果有 source、videoId 和 episodeIndex,检查子目录 if (source && videoId && episodeIndex !== undefined) { const sourceDirHandle = await dirHandle.getDirectoryHandle(source, { create: false }); @@ -1672,6 +1701,7 @@ function PlayPageClient() { } } catch (error) { // 文件不存在 + console.error('检查本地文件失败:', error); resolve({ hasLocal: false }); } }; diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..d943335 --- /dev/null +++ b/src/components/ConfirmDialog.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { AlertTriangle, X } from 'lucide-react'; +import { createPortal } from 'react-dom'; + +interface ConfirmDialogProps { + isOpen: boolean; + title: string; + message: string; + confirmText?: string; + cancelText?: string; + onConfirm: () => void; + onCancel: () => void; + variant?: 'danger' | 'warning' | 'info'; +} + +export function ConfirmDialog({ + isOpen, + title, + message, + confirmText = '确定', + cancelText = '取消', + onConfirm, + onCancel, + variant = 'warning', +}: ConfirmDialogProps) { + if (!isOpen) return null; + + const variantStyles = { + danger: { + icon: 'text-red-500', + button: 'bg-red-500 hover:bg-red-600', + }, + warning: { + icon: 'text-yellow-500', + button: 'bg-yellow-500 hover:bg-yellow-600', + }, + info: { + icon: 'text-blue-500', + button: 'bg-blue-500 hover:bg-blue-600', + }, + }; + + const styles = variantStyles[variant]; + + return createPortal( +
+
+
+ {/* Header */} +
+
+ +

+ {title} +

+
+ +
+ + {/* Content */} +
+

+ {message} +

+
+ + {/* Footer */} +
+ + +
+
+
, + document.body + ); +} diff --git a/src/components/DownloadManagementPanel.tsx b/src/components/DownloadManagementPanel.tsx new file mode 100644 index 0000000..b42aff7 --- /dev/null +++ b/src/components/DownloadManagementPanel.tsx @@ -0,0 +1,314 @@ +'use client'; + +import { Check, Trash2, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { downloadDB, CompletedTask } from '@/lib/download-db'; + +import { ConfirmDialog } from './ConfirmDialog'; + +interface DownloadManagementPanelProps { + isOpen: boolean; + onClose: () => void; +} + +export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementPanelProps) { + const [completedTasks, setCompletedTasks] = useState([]); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [isDeleting, setIsDeleting] = 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 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)); + + // 删除文件系统中的文件 + for (const task of tasksToDelete) { + if (task.downloadMode === 'filesystem') { + try { + // 从 IndexedDB 读取目录句柄 + const dbName = 'MoonTVPlus'; + const storeName = 'dirHandles'; + + const dirHandle = await new Promise((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); + } + } + } + + // 从数据库删除记录 + await downloadDB.deleteCompletedTasks(Array.from(selectedIds)); + await loadCompletedTasks(); + setSelectedIds(new Set()); + } catch (error) { + console.error('删除任务失败:', error); + alert('删除失败,请重试'); + } finally { + setIsDeleting(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'; + }; + + if (!mounted || !isOpen) return null; + + return ( + <> + {createPortal( +
+
+
+ {/* Header */} +
+

+ 下载文件管理 +

+ +
+ + {/* Toolbar */} +
+
+ + + 已选择 {selectedIds.size} / {completedTasks.length} + +
+ +
+ + {/* Content */} +
+ {completedTasks.length === 0 ? ( +
+ 暂无下载记录 +
+ ) : ( +
+ {completedTasks.map((task) => ( +
handleToggleSelect(task.id)} + > +
+
+
+ {selectedIds.has(task.id) && ( + + )} +
+
+
+
+
+

+ {task.title} +

+ {task.videoTitle && ( +

+ {task.videoTitle} +

+ )} + {task.episodeTitle && ( +

+ {task.episodeTitle} +

+ )} +
+
+
+ {formatDate(task.completedAt)} +
+ {task.fileSize && ( +
+ {formatFileSize(task.fileSize)} +
+ )} +
+
+
+ 来源: {task.source} + + 第 {task.episodeIndex + 1} 集 + + {task.downloadMode === 'filesystem' ? 'File System API' : '浏览器下载'} +
+
+
+
+ ))} +
+ )} +
+
+
, + document.body + )} + + setShowConfirmDialog(false)} + /> + + ); +} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index aa6471f..04d5a52 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -47,6 +47,7 @@ import { NotificationPanel } from './NotificationPanel'; import { OfflineDownloadPanel } from './OfflineDownloadPanel'; import { useVersionCheck } from './VersionCheckProvider'; import { VersionPanel } from './VersionPanel'; +import { DownloadManagementPanel } from './DownloadManagementPanel'; interface AuthInfo { username?: string; @@ -68,6 +69,7 @@ export const UserMenu: React.FC = () => { const [isDeviceManagementOpen, setIsDeviceManagementOpen] = useState(false); const [isEcoAppsOpen, setIsEcoAppsOpen] = useState(false); const [isReportOpen, setIsReportOpen] = useState(false); + const [isDownloadManagementOpen, setIsDownloadManagementOpen] = useState(false); const [authInfo, setAuthInfo] = useState(null); const [storageType, setStorageType] = useState('localstorage'); const [mounted, setMounted] = useState(false); @@ -85,7 +87,7 @@ export const UserMenu: React.FC = () => { // Body 滚动锁定 - 使用 overflow 方式避免布局问题 useEffect(() => { - if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen || isDeviceManagementOpen || isEcoAppsOpen || isReportOpen) { + if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen || isDeviceManagementOpen || isEcoAppsOpen || isReportOpen || isDownloadManagementOpen) { const body = document.body; const html = document.documentElement; @@ -975,27 +977,59 @@ export const UserMenu: React.FC = () => { // 保存目录句柄到 IndexedDB const dbName = 'MoonTVPlus'; const storeName = 'dirHandles'; - const request = indexedDB.open(dbName, 1); - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(storeName)) { - db.createObjectStore(storeName); - } - }; + // 使用 Promise 包装 IndexedDB 操作 + await new Promise((resolve, reject) => { + const request = indexedDB.open(dbName, 2); // 使用版本 2,与 download-db.ts 保持一致 - request.onsuccess = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - const transaction = db.transaction([storeName], 'readwrite'); - const store = transaction.objectStore(storeName); - store.put(dirHandle, 'downloadDir'); - }; + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; - request.onerror = () => { - console.error('无法打开 IndexedDB'); - }; + // 创建 dirHandles 表(如果不存在) + if (!db.objectStoreNames.contains(storeName)) { + db.createObjectStore(storeName); + } + + // 创建 activeTasks 表(如果不存在) + if (!db.objectStoreNames.contains('activeTasks')) { + const activeStore = db.createObjectStore('activeTasks', { keyPath: 'id' }); + activeStore.createIndex('status', 'status', { unique: false }); + activeStore.createIndex('createdAt', 'createdAt', { unique: false }); + } + + // 创建 completedTasks 表(如果不存在) + if (!db.objectStoreNames.contains('completedTasks')) { + const completedStore = db.createObjectStore('completedTasks', { keyPath: 'id' }); + completedStore.createIndex('source', 'source', { unique: false }); + completedStore.createIndex('videoId', 'videoId', { unique: false }); + completedStore.createIndex('completedAt', 'completedAt', { unique: false }); + completedStore.createIndex('sourceVideoId', ['source', 'videoId'], { unique: false }); + } + }; + + request.onsuccess = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + const transaction = db.transaction([storeName], 'readwrite'); + const store = transaction.objectStore(storeName); + const putRequest = store.put(dirHandle, 'downloadDir'); + + putRequest.onsuccess = () => { + db.close(); + resolve(); + }; + + putRequest.onerror = () => { + db.close(); + reject(new Error('保存目录句柄失败')); + }; + }; + + request.onerror = () => { + reject(new Error('无法打开 IndexedDB')); + }; + }); } catch (err) { - console.error('用户取消选择目录', err); + console.error('选择目录失败:', err); } }; @@ -2329,6 +2363,17 @@ export const UserMenu: React.FC = () => {
)}
+ + {/* 下载文件管理 */} +
+ +
)} @@ -3676,6 +3721,17 @@ export const UserMenu: React.FC = () => { document.body )} + {/* 使用 Portal 将下载文件管理面板渲染到 document.body */} + {isDownloadManagementOpen && + mounted && + createPortal( + setIsDownloadManagementOpen(false)} + />, + document.body + )} + {/* 使用 Portal 将邮件设置面板渲染到 document.body */} {isEmailSettingsOpen && mounted && diff --git a/src/contexts/DownloadContext.tsx b/src/contexts/DownloadContext.tsx index 5785aa6..3543fac 100644 --- a/src/contexts/DownloadContext.tsx +++ b/src/contexts/DownloadContext.tsx @@ -4,6 +4,7 @@ import React, { createContext, useCallback, useContext, useState, useEffect } fr import { M3U8Downloader, M3U8DownloadTask } from '@/lib/m3u8-downloader'; import Toast from '@/components/Toast'; +import { downloadDB } from '@/lib/download-db'; interface DownloadContextType { downloader: M3U8Downloader; @@ -62,8 +63,49 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { // 保存任务状态 saveTasks(downloader.getAllTasks()); }, - onComplete: (task) => { + onComplete: async (task) => { setTasks(downloader.getAllTasks()); + + // 只有 filesystem 模式才保存到已完成任务表 + if (task.downloadMode === 'filesystem' && task.source && task.videoId && task.episodeIndex !== undefined) { + try { + // 计算文件大小 + let fileSize: number | undefined; + if (task.filesystemDirHandle) { + try { + const sourceDirHandle = await task.filesystemDirHandle.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 }); + + let totalSize = 0; + for await (const entry of epDirHandle.values()) { + if (entry.kind === 'file') { + const fileHandle = entry as FileSystemFileHandle; + const file = await fileHandle.getFile(); + totalSize += file.size; + } + } + fileSize = totalSize; + } catch (error) { + console.error('计算文件大小失败:', error); + } + } + + await downloadDB.saveCompletedTask({ + id: task.id, + title: task.title, + source: task.source, + videoId: task.videoId, + episodeIndex: task.episodeIndex, + completedAt: Date.now(), + downloadMode: 'filesystem', + fileSize, + }); + } catch (error) { + console.error('保存已完成任务失败:', error); + } + } + // 保存任务状态 saveTasks(downloader.getAllTasks()); // 任务完成后,尝试启动下一个等待的任务 @@ -79,46 +121,54 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { }, })); - // 保存任务到 localStorage - const saveTasks = useCallback((tasks: M3U8DownloadTask[]) => { + // 保存任务到 IndexedDB + const saveTasks = useCallback(async (tasks: M3U8DownloadTask[]) => { if (typeof window === 'undefined') return; try { // 只保存必要的信息,不保存 ArrayBuffer 等无法序列化的数据 - const tasksToSave = tasks.map(task => ({ - id: task.id, - url: task.url, - title: task.title, - type: task.type, - status: task.status, - finishList: task.finishList, - downloadIndex: task.downloadIndex, - finishNum: task.finishNum, - errorNum: task.errorNum, - source: task.source, - videoId: task.videoId, - episodeIndex: task.episodeIndex, - downloadMode: task.downloadMode, - rangeDownload: task.rangeDownload, - m3u8Content: task.m3u8Content, - })); + // browser 模式的已完成任务不需要保存 + const tasksToSave = tasks + .filter(task => { + // 过滤掉 browser 模式的已完成任务 + if (task.downloadMode === 'browser' && task.status === 'done') { + return false; + } + return true; + }) + .map(task => ({ + id: task.id, + url: task.url, + title: task.title, + type: task.type, + status: task.status, + finishList: task.finishList, + downloadIndex: task.downloadIndex, + finishNum: task.finishNum, + errorNum: task.errorNum, + source: task.source, + videoId: task.videoId, + episodeIndex: task.episodeIndex, + downloadMode: task.downloadMode, + rangeDownload: task.rangeDownload, + m3u8Content: task.m3u8Content, + createdAt: task.createdAt || Date.now(), + })); - localStorage.setItem('downloadTasks', JSON.stringify(tasksToSave)); + await downloadDB.saveActiveTasks(tasksToSave); } catch (error) { console.error('保存任务失败:', error); } }, []); - // 从 localStorage 恢复任务 + // 从 IndexedDB 恢复任务 useEffect(() => { if (typeof window === 'undefined') return; const restoreTasks = async () => { try { - const savedTasks = localStorage.getItem('downloadTasks'); - if (!savedTasks) return; - - const tasks = JSON.parse(savedTasks); + const savedTasks = await downloadDB.getActiveTasks(); + if (!savedTasks || savedTasks.length === 0) return; // 读取下载模式和目录句柄 const downloadMode = localStorage.getItem('downloadMode') as 'browser' | 'filesystem' || 'browser'; @@ -129,7 +179,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { const storeName = 'dirHandles'; dirHandle = await new Promise((resolve) => { - const request = indexedDB.open(dbName, 1); + const request = indexedDB.open(dbName, 2); request.onsuccess = (event) => { const db = (event.target as IDBOpenDBRequest).result; @@ -162,10 +212,25 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { }); } + // 收集需要删除的任务 ID + const tasksToDelete: string[] = []; + // 恢复任务 - for (const savedTask of tasks) { - // 只恢复未完成的任务 - if (savedTask.status === 'downloading' || savedTask.status === 'pause' || savedTask.status === 'ready') { + for (const savedTask of savedTasks) { + // browser 模式的任务标记为删除 + if (savedTask.downloadMode === 'browser') { + tasksToDelete.push(savedTask.id); + continue; + } + + // 已完成的 filesystem 任务标记为删除 + if (savedTask.downloadMode === 'filesystem' && savedTask.status === 'done') { + tasksToDelete.push(savedTask.id); + continue; + } + + // 只恢复 filesystem 模式的未完成任务 + if (savedTask.downloadMode === 'filesystem' && (savedTask.status === 'downloading' || savedTask.status === 'pause' || savedTask.status === 'ready')) { try { const taskId = await downloader.createTask( savedTask.url, @@ -195,10 +260,21 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { } } catch (error) { console.error('恢复任务失败:', savedTask.title, error); + // 恢复失败的任务也标记为删除 + tasksToDelete.push(savedTask.id); } + } else { + // 其他状态的任务标记为删除 + tasksToDelete.push(savedTask.id); } } + // 批量删除无效任务 + if (tasksToDelete.length > 0) { + console.log('清理无效任务:', tasksToDelete.length, '个'); + await downloadDB.deleteActiveTasks(tasksToDelete); + } + setTasks(downloader.getAllTasks()); } catch (error) { console.error('恢复任务失败:', error); @@ -231,7 +307,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { const storeName = 'dirHandles'; const alreadyDownloaded = await new Promise((resolve) => { - const request = indexedDB.open(dbName, 1); + const request = indexedDB.open(dbName, 2); request.onsuccess = async (event) => { const db = (event.target as IDBOpenDBRequest).result; @@ -282,7 +358,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { }); if (alreadyDownloaded) { - console.log('视频已下载,跳过:', title); + console.log('视频已下载(文件系统检查),跳过:', title, metadata); setToast({ message: `${title} 已经下载过了,无需重复下载`, type: 'info' }); return; } @@ -293,6 +369,12 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { const taskId = await downloader.createTask(url, title, type, metadata); + // 设置下载模式 + const task = downloader.getTask(taskId); + if (task) { + task.downloadMode = downloadMode; + } + // 如果是 filesystem 模式,从 IndexedDB 读取目录句柄 if (downloadMode === 'filesystem' && typeof window !== 'undefined') { try { @@ -301,7 +383,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { // 使用 Promise 包装 IndexedDB 操作,确保在启动任务前完成 await new Promise((resolve, reject) => { - const request = indexedDB.open(dbName, 1); + const request = indexedDB.open(dbName, 2); request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; @@ -328,14 +410,19 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) { getRequest.onsuccess = () => { const dirHandle = getRequest.result as FileSystemDirectoryHandle | undefined; if (dirHandle) { - // 更新任务的下载模式和目录句柄 + // 更新任务的目录句柄 const task = downloader.getTask(taskId); if (task) { - task.downloadMode = 'filesystem'; task.filesystemDirHandle = dirHandle; + console.log('已设置 filesystem 目录句柄:', dirHandle.name); } } else { console.warn('未找到保存目录,使用浏览器下载模式'); + // 如果没有目录句柄,回退到 browser 模式 + const task = downloader.getTask(taskId); + if (task) { + task.downloadMode = 'browser'; + } } db.close(); resolve(); diff --git a/src/lib/download-db.ts b/src/lib/download-db.ts new file mode 100644 index 0000000..72a651e --- /dev/null +++ b/src/lib/download-db.ts @@ -0,0 +1,256 @@ +/** + * 下载任务数据库管理 + */ + +export interface SavedTask { + id: string; + url: string; + title: string; + type: 'TS' | 'MP4'; + status: 'ready' | 'downloading' | 'pause' | 'done' | 'error'; + finishList: Array<{ + title: string; + status: '' | 'is-downloading' | 'is-success' | 'is-error'; + retryCount?: number; + }>; + downloadIndex: number; + finishNum: number; + errorNum: number; + source?: string; + videoId?: string; + episodeIndex?: number; + downloadMode?: 'browser' | 'filesystem'; + rangeDownload: { + isShowRange: boolean; + startSegment: number; + endSegment: number; + targetSegment: number; + }; + m3u8Content?: string; + createdAt: number; + completedAt?: number; +} + +export interface CompletedTask { + id: string; + title: string; + source: string; + videoId: string; + episodeIndex: number; + videoTitle?: string; // 视频总标题 + episodeTitle?: string; // 集数标题 + fileSize?: number; // 文件大小(字节) + completedAt: number; + downloadMode: 'browser' | 'filesystem'; +} + +const DB_NAME = 'MoonTVPlus'; +const DB_VERSION = 2; +const ACTIVE_TASKS_STORE = 'activeTasks'; +const COMPLETED_TASKS_STORE = 'completedTasks'; + +class DownloadDB { + private db: IDBDatabase | null = null; + + async init(): Promise { + if (this.db) return; + + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => reject(request.error); + request.onsuccess = () => { + this.db = request.result; + resolve(); + }; + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + // 创建 activeTasks 表 + if (!db.objectStoreNames.contains(ACTIVE_TASKS_STORE)) { + const activeStore = db.createObjectStore(ACTIVE_TASKS_STORE, { keyPath: 'id' }); + activeStore.createIndex('status', 'status', { unique: false }); + activeStore.createIndex('createdAt', 'createdAt', { unique: false }); + } + + // 创建 completedTasks 表 + if (!db.objectStoreNames.contains(COMPLETED_TASKS_STORE)) { + const completedStore = db.createObjectStore(COMPLETED_TASKS_STORE, { keyPath: 'id' }); + completedStore.createIndex('source', 'source', { unique: false }); + completedStore.createIndex('videoId', 'videoId', { unique: false }); + completedStore.createIndex('completedAt', 'completedAt', { unique: false }); + completedStore.createIndex('sourceVideoId', ['source', 'videoId'], { unique: false }); + } + }; + }); + } + + // 保存活动任务 + async saveActiveTask(task: SavedTask): Promise { + await this.init(); + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([ACTIVE_TASKS_STORE], 'readwrite'); + const store = transaction.objectStore(ACTIVE_TASKS_STORE); + const request = store.put(task); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + // 批量保存活动任务 + async saveActiveTasks(tasks: SavedTask[]): Promise { + await this.init(); + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([ACTIVE_TASKS_STORE], 'readwrite'); + const store = transaction.objectStore(ACTIVE_TASKS_STORE); + + // 先清空 + store.clear(); + + // 再添加 + for (const task of tasks) { + store.put(task); + } + + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); + } + + // 获取所有活动任务 + async getActiveTasks(): Promise { + await this.init(); + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([ACTIVE_TASKS_STORE], 'readonly'); + const store = transaction.objectStore(ACTIVE_TASKS_STORE); + const request = store.getAll(); + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } + + // 删除活动任务 + async deleteActiveTask(id: string): Promise { + await this.init(); + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([ACTIVE_TASKS_STORE], 'readwrite'); + const store = transaction.objectStore(ACTIVE_TASKS_STORE); + const request = store.delete(id); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + // 批量删除活动任务 + async deleteActiveTasks(ids: string[]): Promise { + await this.init(); + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([ACTIVE_TASKS_STORE], 'readwrite'); + const store = transaction.objectStore(ACTIVE_TASKS_STORE); + + for (const id of ids) { + store.delete(id); + } + + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); + } + + // 保存已完成任务 + async saveCompletedTask(task: CompletedTask): Promise { + await this.init(); + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([COMPLETED_TASKS_STORE], 'readwrite'); + const store = transaction.objectStore(COMPLETED_TASKS_STORE); + const request = store.put(task); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + // 获取所有已完成任务 + async getCompletedTasks(): Promise { + await this.init(); + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([COMPLETED_TASKS_STORE], 'readonly'); + const store = transaction.objectStore(COMPLETED_TASKS_STORE); + const index = store.index('completedAt'); + const request = index.openCursor(null, 'prev'); // 按完成时间倒序 + + const results: CompletedTask[] = []; + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + results.push(cursor.value); + cursor.continue(); + } else { + resolve(results); + } + }; + request.onerror = () => reject(request.error); + }); + } + + // 删除已完成任务 + async deleteCompletedTask(id: string): Promise { + await this.init(); + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([COMPLETED_TASKS_STORE], 'readwrite'); + const store = transaction.objectStore(COMPLETED_TASKS_STORE); + const request = store.delete(id); + + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + // 批量删除已完成任务 + async deleteCompletedTasks(ids: string[]): Promise { + await this.init(); + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([COMPLETED_TASKS_STORE], 'readwrite'); + const store = transaction.objectStore(COMPLETED_TASKS_STORE); + + for (const id of ids) { + store.delete(id); + } + + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); + } + + // 检查是否已下载 + async isDownloaded(source: string, videoId: string, episodeIndex: number): Promise { + await this.init(); + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction([COMPLETED_TASKS_STORE], 'readonly'); + const store = transaction.objectStore(COMPLETED_TASKS_STORE); + const index = store.index('sourceVideoId'); + const request = index.openCursor(IDBKeyRange.only([source, videoId])); + + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + const task = cursor.value as CompletedTask; + if (task.episodeIndex === episodeIndex) { + resolve(true); + return; + } + cursor.continue(); + } else { + resolve(false); + } + }; + request.onerror = () => reject(request.error); + }); + } +} + +export const downloadDB = new DownloadDB(); diff --git a/src/lib/m3u8-downloader.ts b/src/lib/m3u8-downloader.ts index 01c8069..b7b5ecf 100644 --- a/src/lib/m3u8-downloader.ts +++ b/src/lib/m3u8-downloader.ts @@ -51,6 +51,7 @@ export interface M3U8DownloadTask { source?: string; videoId?: string; episodeIndex?: number; + createdAt?: number; // 创建时间戳 } export interface M3U8DownloaderOptions { @@ -158,6 +159,7 @@ export class M3U8Downloader { source: metadata?.source, videoId: metadata?.videoId, episodeIndex: metadata?.episodeIndex, + createdAt: Date.now(), }; // 解析 TS 片段