abdds 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- abdds/__init__.py +39 -0
- abdds/_http.py +165 -0
- abdds/client.py +419 -0
- abdds/download.py +125 -0
- abdds/errors.py +38 -0
- abdds/models.py +58 -0
- abdds/py.typed +0 -0
- abdds/upload.py +368 -0
- abdds-0.1.0.dist-info/METADATA +193 -0
- abdds-0.1.0.dist-info/RECORD +12 -0
- abdds-0.1.0.dist-info/WHEEL +5 -0
- abdds-0.1.0.dist-info/top_level.txt +1 -0
abdds/download.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""下载相关逻辑 - 异步流式下载、断点续传"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, AsyncGenerator
|
|
9
|
+
|
|
10
|
+
import aiofiles
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._http import UserAgent
|
|
14
|
+
from .errors import BaiduPanNetworkError
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ._http import _HttpTransport
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("abdds")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def download(
|
|
23
|
+
transport: _HttpTransport,
|
|
24
|
+
dlink: str,
|
|
25
|
+
file_to: Path | None = None,
|
|
26
|
+
chunk_size: int = 1024 * 1024,
|
|
27
|
+
max_retries: int = 5,
|
|
28
|
+
) -> AsyncGenerator[bytes, None] | Path:
|
|
29
|
+
"""
|
|
30
|
+
异步下载文件
|
|
31
|
+
|
|
32
|
+
- file_to=None: 返回异步字节迭代器 (流式下载)
|
|
33
|
+
- file_to=Path: 下载到本地文件,返回 Path
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
transport: 异步 HTTP 传输层
|
|
37
|
+
dlink: 下载链接
|
|
38
|
+
file_to: 本地保存路径 (Path),为 None 时返回迭代器
|
|
39
|
+
chunk_size: 分块大小,默认 1MB
|
|
40
|
+
max_retries: 最大重试次数,默认 5
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
AsyncGenerator[bytes] 当 file_to=None,Path 当 file_to 指定路径
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
TypeError: file_to 类型不正确
|
|
47
|
+
"""
|
|
48
|
+
if file_to is not None and not isinstance(file_to, Path):
|
|
49
|
+
raise TypeError(
|
|
50
|
+
f"file_to must be Path or None, got {type(file_to).__name__}"
|
|
51
|
+
)
|
|
52
|
+
if chunk_size <= 0:
|
|
53
|
+
raise ValueError(f"chunk_size must be positive, got {chunk_size}")
|
|
54
|
+
|
|
55
|
+
stream = _download_stream(transport, dlink, chunk_size, max_retries)
|
|
56
|
+
|
|
57
|
+
if file_to is None:
|
|
58
|
+
return stream
|
|
59
|
+
|
|
60
|
+
file_to.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
|
|
62
|
+
async with aiofiles.open(file_to, "wb") as f:
|
|
63
|
+
async for chunk in stream:
|
|
64
|
+
await f.write(chunk)
|
|
65
|
+
|
|
66
|
+
logger.info("Downloaded to %s", file_to)
|
|
67
|
+
return file_to
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _download_stream(
|
|
71
|
+
transport: _HttpTransport,
|
|
72
|
+
dlink: str,
|
|
73
|
+
chunk_size: int = 1024 * 1024,
|
|
74
|
+
max_retries: int = 5,
|
|
75
|
+
) -> AsyncGenerator[bytes, None]:
|
|
76
|
+
"""
|
|
77
|
+
通过 dlink 异步流式下载文件,支持断点续传和指数重试
|
|
78
|
+
"""
|
|
79
|
+
downloaded_bytes = 0
|
|
80
|
+
retry_count = 0
|
|
81
|
+
|
|
82
|
+
while True:
|
|
83
|
+
try:
|
|
84
|
+
headers = {"User-Agent": UserAgent}
|
|
85
|
+
|
|
86
|
+
if downloaded_bytes > 0:
|
|
87
|
+
headers["Range"] = f"bytes={downloaded_bytes}-"
|
|
88
|
+
logger.info("Resuming download from byte %d...", downloaded_bytes)
|
|
89
|
+
|
|
90
|
+
async with transport.client.stream(
|
|
91
|
+
"GET",
|
|
92
|
+
dlink,
|
|
93
|
+
headers=headers,
|
|
94
|
+
params={"access_token": transport._access_token},
|
|
95
|
+
timeout=httpx.Timeout(60, connect=15),
|
|
96
|
+
) as response:
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
|
|
99
|
+
# 请求了 Range 但服务器返回 200,说明不支持断点续传
|
|
100
|
+
if downloaded_bytes > 0 and response.status_code == 200:
|
|
101
|
+
logger.warning("Server does not support Range, restarting from beginning")
|
|
102
|
+
downloaded_bytes = 0
|
|
103
|
+
|
|
104
|
+
async for chunk in response.aiter_bytes(chunk_size=chunk_size):
|
|
105
|
+
if chunk:
|
|
106
|
+
yield chunk
|
|
107
|
+
downloaded_bytes += len(chunk)
|
|
108
|
+
|
|
109
|
+
break # 下载完成
|
|
110
|
+
|
|
111
|
+
except httpx.RequestError as e:
|
|
112
|
+
retry_count += 1
|
|
113
|
+
if retry_count > max_retries:
|
|
114
|
+
raise BaiduPanNetworkError(
|
|
115
|
+
f"Download failed after {max_retries} retries "
|
|
116
|
+
f"({downloaded_bytes} bytes downloaded): {e}",
|
|
117
|
+
url=dlink,
|
|
118
|
+
) from e
|
|
119
|
+
|
|
120
|
+
wait_time = 2 ** (retry_count - 1)
|
|
121
|
+
logger.warning(
|
|
122
|
+
"Download interrupted at %d bytes: %s. Retrying in %ds...",
|
|
123
|
+
downloaded_bytes, e, wait_time,
|
|
124
|
+
)
|
|
125
|
+
await asyncio.sleep(wait_time)
|
abdds/errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""abdds 异常体系"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BaiduPanError(Exception):
|
|
5
|
+
"""SDK 基础异常"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaiduPanNetworkError(BaiduPanError):
|
|
9
|
+
"""网络通信异常"""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self, message: str, *, url: str = "", status_code: int | None = None
|
|
13
|
+
):
|
|
14
|
+
self.url = url
|
|
15
|
+
self.status_code = status_code
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaiduPanAPIError(BaiduPanError):
|
|
20
|
+
"""API 业务异常 (errno != 0)"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self, errno: int, errmsg: str = "", *, request_id: str = ""
|
|
24
|
+
):
|
|
25
|
+
self.errno = errno
|
|
26
|
+
self.errmsg = errmsg
|
|
27
|
+
self.request_id = request_id
|
|
28
|
+
detail = f"[errno:{errno}] {errmsg}"
|
|
29
|
+
if request_id:
|
|
30
|
+
detail += f" (request_id:{request_id})"
|
|
31
|
+
super().__init__(detail)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TokenExpiredError(BaiduPanError):
|
|
35
|
+
"""Token 过期或无效"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, message: str = "Access token expired or invalid"):
|
|
38
|
+
super().__init__(message)
|
abdds/models.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""abdds 数据模型"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class UploadResult:
|
|
10
|
+
"""上传结果"""
|
|
11
|
+
|
|
12
|
+
fs_id: int | None
|
|
13
|
+
md5: str
|
|
14
|
+
path: str
|
|
15
|
+
size: int | None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class _ApiPreUploadResponse:
|
|
20
|
+
"""预上传响应(内部模型)"""
|
|
21
|
+
|
|
22
|
+
path: str
|
|
23
|
+
uploadid: str
|
|
24
|
+
block_list: list[int]
|
|
25
|
+
block_list_md5: list[str]
|
|
26
|
+
file_size: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ApiFileMeta:
|
|
31
|
+
"""文件元信息"""
|
|
32
|
+
|
|
33
|
+
md5: str
|
|
34
|
+
path: str
|
|
35
|
+
size: int
|
|
36
|
+
fs_id: int
|
|
37
|
+
dlink: str
|
|
38
|
+
filename: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ApiFileListItem:
|
|
43
|
+
"""文件列表项"""
|
|
44
|
+
|
|
45
|
+
md5: str
|
|
46
|
+
path: str
|
|
47
|
+
size: int
|
|
48
|
+
fs_id: int
|
|
49
|
+
is_dir: int
|
|
50
|
+
filename: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ApiQuotaInfo:
|
|
55
|
+
"""空间配额信息"""
|
|
56
|
+
|
|
57
|
+
total: int
|
|
58
|
+
used: int
|
abdds/py.typed
ADDED
|
File without changes
|
abdds/upload.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""上传相关逻辑 - 异步小文件单步上传 & 大文件分片上传"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import inspect
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import AsyncGenerator as _AsyncGeneratorABC
|
|
11
|
+
from collections.abc import Generator as _GeneratorABC
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
from typing import TYPE_CHECKING, AsyncGenerator, Generator
|
|
14
|
+
|
|
15
|
+
import aiofiles
|
|
16
|
+
import aiofiles.os
|
|
17
|
+
|
|
18
|
+
from .errors import BaiduPanAPIError
|
|
19
|
+
from .models import _ApiPreUploadResponse, UploadResult
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from ._http import _HttpTransport
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("abdds")
|
|
25
|
+
|
|
26
|
+
BLOCK_SIZE = 4 * 1024 * 1024 # 4MB
|
|
27
|
+
|
|
28
|
+
# file_from 参数可接受类型
|
|
29
|
+
UploadSource = Path | bytes | Generator[bytes, None, None] | AsyncGenerator[bytes, None]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_generator(obj: object) -> bool:
|
|
33
|
+
"""判断对象是否为同步生成器"""
|
|
34
|
+
return isinstance(obj, _GeneratorABC) and not isinstance(obj, _AsyncGeneratorABC)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_async_generator(obj: object) -> bool:
|
|
38
|
+
"""判断对象是否为异步生成器"""
|
|
39
|
+
return isinstance(obj, _AsyncGeneratorABC)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def upload(
|
|
43
|
+
transport: _HttpTransport,
|
|
44
|
+
file_from: UploadSource,
|
|
45
|
+
to: PurePosixPath,
|
|
46
|
+
pan_dir_base: str,
|
|
47
|
+
) -> UploadResult:
|
|
48
|
+
"""
|
|
49
|
+
异步上传数据到百度网盘
|
|
50
|
+
|
|
51
|
+
- 小文件 (<=4MB): 单步上传 (precreate + direct upload)
|
|
52
|
+
- 大文件 (>4MB): 分片上传 (precreate + 分片 + merge)
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
transport: 异步 HTTP 传输层
|
|
56
|
+
file_from: 数据来源,支持 Path / bytes / Generator[bytes] / AsyncGenerator[bytes]
|
|
57
|
+
to: 远程路径 (PurePosixPath),将拼接在 /apps/{app_name} 之后
|
|
58
|
+
pan_dir_base: 应用目录前缀,如 /apps/myapp
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
TypeError: file_from 类型不正确
|
|
62
|
+
"""
|
|
63
|
+
# 类型校验
|
|
64
|
+
if (
|
|
65
|
+
not isinstance(file_from, (Path, bytes))
|
|
66
|
+
and not _is_generator(file_from)
|
|
67
|
+
and not _is_async_generator(file_from)
|
|
68
|
+
):
|
|
69
|
+
raise TypeError(
|
|
70
|
+
f"file_from must be Path, bytes, Generator[bytes], or AsyncGenerator[bytes], "
|
|
71
|
+
f"got {type(file_from).__name__}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# 构造完整远程路径: pan_dir_base + to
|
|
75
|
+
remote_path = f"{pan_dir_base}{to.as_posix()}"
|
|
76
|
+
|
|
77
|
+
# 根据 file_from 类型提取数据
|
|
78
|
+
if isinstance(file_from, Path):
|
|
79
|
+
if not file_from.is_file():
|
|
80
|
+
raise FileNotFoundError(f"File not found: {file_from}")
|
|
81
|
+
file_size = (await aiofiles.os.stat(file_from)).st_size
|
|
82
|
+
if file_size == 0:
|
|
83
|
+
raise ValueError("Cannot upload empty file (0 bytes)")
|
|
84
|
+
block_md5_list = await _calculate_file_md5_blocks(file_from)
|
|
85
|
+
elif isinstance(file_from, bytes):
|
|
86
|
+
file_size = len(file_from)
|
|
87
|
+
if file_size == 0:
|
|
88
|
+
raise ValueError("Cannot upload empty data (0 bytes)")
|
|
89
|
+
block_md5_list = _calculate_bytes_md5_blocks(file_from)
|
|
90
|
+
elif _is_async_generator(file_from):
|
|
91
|
+
# 异步生成器需要消费一次来计算大小和 MD5,缓存在内存中
|
|
92
|
+
chunks: list[bytes] = []
|
|
93
|
+
file_size = 0
|
|
94
|
+
md5_list: list[str] = []
|
|
95
|
+
current_chunk = io.BytesIO()
|
|
96
|
+
|
|
97
|
+
async for data in file_from:
|
|
98
|
+
current_chunk.write(data)
|
|
99
|
+
file_size += len(data)
|
|
100
|
+
if current_chunk.tell() >= BLOCK_SIZE:
|
|
101
|
+
block_data = current_chunk.getvalue()
|
|
102
|
+
chunks.append(block_data)
|
|
103
|
+
md5_list.append(hashlib.md5(block_data).hexdigest())
|
|
104
|
+
current_chunk = io.BytesIO()
|
|
105
|
+
|
|
106
|
+
# 处理最后不足一个 BLOCK_SIZE 的数据
|
|
107
|
+
remaining = current_chunk.getvalue()
|
|
108
|
+
if remaining:
|
|
109
|
+
chunks.append(remaining)
|
|
110
|
+
md5_list.append(hashlib.md5(remaining).hexdigest())
|
|
111
|
+
|
|
112
|
+
block_md5_list = md5_list
|
|
113
|
+
|
|
114
|
+
if file_size == 0:
|
|
115
|
+
raise ValueError("Cannot upload empty async generator (0 bytes yielded)")
|
|
116
|
+
|
|
117
|
+
# 将 chunks 重新组装为异步生成器供上传使用
|
|
118
|
+
async def _replay_chunks() -> AsyncGenerator[bytes, None]:
|
|
119
|
+
for chunk in chunks:
|
|
120
|
+
yield chunk
|
|
121
|
+
|
|
122
|
+
file_from = _replay_chunks()
|
|
123
|
+
elif _is_generator(file_from):
|
|
124
|
+
# 同步生成器
|
|
125
|
+
chunks2: list[bytes] = []
|
|
126
|
+
file_size = 0
|
|
127
|
+
md5_list2: list[str] = []
|
|
128
|
+
current_chunk2 = io.BytesIO()
|
|
129
|
+
|
|
130
|
+
for data in file_from:
|
|
131
|
+
current_chunk2.write(data)
|
|
132
|
+
file_size += len(data)
|
|
133
|
+
if current_chunk2.tell() >= BLOCK_SIZE:
|
|
134
|
+
block_data = current_chunk2.getvalue()
|
|
135
|
+
chunks2.append(block_data)
|
|
136
|
+
md5_list2.append(hashlib.md5(block_data).hexdigest())
|
|
137
|
+
current_chunk2 = io.BytesIO()
|
|
138
|
+
|
|
139
|
+
remaining2 = current_chunk2.getvalue()
|
|
140
|
+
if remaining2:
|
|
141
|
+
chunks2.append(remaining2)
|
|
142
|
+
md5_list2.append(hashlib.md5(remaining2).hexdigest())
|
|
143
|
+
|
|
144
|
+
block_md5_list = md5_list2
|
|
145
|
+
|
|
146
|
+
if file_size == 0:
|
|
147
|
+
raise ValueError("Cannot upload empty generator (0 bytes yielded)")
|
|
148
|
+
|
|
149
|
+
file_from = (chunk for chunk in chunks2)
|
|
150
|
+
else:
|
|
151
|
+
raise TypeError(
|
|
152
|
+
f"file_from must be Path, bytes, Generator[bytes], or AsyncGenerator[bytes], "
|
|
153
|
+
f"got {type(file_from).__name__}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
logger.info("Uploading -> %s (%d bytes)", remote_path, file_size)
|
|
157
|
+
|
|
158
|
+
# 预上传
|
|
159
|
+
pre_resp = await _api_pre_create(transport, remote_path, file_size, block_md5_list)
|
|
160
|
+
|
|
161
|
+
# 获取上传服务器
|
|
162
|
+
upload_host = await _get_upload_server(transport, remote_path, pre_resp.uploadid)
|
|
163
|
+
|
|
164
|
+
if file_size <= BLOCK_SIZE:
|
|
165
|
+
return await _upload_small(transport, upload_host, pre_resp, file_from, file_size)
|
|
166
|
+
else:
|
|
167
|
+
return await _upload_multipart(transport, upload_host, pre_resp, file_from, block_md5_list)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def _upload_small(
|
|
171
|
+
transport: _HttpTransport,
|
|
172
|
+
host: str,
|
|
173
|
+
pre_resp: _ApiPreUploadResponse,
|
|
174
|
+
file_from: UploadSource,
|
|
175
|
+
file_size: int,
|
|
176
|
+
) -> UploadResult:
|
|
177
|
+
"""小文件单步上传"""
|
|
178
|
+
url = f"{host}/rest/2.0/pcs/file"
|
|
179
|
+
params = {"method": "upload", "path": pre_resp.path}
|
|
180
|
+
|
|
181
|
+
if isinstance(file_from, Path):
|
|
182
|
+
async with aiofiles.open(file_from, "rb") as f:
|
|
183
|
+
data = await f.read()
|
|
184
|
+
elif isinstance(file_from, bytes):
|
|
185
|
+
data = file_from
|
|
186
|
+
elif _is_async_generator(file_from):
|
|
187
|
+
chunks: list[bytes] = []
|
|
188
|
+
async for chunk in file_from:
|
|
189
|
+
chunks.append(chunk)
|
|
190
|
+
data = b"".join(chunks)
|
|
191
|
+
elif _is_generator(file_from):
|
|
192
|
+
data = b"".join(file_from)
|
|
193
|
+
else:
|
|
194
|
+
data = b""
|
|
195
|
+
|
|
196
|
+
files = {"file": ("blob", data)}
|
|
197
|
+
resp = await transport.request("POST", url, params=params, files=files)
|
|
198
|
+
return UploadResult(
|
|
199
|
+
fs_id=resp.get("fs_id"),
|
|
200
|
+
md5=resp.get("md5"),
|
|
201
|
+
path=resp.get("path"),
|
|
202
|
+
size=resp.get("size"),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def _upload_multipart(
|
|
207
|
+
transport: _HttpTransport,
|
|
208
|
+
host: str,
|
|
209
|
+
pre_resp: _ApiPreUploadResponse,
|
|
210
|
+
file_from: UploadSource,
|
|
211
|
+
block_md5_list: list[str],
|
|
212
|
+
) -> UploadResult:
|
|
213
|
+
"""大文件分片上传"""
|
|
214
|
+
url_prefix = f"{host}/rest/2.0/pcs/superfile2"
|
|
215
|
+
|
|
216
|
+
if isinstance(file_from, Path):
|
|
217
|
+
async with aiofiles.open(file_from, "rb") as f:
|
|
218
|
+
for idx in range(len(block_md5_list)):
|
|
219
|
+
await f.seek(idx * BLOCK_SIZE)
|
|
220
|
+
chunk_data = await f.read(BLOCK_SIZE)
|
|
221
|
+
await _upload_part(transport, url_prefix, pre_resp, idx, chunk_data)
|
|
222
|
+
elif isinstance(file_from, bytes):
|
|
223
|
+
for idx in range(len(block_md5_list)):
|
|
224
|
+
chunk_data = file_from[idx * BLOCK_SIZE : (idx + 1) * BLOCK_SIZE]
|
|
225
|
+
await _upload_part(transport, url_prefix, pre_resp, idx, chunk_data)
|
|
226
|
+
elif _is_async_generator(file_from):
|
|
227
|
+
idx = 0
|
|
228
|
+
async for chunk_data in file_from:
|
|
229
|
+
await _upload_part(transport, url_prefix, pre_resp, idx, chunk_data)
|
|
230
|
+
idx += 1
|
|
231
|
+
elif _is_generator(file_from):
|
|
232
|
+
for idx, chunk_data in enumerate(file_from):
|
|
233
|
+
await _upload_part(transport, url_prefix, pre_resp, idx, chunk_data)
|
|
234
|
+
|
|
235
|
+
# 合并分片
|
|
236
|
+
return await _api_create_file(
|
|
237
|
+
transport, pre_resp.path, pre_resp.uploadid, pre_resp.file_size, block_md5_list
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
async def _upload_part(
|
|
242
|
+
transport: _HttpTransport,
|
|
243
|
+
url_prefix: str,
|
|
244
|
+
pre_resp: _ApiPreUploadResponse,
|
|
245
|
+
partseq: int,
|
|
246
|
+
chunk_data: bytes,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""上传单个分片"""
|
|
249
|
+
logger.debug("Uploading part %d", partseq + 1)
|
|
250
|
+
params = {
|
|
251
|
+
"method": "upload",
|
|
252
|
+
"type": "tmpfile",
|
|
253
|
+
"path": pre_resp.path,
|
|
254
|
+
"uploadid": pre_resp.uploadid,
|
|
255
|
+
"partseq": str(partseq),
|
|
256
|
+
}
|
|
257
|
+
files = {"file": ("blob", chunk_data)}
|
|
258
|
+
await transport.request("POST", url_prefix, params=params, files=files)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
async def _api_pre_create(
|
|
262
|
+
transport: _HttpTransport,
|
|
263
|
+
path: str,
|
|
264
|
+
size: int,
|
|
265
|
+
block_list: list[str],
|
|
266
|
+
) -> _ApiPreUploadResponse:
|
|
267
|
+
"""预上传 - 通知云端新建上传任务"""
|
|
268
|
+
params = {"method": "precreate"}
|
|
269
|
+
data = {
|
|
270
|
+
"path": path,
|
|
271
|
+
"size": size,
|
|
272
|
+
"isdir": "0",
|
|
273
|
+
"autoinit": "1",
|
|
274
|
+
"rtype": "3", # 覆盖
|
|
275
|
+
"block_list": json.dumps(block_list),
|
|
276
|
+
}
|
|
277
|
+
resp = await transport.request("POST", "https://pan.baidu.com/rest/2.0/xpan/file", params=params, data=data)
|
|
278
|
+
return _ApiPreUploadResponse(
|
|
279
|
+
path=path,
|
|
280
|
+
uploadid=resp["uploadid"],
|
|
281
|
+
block_list=resp.get("block_list", []),
|
|
282
|
+
block_list_md5=block_list,
|
|
283
|
+
file_size=size,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
async def _get_upload_server(transport: _HttpTransport, path: str, uploadid: str) -> str:
|
|
288
|
+
"""获取上传服务器域名"""
|
|
289
|
+
params = {
|
|
290
|
+
"method": "locateupload",
|
|
291
|
+
"appid": "250528",
|
|
292
|
+
"path": path,
|
|
293
|
+
"uploadid": uploadid,
|
|
294
|
+
"upload_version": "2.0",
|
|
295
|
+
}
|
|
296
|
+
resp = await transport.request("GET", "https://pan.baidu.com/rest/2.0/xpan/file", params=params)
|
|
297
|
+
servers = resp.get("servers", [])
|
|
298
|
+
if not servers:
|
|
299
|
+
raise BaiduPanAPIError(-1, "No upload server available")
|
|
300
|
+
|
|
301
|
+
for s in servers:
|
|
302
|
+
server_url = s.get("server", "")
|
|
303
|
+
if server_url.startswith("https"):
|
|
304
|
+
return server_url
|
|
305
|
+
|
|
306
|
+
# 降级:返回第一个有效 server
|
|
307
|
+
for s in servers:
|
|
308
|
+
server_url = s.get("server", "")
|
|
309
|
+
if server_url:
|
|
310
|
+
return server_url
|
|
311
|
+
|
|
312
|
+
raise BaiduPanAPIError(-1, "No valid upload server in response")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
async def _api_create_file(
|
|
316
|
+
transport: _HttpTransport,
|
|
317
|
+
path: str,
|
|
318
|
+
uploadid: str,
|
|
319
|
+
size: int,
|
|
320
|
+
block_list: list[str],
|
|
321
|
+
) -> UploadResult:
|
|
322
|
+
"""合并分片完成上传"""
|
|
323
|
+
params = {"method": "create"}
|
|
324
|
+
data = {
|
|
325
|
+
"path": path,
|
|
326
|
+
"size": size,
|
|
327
|
+
"isdir": "0",
|
|
328
|
+
"rtype": "3",
|
|
329
|
+
"uploadid": uploadid,
|
|
330
|
+
"block_list": json.dumps(block_list),
|
|
331
|
+
}
|
|
332
|
+
resp = await transport.request("POST", "https://pan.baidu.com/rest/2.0/xpan/file", params=params, data=data)
|
|
333
|
+
return UploadResult(
|
|
334
|
+
fs_id=resp.get("fs_id"),
|
|
335
|
+
md5=resp.get("md5"),
|
|
336
|
+
path=resp.get("path"),
|
|
337
|
+
size=resp.get("size"),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
async def _calculate_file_md5_blocks(file_path: Path) -> list[str]:
|
|
342
|
+
"""异步计算文件分片 MD5"""
|
|
343
|
+
file_size = (await aiofiles.os.stat(file_path)).st_size
|
|
344
|
+
|
|
345
|
+
async with aiofiles.open(file_path, "rb") as f:
|
|
346
|
+
if file_size <= BLOCK_SIZE:
|
|
347
|
+
content = await f.read()
|
|
348
|
+
return [hashlib.md5(content).hexdigest()]
|
|
349
|
+
|
|
350
|
+
md5_list: list[str] = []
|
|
351
|
+
while True:
|
|
352
|
+
chunk = await f.read(BLOCK_SIZE)
|
|
353
|
+
if not chunk:
|
|
354
|
+
break
|
|
355
|
+
md5_list.append(hashlib.md5(chunk).hexdigest())
|
|
356
|
+
|
|
357
|
+
return md5_list
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _calculate_bytes_md5_blocks(data: bytes) -> list[str]:
|
|
361
|
+
"""计算 bytes 对象的分片 MD5"""
|
|
362
|
+
if len(data) <= BLOCK_SIZE:
|
|
363
|
+
return [hashlib.md5(data).hexdigest()]
|
|
364
|
+
|
|
365
|
+
md5_list: list[str] = []
|
|
366
|
+
for i in range(0, len(data), BLOCK_SIZE):
|
|
367
|
+
md5_list.append(hashlib.md5(data[i : i + BLOCK_SIZE]).hexdigest())
|
|
368
|
+
return md5_list
|