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 */}
+
+
+ {/* Content */}
+
+
+ {/* 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 片段