Files
LitePan/core/upload_task_manager.py
T
KingandCursor 463045a4f1 新增123云盘Open与OneDrive驱动
- 接入123云盘Open与OneDrive基础驱动能力,补充OAuth映射与图标资源
- 优化账号连通性测试和驱动实例复用,避免临时测试串用旧驱动
- 增加上传速度显示,上传文件夹跳过空文件和常见系统垃圾文件
- 优化前台删除、重命名、移动后的列表刷新体验
- 调整添加账号弹窗、STRM空状态和深色模式细节

Co-authored-by: Cursor <[email protected]>
2026-06-05 21:10:12 +08:00

836 lines
32 KiB
Python
Executable File

"""上传任务管理器:内存级队列 + 并发槽位 + 临时文件清理。进程重启后任务不持久化。"""
import asyncio
import json
import os
import time
import uuid
from contextlib import asynccontextmanager
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from core.operation_wrapper import current_account_id
@dataclass
class UploadTask:
task_id: str
account_id: int
account_name: str
driver_type: str
file_name: str
target_path: str
local_path: str
total_bytes: int
target_display_path: str = ""
client_task_id: str = ""
conflict_policy: str = "overwrite"
status: str = "pending"
progress: int = 0
uploaded_bytes: int = 0
speed_bytes_per_second: float = 0.0
message: str = "等待上传"
error: str = ""
result: Optional[Dict[str, Any]] = None
resume_data: Optional[Dict[str, Any]] = None
queue_order: int = 0
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
_runner: Optional[asyncio.Task] = field(default=None, repr=False)
_cancel_mode: Optional[str] = field(default=None, repr=False)
_last_progress_emit_at: float = field(default=0.0, repr=False)
_last_progress_emit_value: int = field(default=0, repr=False)
_last_progress_emit_message: str = field(default="", repr=False)
_last_speed_sample_at: float = field(default=0.0, repr=False)
_last_speed_sample_bytes: int = field(default=0, repr=False)
def to_dict(self) -> Dict[str, Any]:
return {
"task_id": self.task_id,
"client_task_id": self.client_task_id,
"account_id": self.account_id,
"account_name": self.account_name,
"driver_type": self.driver_type,
"file_name": self.file_name,
"target_path": self.target_path,
"target_display_path": self.target_display_path,
"status": self.status,
"progress": self.progress,
"uploaded_bytes": self.uploaded_bytes,
"speed_bytes_per_second": self.speed_bytes_per_second,
"total_bytes": self.total_bytes,
"message": self.message,
"error": self.error,
"result": deepcopy(self.result),
"resume_data": deepcopy(self.resume_data),
"queue_order": self.queue_order,
"created_at": self.created_at,
"updated_at": self.updated_at,
}
class UploadTaskManager:
_PROGRESS_UPDATE_INTERVAL = 0.25
_TEMP_UPLOAD_DIR = os.path.join("data", "upload_tasks")
_TEMP_FILE_CLEANUP_INTERVAL = 3600
_TEMP_FILE_MAX_AGE = 24 * 3600
def __init__(self):
self._tasks: Dict[str, UploadTask] = {}
self._lock = asyncio.Lock()
self._concurrency_limit = 3
self._running_count = 0
self._concurrency_condition = asyncio.Condition()
self._next_queue_order = 0
self._subscribers: set[asyncio.Queue[str]] = set()
self._temp_cleanup_task: Optional[asyncio.Task] = None
def _allocate_queue_order(self) -> int:
self._next_queue_order += 1
return self._next_queue_order
async def create_upload_task(
self,
*,
client_task_id: str = "",
account_id: int,
account_name: str,
driver_type: str,
file_name: str,
target_path: str,
target_display_path: str = "",
local_path: str,
total_bytes: int,
conflict_policy: str = "overwrite",
display_name: str = "",
) -> Dict[str, Any]:
task = UploadTask(
task_id=uuid.uuid4().hex,
client_task_id=client_task_id,
account_id=account_id,
account_name=account_name,
driver_type=driver_type,
file_name=display_name or file_name,
target_path=target_path,
target_display_path=target_display_path,
local_path=local_path,
total_bytes=total_bytes,
conflict_policy=conflict_policy,
queue_order=self._allocate_queue_order(),
)
async with self._lock:
self._tasks[task.task_id] = task
task._runner = asyncio.create_task(self._run_upload_task(task.task_id))
self._prune_tasks_locked()
task_data = task.to_dict()
await self._broadcast_tasks_snapshot()
return task_data
async def list_tasks(self, account_id: Optional[int] = None) -> List[Dict[str, Any]]:
async with self._lock:
tasks = list(self._tasks.values())
if account_id is not None:
tasks = [task for task in tasks if task.account_id == account_id]
tasks.sort(key=lambda item: item.created_at, reverse=True)
return [task.to_dict() for task in tasks]
async def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
async with self._lock:
task = self._tasks.get(task_id)
return task.to_dict() if task else None
async def pause_task(self, task_id: str) -> Optional[Dict[str, Any]]:
runner = None
async with self._lock:
task = self._tasks.get(task_id)
if not task:
return None
if task.status not in {"pending", "running"}:
return task.to_dict()
task._cancel_mode = "pause"
self._apply_task_state(
task,
status="paused",
speed_bytes_per_second=0.0,
message="上传已暂停",
error="",
)
runner = task._runner
await self._cancel_runner(runner)
await self._broadcast_tasks_snapshot()
return await self.get_task(task_id)
async def resume_task(self, task_id: str) -> Optional[Dict[str, Any]]:
async with self._lock:
task = self._tasks.get(task_id)
if not task:
return None
if task.status not in {"paused", "failed", "canceled"}:
return task.to_dict()
if not task.local_path or not os.path.exists(task.local_path):
self._apply_task_state(
task,
status="failed",
message="上传失败",
error="本地临时文件不存在,无法继续上传",
)
return task.to_dict()
resumed_progress_state = self._get_resumed_progress_state(task)
task.status = "pending"
task.queue_order = self._allocate_queue_order()
if task.resume_data:
task.progress = resumed_progress_state["progress"]
task.uploaded_bytes = resumed_progress_state["uploaded_bytes"]
task.message = "准备继续上传"
else:
task.progress = 0
task.uploaded_bytes = 0
task.message = "等待上传"
task.speed_bytes_per_second = 0.0
task.error = ""
task.result = None
task._cancel_mode = None
task._runner = asyncio.create_task(self._run_upload_task(task.task_id))
task.updated_at = time.time()
task_data = task.to_dict()
await self._broadcast_tasks_snapshot()
return task_data
async def delete_task(self, task_id: str, delete_uploaded_file: bool = False) -> bool:
task = await self._get_task_object(task_id)
if not task:
return False
runner = task._runner
was_running = bool(runner and not runner.done())
if runner and not runner.done():
async with self._lock:
current = self._tasks.get(task_id)
if current:
current._cancel_mode = "delete"
await self._cancel_runner(runner)
task = await self._get_task_object(task_id) or task
should_delete_uploaded_file = delete_uploaded_file or (was_running and task.status == "success")
if should_delete_uploaded_file and task.status == "success":
await self._delete_uploaded_file(task)
await self._remove_local_file(task.local_path)
async with self._lock:
self._tasks.pop(task_id, None)
await self._broadcast_tasks_snapshot()
return True
async def batch_delete_tasks(self, task_ids: List[str], delete_uploaded_file: bool = False) -> Dict[str, Any]:
normalized_task_ids = []
seen = set()
for task_id in task_ids:
normalized = str(task_id or "").strip()
if normalized and normalized not in seen:
seen.add(normalized)
normalized_task_ids.append(normalized)
if not normalized_task_ids:
return {
"deleted_task_ids": [],
"failed_task_ids": [],
"missing_task_ids": [],
"failed_messages": {},
}
async with self._lock:
existing_tasks = {task_id: self._tasks.get(task_id) for task_id in normalized_task_ids}
missing_task_ids = [task_id for task_id, task in existing_tasks.items() if not task]
tasks = [task for task in existing_tasks.values() if task]
if not tasks:
return {
"deleted_task_ids": [],
"failed_task_ids": [],
"missing_task_ids": missing_task_ids,
"failed_messages": {},
}
runners = []
running_task_ids = set()
async with self._lock:
for task in tasks:
if task._runner and not task._runner.done():
task._cancel_mode = "delete"
runners.append(task._runner)
running_task_ids.add(task.task_id)
await self._cancel_runners(runners)
failed_messages: Dict[str, str] = {}
refreshed_tasks: List[UploadTask] = []
for task in tasks:
current_task = await self._get_task_object(task.task_id)
refreshed_tasks.append(current_task or task)
tasks = refreshed_tasks
tasks_to_delete_uploaded_files = [
task for task in tasks
if task.status == "success" and (delete_uploaded_file or task.task_id in running_task_ids)
]
if tasks_to_delete_uploaded_files:
await self._batch_delete_uploaded_files(tasks_to_delete_uploaded_files, failed_messages)
deleted_task_ids: List[str] = []
async with self._lock:
for task in tasks:
if task.task_id in failed_messages:
continue
await self._remove_local_file(task.local_path)
self._tasks.pop(task.task_id, None)
deleted_task_ids.append(task.task_id)
failed_task_ids = list(failed_messages.keys())
if deleted_task_ids:
await self._broadcast_tasks_snapshot()
return {
"deleted_task_ids": deleted_task_ids,
"failed_task_ids": failed_task_ids,
"missing_task_ids": missing_task_ids,
"failed_messages": failed_messages,
}
async def stop(self) -> None:
async with self._lock:
tasks = [task for task in self._tasks.values() if task._runner and not task._runner.done()]
for task in tasks:
task._cancel_mode = "shutdown"
await self._cancel_runners([task._runner for task in tasks])
await self.stop_temp_file_cleanup()
async def cleanup_orphan_temp_files_on_startup(self) -> int:
return await self._cleanup_temp_upload_files(max_age_seconds=0, cleanup_reason="startup")
async def start_temp_file_cleanup(self) -> None:
if self._temp_cleanup_task and not self._temp_cleanup_task.done():
return
self._temp_cleanup_task = asyncio.create_task(self._temp_file_cleanup_loop())
async def stop_temp_file_cleanup(self) -> None:
if not self._temp_cleanup_task:
return
self._temp_cleanup_task.cancel()
try:
await self._temp_cleanup_task
except asyncio.CancelledError:
pass
finally:
self._temp_cleanup_task = None
async def apply_concurrency_limit(self, limit: Any) -> int:
normalized_limit = self._normalize_concurrency_limit(limit)
async with self._concurrency_condition:
self._concurrency_limit = normalized_limit
self._concurrency_condition.notify_all()
return normalized_limit
async def refresh_concurrency_limit(self) -> int:
try:
from config import config_manager
limit = await config_manager.get_async("upload_task_concurrency")
except Exception:
limit = None
return await self.apply_concurrency_limit(limit)
async def _run_upload_task(self, task_id: str) -> None:
task = await self._get_task_object(task_id)
if not task:
return
try:
await self.refresh_concurrency_limit()
await self._update_task(
task_id,
status="pending",
message="排队中",
error="",
)
async with self._concurrency_slot(task_id):
resumed_progress_state = self._get_resumed_progress_state(task)
initial_progress = resumed_progress_state["progress"] if task.resume_data else 0
initial_uploaded_bytes = resumed_progress_state["uploaded_bytes"] if task.resume_data else 0
initial_message = "正在继续上传" if task.resume_data else "正在上传到网盘"
speed_sample_started_at = time.time()
task._last_speed_sample_at = speed_sample_started_at
task._last_speed_sample_bytes = initial_uploaded_bytes
await self._update_task(
task_id,
status="running",
progress=initial_progress,
uploaded_bytes=initial_uploaded_bytes,
speed_bytes_per_second=0.0,
message=initial_message,
error="",
)
from core.driver_service import get_account_driver_instance
driver = await get_account_driver_instance(task.account_id)
if not hasattr(driver, "upload_local_file") or not callable(getattr(driver, "upload_local_file")):
raise Exception("当前驱动暂不支持后台上传任务")
current_account_id.set(str(task.account_id))
async def progress_callback(uploaded_bytes: int, total_bytes: int, message: str = ""):
total = max(total_bytes or task.total_bytes, 1)
progress = min(99, int(uploaded_bytes * 100 / total)) if uploaded_bytes < total else 100
now = time.time()
speed_bytes_per_second = task.speed_bytes_per_second
if task._last_speed_sample_at > 0 and uploaded_bytes >= task._last_speed_sample_bytes:
elapsed_seconds = now - task._last_speed_sample_at
delta_bytes = uploaded_bytes - task._last_speed_sample_bytes
if elapsed_seconds > 0:
speed_bytes_per_second = delta_bytes / elapsed_seconds
elif uploaded_bytes <= 0:
speed_bytes_per_second = 0.0
normalized_message = message or "正在上传到网盘"
is_first = task._last_progress_emit_at <= 0
is_complete = uploaded_bytes >= total
progress_changed = progress != task._last_progress_emit_value
crossed_percent = progress >= task._last_progress_emit_value + 1
message_changed = normalized_message != task._last_progress_emit_message
interval_elapsed = (now - task._last_progress_emit_at) >= self._PROGRESS_UPDATE_INTERVAL
should_emit = (
is_first or
is_complete or
message_changed or
(progress_changed and crossed_percent) or
(progress_changed and interval_elapsed)
)
if not should_emit:
return
task._last_progress_emit_at = now
task._last_progress_emit_value = progress
task._last_progress_emit_message = normalized_message
task._last_speed_sample_at = now
task._last_speed_sample_bytes = uploaded_bytes
await self._update_task(
task_id,
status="running",
progress=progress,
uploaded_bytes=uploaded_bytes,
speed_bytes_per_second=speed_bytes_per_second,
total_bytes=total_bytes or task.total_bytes,
message=normalized_message,
)
async def state_callback(resume_data: Dict[str, Any]):
await self._update_task(task_id, resume_data=deepcopy(resume_data))
upload_method = getattr(driver, "upload_local_file_with_resume", None)
if callable(upload_method):
result = await upload_method(
task.local_path,
task.file_name,
task.target_path,
progress_callback=progress_callback,
conflict_policy=task.conflict_policy,
resume_state=deepcopy(task.resume_data) if task.resume_data else None,
state_callback=state_callback,
)
else:
result = await driver.upload_local_file(
task.local_path,
task.file_name,
task.target_path,
progress_callback=progress_callback,
conflict_policy=task.conflict_policy,
)
if result.success:
await self._cleanup_directory_cache_after_success(task, result)
await self._update_task(
task_id,
status="success",
progress=100,
uploaded_bytes=task.total_bytes,
speed_bytes_per_second=0.0,
message=result.message or "上传成功",
result=deepcopy(result.data) if result.data else None,
error="",
resume_data=None,
)
else:
await self._update_task(
task_id,
status="failed",
speed_bytes_per_second=0.0,
message="上传失败",
error=self._translate_error_message(result.message),
)
except asyncio.CancelledError:
current = await self._get_task_object(task_id)
cancel_mode = current._cancel_mode if current else None
if cancel_mode == "pause":
await self._update_task(
task_id,
status="paused",
speed_bytes_per_second=0.0,
message="上传已暂停",
error="",
)
elif cancel_mode == "shutdown":
await self._update_task(
task_id,
status="canceled",
speed_bytes_per_second=0.0,
message="上传任务已取消",
error="上传任务已取消",
)
raise
except Exception as e:
await self._update_task(
task_id,
status="failed",
speed_bytes_per_second=0.0,
message="上传失败",
error=self._translate_error_message(str(e)),
)
finally:
final_task = await self._get_task_object(task_id)
if final_task and final_task.status == "success":
await self._remove_local_file(final_task.local_path)
if final_task:
final_task._cancel_mode = None
async def _delete_uploaded_file(self, task: UploadTask) -> None:
file_id = self._get_task_uploaded_file_id(task)
if not file_id:
return
from core.driver_service import get_account_driver_instance
current_account_id.set(str(task.account_id))
driver = await get_account_driver_instance(task.account_id)
if hasattr(driver, "delete_file") and callable(getattr(driver, "delete_file")):
await driver.delete_file(file_id)
async def _batch_delete_uploaded_files(self, tasks: List[UploadTask], failed_messages: Dict[str, str]) -> None:
account_groups: Dict[int, List[UploadTask]] = {}
for task in tasks:
if task.status != "success":
continue
file_id = self._get_task_uploaded_file_id(task)
if not file_id:
continue
account_groups.setdefault(task.account_id, []).append(task)
if not account_groups:
return
from core.driver_service import get_account_driver_instance
for account_id, account_tasks in account_groups.items():
current_account_id.set(str(account_id))
driver = await get_account_driver_instance(account_id)
file_ids = [self._get_task_uploaded_file_id(task) for task in account_tasks]
file_ids = [file_id for file_id in file_ids if file_id]
if not file_ids:
continue
try:
if (
len(file_ids) > 1 and
hasattr(driver, "batch_delete_file") and
callable(getattr(driver, "batch_delete_file"))
):
result = await driver.batch_delete_file(file_ids)
if not result.success:
error_message = self._translate_error_message(result.message)
for task in account_tasks:
failed_messages[task.task_id] = error_message
continue
else:
for task in account_tasks:
file_id = self._get_task_uploaded_file_id(task)
if not file_id:
continue
result = await driver.delete_file(file_id)
if not result.success:
failed_messages[task.task_id] = self._translate_error_message(result.message)
except Exception as e:
error_message = self._translate_error_message(str(e))
for task in account_tasks:
failed_messages[task.task_id] = error_message
async def _remove_local_file(self, local_path: str) -> None:
if local_path and os.path.exists(local_path):
try:
os.remove(local_path)
except OSError:
pass
async def _temp_file_cleanup_loop(self) -> None:
try:
while True:
await asyncio.sleep(self._TEMP_FILE_CLEANUP_INTERVAL)
await self._cleanup_temp_upload_files(
max_age_seconds=self._TEMP_FILE_MAX_AGE,
cleanup_reason="scheduled",
)
except asyncio.CancelledError:
raise
async def _cleanup_temp_upload_files(self, *, max_age_seconds: int, cleanup_reason: str) -> int:
temp_dir = self._TEMP_UPLOAD_DIR
if not os.path.isdir(temp_dir):
return 0
active_paths = await self._get_active_temp_paths()
now = time.time()
deleted_count = 0
try:
entries = list(os.scandir(temp_dir))
except OSError:
return 0
for entry in entries:
if not entry.is_file():
continue
normalized_path = self._normalize_temp_path(entry.path)
if normalized_path in active_paths:
continue
try:
modified_at = entry.stat().st_mtime
except OSError:
continue
if max_age_seconds > 0 and (now - modified_at) < max_age_seconds:
continue
try:
os.remove(entry.path)
deleted_count += 1
except OSError:
continue
if deleted_count > 0:
self._log_temp_cleanup(
"info",
f"上传临时文件清理完成: {cleanup_reason},删除 {deleted_count} 个文件",
)
return deleted_count
async def _get_active_temp_paths(self) -> set[str]:
async with self._lock:
local_paths = [task.local_path for task in self._tasks.values() if task.local_path]
return {self._normalize_temp_path(path) for path in local_paths}
def _normalize_temp_path(self, path: str) -> str:
return os.path.normcase(os.path.abspath(path))
def _log_temp_cleanup(self, level: str, message: str) -> None:
try:
from core.log_manager import LogModule, get_writer
logger = get_writer(LogModule.FILE_OP)
getattr(logger, level)(message)
except Exception:
pass
async def _cleanup_directory_cache_after_success(self, task: UploadTask, result) -> None:
try:
from core.operation_wrapper import clear_operation_cache
result_data = result.data or {}
parent_id = result_data.get("parent_id") or result_data.get("parent_path") or task.target_path or "0"
file_name = result_data.get("file_name") or task.file_name
file_id = result_data.get("file_id")
await clear_operation_cache(
str(task.account_id),
"file_created",
parent_id=parent_id,
file_name=file_name,
file_id=file_id,
)
except Exception:
# 缓存兜底清理失败不能反过来影响任务完成状态
pass
def _get_task_uploaded_file_id(self, task: UploadTask) -> Optional[str]:
result = task.result or {}
file_id = result.get("file_id")
return str(file_id) if file_id not in (None, "") else None
def _get_resumed_progress_state(self, task: UploadTask) -> Dict[str, int]:
resume_data = task.resume_data or {}
return {
"progress": int(resume_data.get("progress") or task.progress or 0),
"uploaded_bytes": int(resume_data.get("uploaded_bytes") or task.uploaded_bytes or 0),
}
def _apply_task_state(self, task: UploadTask, **updates: Any) -> None:
for key, value in updates.items():
if value is not None:
setattr(task, key, value)
task.updated_at = time.time()
async def _cancel_runner(self, runner: Optional[asyncio.Task]) -> None:
if not runner or runner.done():
return
runner.cancel()
try:
await runner
except asyncio.CancelledError:
pass
async def _cancel_runners(self, runners: List[Optional[asyncio.Task]]) -> None:
active_runners = [runner for runner in runners if runner and not runner.done()]
for runner in active_runners:
runner.cancel()
for runner in active_runners:
try:
await runner
except asyncio.CancelledError:
pass
async def _get_task_object(self, task_id: str) -> Optional[UploadTask]:
async with self._lock:
return self._tasks.get(task_id)
async def _update_task(self, task_id: str, **updates) -> None:
async with self._lock:
task = self._tasks.get(task_id)
if not task:
return
for key, value in updates.items():
if value is not None:
setattr(task, key, value)
task.updated_at = time.time()
await self._broadcast_tasks_snapshot()
async def subscribe_task_stream(self) -> asyncio.Queue[str]:
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=1)
async with self._lock:
self._subscribers.add(queue)
initial_payload = await self._build_tasks_snapshot_payload()
queue.put_nowait(initial_payload)
return queue
async def unsubscribe_task_stream(self, queue: asyncio.Queue[str]) -> None:
async with self._lock:
self._subscribers.discard(queue)
async def _build_tasks_snapshot_payload(self) -> str:
tasks = await self.list_tasks()
return json.dumps({
"type": "snapshot",
"tasks": tasks,
}, ensure_ascii=False)
async def _broadcast_tasks_snapshot(self) -> None:
async with self._lock:
subscribers = list(self._subscribers)
if not subscribers:
return
payload = await self._build_tasks_snapshot_payload()
for queue in subscribers:
if queue.full():
try:
queue.get_nowait()
except asyncio.QueueEmpty:
pass
try:
queue.put_nowait(payload)
except asyncio.QueueFull:
pass
def _translate_error_message(self, message: Optional[str]) -> str:
text = str(message or "").strip()
if not text:
return "上传失败"
if "Server disconnected" in text:
return "服务器连接已断开"
if "Connection timeout" in text:
return "连接服务器超时"
if "Timeout" in text:
return "请求超时"
if "Network Error" in text:
return "网络连接异常"
if "Failed to fetch" in text:
return "网络请求失败"
return text
def _is_next_pending_task_locked(self, task_id: str) -> bool:
pending_tasks = [
task for task in self._tasks.values()
if task.status == "pending" and task._cancel_mode is None
]
if not pending_tasks:
return False
pending_tasks.sort(key=lambda item: (item.queue_order, item.created_at))
return pending_tasks[0].task_id == task_id
async def _acquire_upload_slot(self, task_id: str) -> None:
async with self._concurrency_condition:
while (
self._running_count >= self._concurrency_limit or
not self._is_next_pending_task_locked(task_id)
):
await self._concurrency_condition.wait()
self._running_count += 1
async def _release_upload_slot(self) -> None:
async with self._concurrency_condition:
if self._running_count > 0:
self._running_count -= 1
self._concurrency_condition.notify_all()
@asynccontextmanager
async def _concurrency_slot(self, task_id: str):
await self._acquire_upload_slot(task_id)
try:
yield
finally:
await self._release_upload_slot()
def _normalize_concurrency_limit(self, limit: Any) -> int:
try:
normalized = int(limit)
except (TypeError, ValueError):
normalized = 3
return max(1, min(normalized, 5))
def _prune_tasks_locked(self) -> None:
if len(self._tasks) <= 100:
return
completed_statuses = {"success", "failed", "canceled", "skipped"}
sorted_tasks = sorted(self._tasks.values(), key=lambda item: item.updated_at)
for task in sorted_tasks:
if len(self._tasks) <= 100:
break
if task.status in completed_statuses:
self._tasks.pop(task.task_id, None)
upload_task_manager = UploadTaskManager()