fix 上传进度条

This commit is contained in:
jxxghp
2025-03-29 14:33:45 +08:00
parent 7ffafb49c4
commit c44aa50ef5
3 changed files with 159 additions and 72 deletions
+134 -56
View File
@@ -1,3 +1,4 @@
import base64
import hashlib import hashlib
import secrets import secrets
import threading import threading
@@ -6,6 +7,7 @@ from pathlib import Path
from typing import List, Dict, Optional, Tuple, Union from typing import List, Dict, Optional, Tuple, Union
import requests import requests
from tqdm import tqdm
from app import schemas from app import schemas
from app.core.config import settings from app.core.config import settings
@@ -72,7 +74,7 @@ class AliPan(StorageBase, metaclass=Singleton):
conf = self.get_conf() conf = self.get_conf()
drive_id = conf.get("resource_drive_id") or conf.get("backup_drive_id") or conf.get("default_drive_id") drive_id = conf.get("resource_drive_id") or conf.get("backup_drive_id") or conf.get("default_drive_id")
if not drive_id: if not drive_id:
raise Exception("请先登录阿里云盘") raise Exception("【阿里云盘】请先扫码登录")
return drive_id return drive_id
@property @property
@@ -171,7 +173,7 @@ class AliPan(StorageBase, metaclass=Singleton):
确认登录后,获取相关token 确认登录后,获取相关token
""" """
if not self._auth_state: if not self._auth_state:
raise Exception("请先生成二维码") raise Exception("【阿里云盘】请先生成二维码")
resp = self.session.post( resp = self.session.post(
f"{self.base_url}/oauth/access_token", f"{self.base_url}/oauth/access_token",
json={ json={
@@ -182,10 +184,10 @@ class AliPan(StorageBase, metaclass=Singleton):
} }
) )
if resp is None: if resp is None:
raise Exception("获取 access_token 失败") raise Exception("【阿里云盘】获取 access_token 失败")
result = resp.json() result = resp.json()
if result.get("code"): if result.get("code"):
raise Exception(f"{result.get('code')} - {result.get('message')}") raise Exception(f"【阿里云盘】{result.get('code')} - {result.get('message')}")
return result return result
def __refresh_access_token(self, refresh_token: str) -> Optional[dict]: def __refresh_access_token(self, refresh_token: str) -> Optional[dict]:
@@ -193,7 +195,7 @@ class AliPan(StorageBase, metaclass=Singleton):
刷新access_token 刷新access_token
""" """
if not refresh_token: if not refresh_token:
raise Exception("会话失效,请重新扫码登录!") raise Exception("【阿里云盘】会话失效,请重新扫码登录!")
resp = self.session.post( resp = self.session.post(
f"{self.base_url}/oauth/access_token", f"{self.base_url}/oauth/access_token",
json={ json={
@@ -259,13 +261,10 @@ class AliPan(StorageBase, metaclass=Singleton):
time.sleep(reset_time + 5) time.sleep(reset_time + 5)
return self._request_api(method, endpoint, result_key, **kwargs) return self._request_api(method, endpoint, result_key, **kwargs)
# 处理请求错误
resp.raise_for_status()
# 返回数据 # 返回数据
ret_data = resp.json() ret_data = resp.json()
if ret_data.get("code"): if ret_data.get("code"):
logger.warn(f"【阿里云盘】{method} 请求 {endpoint} 出错{ret_data.get('message')}") logger.warn(f"【阿里云盘】{method} {endpoint} 返回{ret_data.get('code')} {ret_data.get('message')}")
if result_key: if result_key:
return ret_data.get(result_key) return ret_data.get(result_key)
@@ -460,6 +459,38 @@ class AliPan(StorageBase, metaclass=Singleton):
sha1.update(data) sha1.update(data)
return sha1.hexdigest() return sha1.hexdigest()
def _calculate_proof_code(self, file_path: Path):
"""
计算秒传所需的proof_code
"""
file_size = file_path.stat().st_size
if file_size == 0:
return ""
# Step 1-3: 计算access_token的MD5并取前16位
md5 = hashlib.md5(self.access_token.encode()).hexdigest()
hex_str = md5[:16]
# Step 4: 转换为无符号int64
try:
tmp_int = int(hex_str, 16)
except ValueError:
raise ValueError("【阿里云盘】Invalid hex string for proof code calculation")
# Step 5-7: 计算读取范围
index = tmp_int % file_size
start = index
end = index + 8
if end > file_size:
end = file_size
# Step 8: 读取文件范围数据并编码
with open(file_path, 'rb') as f:
f.seek(start)
chunk = f.read(end - start)
return base64.b64encode(chunk).decode()
@staticmethod @staticmethod
def _calculate_content_hash(file_path: Path): def _calculate_content_hash(file_path: Path):
""" """
@@ -475,19 +506,19 @@ class AliPan(StorageBase, metaclass=Singleton):
return sha1.hexdigest() return sha1.hexdigest()
def _create_file(self, drive_id: str, parent_file_id: str, def _create_file(self, drive_id: str, parent_file_id: str,
file_name: str, file_path: Path, check_name_mode="auto_rename", file_name: str, file_path: Path, check_name_mode="refuse",
chunk_size: int = 1 * 1024 * 1024 * 1024): chunk_size: int = 1 * 1024 * 1024 * 1024):
""" """
创建文件请求,尝试秒传 创建文件请求,尝试秒传
""" """
file_size = file_path.stat().st_size file_size = file_path.stat().st_size
pre_hash = self._calculate_pre_hash(file_path) pre_hash = self._calculate_pre_hash(file_path)
content_hash = self._calculate_content_hash(file_path)
num_parts = (file_size + chunk_size - 1) // chunk_size num_parts = (file_size + chunk_size - 1) // chunk_size
# 构建分片信息 # 构建分片信息
part_info_list = [{"part_number": i + 1} for i in range(num_parts)] part_info_list = [{"part_number": i + 1} for i in range(num_parts)]
# 确定是否能秒传
data = { data = {
"drive_id": drive_id, "drive_id": drive_id,
"parent_file_id": parent_file_id, "parent_file_id": parent_file_id,
@@ -496,19 +527,36 @@ class AliPan(StorageBase, metaclass=Singleton):
"check_name_mode": check_name_mode, "check_name_mode": check_name_mode,
"size": file_size, "size": file_size,
"pre_hash": pre_hash, "pre_hash": pre_hash,
"content_hash": content_hash,
"content_hash_name": "sha1",
"part_info_list": part_info_list "part_info_list": part_info_list
} }
resp = self._request_api( resp = self._request_api(
"POST", "POST",
"/adrive/v1.0/openFile/create", "/adrive/v1.0/openFile/create",
json=data json=data
) )
if not resp: if not resp:
raise Exception("创建文件失败!") raise Exception("【阿里云盘】创建文件失败!")
if resp.get("code"): if resp.get("code") == "PreHashMatched":
# 可以秒传
proof_code = self._calculate_proof_code(file_path)
content_hash = self._calculate_content_hash(file_path)
data.pop("pre_hash")
data.update({
"proof_code": proof_code,
"proof_version": "v1",
"content_hash": content_hash,
"content_hash_name": "sha1",
})
resp = self._request_api(
"POST",
"/adrive/v1.0/openFile/create",
json=data
)
if not resp:
raise Exception("【阿里云盘】创建文件失败!")
if resp.get("code"):
raise Exception(resp.get("message"))
else:
raise Exception(resp.get("message")) raise Exception(resp.get("message"))
return resp return resp
@@ -528,7 +576,7 @@ class AliPan(StorageBase, metaclass=Singleton):
json=data json=data
) )
if not resp: if not resp:
raise Exception("刷新分片上传地址失败!") raise Exception("【阿里云盘】刷新分片上传地址失败!")
if resp.get("code"): if resp.get("code"):
raise Exception(resp.get("message")) raise Exception(resp.get("message"))
return resp.get('part_info_list', []) return resp.get('part_info_list', [])
@@ -542,8 +590,7 @@ class AliPan(StorageBase, metaclass=Singleton):
'Content-Length': str(len(data)), 'Content-Length': str(len(data)),
'Content-Type': 'application/octet-stream' 'Content-Type': 'application/octet-stream'
} }
response = requests.put(upload_url, data=data, headers=headers) return requests.put(upload_url, data=data, headers=headers)
return response
def _list_uploaded_parts(self, drive_id: str, file_id: str, upload_id: str) -> dict: def _list_uploaded_parts(self, drive_id: str, file_id: str, upload_id: str) -> dict:
""" """
@@ -560,7 +607,7 @@ class AliPan(StorageBase, metaclass=Singleton):
json=data json=data
) )
if not resp: if not resp:
raise Exception("获取已上传分片失败!") raise Exception("【阿里云盘】获取已上传分片失败!")
if resp.get("code"): if resp.get("code"):
raise Exception(resp.get("message")) raise Exception(resp.get("message"))
return resp return resp
@@ -578,7 +625,7 @@ class AliPan(StorageBase, metaclass=Singleton):
json=data json=data
) )
if not resp: if not resp:
raise Exception("完成上传失败!") raise Exception("【阿里云盘】完成上传失败!")
if resp.get("code"): if resp.get("code"):
raise Exception(resp.get("message")) raise Exception(resp.get("message"))
return resp return resp
@@ -590,23 +637,23 @@ class AliPan(StorageBase, metaclass=Singleton):
""" """
target_name = new_name or local_path.name target_name = new_name or local_path.name
target_path = Path(target_dir.path) / target_name target_path = Path(target_dir.path) / target_name
file_size = local_path.stat().st_size
# 1. 创建文件并检查秒传 # 1. 创建文件并检查秒传
chunk_size = 1 * 1024 * 1024 * 1024 chunk_size = 100 * 1024 * 1024 # 分片大小 100M
create_res = self._create_file(drive_id=target_dir.drive_id, create_res = self._create_file(drive_id=target_dir.drive_id,
parent_file_id=target_dir.fileid, parent_file_id=target_dir.fileid,
file_name=new_name, file_name=new_name,
file_path=local_path, file_path=local_path,
chunk_size=chunk_size) chunk_size=chunk_size)
if create_res.get('rapid_upload', False): if create_res.get('rapid_upload', False):
logger.info(f"{target_name} 秒传完成!") logger.info(f"【阿里云盘】{target_name} 秒传完成!")
return self.get_item(target_path) return self.get_item(target_path)
# 2. 准备分片上传参数 # 2. 准备分片上传参数
file_id = create_res['file_id'] file_id = create_res.get('file_id')
upload_id = create_res['upload_id'] upload_id = create_res.get('upload_id')
part_info_list = create_res['part_info_list'] part_info_list = create_res.get('part_info_list')
file_size = local_path.stat().st_size
uploaded_parts = set() uploaded_parts = set()
# 3. 获取已上传分片 # 3. 获取已上传分片
@@ -614,48 +661,79 @@ class AliPan(StorageBase, metaclass=Singleton):
for part in uploaded_info.get('uploaded_parts', []): for part in uploaded_info.get('uploaded_parts', []):
uploaded_parts.add(part['part_number']) uploaded_parts.add(part['part_number'])
# 4. 遍历并上传分片 # 4. 初始化进度条
logger.info(f"【阿里云盘】开始上传: {local_path} -> {target_path},分片数:{len(part_info_list)}")
progress_bar = tqdm(
total=file_size,
unit='B',
unit_scale=True,
desc="上传进度",
ascii=True
)
# 5. 分片上传循环
with open(local_path, 'rb') as f: with open(local_path, 'rb') as f:
for part_info in part_info_list: for part_info in part_info_list:
part_num = part_info['part_number'] part_num = part_info['part_number']
if part_num in uploaded_parts:
logger.info(f"分片 {part_num} 已存在,跳过上传")
continue
# 计算分片偏移量 # 计算分片参数
start = (part_num - 1) * chunk_size start = (part_num - 1) * chunk_size
end = min(start + chunk_size, file_size) end = min(start + chunk_size, file_size)
current_chunk_size = end - start
# 更新进度条(已存在的分片)
if part_num in uploaded_parts:
progress_bar.update(current_chunk_size)
continue
# 准备分片数据
f.seek(start) f.seek(start)
data = f.read(end - start) data = f.read(current_chunk_size)
# 尝试上传分片 # 上传分片(带重试逻辑)
response = self._upload_part(upload_url=part_info['upload_url'], data=data) success = False
if response.status_code == 200: for attempt in range(3): # 最大重试次数
logger.info(f"分片 {part_num} 上传成功") try:
uploaded_parts.add(part_num) # 获取当前上传地址(可能刷新)
else: if attempt > 0:
# 刷新地址后重试 new_urls = self._refresh_upload_urls(drive_id=target_dir.drive_id, file_id=file_id,
logger.info(f"分片 {part_num} 上传失败,刷新地址...") upload_id=upload_id, part_numbers=[part_num])
new_urls = self._refresh_upload_urls(drive_id=target_dir.drive_id, file_id=file_id, upload_url = new_urls[0]['upload_url']
upload_id=upload_id,
part_numbers=[part_num])
if new_urls:
new_url = new_urls[0]['upload_url']
response = self._upload_part(upload_url=new_url, data=data)
if response.status_code == 200:
logger.info(f"分片 {part_num} 重传成功")
uploaded_parts.add(part_num)
else: else:
raise Exception(f"分片 {part_num} 上传失败: {response.text}") upload_url = part_info['upload_url']
else:
raise Exception("无法获取新的上传地址")
# 5. 完成上传 # 执行上传
logger.info(
f"【阿里云盘】开始 第{attempt + 1}次 上传 {target_name} 分片 {part_num} ...")
response = self._upload_part(upload_url=upload_url, data=data)
if response is None:
continue
if response.status_code == 200:
success = True
break
else:
logger.warn(
f"【阿里云盘】{target_name} 分片 {part_num}{attempt + 1} 次上传失败:{response.text}")
except Exception as e:
logger.warn(f"【阿里云盘】{target_name} 分片 {part_num} 上传异常: {str(e)}")
# 处理上传结果
if success:
uploaded_parts.add(part_num)
progress_bar.update(current_chunk_size)
else:
raise Exception(f"【阿里云盘】{target_name} 分片 {part_num} 上传失败!")
# 6. 关闭进度条
if progress_bar:
progress_bar.close()
# 7. 完成上传
result = self._complete_upload(drive_id=target_dir.drive_id, file_id=file_id, upload_id=upload_id) result = self._complete_upload(drive_id=target_dir.drive_id, file_id=file_id, upload_id=upload_id)
if not result: if not result:
raise Exception("完成上传失败!") raise Exception("【阿里云盘】完成上传失败!")
if result.get("code"): if result.get("code"):
logger.warn(f"{target_name} 上传失败:{result.get('message')}") logger.warn(f"【阿里云盘】{target_name} 上传失败:{result.get('message')}")
return self.__get_fileitem(result) return self.__get_fileitem(result)
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
+22 -15
View File
@@ -11,6 +11,7 @@ import oss2
import requests import requests
from oss2 import SizedFileAdapter, determine_part_size from oss2 import SizedFileAdapter, determine_part_size
from oss2.models import PartInfo from oss2.models import PartInfo
from tqdm import tqdm
from app import schemas from app import schemas
from app.core.config import settings from app.core.config import settings
@@ -166,7 +167,7 @@ class U115Pan(StorageBase, metaclass=Singleton):
确认登录后,获取相关token 确认登录后,获取相关token
""" """
if not self._auth_state: if not self._auth_state:
raise Exception("请先生成二维码") raise Exception("【115】请先生成二维码")
resp = self.session.post( resp = self.session.post(
"https://passportapi.115.com/open/deviceCodeToToken", "https://passportapi.115.com/open/deviceCodeToToken",
data={ data={
@@ -398,16 +399,6 @@ class U115Pan(StorageBase, metaclass=Singleton):
实现带秒传、断点续传和二次认证的文件上传 实现带秒传、断点续传和二次认证的文件上传
""" """
def progress_callback(consumed_bytes: int, total_bytes: int):
"""
上传进度回调
"""
progress = round(consumed_bytes / total_bytes * 100)
if round(progress, -1) != self._last_progress:
logger.info(f"【115】已上传: {StringUtils.str_filesize(consumed_bytes)}"
f" / {StringUtils.str_filesize(total_bytes)}, 进度: {progress}%")
self._last_progress = round(progress, -1)
def encode_callback(cb: dict): def encode_callback(cb: dict):
""" """
回调参数Base64编码函数 回调参数Base64编码函数
@@ -559,9 +550,19 @@ class U115Pan(StorageBase, metaclass=Singleton):
# 补充参数 # 补充参数
logger.debug(f"【115】上传 Step 6 回调参数:{callback_dict} {callback_var_dict}") logger.debug(f"【115】上传 Step 6 回调参数:{callback_dict} {callback_var_dict}")
# 填写不能包含Bucket名称在内的Object完整路径,例如exampledir/exampleobject.txt。 # 填写不能包含Bucket名称在内的Object完整路径,例如exampledir/exampleobject.txt。
# determine_part_size方法用于确定分片大小,设置分片大小为 1GB # determine_part_size方法用于确定分片大小,设置分片大小为 100M
part_size = determine_part_size(file_size, preferred_size=1 * 1024 * 1024 * 1024) part_size = determine_part_size(file_size, preferred_size=100 * 1024 * 1024)
# 初始化进度条
logger.info(f"【115】开始上传: {local_path} -> {target_path},分片大小:{StringUtils.str_filesize(part_size)}") logger.info(f"【115】开始上传: {local_path} -> {target_path},分片大小:{StringUtils.str_filesize(part_size)}")
progress_bar = tqdm(
total=file_size,
unit='B',
unit_scale=True,
desc="上传进度",
ascii=True
)
# 初始化分片 # 初始化分片
upload_id = bucket.init_multipart_upload(object_name, upload_id = bucket.init_multipart_upload(object_name,
params={ params={
@@ -578,12 +579,18 @@ class U115Pan(StorageBase, metaclass=Singleton):
# 调用SizedFileAdapter(fileobj, size)方法会生成一个新的文件对象,重新计算起始追加位置。 # 调用SizedFileAdapter(fileobj, size)方法会生成一个新的文件对象,重新计算起始追加位置。
logger.info(f"【115】开始上传 {target_name} 分片 {part_number}: {offset} -> {offset + num_to_upload}") logger.info(f"【115】开始上传 {target_name} 分片 {part_number}: {offset} -> {offset + num_to_upload}")
result = bucket.upload_part(object_name, upload_id, part_number, result = bucket.upload_part(object_name, upload_id, part_number,
data=SizedFileAdapter(fileobj, num_to_upload), data=SizedFileAdapter(fileobj, num_to_upload))
progress_callback=progress_callback)
parts.append(PartInfo(part_number, result.etag)) parts.append(PartInfo(part_number, result.etag))
logger.info(f"【115】{target_name} 分片 {part_number} 上传完成") logger.info(f"【115】{target_name} 分片 {part_number} 上传完成")
offset += num_to_upload offset += num_to_upload
part_number += 1 part_number += 1
# 更新进度
progress_bar.update(num_to_upload)
# 关闭进度条
if progress_bar:
progress_bar.close()
# 请求头 # 请求头
headers = { headers = {
'X-oss-callback': encode_callback(callback_dict), 'X-oss-callback': encode_callback(callback_dict),
+2
View File
@@ -69,3 +69,5 @@ async_timeout~=5.0.1; python_full_version < "3.11.3"
packaging~=24.2 packaging~=24.2
cf_clearance~=0.31.0 cf_clearance~=0.31.0
oss2~=2.19.1 oss2~=2.19.1
tqdm~=4.67.1
setuptools~=65.5.0