mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-03-20 03:57:30 +08:00
1. 为阿里云盘添加 ALIPAN_SNAPSHOT_CHECK_FOLDER_MODTIME 配置(默认 False)
- 阿里云盘目录的 updated_at 不会随子文件变更而更新,导致增量快照
始终跳过目录,快照结果为空
- 与 Rclone/Alist 保持一致的配置模式
2. 移除 snapshot() 中文件级 modify_time 过滤
- 原逻辑:仅包含 modify_time > last_snapshot_time 的文件
- 问题:首次快照建立基准后,save_snapshot 将 timestamp 设为
max(modify_times),后续快照中未变更的文件因 modify_time 不大于
timestamp 而被排除,导致 compare_snapshots 无法检测到任何变化
- 此外当 last_snapshot_time 为 None 时,比较会触发 TypeError
并被静默捕获
- 修复:始终包含所有遍历到的文件,由 compare_snapshots 负责变化检测
目录级优化仍由 snapshot_check_folder_modtime 控制
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
988 lines
34 KiB
Python
988 lines
34 KiB
Python
import base64
|
||
import hashlib
|
||
import secrets
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
from typing import List, Optional, Tuple, Union
|
||
|
||
import requests
|
||
|
||
from app import schemas
|
||
from app.core.config import settings, global_vars
|
||
from app.log import logger
|
||
from app.modules.filemanager import StorageBase
|
||
from app.modules.filemanager.storages import transfer_process
|
||
from app.schemas.types import StorageSchema
|
||
from app.utils.http import RequestUtils
|
||
from app.utils.singleton import WeakSingleton
|
||
from app.utils.string import StringUtils
|
||
|
||
lock = threading.Lock()
|
||
|
||
|
||
class NoCheckInException(Exception):
|
||
pass
|
||
|
||
|
||
class SessionInvalidException(Exception):
|
||
pass
|
||
|
||
|
||
class AliPan(StorageBase, metaclass=WeakSingleton):
|
||
"""
|
||
阿里云盘相关操作
|
||
"""
|
||
|
||
# 存储类型
|
||
schema = StorageSchema.Alipan
|
||
|
||
# 支持的整理方式
|
||
transtype = {"move": "移动", "copy": "复制"}
|
||
|
||
# 基础url
|
||
base_url = "https://openapi.alipan.com"
|
||
|
||
# 阿里云盘目录时间不随子文件变更而更新,默认关闭目录修改时间检查
|
||
snapshot_check_folder_modtime = settings.ALIPAN_SNAPSHOT_CHECK_FOLDER_MODTIME
|
||
|
||
# 文件块大小,默认10MB
|
||
chunk_size = 10 * 1024 * 1024
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self._auth_state = {}
|
||
self.session = requests.Session()
|
||
self._init_session()
|
||
|
||
def _init_session(self):
|
||
"""
|
||
初始化带速率限制的会话
|
||
"""
|
||
self.session.headers.update({"Content-Type": "application/json"})
|
||
|
||
def _check_session(self):
|
||
"""
|
||
检查会话是否过期
|
||
"""
|
||
if not self.access_token:
|
||
raise NoCheckInException("【阿里云盘】请先扫码登录!")
|
||
|
||
@property
|
||
def _default_drive_id(self) -> str:
|
||
"""
|
||
获取默认存储桶ID
|
||
"""
|
||
conf = self.get_conf()
|
||
drive_id = (
|
||
conf.get("resource_drive_id")
|
||
or conf.get("backup_drive_id")
|
||
or conf.get("default_drive_id")
|
||
)
|
||
if not drive_id:
|
||
raise NoCheckInException("【阿里云盘】请先扫码登录!")
|
||
return drive_id
|
||
|
||
@property
|
||
def access_token(self) -> Optional[str]:
|
||
"""
|
||
访问token
|
||
"""
|
||
with lock:
|
||
tokens = self.get_conf()
|
||
refresh_token = tokens.get("refresh_token")
|
||
expires_in = tokens.get("expires_in", 0)
|
||
refresh_time = tokens.get("refresh_time", 0)
|
||
if expires_in and refresh_time + expires_in < int(time.time()):
|
||
tokens = self.__refresh_access_token(refresh_token)
|
||
if tokens:
|
||
self.set_config({"refresh_time": int(time.time()), **tokens})
|
||
access_token = tokens.get("access_token")
|
||
if access_token:
|
||
self.session.headers.update({"Authorization": f"Bearer {access_token}"})
|
||
return access_token
|
||
|
||
def generate_qrcode(self) -> Tuple[dict, str]:
|
||
"""
|
||
实现PKCE规范的设备授权二维码生成
|
||
"""
|
||
|
||
# 生成PKCE参数
|
||
code_verifier = secrets.token_urlsafe(96)[:128]
|
||
# 请求设备码
|
||
resp = self.session.post(
|
||
f"{self.base_url}/oauth/authorize/qrcode",
|
||
json={
|
||
"client_id": settings.ALIPAN_APP_ID,
|
||
"scopes": [
|
||
"user:base",
|
||
"file:all:read",
|
||
"file:all:write",
|
||
"file:share:write",
|
||
],
|
||
"code_challenge": code_verifier,
|
||
"code_challenge_method": "plain",
|
||
},
|
||
)
|
||
if resp is None:
|
||
return {}, "网络错误"
|
||
result = resp.json()
|
||
if result.get("code"):
|
||
return {}, result.get("message")
|
||
# 持久化验证参数
|
||
self._auth_state = {"sid": result.get("sid"), "code_verifier": code_verifier}
|
||
# 生成二维码内容
|
||
return {"codeUrl": result.get("qrCodeUrl")}, ""
|
||
|
||
def check_login(self) -> Optional[Tuple[dict, str]]:
|
||
"""
|
||
改进的带PKCE校验的登录状态检查
|
||
"""
|
||
|
||
_status_text = {
|
||
"WaitLogin": "等待登录",
|
||
"ScanSuccess": "扫码成功",
|
||
"LoginSuccess": "登录成功",
|
||
"QRCodeExpired": "二维码过期",
|
||
}
|
||
|
||
if not self._auth_state:
|
||
return {}, "生成二维码失败"
|
||
try:
|
||
resp = self.session.get(
|
||
f"{self.base_url}/oauth/qrcode/{self._auth_state['sid']}/status"
|
||
)
|
||
if resp is None:
|
||
return {}, "网络错误"
|
||
result = resp.json()
|
||
# 扫码结果
|
||
status = result.get("status")
|
||
if status == "LoginSuccess":
|
||
authCode = result.get("authCode")
|
||
self._auth_state["authCode"] = authCode
|
||
tokens = self.__get_access_token()
|
||
if tokens:
|
||
self.set_config({"refresh_time": int(time.time()), **tokens})
|
||
self.__get_drive_id()
|
||
return {"status": status, "tip": _status_text.get(status, "未知错误")}, ""
|
||
except Exception as e:
|
||
return {}, str(e)
|
||
|
||
def __get_access_token(self) -> dict:
|
||
"""
|
||
确认登录后,获取相关token
|
||
"""
|
||
if not self._auth_state:
|
||
raise SessionInvalidException("【阿里云盘】请先生成二维码")
|
||
resp = self.session.post(
|
||
f"{self.base_url}/oauth/access_token",
|
||
json={
|
||
"client_id": settings.ALIPAN_APP_ID,
|
||
"grant_type": "authorization_code",
|
||
"code": self._auth_state["authCode"],
|
||
"code_verifier": self._auth_state["code_verifier"],
|
||
},
|
||
)
|
||
if resp is None:
|
||
raise SessionInvalidException("【阿里云盘】获取 access_token 失败")
|
||
result = resp.json()
|
||
if result.get("code"):
|
||
raise Exception(
|
||
f"【阿里云盘】{result.get('code')} - {result.get('message')}!"
|
||
)
|
||
return result
|
||
|
||
def __refresh_access_token(self, refresh_token: str) -> Optional[dict]:
|
||
"""
|
||
刷新access_token
|
||
"""
|
||
if not refresh_token:
|
||
raise SessionInvalidException("【阿里云盘】会话失效,请重新扫码登录!")
|
||
resp = self.session.post(
|
||
f"{self.base_url}/oauth/access_token",
|
||
json={
|
||
"client_id": settings.ALIPAN_APP_ID,
|
||
"grant_type": "refresh_token",
|
||
"refresh_token": refresh_token,
|
||
},
|
||
)
|
||
if resp is None:
|
||
logger.error(
|
||
f"【阿里云盘】刷新 access_token 失败:refresh_token={refresh_token}"
|
||
)
|
||
return None
|
||
result = resp.json()
|
||
if result.get("code"):
|
||
logger.warn(
|
||
f"【阿里云盘】刷新 access_token 失败:{result.get('code')} - {result.get('message')}!"
|
||
)
|
||
return result
|
||
|
||
def __get_drive_id(self):
|
||
"""
|
||
获取默认存储桶ID
|
||
"""
|
||
resp = self.session.post(f"{self.base_url}/adrive/v1.0/user/getDriveInfo")
|
||
if resp is None:
|
||
logger.error("获取默认存储桶ID失败")
|
||
return None
|
||
result = resp.json()
|
||
if result.get("code"):
|
||
logger.warn(
|
||
f"获取默认存储ID失败:{result.get('code')} - {result.get('message')}!"
|
||
)
|
||
return None
|
||
# 保存用户参数
|
||
"""
|
||
user_id string 是 用户ID,具有唯一性
|
||
name string 是 昵称
|
||
avatar string 是 头像地址
|
||
default_drive_id string 是 默认drive
|
||
resource_drive_id string 否 资源库。用户选择了授权才会返回
|
||
backup_drive_id string 否 备份盘。用户选择了授权才会返回
|
||
"""
|
||
conf = self.get_conf()
|
||
conf.update(result)
|
||
self.set_config(conf)
|
||
return None
|
||
|
||
def _request_api(
|
||
self, method: str, endpoint: str, result_key: Optional[str] = None, **kwargs
|
||
) -> Optional[Union[dict, list]]:
|
||
"""
|
||
带错误处理和速率限制的API请求
|
||
"""
|
||
# 检查会话
|
||
self._check_session()
|
||
|
||
# 错误日志控制
|
||
no_error_log = kwargs.pop("no_error_log", False)
|
||
|
||
try:
|
||
resp = self.session.request(method, f"{self.base_url}{endpoint}", **kwargs)
|
||
except requests.exceptions.RequestException as e:
|
||
logger.error(f"【阿里云盘】{method} 请求 {endpoint} 网络错误: {str(e)}")
|
||
return None
|
||
|
||
if resp is None:
|
||
logger.warn(f"【阿里云盘】{method} 请求 {endpoint} 失败!")
|
||
return None
|
||
|
||
# 处理速率限制
|
||
if resp.status_code == 429:
|
||
reset_time = int(resp.headers.get("X-RateLimit-Reset", 60))
|
||
time.sleep(reset_time + 5)
|
||
return self._request_api(method, endpoint, result_key, **kwargs)
|
||
|
||
# 返回数据
|
||
ret_data = resp.json()
|
||
if ret_data.get("code"):
|
||
if not no_error_log:
|
||
logger.warn(
|
||
f"【阿里云盘】{method} {endpoint} 返回:{ret_data.get('code')} {ret_data.get('message')}"
|
||
)
|
||
|
||
if result_key:
|
||
return ret_data.get(result_key)
|
||
return ret_data
|
||
|
||
def __get_fileitem(self, fileinfo: dict, parent: str = "/") -> schemas.FileItem:
|
||
"""
|
||
获取文件信息
|
||
"""
|
||
if not fileinfo:
|
||
return schemas.FileItem()
|
||
if not parent.endswith("/"):
|
||
parent += "/"
|
||
if fileinfo.get("type") == "folder":
|
||
return schemas.FileItem(
|
||
storage=self.schema.value,
|
||
fileid=fileinfo.get("file_id"),
|
||
parent_fileid=fileinfo.get("parent_file_id"),
|
||
type="dir",
|
||
path=f"{parent}{fileinfo.get('name')}" + "/",
|
||
name=fileinfo.get("name"),
|
||
basename=fileinfo.get("name"),
|
||
size=fileinfo.get("size"),
|
||
modify_time=StringUtils.str_to_timestamp(fileinfo.get("updated_at")),
|
||
drive_id=fileinfo.get("drive_id"),
|
||
)
|
||
else:
|
||
return schemas.FileItem(
|
||
storage=self.schema.value,
|
||
fileid=fileinfo.get("file_id"),
|
||
parent_fileid=fileinfo.get("parent_file_id"),
|
||
type="file",
|
||
path=f"{parent}{fileinfo.get('name')}",
|
||
name=fileinfo.get("name"),
|
||
basename=Path(fileinfo.get("name")).stem,
|
||
size=fileinfo.get("size"),
|
||
extension=fileinfo.get("file_extension"),
|
||
modify_time=StringUtils.str_to_timestamp(fileinfo.get("updated_at")),
|
||
thumbnail=fileinfo.get("thumbnail"),
|
||
drive_id=fileinfo.get("drive_id"),
|
||
)
|
||
|
||
@staticmethod
|
||
def _calc_sha1(filepath: Path, size: Optional[int] = None) -> str:
|
||
"""
|
||
计算文件SHA1(符合阿里云盘规范)
|
||
size: 前多少字节
|
||
"""
|
||
sha1 = hashlib.sha1()
|
||
with open(filepath, "rb") as f:
|
||
if size:
|
||
chunk = f.read(size)
|
||
sha1.update(chunk)
|
||
else:
|
||
while chunk := f.read(8192):
|
||
sha1.update(chunk)
|
||
return sha1.hexdigest()
|
||
|
||
def init_storage(self):
|
||
pass
|
||
|
||
def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]:
|
||
"""
|
||
目录遍历实现
|
||
"""
|
||
if fileitem.type == "file":
|
||
item = self.detail(fileitem)
|
||
if item:
|
||
return [item]
|
||
return []
|
||
|
||
if fileitem.path == "/":
|
||
parent_file_id = "root"
|
||
drive_id = self._default_drive_id
|
||
else:
|
||
parent_file_id = fileitem.fileid
|
||
drive_id = fileitem.drive_id
|
||
|
||
items = []
|
||
next_marker = None
|
||
|
||
while True:
|
||
resp = self._request_api(
|
||
"POST",
|
||
"/adrive/v1.0/openFile/list",
|
||
json={
|
||
"drive_id": drive_id,
|
||
"limit": 100,
|
||
"marker": next_marker,
|
||
"parent_file_id": parent_file_id,
|
||
},
|
||
)
|
||
if resp is None:
|
||
raise FileNotFoundError(f"【阿里云盘】{fileitem.path} 检索出错!")
|
||
if not resp:
|
||
break
|
||
next_marker = resp.get("next_marker")
|
||
for item in resp.get("items", []):
|
||
items.append(self.__get_fileitem(item, parent=str(fileitem.path)))
|
||
if len(resp.get("items")) < 100:
|
||
break
|
||
return items
|
||
|
||
def _delay_get_item(self, path: Path) -> Optional[schemas.FileItem]:
|
||
"""
|
||
自动延迟重试 get_item 模块
|
||
"""
|
||
for _ in range(2):
|
||
time.sleep(2)
|
||
fileitem = self.get_item(path)
|
||
if fileitem:
|
||
return fileitem
|
||
return None
|
||
|
||
def create_folder(
|
||
self, parent_item: schemas.FileItem, name: str
|
||
) -> Optional[schemas.FileItem]:
|
||
"""
|
||
创建目录
|
||
"""
|
||
resp = self._request_api(
|
||
"POST",
|
||
"/adrive/v1.0/openFile/create",
|
||
json={
|
||
"drive_id": parent_item.drive_id,
|
||
"parent_file_id": parent_item.fileid or "root",
|
||
"name": name,
|
||
"type": "folder",
|
||
},
|
||
)
|
||
if not resp:
|
||
return None
|
||
if resp.get("code"):
|
||
logger.warn(f"【阿里云盘】创建目录失败: {resp.get('message')}")
|
||
return None
|
||
# 缓存新目录
|
||
new_path = Path(parent_item.path) / name
|
||
return self._delay_get_item(new_path)
|
||
|
||
@staticmethod
|
||
def _calculate_pre_hash(file_path: Path):
|
||
"""
|
||
计算文件前1KB的SHA1作为pre_hash
|
||
"""
|
||
sha1 = hashlib.sha1()
|
||
with open(file_path, "rb") as f:
|
||
data = f.read(1024)
|
||
sha1.update(data)
|
||
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
|
||
def _calculate_content_hash(file_path: Path):
|
||
"""
|
||
计算整个文件的SHA1作为content_hash
|
||
"""
|
||
sha1 = hashlib.sha1()
|
||
with open(file_path, "rb") as f:
|
||
while True:
|
||
chunk = f.read(8192)
|
||
if not chunk:
|
||
break
|
||
sha1.update(chunk)
|
||
return sha1.hexdigest()
|
||
|
||
def _create_file(
|
||
self,
|
||
drive_id: str,
|
||
parent_file_id: str,
|
||
file_name: str,
|
||
file_path: Path,
|
||
check_name_mode="refuse",
|
||
chunk_size: int = 1 * 1024 * 1024 * 1024,
|
||
):
|
||
"""
|
||
创建文件请求,尝试秒传
|
||
"""
|
||
file_size = file_path.stat().st_size
|
||
pre_hash = self._calculate_pre_hash(file_path)
|
||
num_parts = (file_size + chunk_size - 1) // chunk_size
|
||
|
||
# 构建分片信息
|
||
part_info_list = [{"part_number": i + 1} for i in range(num_parts)]
|
||
|
||
# 确定是否能秒传
|
||
data = {
|
||
"drive_id": drive_id,
|
||
"parent_file_id": parent_file_id,
|
||
"name": file_name,
|
||
"type": "file",
|
||
"check_name_mode": check_name_mode,
|
||
"size": file_size,
|
||
"pre_hash": pre_hash,
|
||
"part_info_list": part_info_list,
|
||
}
|
||
resp = self._request_api("POST", "/adrive/v1.0/openFile/create", json=data)
|
||
if not resp:
|
||
raise Exception("【阿里云盘】创建文件失败!")
|
||
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"))
|
||
return resp
|
||
|
||
def _refresh_upload_urls(
|
||
self, drive_id: str, file_id: str, upload_id: str, part_numbers: List[int]
|
||
):
|
||
"""
|
||
刷新分片上传地址
|
||
"""
|
||
data = {
|
||
"drive_id": drive_id,
|
||
"file_id": file_id,
|
||
"upload_id": upload_id,
|
||
"part_info_list": [{"part_number": num} for num in part_numbers],
|
||
}
|
||
resp = self._request_api(
|
||
"POST", "/adrive/v1.0/openFile/getUploadUrl", json=data
|
||
)
|
||
if not resp:
|
||
raise Exception("【阿里云盘】刷新分片上传地址失败!")
|
||
if resp.get("code"):
|
||
raise Exception(resp.get("message"))
|
||
return resp.get("part_info_list", [])
|
||
|
||
@staticmethod
|
||
def _upload_part(upload_url: str, data: bytes):
|
||
"""
|
||
上传单个分片
|
||
"""
|
||
return requests.put(upload_url, data=data)
|
||
|
||
def _list_uploaded_parts(self, drive_id: str, file_id: str, upload_id: str) -> dict:
|
||
"""
|
||
获取已上传分片列表
|
||
"""
|
||
data = {"drive_id": drive_id, "file_id": file_id, "upload_id": upload_id}
|
||
resp = self._request_api(
|
||
"POST", "/adrive/v1.0/openFile/listUploadedParts", json=data
|
||
)
|
||
if not resp:
|
||
raise Exception("【阿里云盘】获取已上传分片失败!")
|
||
if resp.get("code"):
|
||
raise Exception(resp.get("message"))
|
||
return resp
|
||
|
||
def _complete_upload(self, drive_id: str, file_id: str, upload_id: str):
|
||
"""标记上传完成"""
|
||
data = {"drive_id": drive_id, "file_id": file_id, "upload_id": upload_id}
|
||
resp = self._request_api("POST", "/adrive/v1.0/openFile/complete", json=data)
|
||
if not resp:
|
||
raise Exception("【阿里云盘】完成上传失败!")
|
||
if resp.get("code"):
|
||
raise Exception(resp.get("message"))
|
||
return resp
|
||
|
||
def upload(
|
||
self,
|
||
target_dir: schemas.FileItem,
|
||
local_path: Path,
|
||
new_name: Optional[str] = None,
|
||
) -> Optional[schemas.FileItem]:
|
||
"""
|
||
文件上传:分片、支持秒传
|
||
"""
|
||
target_name = new_name or local_path.name
|
||
target_path = Path(target_dir.path) / target_name
|
||
file_size = local_path.stat().st_size
|
||
|
||
# 1. 创建文件并检查秒传
|
||
chunk_size = 10 * 1024 * 1024 # 分片大小 10M
|
||
create_res = self._create_file(
|
||
drive_id=target_dir.drive_id,
|
||
parent_file_id=target_dir.fileid,
|
||
file_name=target_name,
|
||
file_path=local_path,
|
||
chunk_size=chunk_size,
|
||
)
|
||
if create_res.get("rapid_upload", False):
|
||
logger.info(f"【阿里云盘】{target_name} 秒传完成!")
|
||
return self._delay_get_item(target_path)
|
||
|
||
if create_res.get("exist", False):
|
||
logger.info(f"【阿里云盘】{target_name} 已存在")
|
||
return self.get_item(target_path)
|
||
|
||
# 2. 准备分片上传参数
|
||
file_id = create_res.get("file_id")
|
||
if not file_id:
|
||
logger.warn(f"【阿里云盘】创建 {target_name} 文件失败!")
|
||
return None
|
||
upload_id = create_res.get("upload_id")
|
||
part_info_list = create_res.get("part_info_list")
|
||
uploaded_parts = set()
|
||
|
||
# 3. 获取已上传分片
|
||
uploaded_info = self._list_uploaded_parts(
|
||
drive_id=target_dir.drive_id, file_id=file_id, upload_id=upload_id
|
||
)
|
||
for part in uploaded_info.get("uploaded_parts", []):
|
||
uploaded_parts.add(part["part_number"])
|
||
|
||
# 4. 初始化进度条
|
||
logger.info(
|
||
f"【阿里云盘】开始上传: {local_path} -> {target_path},分片数:{len(part_info_list)}"
|
||
)
|
||
progress_callback = transfer_process(local_path.as_posix())
|
||
|
||
# 5. 分片上传循环
|
||
uploaded_size = 0
|
||
with open(local_path, "rb") as f:
|
||
for part_info in part_info_list:
|
||
if global_vars.is_transfer_stopped(local_path.as_posix()):
|
||
logger.info(f"【阿里云盘】{target_name} 上传已取消!")
|
||
return None
|
||
|
||
# 计算分片参数
|
||
part_num = part_info["part_number"]
|
||
start = (part_num - 1) * chunk_size
|
||
end = min(start + chunk_size, file_size)
|
||
current_chunk_size = end - start
|
||
|
||
# 更新进度条(已存在的分片)
|
||
if part_num in uploaded_parts:
|
||
uploaded_size += current_chunk_size
|
||
progress_callback((uploaded_size * 100) / file_size)
|
||
continue
|
||
|
||
# 准备分片数据
|
||
f.seek(start)
|
||
data = f.read(current_chunk_size)
|
||
|
||
# 上传分片(带重试逻辑)
|
||
success = False
|
||
for attempt in range(3): # 最大重试次数
|
||
try:
|
||
# 获取当前上传地址(可能刷新)
|
||
if attempt > 0:
|
||
new_urls = self._refresh_upload_urls(
|
||
drive_id=target_dir.drive_id,
|
||
file_id=file_id,
|
||
upload_id=upload_id,
|
||
part_numbers=[part_num],
|
||
)
|
||
upload_url = new_urls[0]["upload_url"]
|
||
else:
|
||
upload_url = part_info["upload_url"]
|
||
# 执行上传
|
||
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)
|
||
uploaded_size += current_chunk_size
|
||
progress_callback((uploaded_size * 100) / file_size)
|
||
else:
|
||
raise Exception(
|
||
f"【阿里云盘】{target_name} 分片 {part_num} 上传失败!"
|
||
)
|
||
|
||
# 6. 关闭进度条
|
||
progress_callback(100)
|
||
|
||
# 7. 完成上传
|
||
result = self._complete_upload(
|
||
drive_id=target_dir.drive_id, file_id=file_id, upload_id=upload_id
|
||
)
|
||
if not result:
|
||
raise Exception("【阿里云盘】完成上传失败!")
|
||
if result.get("code"):
|
||
logger.warn(
|
||
f"【阿里云盘】{target_name} 上传失败:{result.get('message')}!"
|
||
)
|
||
return self.__get_fileitem(result, parent=target_dir.path)
|
||
|
||
def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
|
||
"""
|
||
带实时进度显示的下载
|
||
"""
|
||
download_info = self._request_api(
|
||
"POST",
|
||
"/adrive/v1.0/openFile/getDownloadUrl",
|
||
json={
|
||
"drive_id": fileitem.drive_id,
|
||
"file_id": fileitem.fileid,
|
||
},
|
||
)
|
||
if not download_info:
|
||
logger.error(f"【阿里云盘】获取下载链接失败: {fileitem.name}")
|
||
return None
|
||
|
||
download_url = download_info.get("url")
|
||
if not download_url:
|
||
logger.error(f"【阿里云盘】下载链接为空: {fileitem.name}")
|
||
return None
|
||
|
||
local_path = (path or settings.TEMP_PATH) / fileitem.name
|
||
|
||
# 获取文件大小
|
||
file_size = fileitem.size
|
||
|
||
# 初始化进度条
|
||
logger.info(f"【阿里云盘】开始下载: {fileitem.name} -> {local_path}")
|
||
progress_callback = transfer_process(Path(fileitem.path).as_posix())
|
||
|
||
try:
|
||
# 构建请求头,包含必要的认证信息
|
||
headers = {
|
||
"User-Agent": settings.NORMAL_USER_AGENT,
|
||
"Referer": "https://www.aliyundrive.com/",
|
||
"Accept": "*/*",
|
||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||
"Accept-Encoding": "gzip, deflate, br",
|
||
"Connection": "keep-alive",
|
||
"Sec-Fetch-Dest": "empty",
|
||
"Sec-Fetch-Mode": "cors",
|
||
"Sec-Fetch-Site": "cross-site",
|
||
}
|
||
|
||
# 如果有access_token,添加到请求头
|
||
if self.access_token:
|
||
headers["Authorization"] = f"Bearer {self.access_token}"
|
||
|
||
request_utils = RequestUtils(headers=headers)
|
||
with request_utils.get_stream(download_url, raise_exception=True) as r:
|
||
r.raise_for_status()
|
||
downloaded_size = 0
|
||
with open(local_path, "wb") as f:
|
||
for chunk in r.iter_content(chunk_size=self.chunk_size):
|
||
if global_vars.is_transfer_stopped(fileitem.path):
|
||
logger.info(f"【阿里云盘】{fileitem.path} 下载已取消!")
|
||
return None
|
||
if chunk:
|
||
f.write(chunk)
|
||
# 更新进度
|
||
downloaded_size += len(chunk)
|
||
if file_size:
|
||
progress = (downloaded_size * 100) / file_size
|
||
progress_callback(progress)
|
||
|
||
# 完成下载
|
||
progress_callback(100)
|
||
logger.info(f"【阿里云盘】下载完成: {fileitem.name}")
|
||
return local_path
|
||
except Exception as e:
|
||
logger.error(f"【阿里云盘】下载失败: {fileitem.name} - {str(e)}")
|
||
if local_path.exists():
|
||
local_path.unlink()
|
||
return None
|
||
|
||
def check(self) -> bool:
|
||
return self.access_token is not None
|
||
|
||
def delete(self, fileitem: schemas.FileItem) -> bool:
|
||
"""
|
||
删除文件/目录
|
||
"""
|
||
try:
|
||
self._request_api(
|
||
"POST",
|
||
"/adrive/v1.0/openFile/recyclebin/trash",
|
||
json={"drive_id": fileitem.drive_id, "file_id": fileitem.fileid},
|
||
)
|
||
return True
|
||
except requests.exceptions.HTTPError:
|
||
return False
|
||
|
||
def rename(self, fileitem: schemas.FileItem, name: str) -> bool:
|
||
"""
|
||
重命名文件/目录
|
||
"""
|
||
resp = self._request_api(
|
||
"POST",
|
||
"/adrive/v1.0/openFile/update",
|
||
json={
|
||
"drive_id": fileitem.drive_id,
|
||
"file_id": fileitem.fileid,
|
||
"name": name,
|
||
},
|
||
)
|
||
if not resp:
|
||
return False
|
||
if resp.get("code"):
|
||
logger.warn(f"【阿里云盘】重命名失败: {resp.get('message')}")
|
||
return False
|
||
return True
|
||
|
||
def get_item(self, path: Path, drive_id: str = None) -> Optional[schemas.FileItem]:
|
||
"""
|
||
获取指定路径的文件/目录项
|
||
"""
|
||
try:
|
||
resp = self._request_api(
|
||
"POST",
|
||
"/adrive/v1.0/openFile/get_by_path",
|
||
json={
|
||
"drive_id": drive_id or self._default_drive_id,
|
||
"file_path": path.as_posix(),
|
||
},
|
||
no_error_log=True,
|
||
)
|
||
if not resp:
|
||
return None
|
||
if resp.get("code"):
|
||
logger.debug(f"【阿里云盘】获取文件信息失败: {resp.get('message')}")
|
||
return None
|
||
return self.__get_fileitem(resp, parent=str(path.parent))
|
||
except Exception as e:
|
||
logger.debug(f"【阿里云盘】获取文件信息失败: {str(e)}")
|
||
return None
|
||
|
||
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:
|
||
"""
|
||
获取指定路径的文件夹,如不存在则创建
|
||
"""
|
||
|
||
def __find_dir(
|
||
_fileitem: schemas.FileItem, _name: str
|
||
) -> Optional[schemas.FileItem]:
|
||
"""
|
||
查找下级目录中匹配名称的目录
|
||
"""
|
||
for sub_folder in self.list(_fileitem):
|
||
if sub_folder.type != "dir":
|
||
continue
|
||
if sub_folder.name == _name:
|
||
return sub_folder
|
||
return None
|
||
|
||
# 是否已存在
|
||
folder = self.get_item(path)
|
||
if folder:
|
||
return folder
|
||
# 逐级查找和创建目录
|
||
fileitem = schemas.FileItem(
|
||
storage=self.schema.value, path="/", drive_id=self._default_drive_id
|
||
)
|
||
for part in path.parts[1:]:
|
||
dir_file = __find_dir(fileitem, part)
|
||
if dir_file:
|
||
fileitem = dir_file
|
||
else:
|
||
dir_file = self.create_folder(fileitem, part)
|
||
if not dir_file:
|
||
logger.warn(f"【阿里云盘】创建目录 {fileitem.path}{part} 失败!")
|
||
return None
|
||
fileitem = dir_file
|
||
return fileitem
|
||
|
||
def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
|
||
"""
|
||
获取文件/目录详细信息
|
||
"""
|
||
return self.get_item(Path(fileitem.path))
|
||
|
||
def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||
"""
|
||
复制文件到指定路径
|
||
:param fileitem: 要复制的文件项
|
||
:param path: 目标目录路径
|
||
:param new_name: 新文件名
|
||
"""
|
||
dest_fileitem = self.get_item(path, drive_id=fileitem.drive_id)
|
||
if not dest_fileitem or dest_fileitem.type != "dir":
|
||
logger.warn(f"【阿里云盘】目标路径 {path} 不存在或不是目录!")
|
||
return False
|
||
resp = self._request_api(
|
||
"POST",
|
||
"/adrive/v1.0/openFile/copy",
|
||
json={
|
||
"drive_id": fileitem.drive_id,
|
||
"file_id": fileitem.fileid,
|
||
"to_drive_id": fileitem.drive_id,
|
||
"to_parent_file_id": dest_fileitem.fileid,
|
||
},
|
||
)
|
||
if not resp:
|
||
return False
|
||
if resp.get("code"):
|
||
logger.warn(f"【阿里云盘】复制文件失败: {resp.get('message')}")
|
||
return False
|
||
# 重命名
|
||
new_path = Path(path) / fileitem.name
|
||
new_file = self._delay_get_item(new_path)
|
||
self.rename(new_file, new_name)
|
||
return True
|
||
|
||
def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool:
|
||
"""
|
||
移动文件到指定路径
|
||
:param fileitem: 要移动的文件项
|
||
:param path: 目标目录路径
|
||
:param new_name: 新文件名
|
||
"""
|
||
src_fid = fileitem.fileid
|
||
target_fileitem = self.get_item(path, drive_id=fileitem.drive_id)
|
||
if not target_fileitem or target_fileitem.type != "dir":
|
||
logger.warn(f"【阿里云盘】目标路径 {path} 不存在或不是目录!")
|
||
return False
|
||
|
||
resp = self._request_api(
|
||
"POST",
|
||
"/adrive/v1.0/openFile/move",
|
||
json={
|
||
"drive_id": fileitem.drive_id,
|
||
"file_id": src_fid,
|
||
"to_parent_file_id": target_fileitem.fileid,
|
||
"new_name": new_name,
|
||
},
|
||
)
|
||
if not resp:
|
||
return False
|
||
if resp.get("code"):
|
||
logger.warn(f"【阿里云盘】移动文件失败: {resp.get('message')}")
|
||
return False
|
||
return True
|
||
|
||
def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||
pass
|
||
|
||
def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool:
|
||
pass
|
||
|
||
def usage(self) -> Optional[schemas.StorageUsage]:
|
||
"""
|
||
获取带有企业级配额信息的存储使用情况
|
||
"""
|
||
try:
|
||
resp = self._request_api("POST", "/adrive/v1.0/user/getSpaceInfo")
|
||
if not resp:
|
||
return None
|
||
space = resp.get("personal_space_info") or {}
|
||
total_size = space.get("total_size") or 0
|
||
used_size = space.get("used_size") or 0
|
||
return schemas.StorageUsage(
|
||
total=total_size, available=total_size - used_size
|
||
)
|
||
except NoCheckInException:
|
||
return None
|
||
except SessionInvalidException:
|
||
return None
|