bdpan-cli 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.
- bdpan_cli/__init__.py +15 -0
- bdpan_cli/_vendor/__init__.py +9 -0
- bdpan_cli/_vendor/openapi_client/__init__.py +27 -0
- bdpan_cli/_vendor/openapi_client/api/__init__.py +6 -0
- bdpan_cli/_vendor/openapi_client/api/auth_api.py +649 -0
- bdpan_cli/_vendor/openapi_client/api/fileinfo_api.py +737 -0
- bdpan_cli/_vendor/openapi_client/api/filemanager_api.py +669 -0
- bdpan_cli/_vendor/openapi_client/api/fileupload_api.py +599 -0
- bdpan_cli/_vendor/openapi_client/api/multimediafile_api.py +391 -0
- bdpan_cli/_vendor/openapi_client/api/userinfo_api.py +315 -0
- bdpan_cli/_vendor/openapi_client/api_client.py +864 -0
- bdpan_cli/_vendor/openapi_client/configuration.py +454 -0
- bdpan_cli/_vendor/openapi_client/exceptions.py +197 -0
- bdpan_cli/_vendor/openapi_client/model/__init__.py +5 -0
- bdpan_cli/_vendor/openapi_client/model/oauth_token_authorization_code_response.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/oauth_token_device_code_response.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/oauth_token_device_token_response.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/oauth_token_refresh_token_response.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/quotaresponse.py +276 -0
- bdpan_cli/_vendor/openapi_client/model/uinforesponse.py +284 -0
- bdpan_cli/_vendor/openapi_client/model_utils.py +2039 -0
- bdpan_cli/_vendor/openapi_client/rest.py +385 -0
- bdpan_cli/_version.py +3 -0
- bdpan_cli/account.py +78 -0
- bdpan_cli/apply_scrape_rename.py +99 -0
- bdpan_cli/auth.py +218 -0
- bdpan_cli/cli.py +1428 -0
- bdpan_cli/config.py +26 -0
- bdpan_cli/dedup.py +310 -0
- bdpan_cli/downloader.py +178 -0
- bdpan_cli/file_manager.py +389 -0
- bdpan_cli/foldering.py +181 -0
- bdpan_cli/logger.py +101 -0
- bdpan_cli/mcp_server.py +547 -0
- bdpan_cli/media_info.py +251 -0
- bdpan_cli/ops.py +173 -0
- bdpan_cli/organize.py +108 -0
- bdpan_cli/preview_scrape.py +182 -0
- bdpan_cli/scraper.py +505 -0
- bdpan_cli/uploader.py +391 -0
- bdpan_cli-0.1.0.dist-info/METADATA +167 -0
- bdpan_cli-0.1.0.dist-info/RECORD +44 -0
- bdpan_cli-0.1.0.dist-info/WHEEL +4 -0
- bdpan_cli-0.1.0.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""文件与目录管理客户端,封装创建、重命名、移动等能力。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from bdpan_cli._vendor import openapi_client
|
|
11
|
+
from bdpan_cli._vendor.openapi_client.api.fileinfo_api import FileinfoApi
|
|
12
|
+
from bdpan_cli._vendor.openapi_client.api.filemanager_api import FilemanagerApi
|
|
13
|
+
from bdpan_cli._vendor.openapi_client.api.fileupload_api import FileuploadApi
|
|
14
|
+
from bdpan_cli._vendor.openapi_client.rest import ApiException
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
WalkResult = tuple[list[tuple[int, dict[str, Any]]], list[tuple[str, str]]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FileManagerError(RuntimeError):
|
|
22
|
+
"""文件管理异常基类。"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _validate_filemanager_result(res: Any, prefix: str) -> dict[str, Any]:
|
|
26
|
+
"""统一校验 filemanager 四原语(copy/move/rename/delete)的响应。
|
|
27
|
+
|
|
28
|
+
响应形如 ``{"errno": 0, "info": [{"errno": 0, ...}, ...]}``,批量场景下
|
|
29
|
+
存在顶层 errno=0 但个别条目 info[].errno != 0 的部分失败形态,仅校验
|
|
30
|
+
顶层会漏报,故逐项复核;任一非零即抛 FileManagerError。部分响应可能
|
|
31
|
+
无 info 字段或缺省条目 errno,视作通过以免对未记载形态误报。
|
|
32
|
+
"""
|
|
33
|
+
if not isinstance(res, dict) or res.get("errno") != 0:
|
|
34
|
+
raise FileManagerError(f"{prefix}: {res}")
|
|
35
|
+
info = res.get("info")
|
|
36
|
+
if isinstance(info, list):
|
|
37
|
+
for idx, item in enumerate(info, 1):
|
|
38
|
+
if isinstance(item, dict) and item.get("errno") not in (0, None):
|
|
39
|
+
raise FileManagerError(f"{prefix}: 第 {idx} 项失败 errno={item['errno']} ({item})")
|
|
40
|
+
return res
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FileManager:
|
|
44
|
+
"""网盘文件与目录管理封装。"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, access_token: str, api_client: openapi_client.ApiClient | None = None) -> None:
|
|
47
|
+
self.access_token = access_token
|
|
48
|
+
self.api_client = api_client or openapi_client.ApiClient()
|
|
49
|
+
self._fileinfo_api = FileinfoApi(self.api_client)
|
|
50
|
+
self._filemanager_api = FilemanagerApi(self.api_client)
|
|
51
|
+
self._fileupload_api = FileuploadApi(self.api_client)
|
|
52
|
+
|
|
53
|
+
def list_dir(self, dir_path: str, limit: int = 1000) -> list[dict[str, Any]]:
|
|
54
|
+
"""全量列出指定目录下的条目。"""
|
|
55
|
+
all_entries: list[dict[str, Any]] = []
|
|
56
|
+
start = 0
|
|
57
|
+
page_size = min(limit, 1000)
|
|
58
|
+
while True:
|
|
59
|
+
try:
|
|
60
|
+
res = self._fileinfo_api.xpanfilelist(
|
|
61
|
+
access_token=self.access_token,
|
|
62
|
+
dir=dir_path,
|
|
63
|
+
start=str(start),
|
|
64
|
+
limit=page_size,
|
|
65
|
+
web="1",
|
|
66
|
+
order="name",
|
|
67
|
+
)
|
|
68
|
+
except ApiException as e:
|
|
69
|
+
raise FileManagerError(f"读取目录失败 [{dir_path}]: {e.status} {e.body}") from e
|
|
70
|
+
|
|
71
|
+
entries = res.get("list", [])
|
|
72
|
+
if not entries:
|
|
73
|
+
break
|
|
74
|
+
all_entries.extend(entries)
|
|
75
|
+
if len(entries) < page_size:
|
|
76
|
+
break
|
|
77
|
+
start += len(entries)
|
|
78
|
+
return all_entries
|
|
79
|
+
|
|
80
|
+
def get_file_info(self, path: str) -> dict[str, Any] | None:
|
|
81
|
+
"""获取指定网盘文件/目录的元信息。
|
|
82
|
+
|
|
83
|
+
通过探测其父目录条目列表,按 path 精确匹配返回单条 entry;
|
|
84
|
+
若不存在则返回 None。
|
|
85
|
+
"""
|
|
86
|
+
path = "/" + path.strip("/") if not path.startswith("/") else path
|
|
87
|
+
if path == "/":
|
|
88
|
+
return {"fs_id": 0, "path": "/", "server_filename": "", "isdir": 1, "size": 0}
|
|
89
|
+
parent = os.path.dirname(path) or "/"
|
|
90
|
+
try:
|
|
91
|
+
entries = self.list_dir(parent)
|
|
92
|
+
for entry in entries:
|
|
93
|
+
if entry.get("path") == path:
|
|
94
|
+
return entry
|
|
95
|
+
return None
|
|
96
|
+
except FileManagerError:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
def walk(self, root: str, max_depth: int | None = None) -> WalkResult:
|
|
100
|
+
"""深度优先递归遍历网盘子树。
|
|
101
|
+
|
|
102
|
+
引擎说明:SDK 的 MultimediafileApi.xpanfilelistall 在当前应用权限下
|
|
103
|
+
对所有路径均返回 errno=20020「该路径不在允许的访问范围内」(实测,
|
|
104
|
+
含应用自建目录),故采用已验证可用的 xpanfilelist 客户端侧逐层递归,
|
|
105
|
+
每个子目录一次 API 调用。
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
root: 遍历起点目录。
|
|
109
|
+
max_depth: 最大遍历深度(root 直接子项为深度 0);None 为不限。
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
(entries, errors) 二元组:
|
|
113
|
+
- entries: (depth, entry) 列表,DFS 先序,depth 相对 root;
|
|
114
|
+
- errors: (path, errmsg) 列表,记录不可读目录;单个子目录
|
|
115
|
+
读取失败不中断遍历(降级可观测),root 失败时 entries 为空。
|
|
116
|
+
"""
|
|
117
|
+
entries: list[tuple[int, dict[str, Any]]] = []
|
|
118
|
+
errors: list[tuple[str, str]] = []
|
|
119
|
+
self._walk_recursive(root, 0, max_depth, entries, errors)
|
|
120
|
+
return entries, errors
|
|
121
|
+
|
|
122
|
+
def _walk_recursive(
|
|
123
|
+
self,
|
|
124
|
+
path: str,
|
|
125
|
+
depth: int,
|
|
126
|
+
max_depth: int | None,
|
|
127
|
+
entries: list[tuple[int, dict[str, Any]]],
|
|
128
|
+
errors: list[tuple[str, str]],
|
|
129
|
+
) -> None:
|
|
130
|
+
try:
|
|
131
|
+
children = self.list_dir(path)
|
|
132
|
+
except FileManagerError as e:
|
|
133
|
+
logger.warning("遍历跳过不可读目录 [%s]: %s", path, e)
|
|
134
|
+
errors.append((path, str(e)))
|
|
135
|
+
return
|
|
136
|
+
for entry in children:
|
|
137
|
+
entries.append((depth, entry))
|
|
138
|
+
if entry.get("isdir") == 1 and (max_depth is None or depth + 1 < max_depth):
|
|
139
|
+
self._walk_recursive(entry["path"], depth + 1, max_depth, entries, errors)
|
|
140
|
+
|
|
141
|
+
def search(
|
|
142
|
+
self,
|
|
143
|
+
key: str,
|
|
144
|
+
dir_path: str | None = None,
|
|
145
|
+
recursion: bool = True,
|
|
146
|
+
page: int = 1,
|
|
147
|
+
num: int = 100,
|
|
148
|
+
) -> list[dict[str, Any]]:
|
|
149
|
+
"""按关键词搜索网盘文件(单页结果)。
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
key: 搜索关键词。
|
|
153
|
+
dir_path: 限定搜索目录(None 为全盘)。
|
|
154
|
+
recursion: 是否递归子目录。
|
|
155
|
+
page: 页码(从 1 起)。
|
|
156
|
+
num: 每页条数。
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
当前页条目列表,条目字段同 list_dir(fs_id/path/server_filename/size/isdir 等)。
|
|
160
|
+
"""
|
|
161
|
+
kwargs: dict[str, Any] = {
|
|
162
|
+
"web": "1",
|
|
163
|
+
"num": str(num),
|
|
164
|
+
"page": str(page),
|
|
165
|
+
"recursion": "1" if recursion else "0",
|
|
166
|
+
}
|
|
167
|
+
if dir_path:
|
|
168
|
+
kwargs["dir"] = dir_path
|
|
169
|
+
try:
|
|
170
|
+
res = self._fileinfo_api.xpanfilesearch(
|
|
171
|
+
access_token=self.access_token,
|
|
172
|
+
key=key,
|
|
173
|
+
**kwargs,
|
|
174
|
+
)
|
|
175
|
+
except ApiException as e:
|
|
176
|
+
raise FileManagerError(f"搜索失败 [{key}]: {e.status} {e.body}") from e
|
|
177
|
+
return res.get("list", []) if isinstance(res, dict) else []
|
|
178
|
+
|
|
179
|
+
def mkdir(self, path: str) -> dict[str, Any]:
|
|
180
|
+
"""创建目录。若目录已存在,视作正常成功。"""
|
|
181
|
+
try:
|
|
182
|
+
res = self._fileupload_api.xpanfilecreate(
|
|
183
|
+
access_token=self.access_token,
|
|
184
|
+
path=path,
|
|
185
|
+
isdir=1,
|
|
186
|
+
size=0,
|
|
187
|
+
uploadid="",
|
|
188
|
+
block_list="[]",
|
|
189
|
+
rtype=1,
|
|
190
|
+
)
|
|
191
|
+
# errno=-8 代表文件/目录已存在
|
|
192
|
+
if isinstance(res, dict) and res.get("errno") not in (0, -8, None):
|
|
193
|
+
raise FileManagerError(f"创建目录服务端返回异常 [{path}]: {res}")
|
|
194
|
+
return res if isinstance(res, dict) else {}
|
|
195
|
+
except ApiException as e:
|
|
196
|
+
raise FileManagerError(f"创建目录失败 [{path}]: {e.status} {e.body}") from e
|
|
197
|
+
|
|
198
|
+
def rename(self, path: str, new_name: str) -> dict[str, Any]:
|
|
199
|
+
"""重命名单个文件或目录。"""
|
|
200
|
+
filelist = json.dumps([{"path": path, "newname": new_name}], ensure_ascii=False)
|
|
201
|
+
try:
|
|
202
|
+
res = self._filemanager_api.filemanagerrename(
|
|
203
|
+
access_token=self.access_token,
|
|
204
|
+
_async=0,
|
|
205
|
+
filelist=filelist,
|
|
206
|
+
)
|
|
207
|
+
except ApiException as e:
|
|
208
|
+
raise FileManagerError(f"重命名失败 [{path} -> {new_name}]: {e.status} {e.body}") from e
|
|
209
|
+
|
|
210
|
+
return _validate_filemanager_result(res, f"重命名返回错误 [{path} -> {new_name}]")
|
|
211
|
+
|
|
212
|
+
def batch_rename(
|
|
213
|
+
self,
|
|
214
|
+
items: list[tuple[str, str]],
|
|
215
|
+
batch_size: int = 50,
|
|
216
|
+
) -> list[dict[str, Any]]:
|
|
217
|
+
"""批量重命名文件或目录。
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
items: 包含 (src_path, new_name) 的元组列表。
|
|
221
|
+
batch_size: 每批请求的最大数量(默认 50)。
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
各批次成功结果列表。
|
|
225
|
+
"""
|
|
226
|
+
results: list[dict[str, Any]] = []
|
|
227
|
+
for i in range(0, len(items), batch_size):
|
|
228
|
+
chunk = items[i : i + batch_size]
|
|
229
|
+
payload = [{"path": src, "newname": newname} for src, newname in chunk]
|
|
230
|
+
filelist = json.dumps(payload, ensure_ascii=False)
|
|
231
|
+
try:
|
|
232
|
+
res = self._filemanager_api.filemanagerrename(
|
|
233
|
+
access_token=self.access_token,
|
|
234
|
+
_async=0,
|
|
235
|
+
filelist=filelist,
|
|
236
|
+
)
|
|
237
|
+
except ApiException as e:
|
|
238
|
+
raise FileManagerError(f"批量重命名批次 {i // batch_size + 1} 失败: {e.status} {e.body}") from e
|
|
239
|
+
|
|
240
|
+
_validate_filemanager_result(res, f"批量重命名批次 {i // batch_size + 1} 返回错误")
|
|
241
|
+
results.append(res)
|
|
242
|
+
return results
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def batch_move(
|
|
246
|
+
self,
|
|
247
|
+
items: list[tuple[str, str, str]],
|
|
248
|
+
batch_size: int = 50,
|
|
249
|
+
) -> list[dict[str, Any]]:
|
|
250
|
+
"""批量移动文件或目录。
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
items: 包含 (src_path, dest_dir, new_name) 的元组列表。
|
|
254
|
+
batch_size: 每批请求的最大数量(默认 50,避免接口超时或截断)。
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
各批次成功结果列表。
|
|
258
|
+
"""
|
|
259
|
+
results: list[dict[str, Any]] = []
|
|
260
|
+
for i in range(0, len(items), batch_size):
|
|
261
|
+
chunk = items[i : i + batch_size]
|
|
262
|
+
payload = [
|
|
263
|
+
{"path": src, "dest": dest, "newname": name}
|
|
264
|
+
for src, dest, name in chunk
|
|
265
|
+
]
|
|
266
|
+
filelist = json.dumps(payload, ensure_ascii=False)
|
|
267
|
+
try:
|
|
268
|
+
res = self._filemanager_api.filemanagermove(
|
|
269
|
+
access_token=self.access_token,
|
|
270
|
+
_async=0,
|
|
271
|
+
filelist=filelist,
|
|
272
|
+
)
|
|
273
|
+
except ApiException as e:
|
|
274
|
+
raise FileManagerError(f"批量移动批次 {i // batch_size + 1} 失败: {e.status} {e.body}") from e
|
|
275
|
+
|
|
276
|
+
_validate_filemanager_result(res, f"批量移动批次 {i // batch_size + 1} 返回错误")
|
|
277
|
+
results.append(res)
|
|
278
|
+
return results
|
|
279
|
+
|
|
280
|
+
def copy(self, path: str, dest_dir: str, new_name: str) -> dict[str, Any]:
|
|
281
|
+
"""复制单个文件或目录到目标目录。
|
|
282
|
+
|
|
283
|
+
与 batch_copy 共用同一请求与校验通道,但不经批量入口,错误文案
|
|
284
|
+
按单项语境表述(不出现"批量复制批次 N"字样)。
|
|
285
|
+
"""
|
|
286
|
+
payload = [{"path": path, "dest": dest_dir, "newname": new_name}]
|
|
287
|
+
return self._copy_request(
|
|
288
|
+
payload,
|
|
289
|
+
api_ctx=f"复制 [{path} -> {dest_dir}/{new_name}]",
|
|
290
|
+
err_prefix=f"复制返回错误 [{path} -> {dest_dir}/{new_name}]",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def batch_copy(
|
|
294
|
+
self,
|
|
295
|
+
items: list[tuple[str, str, str]],
|
|
296
|
+
batch_size: int = 50,
|
|
297
|
+
) -> list[dict[str, Any]]:
|
|
298
|
+
"""批量复制文件或目录。
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
items: 包含 (src_path, dest_dir, new_name) 的元组列表。
|
|
302
|
+
batch_size: 每批请求的最大数量(默认 50,避免接口超时或截断)。
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
各批次成功结果列表。
|
|
306
|
+
"""
|
|
307
|
+
results: list[dict[str, Any]] = []
|
|
308
|
+
for i in range(0, len(items), batch_size):
|
|
309
|
+
chunk = items[i : i + batch_size]
|
|
310
|
+
payload = [
|
|
311
|
+
{"path": src, "dest": dest, "newname": name}
|
|
312
|
+
for src, dest, name in chunk
|
|
313
|
+
]
|
|
314
|
+
res = self._copy_request(
|
|
315
|
+
payload,
|
|
316
|
+
api_ctx=f"批量复制批次 {i // batch_size + 1}",
|
|
317
|
+
err_prefix=f"批量复制批次 {i // batch_size + 1} 返回错误",
|
|
318
|
+
)
|
|
319
|
+
results.append(res)
|
|
320
|
+
return results
|
|
321
|
+
|
|
322
|
+
def _copy_request(
|
|
323
|
+
self,
|
|
324
|
+
payload: list[dict[str, str]],
|
|
325
|
+
api_ctx: str,
|
|
326
|
+
err_prefix: str,
|
|
327
|
+
) -> dict[str, Any]:
|
|
328
|
+
"""执行单次 filemanagercopy 请求并统一校验。
|
|
329
|
+
|
|
330
|
+
api_ctx 用于 ApiException 文案("…失败"),err_prefix 用于
|
|
331
|
+
响应校验文案("…返回错误"),二者分离以保持既有文案逐字不变。
|
|
332
|
+
"""
|
|
333
|
+
filelist = json.dumps(payload, ensure_ascii=False)
|
|
334
|
+
try:
|
|
335
|
+
res = self._filemanager_api.filemanagercopy(
|
|
336
|
+
access_token=self.access_token,
|
|
337
|
+
_async=0,
|
|
338
|
+
filelist=filelist,
|
|
339
|
+
)
|
|
340
|
+
except ApiException as e:
|
|
341
|
+
raise FileManagerError(f"{api_ctx}失败: {e.status} {e.body}") from e
|
|
342
|
+
|
|
343
|
+
return _validate_filemanager_result(res, err_prefix)
|
|
344
|
+
|
|
345
|
+
def batch_delete(
|
|
346
|
+
self,
|
|
347
|
+
paths: list[str],
|
|
348
|
+
batch_size: int = 50,
|
|
349
|
+
) -> list[dict[str, Any]]:
|
|
350
|
+
"""批量删除文件或目录(服务端移入回收站)。
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
paths: 待删除的文件或目录路径列表。
|
|
354
|
+
batch_size: 每批请求的最大数量(默认 50,避免接口超时或截断)。
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
各批次成功结果列表。
|
|
358
|
+
"""
|
|
359
|
+
results: list[dict[str, Any]] = []
|
|
360
|
+
for i in range(0, len(paths), batch_size):
|
|
361
|
+
chunk = paths[i : i + batch_size]
|
|
362
|
+
filelist = json.dumps(chunk, ensure_ascii=False)
|
|
363
|
+
try:
|
|
364
|
+
res = self._filemanager_api.filemanagerdelete(
|
|
365
|
+
access_token=self.access_token,
|
|
366
|
+
_async=0,
|
|
367
|
+
filelist=filelist,
|
|
368
|
+
)
|
|
369
|
+
except ApiException as e:
|
|
370
|
+
raise FileManagerError(f"批量删除批次 {i // batch_size + 1} 失败: {e.status} {e.body}") from e
|
|
371
|
+
|
|
372
|
+
_validate_filemanager_result(res, f"批量删除批次 {i // batch_size + 1} 返回错误")
|
|
373
|
+
results.append(res)
|
|
374
|
+
return results
|
|
375
|
+
|
|
376
|
+
def delete(self, path: str) -> dict[str, Any]:
|
|
377
|
+
"""删除单个文件或目录。"""
|
|
378
|
+
filelist = json.dumps([path], ensure_ascii=False)
|
|
379
|
+
try:
|
|
380
|
+
res = self._filemanager_api.filemanagerdelete(
|
|
381
|
+
access_token=self.access_token,
|
|
382
|
+
_async=0,
|
|
383
|
+
filelist=filelist,
|
|
384
|
+
)
|
|
385
|
+
except ApiException as e:
|
|
386
|
+
raise FileManagerError(f"删除失败 [{path}]: {e.status} {e.body}") from e
|
|
387
|
+
|
|
388
|
+
return _validate_filemanager_result(res, f"删除返回错误 [{path}]")
|
|
389
|
+
|
bdpan_cli/foldering.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""散落电影/视频/压缩包文件自动规整:识别并创建方案 A 规范目录并将文件移入。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from bdpan_cli.file_manager import FileManager
|
|
11
|
+
from bdpan_cli.preview_scrape import is_already_conforming
|
|
12
|
+
from bdpan_cli.scraper import MovieScraper
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
MEDIA_EXTENSIONS = {
|
|
17
|
+
".mkv",
|
|
18
|
+
".mp4",
|
|
19
|
+
".avi",
|
|
20
|
+
".mov",
|
|
21
|
+
".wmv",
|
|
22
|
+
".ts",
|
|
23
|
+
".iso",
|
|
24
|
+
".zip",
|
|
25
|
+
".7z",
|
|
26
|
+
".rar",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
SUBTITLE_EXTENSIONS = {
|
|
30
|
+
".srt",
|
|
31
|
+
".ass",
|
|
32
|
+
".sub",
|
|
33
|
+
".vtt",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
ALLOWED_EXTENSIONS = MEDIA_EXTENSIONS | SUBTITLE_EXTENSIONS
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def plan_foldering(
|
|
40
|
+
dir_path: str,
|
|
41
|
+
mgr: FileManager,
|
|
42
|
+
scraper: MovieScraper | None = None,
|
|
43
|
+
name_format: str | None = None,
|
|
44
|
+
) -> list[dict[str, Any]]:
|
|
45
|
+
"""扫描目标目录下散落的音视频、字幕与非规范子目录,生成方案 A 规范目录归集计划。
|
|
46
|
+
|
|
47
|
+
返回列表中的每一项表示一个待创建的独立电影目录及归入其中的文件清单。
|
|
48
|
+
支持自定义命名模板 name_format。
|
|
49
|
+
"""
|
|
50
|
+
if scraper is None:
|
|
51
|
+
scraper = MovieScraper()
|
|
52
|
+
|
|
53
|
+
logger.info("正在扫描目录 %s 中的待整理媒体与子目录...", dir_path)
|
|
54
|
+
entries = mgr.list_dir(dir_path)
|
|
55
|
+
|
|
56
|
+
# 1. 过滤散落媒体文件与非规范子目录
|
|
57
|
+
media_groups: list[dict[str, Any]] = []
|
|
58
|
+
for item in entries:
|
|
59
|
+
if item.get("isdir") == 1:
|
|
60
|
+
subdir_name = item.get("server_filename", "")
|
|
61
|
+
# 若子目录已经严格符合方案 A 规范,跳过不处理(未指定自定义模板时)
|
|
62
|
+
if not name_format and is_already_conforming(subdir_name):
|
|
63
|
+
continue
|
|
64
|
+
# 否则扫描非规范子目录内部的媒体文件与字幕
|
|
65
|
+
sub_path = item.get("path", f"{dir_path}/{subdir_name}")
|
|
66
|
+
try:
|
|
67
|
+
sub_entries = mgr.list_dir(sub_path)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.warning("扫描子目录失败 [%s]: %s", subdir_name, e)
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
sub_media = []
|
|
73
|
+
for sub_item in sub_entries:
|
|
74
|
+
if sub_item.get("isdir") == 1:
|
|
75
|
+
continue
|
|
76
|
+
sfname = sub_item.get("server_filename", "")
|
|
77
|
+
_, sext = os.path.splitext(sfname)
|
|
78
|
+
if sext.lower() in ALLOWED_EXTENSIONS:
|
|
79
|
+
sub_media.append(sub_item)
|
|
80
|
+
|
|
81
|
+
if sub_media:
|
|
82
|
+
media_groups.append({
|
|
83
|
+
"query_name": subdir_name,
|
|
84
|
+
"files": sub_media,
|
|
85
|
+
"source_dir": sub_path,
|
|
86
|
+
})
|
|
87
|
+
else:
|
|
88
|
+
fname = item.get("server_filename", "")
|
|
89
|
+
_, ext = os.path.splitext(fname)
|
|
90
|
+
if ext.lower() in ALLOWED_EXTENSIONS:
|
|
91
|
+
base_name, _ = os.path.splitext(fname)
|
|
92
|
+
media_groups.append({
|
|
93
|
+
"query_name": base_name,
|
|
94
|
+
"files": [item],
|
|
95
|
+
"source_dir": None,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
logger.info("共发现 %d 组待整理媒体项", len(media_groups))
|
|
99
|
+
|
|
100
|
+
# 2. 逐一识别并匹配方案 A 规范目录名
|
|
101
|
+
folder_groups: dict[str, list[dict[str, Any]]] = {}
|
|
102
|
+
|
|
103
|
+
for group in media_groups:
|
|
104
|
+
query_name = group["query_name"]
|
|
105
|
+
base_name, _ = os.path.splitext(query_name)
|
|
106
|
+
is_trailer = bool(re.search(r"Theatrical\s*Trailer|Trailer|预告片?", base_name, re.IGNORECASE))
|
|
107
|
+
query = base_name
|
|
108
|
+
if is_trailer:
|
|
109
|
+
query = re.sub(r"Theatrical\s*Trailer|Trailer|预告片?", " ", query, flags=re.IGNORECASE).strip()
|
|
110
|
+
|
|
111
|
+
# 特殊情况消歧:例如张艺谋《狙击手》无年份标注但打包于2022
|
|
112
|
+
if "狙击手" in query and not re.search(r"\b(19\d{2}|20\d{2})\b", query):
|
|
113
|
+
query = f"{query} 2022"
|
|
114
|
+
|
|
115
|
+
meta = scraper.scrape_folder_name(query)
|
|
116
|
+
if meta and meta.formatted_name:
|
|
117
|
+
target_name = meta.format_name(name_format)
|
|
118
|
+
if not name_format:
|
|
119
|
+
# 方案 A 默认:净化中英文标点与逗号
|
|
120
|
+
target_name = re.sub(r"[\/\\:\*\?\"<>\|\s_,]+", ".", target_name.strip())
|
|
121
|
+
target_name = re.sub(r"\.{2,}", ".", target_name).strip(".")
|
|
122
|
+
if is_trailer and not target_name.endswith(".Trailer"):
|
|
123
|
+
target_name = f"{target_name}.Trailer"
|
|
124
|
+
else:
|
|
125
|
+
# 无法识别时回退为原文件名(去除扩展名)
|
|
126
|
+
target_name = base_name.strip(".")
|
|
127
|
+
|
|
128
|
+
if target_name not in folder_groups:
|
|
129
|
+
folder_groups[target_name] = []
|
|
130
|
+
|
|
131
|
+
for f_item in group["files"]:
|
|
132
|
+
fname = f_item.get("server_filename", "")
|
|
133
|
+
fpath = f_item.get("path", f"{dir_path}/{fname}")
|
|
134
|
+
folder_groups[target_name].append({
|
|
135
|
+
"filename": fname,
|
|
136
|
+
"path": fpath,
|
|
137
|
+
"size": f_item.get("size", 0),
|
|
138
|
+
"is_trailer": is_trailer,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
# 3. 组织为计划清单
|
|
142
|
+
plan: list[dict[str, Any]] = []
|
|
143
|
+
for folder_name, files in folder_groups.items():
|
|
144
|
+
plan.append({
|
|
145
|
+
"target_folder_name": folder_name,
|
|
146
|
+
"target_folder_path": f"{dir_path}/{folder_name}",
|
|
147
|
+
"files": files,
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
return plan
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def execute_foldering(
|
|
154
|
+
dir_path: str,
|
|
155
|
+
plan: list[dict[str, Any]],
|
|
156
|
+
mgr: FileManager,
|
|
157
|
+
batch_size: int = 25,
|
|
158
|
+
) -> dict[str, int]:
|
|
159
|
+
"""依据整理计划执行目录创建与文件原子移动。"""
|
|
160
|
+
created_count = 0
|
|
161
|
+
items_to_move: list[tuple[str, str, str]] = []
|
|
162
|
+
|
|
163
|
+
# 1. 批量创建目标方案 A 电影目录
|
|
164
|
+
for group in plan:
|
|
165
|
+
target_dir = group["target_folder_path"]
|
|
166
|
+
logger.info("创建电影规范目录: %s", target_dir)
|
|
167
|
+
mgr.mkdir(target_dir)
|
|
168
|
+
created_count += 1
|
|
169
|
+
|
|
170
|
+
for f in group["files"]:
|
|
171
|
+
items_to_move.append((f["path"], target_dir, f["filename"]))
|
|
172
|
+
|
|
173
|
+
# 2. 批量将散落文件移入对应的电影目录
|
|
174
|
+
logger.info("正在批量移动 %d 个文件至对应规范电影目录...", len(items_to_move))
|
|
175
|
+
mgr.batch_move(items_to_move, batch_size=batch_size)
|
|
176
|
+
|
|
177
|
+
logger.info("完成规整!共创建 %d 个目录,移动 %d 个文件", created_count, len(items_to_move))
|
|
178
|
+
return {
|
|
179
|
+
"folders_created": created_count,
|
|
180
|
+
"files_moved": len(items_to_move),
|
|
181
|
+
}
|
bdpan_cli/logger.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""bdpan-cli 日志治理模块 (BK-0041).
|
|
2
|
+
|
|
3
|
+
实现方案 A 日志体系:
|
|
4
|
+
1. 终端与日志流解耦:控制台日志输出至 sys.stderr,保留 sys.stdout 给表格、进度与 --json 机器信封;
|
|
5
|
+
2. 动态级别控制:支持全局 --debug / -v(输出详细诊断与异常追踪)与 --quiet / -q(静音非错误输出);
|
|
6
|
+
3. 本地滚动日志双写:自动将完整诊断日志(DEBUG 级别)持久化写入 ~/.config/bdpan/logs/bdpan.log(10MB x 5 轮转备份)。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from logging.handlers import RotatingFileHandler
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
DEFAULT_LOG_DIR = Path.home() / ".config" / "bdpan" / "logs"
|
|
16
|
+
DEFAULT_LOG_FILE = DEFAULT_LOG_DIR / "bdpan.log"
|
|
17
|
+
|
|
18
|
+
_LOGGING_INITIALIZED = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_log_file_path() -> Path:
|
|
22
|
+
"""获取默认日志落盘路径,支持 BDPAN_LOG_FILE 环境变量覆盖。"""
|
|
23
|
+
env_path = os.environ.get("BDPAN_LOG_FILE")
|
|
24
|
+
if env_path:
|
|
25
|
+
return Path(env_path)
|
|
26
|
+
return DEFAULT_LOG_FILE
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def setup_logging(
|
|
30
|
+
debug: bool = False,
|
|
31
|
+
quiet: bool = False,
|
|
32
|
+
log_file: Path | str | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""初始化并配置全局日志系统。
|
|
35
|
+
|
|
36
|
+
:param debug: 是否开启调试模式(详细 Traceback、代码行号与请求响应体)
|
|
37
|
+
:param quiet: 是否开启静音模式(仅打印 ERROR 及以上)
|
|
38
|
+
:param log_file: 自定义日志文件路径,为 None 时使用默认 ~/.config/bdpan/logs/bdpan.log
|
|
39
|
+
"""
|
|
40
|
+
global _LOGGING_INITIALIZED
|
|
41
|
+
|
|
42
|
+
target_log_file = Path(log_file) if log_file else get_log_file_path()
|
|
43
|
+
|
|
44
|
+
# 确定控制台输出级别
|
|
45
|
+
if quiet:
|
|
46
|
+
console_level = logging.ERROR
|
|
47
|
+
elif debug:
|
|
48
|
+
console_level = logging.DEBUG
|
|
49
|
+
else:
|
|
50
|
+
console_level = logging.INFO
|
|
51
|
+
|
|
52
|
+
# 确定格式
|
|
53
|
+
if debug:
|
|
54
|
+
console_fmt = "%(asctime)s [%(levelname)s] [%(name)s:%(lineno)d] %(message)s"
|
|
55
|
+
else:
|
|
56
|
+
console_fmt = "%(asctime)s [%(levelname)s] %(message)s"
|
|
57
|
+
file_fmt = "%(asctime)s [%(levelname)s] [%(name)s:%(lineno)d] %(message)s"
|
|
58
|
+
|
|
59
|
+
# 配置根 bdpan_cli logger
|
|
60
|
+
logger = logging.getLogger("bdpan_cli")
|
|
61
|
+
logger.setLevel(logging.DEBUG) # 保持最低级别,由 handlers 分别过滤
|
|
62
|
+
|
|
63
|
+
# 清理既有 handlers 避免重复添加
|
|
64
|
+
for handler in list(logger.handlers):
|
|
65
|
+
logger.removeHandler(handler)
|
|
66
|
+
|
|
67
|
+
# 1. 控制台 Handler (输出至 stderr,确保 stdout 纯净)
|
|
68
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
69
|
+
console_handler.setLevel(console_level)
|
|
70
|
+
console_handler.setFormatter(logging.Formatter(console_fmt))
|
|
71
|
+
logger.addHandler(console_handler)
|
|
72
|
+
|
|
73
|
+
# 2. 文件 Handler (自动滚动记录,始终保留完整 DEBUG 诊断细节)
|
|
74
|
+
try:
|
|
75
|
+
target_log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
file_handler = RotatingFileHandler(
|
|
77
|
+
target_log_file,
|
|
78
|
+
maxBytes=10 * 1024 * 1024, # 10 MB
|
|
79
|
+
backupCount=5,
|
|
80
|
+
encoding="utf-8",
|
|
81
|
+
)
|
|
82
|
+
file_handler.setLevel(logging.DEBUG)
|
|
83
|
+
file_handler.setFormatter(logging.Formatter(file_fmt))
|
|
84
|
+
logger.addHandler(file_handler)
|
|
85
|
+
except OSError as err:
|
|
86
|
+
# 权限或文件系统异常时降级处理,不阻断主业务
|
|
87
|
+
logger.warning("无法创建本地日志文件 %s: %s", target_log_file, err)
|
|
88
|
+
|
|
89
|
+
# 调整底层 HTTP 客户端日志级别
|
|
90
|
+
urllib3_logger = logging.getLogger("urllib3")
|
|
91
|
+
if debug:
|
|
92
|
+
urllib3_logger.setLevel(logging.DEBUG)
|
|
93
|
+
else:
|
|
94
|
+
urllib3_logger.setLevel(logging.WARNING)
|
|
95
|
+
|
|
96
|
+
_LOGGING_INITIALIZED = True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_logger(name: str = "bdpan_cli") -> logging.Logger:
|
|
100
|
+
"""获取标准命名空间 logger。"""
|
|
101
|
+
return logging.getLogger(name)
|