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) def __init__(self, fileId: str, fileType: str): super().__init__() logger.debug(f"初始化删除文件线程 - ID: {fileId}, 类型: {fileType}") self.fileId = fileId self.fileType = fileType def run(self): logger.info(f"开始删除文件 - ID: {self.fileId}, 类型: {self.fileType}") try: response = miaoStarsBasicApi.deleteFile(self.fileId, self.fileType) if response["code"] == 0: logger.info(f"文件删除成功 - ID: {self.fileId}") self.successDelete.emit() else: logger.error( f"文件删除失败 - ID: {self.fileId}, 错误: {response.get('msg', '未知错误')}" ) 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() self.policy = policyConfig.returnPolicy().get("id") 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]: """准备上传数据""" try: modification_time = os.path.getmtime(self.file_path) size = os.path.getsize(self.file_path) return { "path": self.current_path, "size": size, "name": self.file_name, "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}") def _uploadWithProgress(self, upload_url: str, credential: str, total_size: int): """带进度显示的上传方法""" try: # 打开文件 self._file_obj = open(self.file_path, "rb") # 准备上传头信息 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": "*/*", "Authorization": credential, "Accept-Language": "zh-CN,zh;q=0.9", "Accept-Encoding": "gzip, deflate, br, zstd", "Content-Type": self.getMimeType(self.file_path), # 使用正确的MIME类型 # 删除硬编码的Origin和Referer,使用miaoStarsBasicApi中已配置的请求头 "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "cross-site", } # 发送预检请求 options_result = miaoStarsBasicApi.returnSession().options( upload_url, headers=upload_headers ) options_result.raise_for_status() logger.info("OPTIONS预检请求完成") # 创建自定义请求体以支持进度监控和取消 class ProgressFileObject: def __init__( self, file_obj, total_size, progress_callback, cancel_check ): 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(): return 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) return data def __len__(self): return self.total_size # 创建带进度监控的文件对象 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} 字节") upload_result = miaoStarsBasicApi.returnSession().post( upload_url, data=progress_file, headers=upload_headers, timeout=60, ) logger.info(f"上传完成,响应状态: {upload_result.status_code}") upload_result.raise_for_status() return upload_result 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() # 检查是否取消 if self._is_cancelled: logger.info("上传在获取上传URL前被取消") return # 执行上传请求获取上传URL logger.info("请求上传URL") # 从miaoStarsBasicApi获取完整URL(去掉/api/v4后缀) base_url = miaoStarsBasicApi.basicApi.rstrip('/api/v4') # 使用API类中定义的基础URL full_url = f"{base_url}{self.applicationUrl}" response = miaoStarsBasicApi.returnSession().put( full_url, json=upload_data, headers=self.headers, timeout=30 ) response.raise_for_status() result = response.json() if result.get("code") == 0: self.uploadApplicationApprovedSignal.emit() upload_urls = result.get("data").get("uploadURLs") credential = result.get("data").get("credential") self.uploadUrl = upload_urls[0] + "?chunk=0" logger.info("获取到上传URL") # 获取文件总大小 total_size = os.path.getsize(self.file_path) # 执行带进度显示的上传 upload_result = self._uploadWithProgress( self.uploadUrl, credential, total_size ) # 检查是否取消 if self._is_cancelled: self.uploadCancelled.emit() return # 处理上传结果 logger.info(f"上传响应: {upload_result}") self.uploadFinished.emit() 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