Files
leonpan-pc/app/core/services/file_thread.py

1639 lines
68 KiB
Python
Raw Normal View History

2025-10-29 22:20:21 +08:00
import mimetypes
import os
from concurrent.futures import as_completed, ThreadPoolExecutor
from typing import Any, Dict
from urllib.parse import urlparse
import requests
from loguru import logger
from PyQt6.QtCore import QThread, pyqtSignal
from app.core import cfg, qconfig
from app.core import miaoStarsBasicApi
from app.core import policyConfig
class ListFileThread(QThread):
"""个人仓内文件搜索工作线程"""
listDictSignal = pyqtSignal(object)
errorSignal = pyqtSignal(str)
def __init__(
self,
path,
):
super().__init__()
self.path = path
logger.debug(f"初始化文件加载线程path: {path}")
def run(self):
"""执行API请求"""
try:
logger.info(f"开始API请求")
response = miaoStarsBasicApi.list(self.path)
if response["code"] == 0:
self.listDictSignal.emit(response)
else:
logger.error(f"API请求失败, 错误: {response['msg']}")
self.errorSignal.emit(response["msg"])
except Exception as e:
logger.exception(f"API请求异常, 异常信息: {e}")
self.errorSignal.emit(str(e))
class ListSearchThread(QThread):
"""搜索线程"""
listDictSignal = pyqtSignal(object)
errorSignal = pyqtSignal(str)
def __init__(self, searchContent, searchType):
super().__init__()
self.searchContent = searchContent
self.searchType = searchType
logger.debug(
f"初始化搜索工作线程, 搜索内容: {searchContent}, 搜索类型: {searchType}"
)
def run(self):
"""执行API请求"""
try:
logger.info(f"开始API请求")
response = miaoStarsBasicApi.wareSearch(self.searchContent, self.searchType)
if response["code"] == 0:
logger.success(f"API请求成功")
self.listDictSignal.emit(response)
else:
logger.error(f"API请求失败, 错误: {response['msg']}")
self.errorSignal.emit(response["msg"])
except Exception as e:
logger.exception(f"API请求异常, 异常信息: {str(e)}")
self.errorSignal.emit(str(e))
class ListShareThread(QThread):
"""分享搜索工作线程"""
listDictSignal = pyqtSignal(object)
errorSignal = pyqtSignal(str)
def __init__(self, keyword, orderBy, order, page):
super().__init__()
self.keyword = keyword
self.orderBy = orderBy
self.order = order
self.page = page
logger.debug(f"初始化API工作线程")
def run(self):
"""执行API请求"""
try:
# shareSearch方法只接受4个参数pageSize参数会在shareSearch方法内部通过params字典设置
response = miaoStarsBasicApi.shareSearch(
self.keyword, self.orderBy, self.order, self.page
)
if response["code"] == 0:
self.listDictSignal.emit(response)
else:
logger.error(f"API请求失败, 错误: {response['msg']}")
self.errorSignal.emit(response["msg"])
except Exception as e:
logger.exception(f"API请求异常, 异常信息: {str(e)}")
self.errorSignal.emit(str(e))
class CreateFolderThread(QThread):
"""创建文件夹的线程"""
successSignal = pyqtSignal()
errorSignal = pyqtSignal(str)
def __init__(self, folderName):
super().__init__()
self.folderName = folderName
def run(self):
"""执行文件夹创建操作"""
try:
response = miaoStarsBasicApi.createFolder(self.folderName)
if response["code"] == 0:
self.successSignal.emit()
else:
logger.error(f"创建文件夹失败, 错误: {response['msg']}")
self.errorSignal.emit(response["msg"])
except Exception as e:
logger.exception(f"创建文件夹异常, 异常信息: {e}")
self.errorSignal.emit(f"网络请求失败: {str(e)}")
class DeleteFileThread(QThread):
"""删除文件线程"""
successDelete = pyqtSignal()
errorDelete = pyqtSignal(str)
2025-11-01 20:14:35 +08:00
def __init__(self, fileUri: str, fileType: str):
2025-10-29 22:20:21 +08:00
super().__init__()
2025-11-01 20:14:35 +08:00
logger.debug(f"初始化删除文件线程 - URI: {fileUri}, 类型: {fileType}")
self.fileUri = fileUri
2025-10-29 22:20:21 +08:00
self.fileType = fileType
def run(self):
2025-11-01 20:14:35 +08:00
logger.info(f"开始删除文件 - URI: {self.fileUri}, 类型: {self.fileType}")
2025-10-29 22:20:21 +08:00
try:
2025-11-01 20:14:35 +08:00
response = miaoStarsBasicApi.deleteFile(self.fileUri, self.fileType)
2025-10-29 22:20:21 +08:00
if response["code"] == 0:
2025-11-01 20:14:35 +08:00
logger.info(f"文件删除成功 - URI: {self.fileUri}")
2025-10-29 22:20:21 +08:00
self.successDelete.emit()
else:
logger.error(
2025-11-01 20:14:35 +08:00
f"文件删除失败 - URI: {self.fileUri}, 错误: {response.get('msg', '未知错误')}"
2025-10-29 22:20:21 +08:00
)
self.errorDelete.emit(f"删除失败: {response.get('msg', '未知错误')}")
except Exception as e:
logger.error(f"文件删除过程中发生异常 - ID: {self.fileId}: {e}")
self.errorDelete.emit("系统错误,请稍后重试")
class GetShareFileInfoThread(QThread):
"""获取分享文件信息线程"""
shareFileInfoSignal = pyqtSignal(object)
errorSignal = pyqtSignal(str)
def __init__(self, shareId: str):
super().__init__()
self.shareId = shareId
logger.debug(f"初始化获取分享文件信息线程 - ID: {shareId}")
def run(self):
"""执行API请求"""
try:
response = miaoStarsBasicApi.getShareFileInfo(self.shareId)
if response["code"] == 0:
self.shareFileInfoSignal.emit(response)
else:
logger.error(f"API请求失败, 错误: {response['msg']}")
self.errorSignal.emit(response["msg"])
except Exception as e:
logger.exception(f"API请求异常, 异常信息: {str(e)}")
self.errorSignal.emit(str(e))
class UpdateFileContentThread(QThread):
successUpdated = pyqtSignal()
errorUpdated = pyqtSignal(str)
def __init__(self, fileId: str, content: str):
super().__init__()
self.content = content
self.fileId = fileId
def run(self):
try:
# 设置超时时间例如10秒连接30秒读取
response = miaoStarsBasicApi.updateFileContent(self.fileId, self.content)
if response.get("code") == 0:
self.successUpdated.emit()
else:
self.errorUpdated.emit(response.get("msg"))
except requests.exceptions.Timeout:
self.errorUpdated.emit("请求超时,请检查网络连接")
except requests.exceptions.RequestException as e:
self.errorUpdated.emit(f"网络请求错误: {str(e)}")
except Exception as e:
self.errorUpdated.emit(f"保存时发生错误: {str(e)}")
class UploadThread(QThread):
# 定义信号用于通信
uploadApplicationApprovedSignal = pyqtSignal() # 上传成功信号(文件名)
uploadFinished = pyqtSignal() # 上传完成信号(成功)
uploadFailed = pyqtSignal(str) # 上传失败信号(错误信息)
uploadProgress = pyqtSignal(float, int, int) # 进度信号:百分比, 已上传大小, 总大小
uploadCancelled = pyqtSignal() # 上传取消信号
def __init__(self, file_path: str):
super().__init__()
self.file_path = file_path
self.file_name = os.path.basename(file_path)
# 添加取消标志和文件对象引用
self._is_cancelled = False
self._file_obj = None
# 配置信息
# 设置Cloudreve V4的上传应用端点
self.applicationUrl = "/file/upload"
self.current_path = policyConfig.returnCurrentPath()
2025-11-01 20:14:35 +08:00
# 获取存储策略如果为None则使用默认值PqI8
self.policy = policyConfig.returnPolicy().get("id", "PqI8")
if self.policy is None:
self.policy = "PqI8"
logger.info("存储策略ID为None已设置默认值PqI8")
2025-10-29 22:20:21 +08:00
self.headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/json",
# 删除硬编码的Origin和Referer使用miaoStarsBasicApi中已配置的请求头
"Connection": "keep-alive",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
}
def getMimeType(self, file_path):
"""
获取文件的 MIME 类型
:param file_path: 文件路径
:return: MIME 类型
"""
mime_type, _ = mimetypes.guess_type(file_path)
return mime_type if mime_type else "application/octet-stream"
def _prepareUploadData(self) -> Dict[str, Any]:
2025-11-01 20:14:35 +08:00
"""准备上传数据符合Cloudreve V4 API规范"""
2025-10-29 22:20:21 +08:00
try:
modification_time = os.path.getmtime(self.file_path)
size = os.path.getsize(self.file_path)
2025-11-01 20:14:35 +08:00
# 构建符合API要求的URI格式: cloudreve://my{path}/{name}
# 处理路径,确保不会有重复斜杠和前缀
# 清理路径确保不包含cloudreve://my前缀
clean_current_path = self.current_path.replace("cloudreve://my", "")
# 处理路径,确保不会有重复斜杠
path_part = clean_current_path if clean_current_path.startswith('/') else f'/{clean_current_path}'
uri = f"cloudreve://my{path_part}/{self.file_name}"
# 确保路径格式正确,移除重复的前缀
uri = uri.replace("cloudreve://my/cloudreve://my", "cloudreve://my")
# 更健壮地处理重复文件名的情况
# 分割路径并去重
path_parts = uri.split('/')
if len(path_parts) > 1:
# 检查最后一个部分是否是文件名
if path_parts[-1] == self.file_name:
# 检查倒数第二个部分是否也是文件名
if len(path_parts) > 2 and path_parts[-2] == self.file_name:
# 移除重复的文件名部分
path_parts.pop(-2)
uri = '/'.join(path_parts)
logger.info(f"构建上传URI: {uri}")
2025-10-29 22:20:21 +08:00
return {
2025-11-01 20:14:35 +08:00
"uri": uri,
2025-10-29 22:20:21 +08:00
"size": size,
"policy_id": self.policy,
"last_modified": int(modification_time * 1000), # 转换为毫秒
"mime_type": self.getMimeType(self.file_path),
}
except (OSError, ValueError) as e:
logger.error(f"准备请求失败: {e}")
2025-11-01 20:14:35 +08:00
raise
2025-10-29 22:20:21 +08:00
def _uploadWithProgress(self, upload_url: str, credential: str, total_size: int):
"""带进度显示的上传方法"""
try:
2025-11-01 20:14:35 +08:00
logger.info("_uploadWithProgress方法开始执行")
logger.info(f"参数检查 - URL: {upload_url}, Credential是否存在: {credential is not None}")
# 处理credential可能为None的情况
if credential is None:
logger.warning("Credential为None尝试不使用Authorization头进行上传")
# 对于本地存储策略可能不需要credential
auth_header = {}
else:
auth_header = {"Authorization": credential}
2025-10-29 22:20:21 +08:00
# 打开文件
2025-11-01 20:14:35 +08:00
logger.info(f"开始打开文件: {self.file_path}")
2025-10-29 22:20:21 +08:00
self._file_obj = open(self.file_path, "rb")
2025-11-01 20:14:35 +08:00
logger.info("文件打开成功")
2025-10-29 22:20:21 +08:00
# 准备上传头信息
upload_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.9",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Content-Type": self.getMimeType(self.file_path), # 使用正确的MIME类型
2025-11-01 20:14:35 +08:00
"Content-Length": str(total_size), # 添加必需的Content-Length头
2025-10-29 22:20:21 +08:00
# 删除硬编码的Origin和Referer使用miaoStarsBasicApi中已配置的请求头
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "cross-site",
2025-11-01 20:14:35 +08:00
**auth_header, # 只在credential不为None时添加Authorization头
2025-10-29 22:20:21 +08:00
}
2025-11-01 20:14:35 +08:00
logger.info(f"上传头信息准备完成: {upload_headers}")
2025-10-29 22:20:21 +08:00
2025-11-01 20:14:35 +08:00
# 尝试发送预检请求,但不阻塞主流程
try:
logger.info("尝试发送OPTIONS预检请求")
options_result = miaoStarsBasicApi.returnSession().options(
upload_url, headers=upload_headers, timeout=10
)
options_result.raise_for_status()
logger.info("OPTIONS预检请求完成")
except Exception as e:
logger.warning(f"OPTIONS预检请求失败但继续上传: {str(e)}")
2025-10-29 22:20:21 +08:00
# 创建自定义请求体以支持进度监控和取消
class ProgressFileObject:
def __init__(
self, file_obj, total_size, progress_callback, cancel_check
):
2025-11-01 20:14:35 +08:00
logger.info("创建ProgressFileObject对象")
2025-10-29 22:20:21 +08:00
self.file_obj = file_obj
self.total_size = total_size
self.progress_callback = progress_callback
self.cancel_check = cancel_check
self.uploaded = 0
def read(self, size=-1):
# 检查是否取消
if self.cancel_check():
2025-11-01 20:14:35 +08:00
logger.info("检测到取消上传请求")
raise InterruptedError("Upload cancelled by user")
2025-10-29 22:20:21 +08:00
data = self.file_obj.read(size)
if data:
self.uploaded += len(data)
progress = (
(self.uploaded / self.total_size) * 100
if self.total_size > 0
else 0
)
self.progress_callback(progress, self.uploaded, self.total_size)
2025-11-01 20:14:35 +08:00
logger.debug(f"上传进度: {progress:.2f}% ({self.uploaded}/{self.total_size} 字节)")
2025-10-29 22:20:21 +08:00
return data
def __len__(self):
return self.total_size
# 创建带进度监控的文件对象
2025-11-01 20:14:35 +08:00
logger.info("创建进度监控文件对象")
2025-10-29 22:20:21 +08:00
progress_file = ProgressFileObject(
self._file_obj,
total_size,
lambda progress, uploaded, total: self.uploadProgress.emit(
progress, uploaded, total
),
lambda: self._is_cancelled,
)
# 执行实际上传
logger.info(f"开始上传文件,总大小: {total_size} 字节")
2025-11-01 20:14:35 +08:00
logger.info(f"上传URL: {upload_url}")
logger.info(f"上传头信息(已脱敏): {{\n 'User-Agent': '...', \n 'Content-Type': '{upload_headers.get("Content-Type")}',\n 'Content-Length': '{upload_headers.get("Content-Length")}',\n 'Authorization': '***' if 'Authorization' in upload_headers else 'None'\n }}")
# 修复优化上传逻辑先尝试PUT方法Cloudreve V4本地存储通常使用PUT
try:
# 首先尝试使用PUT方法
logger.info("尝试使用PUT方法上传")
upload_result = miaoStarsBasicApi.returnSession().put(
upload_url,
data=progress_file,
headers=upload_headers,
timeout=120,
stream=True
)
logger.info(f"PUT方法上传完成响应状态: {upload_result.status_code}")
upload_result.raise_for_status()
return upload_result
except InterruptedError:
# 用户取消上传
raise
except Exception as e:
logger.error(f"PUT方法上传失败: {str(e)}")
# 尝试使用POST方法
logger.info("尝试使用POST方法重新上传...")
# 重新打开文件
if self._file_obj:
self._file_obj.close()
self._file_obj = open(self.file_path, "rb")
# 重新创建进度文件对象
progress_file = ProgressFileObject(
self._file_obj,
total_size,
lambda progress, uploaded, total: self.uploadProgress.emit(
progress, uploaded, total
),
lambda: self._is_cancelled,
)
# 使用POST方法上传
upload_result = miaoStarsBasicApi.returnSession().post(
upload_url,
data=progress_file,
headers=upload_headers,
timeout=120,
stream=True,
)
logger.info(f"POST方法上传完成响应状态: {upload_result.status_code}")
upload_result.raise_for_status()
return upload_result
2025-10-29 22:20:21 +08:00
except Exception as e:
logger.error(f"上传过程中发生错误: {e}")
self.uploadFailed.emit(f"上传过程中发生错误: {e}")
finally:
# 确保文件被关闭
if self._file_obj:
self._file_obj.close()
self._file_obj = None
def run(self):
"""主上传逻辑"""
try:
if self._is_cancelled:
logger.info("上传在开始前已被取消")
return
logger.info(f"开始上传文件: {self.file_name}")
# 准备上传数据
upload_data = self._prepareUploadData()
2025-11-01 20:14:35 +08:00
# 检查policy_id是否为None如果是则尝试获取默认策略
if upload_data.get('policy_id') is None:
logger.warning("存储策略ID为None尝试获取默认存储策略")
try:
# 获取用户的存储策略列表
policies_response = miaoStarsBasicApi.getPolicy()
if policies_response.get('code') == 0 and policies_response.get('data'):
# 使用第一个可用的策略
default_policy = policies_response['data'][0]
upload_data['policy_id'] = default_policy.get('id')
logger.info(f"已设置默认存储策略: {default_policy.get('name')} (ID: {upload_data['policy_id']})")
else:
logger.warning("无法获取存储策略列表使用硬编码默认策略PqI8")
upload_data['policy_id'] = 'PqI8'
except Exception as e:
logger.error(f"获取存储策略失败: {str(e)}使用硬编码默认策略PqI8")
upload_data['policy_id'] = 'PqI8'
2025-10-29 22:20:21 +08:00
# 检查是否取消
if self._is_cancelled:
logger.info("上传在获取上传URL前被取消")
return
# 执行上传请求获取上传URL
logger.info("请求上传URL")
2025-11-01 20:14:35 +08:00
# 直接使用basicApi构建完整URL保留/api/v4后缀
# Cloudreve V4 API要求上传接口路径为/api/v4/file/upload
full_url = f"{miaoStarsBasicApi.basicApi}{self.applicationUrl}"
logger.info(f"构建的上传请求URL: {full_url}")
logger.info(f"上传请求数据: {upload_data}")
2025-10-29 22:20:21 +08:00
response = miaoStarsBasicApi.returnSession().put(
full_url, json=upload_data, headers=self.headers, timeout=30
)
2025-11-01 20:14:35 +08:00
# 记录响应状态码和内容
logger.info(f"上传URL请求响应状态码: {response.status_code}")
response_text = response.text
logger.info(f"上传URL请求响应内容长度: {len(response_text)} 字符")
# 如果响应内容较长只记录前100个字符
if len(response_text) > 100:
logger.info(f"上传URL请求响应内容预览: {response_text[:100]}...")
else:
logger.info(f"上传URL请求响应内容: {response_text}")
2025-10-29 22:20:21 +08:00
response.raise_for_status()
2025-11-01 20:14:35 +08:00
# 添加错误处理检查响应是否为有效的JSON
try:
result = response.json()
logger.info(f"JSON解析成功响应code: {result.get('code', '未找到code字段')}")
except ValueError as e:
logger.error(f"JSON解析失败: {e}")
logger.error(f"无法解析的响应内容: {response_text}")
# 抛出异常让上层处理
raise ValueError(f"无效的响应格式: {response_text}")
2025-10-29 22:20:21 +08:00
if result.get("code") == 0:
self.uploadApplicationApprovedSignal.emit()
2025-11-01 20:14:35 +08:00
data = result.get("data", {})
upload_urls = data.get("upload_urls") # 使用正确的字段名符合API规范
credential = data.get("credential")
session_id = data.get("session_id")
storage_policy = data.get("storage_policy", {})
policy_type = storage_policy.get("type", "")
logger.info(f"获取到上传URL数量: {len(upload_urls) if upload_urls else 0}")
logger.debug(f"存储策略类型: {policy_type}, Session ID: {session_id}")
# 优先检查是否有session_id因为这是必须的
if not session_id:
logger.error("未获取到session_id无法构建上传URL")
self.uploadFailed.emit("未获取到session_id无法构建上传URL")
return
# 根据存储策略类型和API响应构建正确的上传URL
if upload_urls and len(upload_urls) > 0:
# 对于远程存储策略使用返回的upload_urls并正确设置chunk参数
# 注意根据URL情况决定是否添加?或&
if "?" in upload_urls[0]:
self.uploadUrl = upload_urls[0] + "&chunk=0"
else:
self.uploadUrl = upload_urls[0] + "?chunk=0"
logger.info(f"使用远程存储上传URL: {self.uploadUrl}")
else:
# 对于本地存储策略或没有提供upload_urls的情况使用session_id构建上传URL
# 正确的API路径格式: /file/upload/{sessionId}/{index}
# 修复分块索引应该从0开始符合Cloudreve V4 API规范
self.uploadUrl = f"{miaoStarsBasicApi.basicApi}/file/upload/{session_id}/0"
logger.info(f"使用本地存储分块上传路径: {self.uploadUrl}")
logger.info(f"获取到上传URL: {self.uploadUrl}")
2025-10-29 22:20:21 +08:00
# 获取文件总大小
total_size = os.path.getsize(self.file_path)
# 执行带进度显示的上传
2025-11-01 20:14:35 +08:00
logger.info("开始调用_uploadWithProgress方法执行上传")
try:
upload_result = self._uploadWithProgress(
self.uploadUrl, credential, total_size
)
logger.info("_uploadWithProgress方法调用完成")
2025-10-29 22:20:21 +08:00
2025-11-01 20:14:35 +08:00
# 检查是否取消
if self._is_cancelled:
self.uploadCancelled.emit()
return
2025-10-29 22:20:21 +08:00
2025-11-01 20:14:35 +08:00
# 处理上传结果
logger.info(f"上传响应状态码: {upload_result.status_code}")
# 限制日志长度,避免日志过大
response_text = upload_result.text
if len(response_text) > 100:
logger.info(f"上传响应内容预览: {response_text[:100]}...")
else:
logger.info(f"上传响应内容: {response_text}")
# 确保上传成功
upload_result.raise_for_status()
except Exception as e:
logger.error(f"上传过程中发生异常: {str(e)}")
logger.error(f"异常类型: {type(e).__name__}")
self.uploadFailed.emit(f"上传过程中发生错误: {str(e)}")
return
try:
# 解析上传响应
upload_response = upload_result.json()
logger.info(f"上传响应JSON解析成功: {upload_response}")
# 检查上传是否成功完成
if upload_response.get('code') == 0:
logger.info("文件上传成功完成")
self.uploadFinished.emit()
else:
error_msg = upload_response.get('msg', '上传失败')
logger.error(f"上传失败: {error_msg}")
self.uploadFailed.emit(error_msg)
except ValueError as e:
# 如果响应不是有效的JSON只要状态码成功也视为上传成功
logger.warning(f"上传响应不是有效的JSON: {e}")
logger.info("基于成功状态码,假设上传成功")
self.uploadFinished.emit()
2025-10-29 22:20:21 +08:00
else:
error_msg = result.get("msg", "上传失败")
self.uploadFailed.emit(error_msg)
except FileNotFoundError as e:
error_msg = f"未发现文件: {e}"
logger.error(error_msg)
self.uploadFailed.emit(error_msg)
except Exception as e:
if self._is_cancelled:
self.uploadCancelled.emit()
else:
error_msg = f"上传失败: {str(e)}"
logger.error(error_msg)
self.uploadFailed.emit(error_msg)
def cancelUpload(self):
"""取消上传操作"""
logger.info("取消上传请求")
self._is_cancelled = True
# 关闭文件对象(如果存在)
if self._file_obj:
try:
self._file_obj.close()
except:
pass
finally:
self._file_obj = None
class DownloadThread(QThread):
# 定义信号用于通信
downloadUrlAcquired = pyqtSignal(str) # 下载URL获取成功信号
downloadFinished = pyqtSignal() # 下载完成信号
downloadFailed = pyqtSignal(str) # 下载失败信号
downloadProgress = pyqtSignal(float, int, int) # 进度信号:百分比, 已下载大小, 总大小
downloadCancelled = pyqtSignal() # 下载取消信号
fileSavePathSignal = pyqtSignal(str)
def __init__(self, file_id: str, file_path: str = None):
super().__init__()
self.file_id = file_id
self.file_path = file_path
self.save_path = qconfig.get(cfg.downloadSavePath)
self.chunk_size = 1024 * 1024 # 分块大小默认1MB
# 添加取消标志和文件对象引用
self._is_cancelled = False
self._file_obj = None
self.download_url = None
self.total_size = 0
self.downloaded_size = 0
# 配置信息
# 使用Cloudreve V4 API正确的下载URL创建端点
self.download_application_url = "/file/url"
self.headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/json",
# 删除硬编码的Origin和Referer使用miaoStarsBasicApi中已配置的请求头
"Connection": "keep-alive",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
}
def _getFileType(self, url):
"""
通过HTTP请求获取文件类型
"""
try:
# 发送HEAD请求获取文件信息不下载内容
response = requests.head(url, timeout=10, allow_redirects=True)
if response.status_code == 200:
# 从Content-Type头部获取文件类型
content_type = response.headers.get("Content-Type", "")
# 常见文件类型映射
type_mapping = {
"text/plain": "文本文件",
"text/python": "Python文件",
"application/x-python-code": "Python文件",
"application/octet-stream": "二进制文件",
"application/pdf": "PDF文件",
"image/jpeg": "JPEG图片",
"image/png": "PNG图片",
"application/zip": "ZIP压缩文件",
"text/html": "HTML文件",
"application/json": "JSON文件",
}
file_type = type_mapping.get(content_type, content_type)
file_size = response.headers.get("Content-Length", "未知")
return {
"content_type": content_type,
"file_type": file_type,
"file_size": file_size,
"filename": urlparse(url).path.split("/")[-1],
}
else:
return {"error": f"请求失败,状态码: {response.status_code}"}
except requests.exceptions.RequestException as e:
return {"error": f"请求错误: {str(e)}"}
def _getDownloadUrl(self):
"""获取下载URL"""
try:
logger.info(f"===== 开始下载文件API请求 =====")
logger.info(f"[传入内容] 请求下载URL文件路径: {self.file_path}")
logger.info(f"[传入内容] 文件ID: {self.file_id}")
logger.info(f"[传入内容] 保存路径: {self.save_path}")
# 准备Cloudreve V4 API所需的请求体
import urllib.parse
# 确保file_path不为None
if not self.file_path:
raise Exception("文件路径为空无法构建下载URI")
logger.info(f"[传入内容] 验证通过开始构建API请求")
# 使用接收到的file_path构建正确的URI格式
# 对于Cloudreve V4 API需要使用正确的cloudreve://格式
# 先尝试解码可能已经编码过的部分,避免双重编码
try:
# 先解码已有的URL编码部分
decoded_path = urllib.parse.unquote(self.file_path)
# 检查并移除可能重复的文件名部分
path_parts = decoded_path.split('/')
if len(path_parts) > 1:
# 检查最后两个部分是否相同
if len(path_parts) >= 3 and path_parts[-1] == path_parts[-2]:
# 移除重复的文件名部分
path_parts.pop(-1)
decoded_path = '/'.join(path_parts)
logger.info(f"[传入内容] 已移除重复的文件名部分: {decoded_path}")
# 然后对解码后的路径进行一次正确的编码
uri = urllib.parse.quote(decoded_path, safe=':/')
logger.info(f"[传入内容] 处理后的URI: {uri}")
except Exception as e:
logger.warning(f"路径处理失败,使用原始路径: {str(e)}")
# 如果处理失败,则使用原始路径(可能已经是正确格式)
uri = self.file_path
# 准备请求体 - 按照API要求使用uris数组包含完整的Cloudreve URI
request_body = {
"uris": [uri],
"download": True
}
logger.info(f"[传入内容] 构建的请求URI: {uri}")
logger.info(f"[传入内容] 完整API请求体: {request_body}")
# 构建完整的API请求URL
base_url = miaoStarsBasicApi.basicApi.rstrip('/api/v4')
full_url = f"{base_url}/api/v4/file/url"
logger.info(f"[传入内容] API请求URL: {full_url}")
logger.info(f"[传入内容] 请求头部信息: {self.headers}")
logger.info(f"[传入内容] 发送POST请求到API...")
response = miaoStarsBasicApi.returnSession().post(
full_url,
json=request_body,
headers=self.headers,
timeout=30
)
response.raise_for_status()
logger.info(f"[返回内容] API响应状态码: {response.status_code}")
logger.info(f"[返回内容] API响应头部: {dict(response.headers)}")
result = response.json()
logger.info(f"===== [返回内容] API响应内容开始 =====")
logger.info(f"[返回内容] 响应JSON: {result}")
logger.info(f"[返回内容] 响应代码: {result.get('code')}")
logger.info(f"[返回内容] 响应消息: {result.get('msg')}")
if 'data' in result:
logger.info(f"[返回内容] 响应数据部分: {result['data']}")
if 'urls' in result['data']:
logger.info(f"[返回内容] URLs数组长度: {len(result['data']['urls'])}")
for i, url_info in enumerate(result['data']['urls']):
logger.info(f"[返回内容] URL {i+1}: {url_info.get('url')}")
if 'aggregated_error' in result['data']:
logger.info(f"[返回内容] 聚合错误信息: {result['data']['aggregated_error']}")
logger.info(f"===== [返回内容] API响应内容结束 =====")
if result.get("code") == 0:
# 验证响应数据结构
data = result.get("data")
if not data:
raise Exception("响应中缺少data字段")
# 检查URLs数组
urls = data.get("urls", [])
if not urls or not isinstance(urls, list) or len(urls) == 0:
raise Exception("响应中没有URL信息或URLs数组为空")
# 获取第一个URL并验证
download_url_info = urls[0]
if not isinstance(download_url_info, dict) or "url" not in download_url_info:
raise Exception("URL信息格式不正确")
download_url = download_url_info.get("url")
if not download_url or download_url.strip() == "":
# URL为空的情况下尝试检查是否有aggregated_error提供更多信息
error_details = []
if "aggregated_error" in result:
aggregated_errors = result["aggregated_error"]
for uri, error_info in aggregated_errors.items():
error_details.append(f"URI: {uri},错误: {error_info.get('msg', '未知错误')}")
# 尝试使用备用URI格式 - 直接使用文件ID作为content路径
if not error_details:
error_details.append("尝试使用备用URI格式...")
# 构建备用请求体直接使用API路径而不是URI
base_url = miaoStarsBasicApi.basicApi.rstrip('/api/v4')
# 使用self.file_id而不是未定义的file_id变量
alternate_url = f"{base_url}/api/v4/file/content/{self.file_id}/0"
logger.info(f"[返回内容] 尝试备用下载URL: {alternate_url}")
# 发送HEAD请求检查备用URL是否可用
try:
head_response = miaoStarsBasicApi.returnSession().head(
alternate_url,
headers=self.headers,
timeout=10
)
if head_response.status_code == 200:
download_url = alternate_url
logger.info(f"[返回内容] 备用URL可用使用: {download_url}")
except Exception as e:
error_details.append(f"备用URL检查失败: {str(e)}")
if not download_url or download_url.strip() == "":
error_msg = f"获取下载URL失败: 下载URL为空。可能的原因: {'; '.join(error_details)}"
logger.error(f"[返回内容] 错误详情: {error_details}")
raise Exception(error_msg)
# 处理下载URL
logger.info(f"[返回内容] 获取到下载URL: {download_url}")
# 检查URL是否为相对路径如果是则拼接完整URL
if download_url.startswith('/'):
self.download_url = base_url + download_url
logger.info(f"[返回内容] 拼接完整下载URL: {self.download_url}")
else:
self.download_url = download_url
logger.info(f"[返回内容] 完整下载URL: {self.download_url}")
self.downloadUrlAcquired.emit(self.download_url)
logger.info("[返回内容] 成功获取下载URL")
# 获取文件信息
logger.info("[返回内容] 获取文件信息")
self.fileBasicInfo = self._getFileType(download_url)
logger.info(f"[返回内容] 文件信息: {self.fileBasicInfo}")
# 更新保存路径
if "filename" in self.fileBasicInfo:
self.save_path = f"{self.save_path}/{self.fileBasicInfo['filename']}"
self.fileSavePathSignal.emit(self.save_path)
logger.info(f"[返回内容] 文件保存路径已更新: {self.save_path}")
else:
logger.warning("[返回内容] 文件信息中缺少文件名")
return True
else:
# 处理错误响应
error_msg = result.get("msg", "获取下载URL失败")
if "aggregated_error" in result:
aggregated_errors = result["aggregated_error"]
for uri, error_info in aggregated_errors.items():
error_msg += f"URI: {uri},错误: {error_info.get('msg', '未知错误')}"
raise Exception(error_msg)
except requests.exceptions.RequestException as e:
logger.info(f"===== API请求异常 =====")
logger.error(f"HTTP请求异常: {str(e)}")
if hasattr(e, 'response') and e.response:
logger.error(f"异常响应状态码: {e.response.status_code}")
try:
logger.error(f"异常响应内容: {e.response.json()}")
except:
logger.error(f"异常响应文本: {e.response.text}")
logger.info(f"===== API请求异常结束 =====")
except Exception as e:
logger.info(f"===== 下载URL获取失败 =====")
logger.error(f"获取下载URL失败: {str(e)}")
import traceback
logger.error(f"错误堆栈: {traceback.format_exc()}")
logger.info(f"===== 下载URL获取失败结束 =====")
self.downloadFailed.emit(f"获取下载URL失败: {str(e)}")
return False
def _getFileSize(self, url):
"""获取文件总大小 - 完整解决方案"""
try:
# 方法1: 尝试HEAD请求
response = miaoStarsBasicApi.returnSession().head(url, timeout=10)
if response.status_code == 200:
size = response.headers.get("content-length")
if size and size != "0":
size = int(size)
logger.info(f"通过HEAD请求获取文件大小: {size} 字节")
return size
# 方法2: 尝试Range请求
logger.info("HEAD请求未返回文件大小尝试Range请求...")
range_size = self._getFileSizeWithRange(url)
if range_size > 0:
return range_size
# 方法3: 最后尝试GET请求估算
logger.info("Range请求失败尝试GET请求估算...")
get_size = self._getFileSizeByGet(url)
return get_size
except Exception as e:
logger.error(f"获取文件大小失败: {e}")
return 0
def _getFileSizeWithRange(self, url):
"""使用Range请求获取文件大小"""
try:
headers = {"Range": "bytes=0-0"}
response = miaoStarsBasicApi.returnSession().get(
url, headers=headers, timeout=10, stream=True
)
if response.status_code == 206:
content_range = response.headers.get("content-range")
if content_range:
total_size = content_range.split("/")[-1]
if total_size.isdigit():
size = int(total_size)
logger.info(f"通过Range请求获取文件大小: {size} 字节")
response.close()
return size
response.close()
return 0
except Exception as e:
logger.error(f"Range请求获取文件大小失败: {e}")
return 0
def _getFileSizeByGet(self, url):
"""通过GET请求估算文件大小"""
try:
response = miaoStarsBasicApi.returnSession().get(
url, stream=True, timeout=10
)
if response.status_code == 200:
size = 0
max_read_size = 131072 # 最多读取128KB来估算
for chunk in response.iter_content(chunk_size=8192):
size += len(chunk)
if size >= max_read_size:
break
response.close()
# 如果读取到数据,记录估算值
if size > 0:
logger.warning(
f"通过部分下载估算文件大小: {size} 字节(注意:这只是一个估算值)"
)
return size
response.close()
return 0
except Exception as e:
logger.error(f"GET请求估算文件大小失败: {e}")
return 0
def _downloadChunk(self, chunk_info):
"""下载单个分块"""
if self._is_cancelled:
return None
start, end, chunk_num = chunk_info
headers = self.headers.copy()
headers["Range"] = f"bytes={start}-{end}"
try:
response = miaoStarsBasicApi.returnSession().get(
self.download_url, headers=headers, stream=True, timeout=30
)
response.raise_for_status()
chunk_data = b""
for chunk in response.iter_content(chunk_size=8192):
if self._is_cancelled:
return None
if chunk:
chunk_data += chunk
# 更新进度
self.downloaded_size += len(chunk)
progress = (
(self.downloaded_size / self.total_size) * 100
if self.total_size > 0
else 0
)
self.downloadProgress.emit(
progress, self.downloaded_size, self.total_size
)
return chunk_num, chunk_data
except Exception as e:
logger.error(f"下载分块 {chunk_num} 失败: {e}")
return None
def _downloadWithProgress(self):
"""带进度显示的多线程下载方法"""
try:
# 获取文件总大小
self.total_size = self._getFileSize(self.download_url)
# if self.total_size == 0:
# raise Exception("无法获取文件大小")
# 创建保存目录
os.makedirs(os.path.dirname(self.save_path), exist_ok=True)
# 计算分块信息
chunks = []
num_chunks = (self.total_size + self.chunk_size - 1) // self.chunk_size
for i in range(num_chunks):
start = i * self.chunk_size
end = min((i + 1) * self.chunk_size - 1, self.total_size - 1)
chunks.append((start, end, i))
logger.info(
f"开始多线程下载,总大小: {self.total_size} 字节,分块数: {num_chunks}"
)
# 使用线程池进行多线程下载
chunks_data = [None] * num_chunks
successful_chunks = 0
# 根据文件大小决定线程数
max_workers = min(8, max(2, num_chunks // 4))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# 提交所有下载任务
future_to_chunk = {
executor.submit(self._downloadChunk, chunk): chunk
for chunk in chunks
}
# 收集完成的任务
for future in as_completed(future_to_chunk):
if self._is_cancelled:
executor.shutdown(wait=False)
raise Exception("下载被取消")
result = future.result()
if result:
chunk_num, chunk_data = result
chunks_data[chunk_num] = chunk_data
successful_chunks += 1
# logger.debug(f"分块 {chunk_num} 下载完成")
# 检查是否所有分块都下载成功
if successful_chunks != num_chunks:
raise Exception(
f"下载不完整,成功分块: {successful_chunks}/{num_chunks}"
)
# 合并所有分块并写入文件
logger.info("开始合并分块数据")
with open(self.save_path, "wb") as f:
for chunk_data in chunks_data:
if chunk_data:
f.write(chunk_data)
logger.info(f"文件下载完成: {self.save_path}")
return True
except Exception as e:
logger.error(f"下载过程中发生错误: {e}")
# 清理可能已创建的不完整文件
if os.path.exists(self.save_path):
try:
os.remove(self.save_path)
except:
pass
raise e
def run(self):
"""主下载逻辑"""
try:
if self._is_cancelled:
logger.info("下载在开始前已被取消")
return
logger.info(f"开始下载文件ID: {self.file_id}")
# 获取下载URL
if not self._getDownloadUrl():
return
# 检查是否取消
if self._is_cancelled:
logger.info("下载在获取URL后被取消")
return
# 执行带进度显示的多线程下载
self._downloadWithProgress()
# 检查是否取消
if self._is_cancelled:
self.downloadCancelled.emit()
return
# 下载成功
self.downloadFinished.emit()
except Exception as e:
if self._is_cancelled:
self.downloadCancelled.emit()
else:
error_msg = f"下载失败: {str(e)}"
logger.error(error_msg)
self.downloadFailed.emit(error_msg)
def cancelDownload(self):
"""取消下载操作"""
logger.info("取消下载请求")
self._is_cancelled = True
class DownloadShareThread(QThread):
# 定义信号用于通信
downloadUrlAcquired = pyqtSignal(str) # 下载URL获取成功信号
downloadFinished = pyqtSignal() # 下载完成信号
downloadFailed = pyqtSignal(str) # 下载失败信号
downloadProgress = pyqtSignal(float, int, int) # 进度信号:百分比, 已下载大小, 总大小
downloadCancelled = pyqtSignal() # 下载取消信号
fileSavePathSignal = pyqtSignal(str)
def __init__(self, file_id: str, file_path: str):
super().__init__()
self.file_id = file_id.split(".")[-1]
print(file_id.split("."))
self.save_path = qconfig.get(cfg.downloadSavePath)
self.chunk_size = 1024 * 1024 # 分块大小默认1MB
# 添加取消标志和文件对象引用
self._is_cancelled = False
self._file_obj = None
self.download_url = None
self.total_size = 0
self.path = file_id.split(".")[0] + "." + file_id.split(".")[1]
self.downloaded_size = 0
# 配置信息
# 使用V4 API下载分享文件
self.download_application_url = f"/share/download/{self.file_id}?path={self.path}"
print(self.download_application_url)
self.headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/json",
# 删除硬编码的Origin和Referer使用miaoStarsBasicApi中已配置的请求头
"Connection": "keep-alive",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
}
def _getFileType(self, url):
"""
通过HTTP请求获取文件类型
"""
try:
# 发送HEAD请求获取文件信息不下载内容
response = requests.head(url, timeout=30, allow_redirects=True)
if response.status_code == 200:
# 从Content-Type头部获取文件类型
content_type = response.headers.get("Content-Type", "")
# 常见文件类型映射
type_mapping = {
"text/plain": "文本文件",
"text/python": "Python文件",
"application/x-python-code": "Python文件",
"application/octet-stream": "二进制文件",
"application/pdf": "PDF文件",
"image/jpeg": "JPEG图片",
"image/png": "PNG图片",
"application/zip": "ZIP压缩文件",
"text/html": "HTML文件",
"application/json": "JSON文件",
}
file_type = type_mapping.get(content_type, content_type)
file_size = response.headers.get("Content-Length", "未知")
return {
"content_type": content_type,
"file_type": file_type,
"file_size": file_size,
"filename": urlparse(url).path.split("/")[-1],
}
else:
return {"error": f"请求失败,状态码: {response.status_code}"}
except requests.exceptions.RequestException as e:
return {"error": f"请求错误: {str(e)}"}
def _getDownloadUrl(self):
"""获取分享文件下载URL"""
try:
logger.info(f"请求分享文件下载URL文件ID: {self.file_id}")
# 准备Cloudreve V4 API所需的请求体
# 按照API要求构建正确的URI格式
import urllib.parse
# 确保路径部分被正确URL编码
path = self.path if self.path else ''
if path:
# 只对路径部分进行编码,保留/分隔符
parts = path.split('/')
encoded_parts = [urllib.parse.quote(part) for part in parts if part]
encoded_path = '/' + '/'.join(encoded_parts)
else:
encoded_path = ''
# 构建完整的Cloudreve URI - 使用文件ID构建有效的URI
file_uri = f"cloudreve://share/{self.file_id}{encoded_path}"
# 准备请求体 - 按照API要求使用uris数组包含完整的Cloudreve URI
request_body = {
"uris": [file_uri],
"download": True,
"skip_error": True,
"use_primary_site_url": True
# 移除no_cache参数使用API默认缓存行为
}
logger.debug(f"使用正确格式的分享文件URI: {file_uri}")
# 构建完整的API请求URL
base_url = miaoStarsBasicApi.basicApi.rstrip('/api/v4')
full_url = f"{base_url}/api/v4/file/url"
logger.debug(f"请求URL: {full_url}")
logger.debug(f"请求体: {request_body}")
response = miaoStarsBasicApi.returnSession().post(
full_url,
json=request_body,
headers=self.headers,
timeout=30
)
response.raise_for_status()
result = response.json()
logger.debug(f"分享文件下载URL响应: {result}")
if result.get("code") == 0:
# 验证响应数据结构
data = result.get("data")
if not data:
raise Exception("响应中缺少data字段")
# 检查URLs数组
urls = data.get("urls", [])
if not urls or not isinstance(urls, list) or len(urls) == 0:
raise Exception("响应中没有URL信息或URLs数组为空")
# 获取第一个URL并验证
download_url_info = urls[0]
if not isinstance(download_url_info, dict) or "url" not in download_url_info:
raise Exception("URL信息格式不正确")
download_url = download_url_info.get("url")
if not download_url or download_url.strip() == "":
# URL为空的情况下尝试检查是否有aggregated_error提供更多信息
error_details = []
if "aggregated_error" in result:
aggregated_errors = result["aggregated_error"]
for uri, error_info in aggregated_errors.items():
error_details.append(f"URI: {uri},错误: {error_info.get('msg', '未知错误')}")
# 尝试使用备用URI格式 - 直接使用文件ID作为share路径
if not error_details:
error_details.append("尝试使用备用URI格式...")
# 构建备用请求体直接使用API路径而不是URI
base_url = miaoStarsBasicApi.basicApi.rstrip('/api/v4')
alternate_url = f"{base_url}/api/v4/share/{file_id}/download"
if path and path.startswith('/'):
alternate_url += path
logger.debug(f"尝试分享文件备用下载URL: {alternate_url}")
# 发送HEAD请求检查备用URL是否可用
try:
head_response = miaoStarsBasicApi.returnSession().head(
alternate_url,
headers=self.headers,
timeout=10
)
if head_response.status_code == 200:
download_url = alternate_url
logger.info(f"备用URL可用使用: {download_url}")
except Exception as e:
error_details.append(f"备用URL检查失败: {str(e)}")
if not download_url or download_url.strip() == "":
error_msg = f"获取分享文件下载URL失败: 下载URL为空。可能的原因: {'; '.join(error_details)}"
raise Exception(error_msg)
# 处理下载URL
logger.info(f"获取到分享文件下载URL: {download_url}")
# 检查URL是否为相对路径如果是则拼接完整URL
if download_url.startswith('/'):
self.download_url = base_url + download_url
logger.info(f"拼接完整下载URL: {self.download_url}")
else:
self.download_url = download_url
self.downloadUrlAcquired.emit(self.download_url)
logger.info("成功获取分享文件下载URL")
# 获取文件信息
logger.info("获取文件信息")
self.fileBasicInfo = self._getFileType(download_url)
logger.debug(f"文件信息: {self.fileBasicInfo}")
# 更新保存路径
if "filename" in self.fileBasicInfo:
self.save_path = f"{self.save_path}/{self.fileBasicInfo['filename']}"
self.fileSavePathSignal.emit(self.save_path)
logger.info(f"文件保存路径已更新: {self.save_path}")
else:
logger.warning("文件信息中缺少文件名")
return True
else:
# 处理错误响应
error_msg = result.get("msg", "获取下载URL失败")
if "aggregated_error" in result:
aggregated_errors = result["aggregated_error"]
for uri, error_info in aggregated_errors.items():
error_msg += f"URI: {uri},错误: {error_info.get('msg', '未知错误')}"
raise Exception(error_msg)
except Exception as e:
logger.error(f"获取分享文件下载URL失败: {e}")
self.downloadFailed.emit(f"获取分享文件下载URL失败: {str(e)}")
return False
def _getFileSize(self, url):
"""获取文件总大小 - 完整解决方案"""
try:
# 方法1: 尝试HEAD请求
response = miaoStarsBasicApi.returnSession().head(url, timeout=10)
if response.status_code == 200:
size = response.headers.get("content-length")
if size and size != "0":
size = int(size)
logger.info(f"通过HEAD请求获取文件大小: {size} 字节")
return size
# 方法2: 尝试Range请求
logger.info("HEAD请求未返回文件大小尝试Range请求...")
range_size = self._getFileSizeWithRange(url)
if range_size > 0:
return range_size
# 方法3: 最后尝试GET请求估算
logger.info("Range请求失败尝试GET请求估算...")
get_size = self._getFileSizeByGet(url)
return get_size
except Exception as e:
logger.error(f"获取文件大小失败: {e}")
return 0
def _getFileSizeWithRange(self, url):
"""使用Range请求获取文件大小"""
try:
headers = {"Range": "bytes=0-0"}
response = miaoStarsBasicApi.returnSession().get(
url, headers=headers, timeout=10, stream=True
)
if response.status_code == 206:
content_range = response.headers.get("content-range")
if content_range:
total_size = content_range.split("/")[-1]
if total_size.isdigit():
size = int(total_size)
logger.info(f"通过Range请求获取文件大小: {size} 字节")
response.close()
return size
response.close()
return 0
except Exception as e:
logger.error(f"Range请求获取文件大小失败: {e}")
return 0
def _getFileSizeByGet(self, url):
"""通过GET请求估算文件大小"""
try:
response = miaoStarsBasicApi.returnSession().get(
url, stream=True, timeout=10
)
if response.status_code == 200:
size = 0
max_read_size = 131072 # 最多读取128KB来估算
for chunk in response.iter_content(chunk_size=8192):
size += len(chunk)
if size >= max_read_size:
break
response.close()
# 如果读取到数据,记录估算值
if size > 0:
logger.warning(
f"通过部分下载估算文件大小: {size} 字节(注意:这只是一个估算值)"
)
return size
response.close()
return 0
except Exception as e:
logger.error(f"GET请求估算文件大小失败: {e}")
return 0
def _downloadChunk(self, chunk_info):
"""下载单个分块"""
if self._is_cancelled:
return None
start, end, chunk_num = chunk_info
headers = self.headers.copy()
headers["Range"] = f"bytes={start}-{end}"
try:
response = miaoStarsBasicApi.returnSession().get(
self.download_url, headers=headers, stream=True, timeout=30
)
response.raise_for_status()
chunk_data = b""
for chunk in response.iter_content(chunk_size=8192):
if self._is_cancelled:
return None
if chunk:
chunk_data += chunk
# 更新进度
self.downloaded_size += len(chunk)
progress = (
(self.downloaded_size / self.total_size) * 100
if self.total_size > 0
else 0
)
self.downloadProgress.emit(
progress, self.downloaded_size, self.total_size
)
return chunk_num, chunk_data
except Exception as e:
logger.error(f"下载分块 {chunk_num} 失败: {e}")
return None
def _downloadWithProgress(self):
"""带进度显示的多线程下载方法"""
try:
# 获取文件总大小
self.total_size = self._getFileSize(self.download_url)
# if self.total_size == 0:
# raise Exception("无法获取文件大小")
# 创建保存目录
os.makedirs(os.path.dirname(self.save_path), exist_ok=True)
# 计算分块信息
chunks = []
num_chunks = (self.total_size + self.chunk_size - 1) // self.chunk_size
for i in range(num_chunks):
start = i * self.chunk_size
end = min((i + 1) * self.chunk_size - 1, self.total_size - 1)
chunks.append((start, end, i))
logger.info(
f"开始多线程下载,总大小: {self.total_size} 字节,分块数: {num_chunks}"
)
# 使用线程池进行多线程下载
chunks_data = [None] * num_chunks
successful_chunks = 0
# 根据文件大小决定线程数
max_workers = min(8, max(2, num_chunks // 4))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# 提交所有下载任务
future_to_chunk = {
executor.submit(self._downloadChunk, chunk): chunk
for chunk in chunks
}
# 收集完成的任务
for future in as_completed(future_to_chunk):
if self._is_cancelled:
executor.shutdown(wait=False)
raise Exception("下载被取消")
result = future.result()
if result:
chunk_num, chunk_data = result
chunks_data[chunk_num] = chunk_data
successful_chunks += 1
logger.debug(f"分块 {chunk_num} 下载完成")
# 检查是否所有分块都下载成功
if successful_chunks != num_chunks:
raise Exception(
f"下载不完整,成功分块: {successful_chunks}/{num_chunks}"
)
# 合并所有分块并写入文件
logger.info("开始合并分块数据")
with open(self.save_path, "wb") as f:
for chunk_data in chunks_data:
if chunk_data:
f.write(chunk_data)
logger.info(f"文件下载完成: {self.save_path}")
return True
except Exception as e:
logger.error(f"下载过程中发生错误: {e}")
# 清理可能已创建的不完整文件
if os.path.exists(self.save_path):
try:
os.remove(self.save_path)
except:
pass
raise e
def run(self):
"""主下载逻辑"""
try:
if self._is_cancelled:
logger.info("下载在开始前已被取消")
return
logger.info(f"开始下载文件ID: {self.file_id}")
# 获取下载URL
if not self._getDownloadUrl():
return
# 检查是否取消
if self._is_cancelled:
logger.info("下载在获取URL后被取消")
return
# 执行带进度显示的多线程下载
self._downloadWithProgress()
# 检查是否取消
if self._is_cancelled:
self.downloadCancelled.emit()
return
# 下载成功
self.downloadFinished.emit()
except Exception as e:
if self._is_cancelled:
self.downloadCancelled.emit()
else:
error_msg = f"下载失败: {str(e)}"
logger.error(error_msg)
self.downloadFailed.emit(error_msg)
def cancelDownload(self):
"""取消下载操作"""
logger.info("取消下载请求")
self._is_cancelled = True