下载内容管理
This commit is contained in:
+31
-1
@@ -1625,13 +1625,31 @@ function PlayPageClient() {
|
|||||||
const storeName = 'dirHandles';
|
const storeName = 'dirHandles';
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const request = indexedDB.open(dbName, 1);
|
const request = indexedDB.open(dbName, 2); // 使用版本 2
|
||||||
|
|
||||||
request.onupgradeneeded = (event) => {
|
request.onupgradeneeded = (event) => {
|
||||||
const db = (event.target as IDBOpenDBRequest).result;
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
|
|
||||||
|
// 创建 dirHandles 表(如果不存在)
|
||||||
if (!db.objectStoreNames.contains(storeName)) {
|
if (!db.objectStoreNames.contains(storeName)) {
|
||||||
db.createObjectStore(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) => {
|
request.onsuccess = async (event) => {
|
||||||
@@ -1656,6 +1674,17 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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,检查子目录
|
// 如果有 source、videoId 和 episodeIndex,检查子目录
|
||||||
if (source && videoId && episodeIndex !== undefined) {
|
if (source && videoId && episodeIndex !== undefined) {
|
||||||
const sourceDirHandle = await dirHandle.getDirectoryHandle(source, { create: false });
|
const sourceDirHandle = await dirHandle.getDirectoryHandle(source, { create: false });
|
||||||
@@ -1672,6 +1701,7 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 文件不存在
|
// 文件不存在
|
||||||
|
console.error('检查本地文件失败:', error);
|
||||||
resolve({ hasLocal: false });
|
resolve({ hasLocal: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<div className='fixed inset-0 z-[10000] flex items-center justify-center p-4'>
|
||||||
|
<div
|
||||||
|
className='absolute inset-0 bg-black/50'
|
||||||
|
onClick={onCancel}
|
||||||
|
/>
|
||||||
|
<div className='relative w-full max-w-md bg-white dark:bg-gray-900 rounded-lg shadow-xl'>
|
||||||
|
{/* Header */}
|
||||||
|
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<AlertTriangle className={`w-6 h-6 ${styles.icon}`} />
|
||||||
|
<h2 className='text-lg font-semibold text-gray-800 dark:text-gray-200'>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className='p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors'
|
||||||
|
>
|
||||||
|
<X className='w-5 h-5 text-gray-600 dark:text-gray-400' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className='p-4'>
|
||||||
|
<p className='text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line'>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className='flex items-center justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700'>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className='px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors'
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`px-4 py-2 text-sm text-white rounded transition-colors ${styles.button}`}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<CompletedTask[]>([]);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从数据库删除记录
|
||||||
|
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(
|
||||||
|
<div className='fixed inset-0 z-[9999] flex items-center justify-center p-4'>
|
||||||
|
<div
|
||||||
|
className='absolute inset-0 bg-black/50'
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div className='relative w-full max-w-4xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-lg shadow-xl flex flex-col'>
|
||||||
|
{/* Header */}
|
||||||
|
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
|
||||||
|
<h2 className='text-xl font-semibold text-gray-800 dark:text-gray-200'>
|
||||||
|
下载文件管理
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className='p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors'
|
||||||
|
>
|
||||||
|
<X className='w-5 h-5 text-gray-600 dark:text-gray-400' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
|
||||||
|
<div className='flex items-center 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}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={selectedIds.size === 0 || isDeleting}
|
||||||
|
className='px-4 py-2 text-sm bg-red-500 text-white rounded hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2'
|
||||||
|
>
|
||||||
|
<Trash2 className='w-4 h-4' />
|
||||||
|
{isDeleting ? '删除中...' : '删除选中'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className='flex-1 overflow-y-auto p-4'>
|
||||||
|
{completedTasks.length === 0 ? (
|
||||||
|
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
|
||||||
|
暂无下载记录
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
{completedTasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className={`p-4 border rounded-lg transition-colors cursor-pointer ${
|
||||||
|
selectedIds.has(task.id)
|
||||||
|
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleToggleSelect(task.id)}
|
||||||
|
>
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<div className='flex-shrink-0 mt-1'>
|
||||||
|
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
||||||
|
selectedIds.has(task.id)
|
||||||
|
? 'border-green-500 bg-green-500'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
|
}`}>
|
||||||
|
{selectedIds.has(task.id) && (
|
||||||
|
<Check className='w-3 h-3 text-white' />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<div className='flex items-start justify-between gap-2'>
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<h3 className='text-sm font-medium text-gray-800 dark:text-gray-200 truncate'>
|
||||||
|
{task.title}
|
||||||
|
</h3>
|
||||||
|
{task.videoTitle && (
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
{task.videoTitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{task.episodeTitle && (
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
{task.episodeTitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex-shrink-0 text-right'>
|
||||||
|
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
{formatDate(task.completedAt)}
|
||||||
|
</div>
|
||||||
|
{task.fileSize && (
|
||||||
|
<div className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
{formatFileSize(task.fileSize)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2 mt-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
<span>来源: {task.source}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>第 {task.episodeIndex + 1} 集</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{task.downloadMode === 'filesystem' ? 'File System API' : '浏览器下载'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showConfirmDialog}
|
||||||
|
title='确认删除'
|
||||||
|
message={`确定要删除选中的 ${selectedIds.size} 个下载记录吗?\n\n注意:如果是 File System API 下载的文件,将会从磁盘删除实际文件。`}
|
||||||
|
confirmText='删除'
|
||||||
|
cancelText='取消'
|
||||||
|
variant='danger'
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
onCancel={() => setShowConfirmDialog(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+74
-18
@@ -47,6 +47,7 @@ import { NotificationPanel } from './NotificationPanel';
|
|||||||
import { OfflineDownloadPanel } from './OfflineDownloadPanel';
|
import { OfflineDownloadPanel } from './OfflineDownloadPanel';
|
||||||
import { useVersionCheck } from './VersionCheckProvider';
|
import { useVersionCheck } from './VersionCheckProvider';
|
||||||
import { VersionPanel } from './VersionPanel';
|
import { VersionPanel } from './VersionPanel';
|
||||||
|
import { DownloadManagementPanel } from './DownloadManagementPanel';
|
||||||
|
|
||||||
interface AuthInfo {
|
interface AuthInfo {
|
||||||
username?: string;
|
username?: string;
|
||||||
@@ -68,6 +69,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
const [isDeviceManagementOpen, setIsDeviceManagementOpen] = useState(false);
|
const [isDeviceManagementOpen, setIsDeviceManagementOpen] = useState(false);
|
||||||
const [isEcoAppsOpen, setIsEcoAppsOpen] = useState(false);
|
const [isEcoAppsOpen, setIsEcoAppsOpen] = useState(false);
|
||||||
const [isReportOpen, setIsReportOpen] = useState(false);
|
const [isReportOpen, setIsReportOpen] = useState(false);
|
||||||
|
const [isDownloadManagementOpen, setIsDownloadManagementOpen] = useState(false);
|
||||||
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
const [authInfo, setAuthInfo] = useState<AuthInfo | null>(null);
|
||||||
const [storageType, setStorageType] = useState<string>('localstorage');
|
const [storageType, setStorageType] = useState<string>('localstorage');
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@@ -85,7 +87,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
|
|
||||||
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
|
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
|
||||||
useEffect(() => {
|
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 body = document.body;
|
||||||
const html = document.documentElement;
|
const html = document.documentElement;
|
||||||
|
|
||||||
@@ -975,27 +977,59 @@ export const UserMenu: React.FC = () => {
|
|||||||
// 保存目录句柄到 IndexedDB
|
// 保存目录句柄到 IndexedDB
|
||||||
const dbName = 'MoonTVPlus';
|
const dbName = 'MoonTVPlus';
|
||||||
const storeName = 'dirHandles';
|
const storeName = 'dirHandles';
|
||||||
const request = indexedDB.open(dbName, 1);
|
|
||||||
|
|
||||||
request.onupgradeneeded = (event) => {
|
// 使用 Promise 包装 IndexedDB 操作
|
||||||
const db = (event.target as IDBOpenDBRequest).result;
|
await new Promise<void>((resolve, reject) => {
|
||||||
if (!db.objectStoreNames.contains(storeName)) {
|
const request = indexedDB.open(dbName, 2); // 使用版本 2,与 download-db.ts 保持一致
|
||||||
db.createObjectStore(storeName);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onsuccess = (event) => {
|
request.onupgradeneeded = (event) => {
|
||||||
const db = (event.target as IDBOpenDBRequest).result;
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
const transaction = db.transaction([storeName], 'readwrite');
|
|
||||||
const store = transaction.objectStore(storeName);
|
|
||||||
store.put(dirHandle, 'downloadDir');
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onerror = () => {
|
// 创建 dirHandles 表(如果不存在)
|
||||||
console.error('无法打开 IndexedDB');
|
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) {
|
} catch (err) {
|
||||||
console.error('用户取消选择目录', err);
|
console.error('选择目录失败:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2329,6 +2363,17 @@ export const UserMenu: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 下载文件管理 */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDownloadManagementOpen(true)}
|
||||||
|
className='w-full px-4 py-2 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors flex items-center justify-center gap-2'
|
||||||
|
>
|
||||||
|
<Package className='w-4 h-4' />
|
||||||
|
下载文件管理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3676,6 +3721,17 @@ export const UserMenu: React.FC = () => {
|
|||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 使用 Portal 将下载文件管理面板渲染到 document.body */}
|
||||||
|
{isDownloadManagementOpen &&
|
||||||
|
mounted &&
|
||||||
|
createPortal(
|
||||||
|
<DownloadManagementPanel
|
||||||
|
isOpen={isDownloadManagementOpen}
|
||||||
|
onClose={() => setIsDownloadManagementOpen(false)}
|
||||||
|
/>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 使用 Portal 将邮件设置面板渲染到 document.body */}
|
{/* 使用 Portal 将邮件设置面板渲染到 document.body */}
|
||||||
{isEmailSettingsOpen &&
|
{isEmailSettingsOpen &&
|
||||||
mounted &&
|
mounted &&
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { createContext, useCallback, useContext, useState, useEffect } fr
|
|||||||
|
|
||||||
import { M3U8Downloader, M3U8DownloadTask } from '@/lib/m3u8-downloader';
|
import { M3U8Downloader, M3U8DownloadTask } from '@/lib/m3u8-downloader';
|
||||||
import Toast from '@/components/Toast';
|
import Toast from '@/components/Toast';
|
||||||
|
import { downloadDB } from '@/lib/download-db';
|
||||||
|
|
||||||
interface DownloadContextType {
|
interface DownloadContextType {
|
||||||
downloader: M3U8Downloader;
|
downloader: M3U8Downloader;
|
||||||
@@ -62,8 +63,49 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// 保存任务状态
|
// 保存任务状态
|
||||||
saveTasks(downloader.getAllTasks());
|
saveTasks(downloader.getAllTasks());
|
||||||
},
|
},
|
||||||
onComplete: (task) => {
|
onComplete: async (task) => {
|
||||||
setTasks(downloader.getAllTasks());
|
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());
|
saveTasks(downloader.getAllTasks());
|
||||||
// 任务完成后,尝试启动下一个等待的任务
|
// 任务完成后,尝试启动下一个等待的任务
|
||||||
@@ -79,46 +121,54 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 保存任务到 localStorage
|
// 保存任务到 IndexedDB
|
||||||
const saveTasks = useCallback((tasks: M3U8DownloadTask[]) => {
|
const saveTasks = useCallback(async (tasks: M3U8DownloadTask[]) => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 只保存必要的信息,不保存 ArrayBuffer 等无法序列化的数据
|
// 只保存必要的信息,不保存 ArrayBuffer 等无法序列化的数据
|
||||||
const tasksToSave = tasks.map(task => ({
|
// browser 模式的已完成任务不需要保存
|
||||||
id: task.id,
|
const tasksToSave = tasks
|
||||||
url: task.url,
|
.filter(task => {
|
||||||
title: task.title,
|
// 过滤掉 browser 模式的已完成任务
|
||||||
type: task.type,
|
if (task.downloadMode === 'browser' && task.status === 'done') {
|
||||||
status: task.status,
|
return false;
|
||||||
finishList: task.finishList,
|
}
|
||||||
downloadIndex: task.downloadIndex,
|
return true;
|
||||||
finishNum: task.finishNum,
|
})
|
||||||
errorNum: task.errorNum,
|
.map(task => ({
|
||||||
source: task.source,
|
id: task.id,
|
||||||
videoId: task.videoId,
|
url: task.url,
|
||||||
episodeIndex: task.episodeIndex,
|
title: task.title,
|
||||||
downloadMode: task.downloadMode,
|
type: task.type,
|
||||||
rangeDownload: task.rangeDownload,
|
status: task.status,
|
||||||
m3u8Content: task.m3u8Content,
|
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) {
|
} catch (error) {
|
||||||
console.error('保存任务失败:', error);
|
console.error('保存任务失败:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 从 localStorage 恢复任务
|
// 从 IndexedDB 恢复任务
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
const restoreTasks = async () => {
|
const restoreTasks = async () => {
|
||||||
try {
|
try {
|
||||||
const savedTasks = localStorage.getItem('downloadTasks');
|
const savedTasks = await downloadDB.getActiveTasks();
|
||||||
if (!savedTasks) return;
|
if (!savedTasks || savedTasks.length === 0) return;
|
||||||
|
|
||||||
const tasks = JSON.parse(savedTasks);
|
|
||||||
|
|
||||||
// 读取下载模式和目录句柄
|
// 读取下载模式和目录句柄
|
||||||
const downloadMode = localStorage.getItem('downloadMode') as 'browser' | 'filesystem' || 'browser';
|
const downloadMode = localStorage.getItem('downloadMode') as 'browser' | 'filesystem' || 'browser';
|
||||||
@@ -129,7 +179,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const storeName = 'dirHandles';
|
const storeName = 'dirHandles';
|
||||||
|
|
||||||
dirHandle = await new Promise<FileSystemDirectoryHandle | undefined>((resolve) => {
|
dirHandle = await new Promise<FileSystemDirectoryHandle | undefined>((resolve) => {
|
||||||
const request = indexedDB.open(dbName, 1);
|
const request = indexedDB.open(dbName, 2);
|
||||||
|
|
||||||
request.onsuccess = (event) => {
|
request.onsuccess = (event) => {
|
||||||
const db = (event.target as IDBOpenDBRequest).result;
|
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) {
|
for (const savedTask of savedTasks) {
|
||||||
// 只恢复未完成的任务
|
// browser 模式的任务标记为删除
|
||||||
if (savedTask.status === 'downloading' || savedTask.status === 'pause' || savedTask.status === 'ready') {
|
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 {
|
try {
|
||||||
const taskId = await downloader.createTask(
|
const taskId = await downloader.createTask(
|
||||||
savedTask.url,
|
savedTask.url,
|
||||||
@@ -195,10 +260,21 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('恢复任务失败:', savedTask.title, 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());
|
setTasks(downloader.getAllTasks());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('恢复任务失败:', error);
|
console.error('恢复任务失败:', error);
|
||||||
@@ -231,7 +307,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const storeName = 'dirHandles';
|
const storeName = 'dirHandles';
|
||||||
|
|
||||||
const alreadyDownloaded = await new Promise<boolean>((resolve) => {
|
const alreadyDownloaded = await new Promise<boolean>((resolve) => {
|
||||||
const request = indexedDB.open(dbName, 1);
|
const request = indexedDB.open(dbName, 2);
|
||||||
|
|
||||||
request.onsuccess = async (event) => {
|
request.onsuccess = async (event) => {
|
||||||
const db = (event.target as IDBOpenDBRequest).result;
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
@@ -282,7 +358,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (alreadyDownloaded) {
|
if (alreadyDownloaded) {
|
||||||
console.log('视频已下载,跳过:', title);
|
console.log('视频已下载(文件系统检查),跳过:', title, metadata);
|
||||||
setToast({ message: `${title} 已经下载过了,无需重复下载`, type: 'info' });
|
setToast({ message: `${title} 已经下载过了,无需重复下载`, type: 'info' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -293,6 +369,12 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const taskId = await downloader.createTask(url, title, type, metadata);
|
const taskId = await downloader.createTask(url, title, type, metadata);
|
||||||
|
|
||||||
|
// 设置下载模式
|
||||||
|
const task = downloader.getTask(taskId);
|
||||||
|
if (task) {
|
||||||
|
task.downloadMode = downloadMode;
|
||||||
|
}
|
||||||
|
|
||||||
// 如果是 filesystem 模式,从 IndexedDB 读取目录句柄
|
// 如果是 filesystem 模式,从 IndexedDB 读取目录句柄
|
||||||
if (downloadMode === 'filesystem' && typeof window !== 'undefined') {
|
if (downloadMode === 'filesystem' && typeof window !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
@@ -301,7 +383,7 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// 使用 Promise 包装 IndexedDB 操作,确保在启动任务前完成
|
// 使用 Promise 包装 IndexedDB 操作,确保在启动任务前完成
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const request = indexedDB.open(dbName, 1);
|
const request = indexedDB.open(dbName, 2);
|
||||||
|
|
||||||
request.onupgradeneeded = (event) => {
|
request.onupgradeneeded = (event) => {
|
||||||
const db = (event.target as IDBOpenDBRequest).result;
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
@@ -328,14 +410,19 @@ export function DownloadProvider({ children }: { children: React.ReactNode }) {
|
|||||||
getRequest.onsuccess = () => {
|
getRequest.onsuccess = () => {
|
||||||
const dirHandle = getRequest.result as FileSystemDirectoryHandle | undefined;
|
const dirHandle = getRequest.result as FileSystemDirectoryHandle | undefined;
|
||||||
if (dirHandle) {
|
if (dirHandle) {
|
||||||
// 更新任务的下载模式和目录句柄
|
// 更新任务的目录句柄
|
||||||
const task = downloader.getTask(taskId);
|
const task = downloader.getTask(taskId);
|
||||||
if (task) {
|
if (task) {
|
||||||
task.downloadMode = 'filesystem';
|
|
||||||
task.filesystemDirHandle = dirHandle;
|
task.filesystemDirHandle = dirHandle;
|
||||||
|
console.log('已设置 filesystem 目录句柄:', dirHandle.name);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('未找到保存目录,使用浏览器下载模式');
|
console.warn('未找到保存目录,使用浏览器下载模式');
|
||||||
|
// 如果没有目录句柄,回退到 browser 模式
|
||||||
|
const task = downloader.getTask(taskId);
|
||||||
|
if (task) {
|
||||||
|
task.downloadMode = 'browser';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
db.close();
|
db.close();
|
||||||
resolve();
|
resolve();
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<SavedTask[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<CompletedTask[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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();
|
||||||
@@ -51,6 +51,7 @@ export interface M3U8DownloadTask {
|
|||||||
source?: string;
|
source?: string;
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
episodeIndex?: number;
|
episodeIndex?: number;
|
||||||
|
createdAt?: number; // 创建时间戳
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface M3U8DownloaderOptions {
|
export interface M3U8DownloaderOptions {
|
||||||
@@ -158,6 +159,7 @@ export class M3U8Downloader {
|
|||||||
source: metadata?.source,
|
source: metadata?.source,
|
||||||
videoId: metadata?.videoId,
|
videoId: metadata?.videoId,
|
||||||
episodeIndex: metadata?.episodeIndex,
|
episodeIndex: metadata?.episodeIndex,
|
||||||
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 解析 TS 片段
|
// 解析 TS 片段
|
||||||
|
|||||||
Reference in New Issue
Block a user