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
bdpan_cli/cli.py
ADDED
|
@@ -0,0 +1,1428 @@
|
|
|
1
|
+
"""统一命令行工具入口 (bd-net-disc-mgr CLI)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from bdpan_cli.account import AccountClient, AccountError
|
|
14
|
+
from bdpan_cli.auth import TOKEN_CACHE, TokenManager
|
|
15
|
+
from bdpan_cli.config import load_config
|
|
16
|
+
from bdpan_cli.dedup import execute_dedup, plan_dedup
|
|
17
|
+
from bdpan_cli.downloader import DownloadError, Downloader
|
|
18
|
+
from bdpan_cli.file_manager import FileManager, FileManagerError
|
|
19
|
+
from bdpan_cli.foldering import execute_foldering, plan_foldering
|
|
20
|
+
from bdpan_cli.media_info import MediaInfoClient, MediaInfoError
|
|
21
|
+
from bdpan_cli.mcp_server import BdpanMcpServer
|
|
22
|
+
from bdpan_cli.ops import OperationLogger, UndoEngine, UndoError
|
|
23
|
+
from bdpan_cli.preview_scrape import generate_preview
|
|
24
|
+
from bdpan_cli.uploader import Uploader
|
|
25
|
+
from bdpan_cli.apply_scrape_rename import apply_renames
|
|
26
|
+
from bdpan_cli.logger import setup_logging, get_logger
|
|
27
|
+
from bdpan_cli._version import __version__
|
|
28
|
+
|
|
29
|
+
logger = get_logger("bdpan_cli.cli")
|
|
30
|
+
|
|
31
|
+
# 断点续传状态文件:与 token 缓存同目录(项目根,git-ignored)
|
|
32
|
+
UPLOAD_STATE_FILE = TOKEN_CACHE.parent / ".bdpan_uploads.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _output_envelope(data: Any = None, success: bool = True, code: int = 0, error: str | None = None) -> int:
|
|
36
|
+
"""输出标准结构化 JSON 信封,适用于 --json 模式。"""
|
|
37
|
+
payload = {
|
|
38
|
+
"success": success,
|
|
39
|
+
"code": code,
|
|
40
|
+
"data": data,
|
|
41
|
+
"error": error,
|
|
42
|
+
}
|
|
43
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
44
|
+
return code
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_token_manager(args: argparse.Namespace | None = None) -> TokenManager:
|
|
48
|
+
config = load_config()
|
|
49
|
+
account = getattr(args, "account", None) if args else None
|
|
50
|
+
return TokenManager(config, account=account)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_file_manager(args: argparse.Namespace | None = None) -> FileManager:
|
|
54
|
+
token = _get_token_manager(args).get_access_token()
|
|
55
|
+
return FileManager(token)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _get_account_client(args: argparse.Namespace | None = None) -> AccountClient:
|
|
59
|
+
token = _get_token_manager(args).get_access_token()
|
|
60
|
+
return AccountClient(token)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _get_downloader(args: argparse.Namespace | None = None) -> Downloader:
|
|
64
|
+
token = _get_token_manager(args).get_access_token()
|
|
65
|
+
return Downloader(token)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_media_info_client(args: argparse.Namespace | None = None) -> MediaInfoClient:
|
|
69
|
+
token = _get_token_manager(args).get_access_token()
|
|
70
|
+
return MediaInfoClient(token)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _format_size(size_bytes: int) -> str:
|
|
74
|
+
if size_bytes < 1024:
|
|
75
|
+
return f"{size_bytes} B"
|
|
76
|
+
elif size_bytes < 1024 * 1024:
|
|
77
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
78
|
+
elif size_bytes < 1024 * 1024 * 1024:
|
|
79
|
+
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
80
|
+
else:
|
|
81
|
+
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# 子命令实现
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def cmd_ls(args: argparse.Namespace) -> int:
|
|
89
|
+
mgr = _get_file_manager(args)
|
|
90
|
+
raw_path = args.path or "/"
|
|
91
|
+
path = "/" + raw_path.strip("/") if raw_path != "/" else "/"
|
|
92
|
+
entries = mgr.list_dir(path, limit=args.limit)
|
|
93
|
+
|
|
94
|
+
if not entries and path != "/":
|
|
95
|
+
parent_dir = os.path.dirname(path) or "/"
|
|
96
|
+
target_name = os.path.basename(path)
|
|
97
|
+
try:
|
|
98
|
+
siblings = mgr.list_dir(parent_dir)
|
|
99
|
+
match = next(
|
|
100
|
+
(e for e in siblings if e.get("server_filename") == target_name or e.get("path") == path),
|
|
101
|
+
None,
|
|
102
|
+
)
|
|
103
|
+
if not match:
|
|
104
|
+
if getattr(args, "json", False):
|
|
105
|
+
return _output_envelope(None, success=False, code=1, error=f"路径不存在: {path}")
|
|
106
|
+
print(f"❌ 路径不存在: {path}")
|
|
107
|
+
return 1
|
|
108
|
+
if match.get("isdir") != 1:
|
|
109
|
+
if getattr(args, "json", False):
|
|
110
|
+
return _output_envelope([match])
|
|
111
|
+
size_str = _format_size(match.get("size", 0))
|
|
112
|
+
print(f"\n📄 文件: {path} ({size_str})\n")
|
|
113
|
+
return 0
|
|
114
|
+
except FileManagerError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
if getattr(args, "json", False):
|
|
118
|
+
return _output_envelope(entries)
|
|
119
|
+
|
|
120
|
+
dirs = [e for e in entries if e.get("isdir") == 1]
|
|
121
|
+
files = [e for e in entries if e.get("isdir") != 1]
|
|
122
|
+
total_size = sum(f.get("size", 0) for f in files)
|
|
123
|
+
|
|
124
|
+
print(f"\n📂 目录: {path} (共 {len(entries)} 项,包含 {len(dirs)} 个子目录,{len(files)} 个文件,总大小: {_format_size(total_size)})\n")
|
|
125
|
+
for idx, e in enumerate(entries, 1):
|
|
126
|
+
is_dir = e.get("isdir") == 1
|
|
127
|
+
tag = "[DIR] " if is_dir else "[FILE]"
|
|
128
|
+
size_str = "" if is_dir else f" ({_format_size(e.get('size', 0))})"
|
|
129
|
+
print(f" {idx:3d}. {tag} {e.get('server_filename')}{size_str}")
|
|
130
|
+
print()
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_mkdir(args: argparse.Namespace) -> int:
|
|
135
|
+
mgr = _get_file_manager(args)
|
|
136
|
+
path = args.path
|
|
137
|
+
logger.info("正在创建目录: %s", path)
|
|
138
|
+
res = mgr.mkdir(path)
|
|
139
|
+
print(f"✅ 目录创建成功: {path}")
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _resolve_dest(mgr: FileManager, src: str, dest: str) -> tuple[str, str]:
|
|
144
|
+
"""解析目标语义 (cp/mv 共用)。
|
|
145
|
+
|
|
146
|
+
- dest 以 `/` 结尾,或 dest 是网盘上已存在的目录 → 复制/移入该目录并保持源名;
|
|
147
|
+
- 否则将 dest 视为 `目标目录/新名`,实现复制/移动并改名。
|
|
148
|
+
|
|
149
|
+
存在性判定依赖 dest 父目录的条目列表(百度 xpanfilelist 对不存在路径
|
|
150
|
+
返回空列表而非报错,故不能直接 list dest);父目录不可读时按改名语义
|
|
151
|
+
处理,由后续 copy/move 在服务端报出真实错误。
|
|
152
|
+
"""
|
|
153
|
+
src_name = os.path.basename(src.rstrip("/"))
|
|
154
|
+
if dest.endswith("/"):
|
|
155
|
+
return dest.rstrip("/") or "/", src_name
|
|
156
|
+
dest_dir = os.path.dirname(dest) or "/"
|
|
157
|
+
dest_name = os.path.basename(dest)
|
|
158
|
+
try:
|
|
159
|
+
siblings = mgr.list_dir(dest_dir)
|
|
160
|
+
except FileManagerError:
|
|
161
|
+
logger.warning(
|
|
162
|
+
"探测 dest 父目录失败 [%s 不可读],按「目标目录/新名」改名语义处理,真实错误由后续 copy/move 报出",
|
|
163
|
+
dest_dir,
|
|
164
|
+
)
|
|
165
|
+
return dest_dir, dest_name
|
|
166
|
+
dest_is_dir = any(
|
|
167
|
+
e.get("path") == dest and e.get("isdir") == 1 for e in siblings
|
|
168
|
+
)
|
|
169
|
+
return (dest, src_name) if dest_is_dir else (dest_dir, dest_name)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _require_dest_dir(mgr: FileManager, dest: str) -> str:
|
|
173
|
+
"""多源批量目标必须是目录语义。
|
|
174
|
+
|
|
175
|
+
dest 以 / 结尾直接放行;否则探测父目录确认 dest 为已存在目录。
|
|
176
|
+
不满足时抛 FileManagerError(调用方转译为退出码 1)。
|
|
177
|
+
"""
|
|
178
|
+
if dest.endswith("/"):
|
|
179
|
+
return dest.rstrip("/") or "/"
|
|
180
|
+
try:
|
|
181
|
+
siblings = mgr.list_dir(os.path.dirname(dest) or "/")
|
|
182
|
+
except FileManagerError:
|
|
183
|
+
siblings = []
|
|
184
|
+
if any(e.get("path") == dest and e.get("isdir") == 1 for e in siblings):
|
|
185
|
+
return dest
|
|
186
|
+
raise FileManagerError(f"多源批量操作要求目标为已存在目录(或以 / 结尾): {dest}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cmd_cp(args: argparse.Namespace) -> int:
|
|
190
|
+
mgr = _get_file_manager(args)
|
|
191
|
+
srcs = args.srcs
|
|
192
|
+
if len(srcs) == 1:
|
|
193
|
+
dest_dir, new_name = _resolve_dest(mgr, srcs[0], args.dest)
|
|
194
|
+
logger.info("正在复制: %s -> %s/%s", srcs[0], dest_dir, new_name)
|
|
195
|
+
mgr.copy(srcs[0], dest_dir, new_name)
|
|
196
|
+
print(f"✅ 复制成功: {srcs[0]} -> {dest_dir}/{new_name}")
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
dest_dir = _require_dest_dir(mgr, args.dest)
|
|
201
|
+
except FileManagerError as e:
|
|
202
|
+
print(f"❌ {e}")
|
|
203
|
+
return 1
|
|
204
|
+
payloads = [(s, dest_dir, os.path.basename(s.rstrip("/"))) for s in srcs]
|
|
205
|
+
logger.info("正在批量复制 %d 项 -> %s/", len(payloads), dest_dir)
|
|
206
|
+
mgr.batch_copy(payloads)
|
|
207
|
+
print(f"✅ 批量复制成功: {len(payloads)} 项 -> {dest_dir}/")
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def cmd_mv(args: argparse.Namespace) -> int:
|
|
212
|
+
mgr = _get_file_manager(args)
|
|
213
|
+
srcs = args.srcs
|
|
214
|
+
account = getattr(args, "account", None) or "default"
|
|
215
|
+
if len(srcs) == 1:
|
|
216
|
+
src = srcs[0]
|
|
217
|
+
dest_dir, dest_name = _resolve_dest(mgr, src, args.dest)
|
|
218
|
+
logger.info("正在移动: %s -> %s/%s", src, dest_dir, dest_name)
|
|
219
|
+
mgr.batch_move([(src, dest_dir, dest_name)])
|
|
220
|
+
dest_path = f"{dest_dir.rstrip('/')}/{dest_name}"
|
|
221
|
+
op_id = OperationLogger().record_operation("mv", [{"src": src, "dest": dest_path}], account=account)
|
|
222
|
+
print(f"✅ 移动成功: {src} -> {dest_path} (流水号: {op_id})")
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
dest_dir = _require_dest_dir(mgr, args.dest)
|
|
227
|
+
except FileManagerError as e:
|
|
228
|
+
print(f"❌ {e}")
|
|
229
|
+
return 1
|
|
230
|
+
payloads = [(s, dest_dir, os.path.basename(s.rstrip("/"))) for s in srcs]
|
|
231
|
+
logger.info("正在批量移动 %d 项 -> %s/", len(payloads), dest_dir)
|
|
232
|
+
mgr.batch_move(payloads)
|
|
233
|
+
items = [{"src": s, "dest": f"{dest_dir.rstrip('/')}/{n}"} for s, _, n in payloads]
|
|
234
|
+
op_id = OperationLogger().record_operation("mv", items, account=account)
|
|
235
|
+
print(f"✅ 批量移动成功: {len(payloads)} 项 -> {dest_dir}/ (流水号: {op_id})")
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def cmd_rm(args: argparse.Namespace) -> int:
|
|
240
|
+
mgr = _get_file_manager(args)
|
|
241
|
+
targets = args.paths
|
|
242
|
+
print("⚠️ 将从网盘删除以下条目(服务端移入回收站):")
|
|
243
|
+
for p in targets:
|
|
244
|
+
print(f" - {p}")
|
|
245
|
+
if not args.yes:
|
|
246
|
+
try:
|
|
247
|
+
answer = input(f"确认删除 {len(targets)} 项? [y/N]: ").strip().lower()
|
|
248
|
+
except (EOFError, KeyboardInterrupt):
|
|
249
|
+
# 非交互 stdin 或 Ctrl-C 视作取消,与主动回答 N 同语义
|
|
250
|
+
print("\n已取消,未做任何变更。")
|
|
251
|
+
return 0
|
|
252
|
+
if answer != "y":
|
|
253
|
+
print("已取消,未做任何变更。")
|
|
254
|
+
return 0
|
|
255
|
+
failed = 0
|
|
256
|
+
for p in targets:
|
|
257
|
+
try:
|
|
258
|
+
mgr.delete(p)
|
|
259
|
+
print(f"✅ 已删除: {p}")
|
|
260
|
+
except FileManagerError as e:
|
|
261
|
+
print(f"❌ 删除失败: {p} ({e})")
|
|
262
|
+
failed += 1
|
|
263
|
+
return 1 if failed else 0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def cmd_rename(args: argparse.Namespace) -> int:
|
|
267
|
+
mgr = _get_file_manager(args)
|
|
268
|
+
logger.info("正在重命名: %s -> %s", args.path, args.new_name)
|
|
269
|
+
mgr.rename(args.path, args.new_name)
|
|
270
|
+
parent = os.path.dirname(args.path) or "/"
|
|
271
|
+
dest_path = f"{parent.rstrip('/')}/{args.new_name}"
|
|
272
|
+
account = getattr(args, "account", None) or "default"
|
|
273
|
+
op_id = OperationLogger().record_operation("rename", [{"src": args.path, "dest": dest_path}], account=account)
|
|
274
|
+
print(f"✅ 重命名成功: {args.path} -> {args.new_name} (流水号: {op_id})")
|
|
275
|
+
return 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
279
|
+
mgr = _get_file_manager(args)
|
|
280
|
+
entries = mgr.search(
|
|
281
|
+
args.key,
|
|
282
|
+
dir_path=args.dir,
|
|
283
|
+
recursion=not args.no_recursion,
|
|
284
|
+
page=args.page,
|
|
285
|
+
num=args.num,
|
|
286
|
+
)
|
|
287
|
+
if getattr(args, "json", False):
|
|
288
|
+
return _output_envelope(entries)
|
|
289
|
+
|
|
290
|
+
if not entries:
|
|
291
|
+
print(f"🔍 未找到与「{args.key}」匹配的条目。")
|
|
292
|
+
return 0
|
|
293
|
+
dirs = [e for e in entries if e.get("isdir") == 1]
|
|
294
|
+
files = [e for e in entries if e.get("isdir") != 1]
|
|
295
|
+
total_size = sum(f.get("size", 0) for f in files)
|
|
296
|
+
print(
|
|
297
|
+
f"\n🔍 搜索「{args.key}」第 {args.page} 页: 共 {len(entries)} 项"
|
|
298
|
+
f"({len(dirs)} 个目录,{len(files)} 个文件,合计 {_format_size(total_size)})\n"
|
|
299
|
+
)
|
|
300
|
+
for idx, e in enumerate(entries, 1):
|
|
301
|
+
is_dir = e.get("isdir") == 1
|
|
302
|
+
tag = "[DIR] " if is_dir else "[FILE]"
|
|
303
|
+
size_str = "" if is_dir else f" ({_format_size(e.get('size', 0))})"
|
|
304
|
+
print(f" {idx:3d}. {tag} {e.get('path')}{size_str}")
|
|
305
|
+
if len(entries) == args.num:
|
|
306
|
+
print(f"\n💡 本页结果已满,可能还有更多,可尝试 --page {args.page + 1} 翻页。")
|
|
307
|
+
print()
|
|
308
|
+
return 0
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def cmd_tree(args: argparse.Namespace) -> int:
|
|
312
|
+
mgr = _get_file_manager(args)
|
|
313
|
+
root = args.path or "/"
|
|
314
|
+
logger.info("递归遍历目录树: root=%s, max_depth=%s", root, args.depth)
|
|
315
|
+
entries, errors = mgr.walk(root, max_depth=args.depth)
|
|
316
|
+
|
|
317
|
+
dirs = [e for _, e in entries if e.get("isdir") == 1]
|
|
318
|
+
files = [e for _, e in entries if e.get("isdir") != 1]
|
|
319
|
+
total_size = sum(f.get("size", 0) for f in files)
|
|
320
|
+
print(
|
|
321
|
+
f"🌲 {root} (递归 {len(dirs)} 个子目录,{len(files)} 个文件,"
|
|
322
|
+
f"总计 {_format_size(total_size)})\n"
|
|
323
|
+
)
|
|
324
|
+
for depth, e in entries:
|
|
325
|
+
is_dir = e.get("isdir") == 1
|
|
326
|
+
indent = " " * depth
|
|
327
|
+
marker = "📁" if is_dir else "📄"
|
|
328
|
+
size_str = "" if is_dir else f" ({_format_size(e.get('size', 0))})"
|
|
329
|
+
print(f"{indent}{marker} {e.get('server_filename')}{size_str}")
|
|
330
|
+
|
|
331
|
+
if args.index:
|
|
332
|
+
payload = {
|
|
333
|
+
"version": 1,
|
|
334
|
+
"root": root,
|
|
335
|
+
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
|
336
|
+
"stats": {"dirs": len(dirs), "files": len(files), "total_size": total_size},
|
|
337
|
+
"errors": [{"path": p, "error": msg} for p, msg in errors],
|
|
338
|
+
"entries": [e for _, e in entries],
|
|
339
|
+
}
|
|
340
|
+
with open(args.index, "w", encoding="utf-8") as f:
|
|
341
|
+
json.dump(payload, f, ensure_ascii=False, indent=2)
|
|
342
|
+
print(f"\n💾 本地索引已写入: {args.index} ({len(entries)} 条)")
|
|
343
|
+
|
|
344
|
+
if errors:
|
|
345
|
+
print(f"\n⚠️ {len(errors)} 个子目录读取失败,遍历结果不完整:")
|
|
346
|
+
for path, msg in errors:
|
|
347
|
+
print(f" - {path}: {msg}")
|
|
348
|
+
return 1
|
|
349
|
+
return 0
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _print_upload_progress(event: dict[str, Any]) -> None:
|
|
353
|
+
phase = event["phase"]
|
|
354
|
+
if phase == "precreate":
|
|
355
|
+
print("📡 预上传完成,已获取上传任务 ID")
|
|
356
|
+
elif phase == "resume":
|
|
357
|
+
print(f"💡 断点续传: 已有 {event['present']} 片在云端,补传 {event['missing']} 片")
|
|
358
|
+
elif phase == "chunk":
|
|
359
|
+
print(f" ⬆️ 分片 {event['seq'] + 1}/{event['total']} 上传成功")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def cmd_upload(args: argparse.Namespace) -> int:
|
|
363
|
+
local = args.local_path
|
|
364
|
+
if not os.path.isfile(local):
|
|
365
|
+
print(f"❌ 本地文件不存在: {local}")
|
|
366
|
+
return 1
|
|
367
|
+
|
|
368
|
+
mgr = _get_file_manager(args)
|
|
369
|
+
# 目标语义与 cp 一致:尾斜杠/已存在目录 → 保持本地文件名;否则 目标目录/新名
|
|
370
|
+
dest_dir, new_name = _resolve_dest(mgr, local, args.remote_dest)
|
|
371
|
+
remote_path = (dest_dir.rstrip("/") or "") + "/" + new_name
|
|
372
|
+
|
|
373
|
+
uploader = Uploader(mgr.access_token, mgr.api_client)
|
|
374
|
+
rtype = 3 if args.overwrite else 1
|
|
375
|
+
logger.info("开始上传: %s -> %s (rtype=%d)", local, remote_path, rtype)
|
|
376
|
+
res = uploader.upload(
|
|
377
|
+
local,
|
|
378
|
+
remote_path,
|
|
379
|
+
rtype=rtype,
|
|
380
|
+
progress=_print_upload_progress,
|
|
381
|
+
state_file=UPLOAD_STATE_FILE,
|
|
382
|
+
)
|
|
383
|
+
size_str = _format_size(int(res.get("size") or 0))
|
|
384
|
+
print(f"✅ 上传成功: {res.get('path') or remote_path} ({size_str})")
|
|
385
|
+
if res.get("server_filename") and res.get("server_filename") != new_name:
|
|
386
|
+
print(f"ℹ️ 云端冲突已自动改名: {new_name} -> {res['server_filename']}")
|
|
387
|
+
return 0
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def cmd_scrape_preview(args: argparse.Namespace) -> int:
|
|
391
|
+
target_dir = args.dir
|
|
392
|
+
output_file = args.output
|
|
393
|
+
skip_conforming = not args.all
|
|
394
|
+
name_format = getattr(args, "format", None)
|
|
395
|
+
logger.info("启动刮削预演: dir=%s, output=%s, format=%s, skip_conforming=%s", target_dir, output_file, name_format, skip_conforming)
|
|
396
|
+
generate_preview(
|
|
397
|
+
target_dir=target_dir,
|
|
398
|
+
output_file=output_file,
|
|
399
|
+
skip_conforming=skip_conforming,
|
|
400
|
+
limit=args.limit,
|
|
401
|
+
name_format=name_format,
|
|
402
|
+
)
|
|
403
|
+
print(f"\n✅ 刮削预演完成,对照表已生成至: {output_file}\n")
|
|
404
|
+
return 0
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def cmd_scrape_apply(args: argparse.Namespace) -> int:
|
|
408
|
+
preview_file = args.preview_file
|
|
409
|
+
target_dir = args.target_dir
|
|
410
|
+
batch_size = args.batch_size
|
|
411
|
+
logger.info("启动批量更名应用: preview=%s, batch_size=%d", preview_file, batch_size)
|
|
412
|
+
apply_renames(preview_file=preview_file, target_dir=target_dir, batch_size=batch_size)
|
|
413
|
+
print("\n✅ 批量重命名应用完成!\n")
|
|
414
|
+
return 0
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def cmd_scrape_foldering(args: argparse.Namespace) -> int:
|
|
418
|
+
mgr = _get_file_manager(args)
|
|
419
|
+
target_dir = args.dir
|
|
420
|
+
dry_run = args.dry_run
|
|
421
|
+
batch_size = args.batch_size
|
|
422
|
+
name_format = getattr(args, "format", None)
|
|
423
|
+
|
|
424
|
+
plan = plan_foldering(target_dir, mgr, name_format=name_format)
|
|
425
|
+
if not plan:
|
|
426
|
+
print(f"⚠️ 在目录 {target_dir} 中未发现任何待整理的媒体文件。")
|
|
427
|
+
return 0
|
|
428
|
+
|
|
429
|
+
total_files = sum(len(g["files"]) for g in plan)
|
|
430
|
+
fmt_desc = f"(使用自定义模板: {name_format})" if name_format else "(方案 A 规范)"
|
|
431
|
+
print(f"\n📋 扫描发现 {total_files} 个散落媒体文件,拟归集为 {len(plan)} 个电影目录 {fmt_desc}:\n")
|
|
432
|
+
for idx, group in enumerate(plan, 1):
|
|
433
|
+
print(f"[{idx:2d}] 📁 {group['target_folder_name']}")
|
|
434
|
+
for f in group["files"]:
|
|
435
|
+
print(f" └── 📄 {f['filename']} ({_format_size(f['size'])})")
|
|
436
|
+
print()
|
|
437
|
+
|
|
438
|
+
if dry_run:
|
|
439
|
+
print("💡 当前为 --dry-run 预演模式,未对网盘进行任何实际变更。")
|
|
440
|
+
return 0
|
|
441
|
+
|
|
442
|
+
logger.info("开始执行散落文件建目录与归集...")
|
|
443
|
+
res = execute_foldering(target_dir, plan, mgr, batch_size=batch_size)
|
|
444
|
+
print(f"\n🎉 整理归集执行完毕!成功创建 {res['folders_created']} 个规范目录,原子归集 {res['files_moved']} 个文件。\n")
|
|
445
|
+
return 0
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def cmd_meta_nfo(args: argparse.Namespace) -> int:
|
|
449
|
+
print(f"🚧 元数据生成模块开发中 (Target: {args.dir})")
|
|
450
|
+
return 0
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def cmd_meta_poster(args: argparse.Namespace) -> int:
|
|
454
|
+
print(f"🚧 海报抓取与上传模块开发中 (Target: {args.dir})")
|
|
455
|
+
return 0
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _format_vip(vip_type: Any) -> str:
|
|
459
|
+
return {0: "普通用户", 1: "普通会员", 2: "超级会员"}.get(vip_type, f"未知({vip_type})")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def cmd_quota(args: argparse.Namespace) -> int:
|
|
463
|
+
client = _get_account_client(args)
|
|
464
|
+
res = client.quota(check_expire=args.check_expire, check_free=args.check_free)
|
|
465
|
+
if getattr(args, "json", False):
|
|
466
|
+
return _output_envelope(res)
|
|
467
|
+
total = int(res.get("total") or 0)
|
|
468
|
+
used = int(res.get("used") or 0)
|
|
469
|
+
print("💾 网盘容量:")
|
|
470
|
+
print(f" 总容量: {_format_size(total)}")
|
|
471
|
+
print(f" 已用: {_format_size(used)}")
|
|
472
|
+
if total > 0:
|
|
473
|
+
print(f" 可用: {_format_size(max(total - used, 0))} (使用率 {used / total * 100:.1f}%)")
|
|
474
|
+
if res.get("free") is not None:
|
|
475
|
+
print(f" 免费配额: {_format_size(int(res['free']))}")
|
|
476
|
+
expire = res.get("expire")
|
|
477
|
+
if expire:
|
|
478
|
+
expire_str = datetime.fromtimestamp(int(expire)).strftime("%Y-%m-%d %H:%M:%S")
|
|
479
|
+
print(f" 会员容量到期: {expire_str}")
|
|
480
|
+
return 0
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def cmd_whoami(args: argparse.Namespace) -> int:
|
|
484
|
+
client = _get_account_client(args)
|
|
485
|
+
res = client.user_info()
|
|
486
|
+
if getattr(args, "json", False):
|
|
487
|
+
return _output_envelope(res)
|
|
488
|
+
print("👤 网盘账户信息:")
|
|
489
|
+
print(f" 百度账号: {res.get('baidu_name') or '-'}")
|
|
490
|
+
print(f" 网盘名: {res.get('netdisk_name') or '-'}")
|
|
491
|
+
print(f" 会员类型: {_format_vip(res.get('vip_type'))}")
|
|
492
|
+
return 0
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def cmd_dedup(args: argparse.Namespace) -> int:
|
|
496
|
+
mgr = _get_file_manager(args)
|
|
497
|
+
raw_path = args.path or "/"
|
|
498
|
+
target_dir = "/" + raw_path.strip("/") if raw_path != "/" else "/"
|
|
499
|
+
|
|
500
|
+
if args.staging_dir:
|
|
501
|
+
staging_dir = "/" + args.staging_dir.strip("/")
|
|
502
|
+
if staging_dir == target_dir:
|
|
503
|
+
print(f"❌ 隔离目录不能与待扫描目录相同: {staging_dir}")
|
|
504
|
+
return 1
|
|
505
|
+
else:
|
|
506
|
+
staging_dir = None
|
|
507
|
+
|
|
508
|
+
logger.info("扫描冲突副本: target=%s, recursive=%s", target_dir, args.recursive)
|
|
509
|
+
try:
|
|
510
|
+
plan = plan_dedup(
|
|
511
|
+
dir_path=target_dir,
|
|
512
|
+
mgr=mgr,
|
|
513
|
+
recursive=args.recursive,
|
|
514
|
+
include_orphans=args.include_orphans,
|
|
515
|
+
include_empty=args.include_empty,
|
|
516
|
+
)
|
|
517
|
+
except FileManagerError as e:
|
|
518
|
+
print(f"❌ 扫描失败: {e}")
|
|
519
|
+
return 1
|
|
520
|
+
|
|
521
|
+
if not plan.candidates:
|
|
522
|
+
print(f"✨ 目录 {target_dir} 中未发现任何冲突副本或重复条目。")
|
|
523
|
+
if plan.orphan_groups_count > 0:
|
|
524
|
+
print(f"💡 发现 {plan.orphan_groups_count} 组无主冲突副本(如仅有 (1) 与 (2)),可添加 --include-orphans 一并治理。")
|
|
525
|
+
return 0
|
|
526
|
+
|
|
527
|
+
dirs_count = sum(1 for c in plan.candidates if c.isdir)
|
|
528
|
+
files_count = sum(1 for c in plan.candidates if not c.isdir)
|
|
529
|
+
print(
|
|
530
|
+
f"\n📋 发现 {len(plan.candidates)} 个冲突与重复条目"
|
|
531
|
+
f"({dirs_count} 个目录,{files_count} 个文件,合计 {_format_size(plan.total_size)}):\n"
|
|
532
|
+
)
|
|
533
|
+
for idx, c in enumerate(plan.candidates, 1):
|
|
534
|
+
tag = "[DIR] " if c.isdir else "[FILE]"
|
|
535
|
+
size_str = "" if c.isdir else f" ({_format_size(c.size)})"
|
|
536
|
+
print(f" {idx:3d}. {tag} {c.path}{size_str}")
|
|
537
|
+
print(f" 原因: {c.reason}")
|
|
538
|
+
print()
|
|
539
|
+
|
|
540
|
+
# 动作决策
|
|
541
|
+
if args.dry_run or (not staging_dir and not args.delete):
|
|
542
|
+
if args.dry_run:
|
|
543
|
+
print("💡 当前为 --dry-run 预演模式,未对网盘进行任何实际变更。")
|
|
544
|
+
if staging_dir:
|
|
545
|
+
print(f" 拟隔离至: {staging_dir}")
|
|
546
|
+
elif args.delete:
|
|
547
|
+
print(" 拟移入回收站直接删除。")
|
|
548
|
+
return 0
|
|
549
|
+
print("💡 提示: 当前未指定治理动作(预览模式)。可使用以下参数执行治理:")
|
|
550
|
+
print(" --staging-dir <dir> 移动至指定暂存目录隔离(推荐,如 /videos/movies/pending_delete)")
|
|
551
|
+
print(" --delete 直接删除移入回收站")
|
|
552
|
+
print(" --dry-run 预演查看影响范围")
|
|
553
|
+
return 0
|
|
554
|
+
|
|
555
|
+
if staging_dir:
|
|
556
|
+
if not args.yes:
|
|
557
|
+
try:
|
|
558
|
+
ans = input(f"确认将 {len(plan.candidates)} 项移动到隔离目录 {staging_dir}? [y/N]: ").strip().lower()
|
|
559
|
+
except (EOFError, KeyboardInterrupt):
|
|
560
|
+
print("\n已取消,未做任何变更。")
|
|
561
|
+
return 0
|
|
562
|
+
if ans != "y":
|
|
563
|
+
print("已取消,未做任何变更。")
|
|
564
|
+
return 0
|
|
565
|
+
|
|
566
|
+
res = execute_dedup(plan, mgr, staging_dir=staging_dir, batch_size=50)
|
|
567
|
+
print(f"\n🎉 治理完成!已成功将 {res['moved']} 项移入隔离目录: {staging_dir}\n")
|
|
568
|
+
return 0
|
|
569
|
+
|
|
570
|
+
if args.delete:
|
|
571
|
+
if not args.yes:
|
|
572
|
+
try:
|
|
573
|
+
ans = input(f"⚠️ 确认将 {len(plan.candidates)} 项从网盘删除(服务端移入回收站)? [y/N]: ").strip().lower()
|
|
574
|
+
except (EOFError, KeyboardInterrupt):
|
|
575
|
+
print("\n已取消,未做任何变更。")
|
|
576
|
+
return 0
|
|
577
|
+
if ans != "y":
|
|
578
|
+
print("已取消,未做任何变更。")
|
|
579
|
+
return 0
|
|
580
|
+
|
|
581
|
+
res = execute_dedup(plan, mgr, delete=True, batch_size=50)
|
|
582
|
+
print(f"\n🎉 治理完成!已成功从网盘删除 {res['deleted']} 项(已移入回收站)。\n")
|
|
583
|
+
return 0
|
|
584
|
+
|
|
585
|
+
return 0
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def cmd_download(args: argparse.Namespace) -> int:
|
|
589
|
+
"""下载网盘文件到本地。"""
|
|
590
|
+
downloader = _get_downloader()
|
|
591
|
+
dest = args.dest or "."
|
|
592
|
+
paths = args.paths
|
|
593
|
+
if not paths:
|
|
594
|
+
print("❌ 请指定要下载的网盘文件路径")
|
|
595
|
+
return 1
|
|
596
|
+
|
|
597
|
+
success_count = 0
|
|
598
|
+
fail_count = 0
|
|
599
|
+
|
|
600
|
+
print(f"\n📦 开始批量下载任务,共 {len(paths)} 个目标,保存至: {dest}\n")
|
|
601
|
+
|
|
602
|
+
for idx, remote_path in enumerate(paths, 1):
|
|
603
|
+
print(f"[{idx}/{len(paths)}] ⬇️ 正在下载: {remote_path}")
|
|
604
|
+
|
|
605
|
+
def _progress(ev: dict[str, Any]) -> None:
|
|
606
|
+
if ev.get("phase") == "download":
|
|
607
|
+
cur = _format_size(ev["downloaded"])
|
|
608
|
+
tot = _format_size(ev["total"])
|
|
609
|
+
spd = _format_size(int(ev.get("speed", 0)))
|
|
610
|
+
pct = (ev["downloaded"] / ev["total"] * 100) if ev["total"] > 0 else 0
|
|
611
|
+
print(f"\r ⏳ 进度: {pct:.1f}% ({cur}/{tot}) @ {spd}/s", end="", flush=True)
|
|
612
|
+
|
|
613
|
+
try:
|
|
614
|
+
local_path = downloader.download_file(
|
|
615
|
+
remote_path,
|
|
616
|
+
local_dest=dest,
|
|
617
|
+
overwrite=args.overwrite,
|
|
618
|
+
progress=_progress,
|
|
619
|
+
)
|
|
620
|
+
print(f"\n ✅ 完成: {local_path}")
|
|
621
|
+
success_count += 1
|
|
622
|
+
except DownloadError as e:
|
|
623
|
+
print(f"\n ❌ 失败: {e}")
|
|
624
|
+
fail_count += 1
|
|
625
|
+
|
|
626
|
+
print(f"\n🏁 下载任务结束: 成功 {success_count} 个,失败 {fail_count} 个。\n")
|
|
627
|
+
return 1 if fail_count > 0 else 0
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def cmd_dlink(args: argparse.Namespace) -> int:
|
|
631
|
+
"""查询或导出网盘文件高速下载直链。"""
|
|
632
|
+
downloader = _get_downloader(args)
|
|
633
|
+
paths = args.paths
|
|
634
|
+
if not paths:
|
|
635
|
+
print("❌ 请指定网盘文件路径")
|
|
636
|
+
return 1
|
|
637
|
+
|
|
638
|
+
fail_count = 0
|
|
639
|
+
for path in paths:
|
|
640
|
+
try:
|
|
641
|
+
meta = downloader.get_dlink(path)
|
|
642
|
+
dlink = meta["dlink"]
|
|
643
|
+
size_str = _format_size(meta.get("size", 0))
|
|
644
|
+
filename = meta.get("server_filename", os.path.basename(path))
|
|
645
|
+
|
|
646
|
+
if args.aria2:
|
|
647
|
+
cmd = downloader.generate_export_command(dlink, filename, style="aria2")
|
|
648
|
+
print(cmd)
|
|
649
|
+
elif args.curl:
|
|
650
|
+
cmd = downloader.generate_export_command(dlink, filename, style="curl")
|
|
651
|
+
print(cmd)
|
|
652
|
+
else:
|
|
653
|
+
print(f"📄 文件: {path} ({size_str})")
|
|
654
|
+
print(f"🔗 直链: {dlink}")
|
|
655
|
+
print(f"💡 说明: 直链请求头须携带 User-Agent: pan.baidu.com\n")
|
|
656
|
+
except DownloadError as e:
|
|
657
|
+
print(f"❌ 获取失败 [{path}]: {e}", file=sys.stderr)
|
|
658
|
+
fail_count += 1
|
|
659
|
+
|
|
660
|
+
return 1 if fail_count > 0 else 0
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def cmd_ops(args: argparse.Namespace) -> int:
|
|
664
|
+
"""查看网盘操作审计流水。"""
|
|
665
|
+
subcmd = getattr(args, "ops_subcmd", None)
|
|
666
|
+
logger_instance = OperationLogger()
|
|
667
|
+
|
|
668
|
+
if subcmd == "show":
|
|
669
|
+
op = logger_instance.get_operation(args.op_id)
|
|
670
|
+
if getattr(args, "json", False):
|
|
671
|
+
if not op:
|
|
672
|
+
return _output_envelope(None, success=False, code=1, error=f"未找到操作流水: {args.op_id}")
|
|
673
|
+
return _output_envelope(op)
|
|
674
|
+
if not op:
|
|
675
|
+
print(f"❌ 未找到操作流水: {args.op_id}", file=sys.stderr)
|
|
676
|
+
return 1
|
|
677
|
+
status_str = "已撤销" if op.get("undone") else "正常"
|
|
678
|
+
print(f"\n📋 操作流水详情 [{op['op_id']}]")
|
|
679
|
+
print(f" 时间: {op.get('created_at')}")
|
|
680
|
+
print(f" 命令: {op.get('command')}")
|
|
681
|
+
print(f" 账号: {op.get('account')}")
|
|
682
|
+
print(f" 状态: {status_str}")
|
|
683
|
+
if op.get("undone_at"):
|
|
684
|
+
print(f" 撤销时间: {op.get('undone_at')}")
|
|
685
|
+
items = op.get("items") or []
|
|
686
|
+
print(f"\n 变更映射清单 (共 {len(items)} 项):")
|
|
687
|
+
for idx, item in enumerate(items, 1):
|
|
688
|
+
print(f" {idx}. {item.get('src')} -> {item.get('dest')}")
|
|
689
|
+
print()
|
|
690
|
+
return 0
|
|
691
|
+
|
|
692
|
+
# 默认 list
|
|
693
|
+
limit = getattr(args, "limit", 20) or 20
|
|
694
|
+
ops = logger_instance.list_operations(limit=limit)
|
|
695
|
+
if getattr(args, "json", False):
|
|
696
|
+
return _output_envelope(ops)
|
|
697
|
+
if not ops:
|
|
698
|
+
print("📜 暂无操作审计流水。")
|
|
699
|
+
return 0
|
|
700
|
+
|
|
701
|
+
print(f"\n📜 历史操作审计流水 (最近 {len(ops)} 条):")
|
|
702
|
+
print("-" * 80)
|
|
703
|
+
for op in ops:
|
|
704
|
+
status_tag = "[已撤销]" if op.get("undone") else "正常"
|
|
705
|
+
items_cnt = len(op.get("items") or [])
|
|
706
|
+
print(
|
|
707
|
+
f" [{op['op_id']}] | {op.get('created_at', '')[:19]} | "
|
|
708
|
+
f"命令: {op.get('command', ''):<6} | 账号: {op.get('account', 'default'):<8} | "
|
|
709
|
+
f"影响: {items_cnt} 项 | 状态: {status_tag}"
|
|
710
|
+
)
|
|
711
|
+
print("-" * 80)
|
|
712
|
+
print("💡 使用 bdpan ops show <op_id> 查看详情,或 bdpan undo <op_id> 进行逆向回滚。\n")
|
|
713
|
+
return 0
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def cmd_undo(args: argparse.Namespace) -> int:
|
|
717
|
+
"""逆向撤销历史批量操作。"""
|
|
718
|
+
mgr = _get_file_manager(args)
|
|
719
|
+
engine = UndoEngine(mgr)
|
|
720
|
+
op_id = args.op_id
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
plan = engine.plan_undo(op_id)
|
|
724
|
+
except UndoError as e:
|
|
725
|
+
print(f"❌ 无法撤销: {e}", file=sys.stderr)
|
|
726
|
+
return 1
|
|
727
|
+
|
|
728
|
+
reversals = plan.get("reversals") or []
|
|
729
|
+
if not reversals:
|
|
730
|
+
print(f"⚠️ 操作 [{op_id}] 无可撤销条目。")
|
|
731
|
+
return 0
|
|
732
|
+
|
|
733
|
+
print(f"\n🔍 逆向回滚计划 [{op_id}] (共 {len(reversals)} 项):")
|
|
734
|
+
for r in reversals:
|
|
735
|
+
print(f" ↩️ {r['src']} -> {r['dest_dir']}/{r['newname']}")
|
|
736
|
+
print()
|
|
737
|
+
|
|
738
|
+
if args.dry_run:
|
|
739
|
+
print("💡 [dry-run] 仅预演逆向方案,未执行任何网盘变更。")
|
|
740
|
+
return 0
|
|
741
|
+
|
|
742
|
+
if not args.yes:
|
|
743
|
+
try:
|
|
744
|
+
ans = input(f"⚠️ 确认执行逆向回滚(共 {len(reversals)} 项)? [y/N]: ").strip().lower()
|
|
745
|
+
except (EOFError, KeyboardInterrupt):
|
|
746
|
+
print("\n已取消,未做任何变更。")
|
|
747
|
+
return 0
|
|
748
|
+
if ans != "y":
|
|
749
|
+
print("已取消,未做任何变更。")
|
|
750
|
+
return 0
|
|
751
|
+
|
|
752
|
+
try:
|
|
753
|
+
res = engine.execute_undo(op_id, dry_run=False)
|
|
754
|
+
print(f"🎉 撤销成功!已成功逆向还原 {res['undone_count']} 项。\n")
|
|
755
|
+
return 0
|
|
756
|
+
except UndoError as e:
|
|
757
|
+
print(f"❌ 撤销执行失败: {e}", file=sys.stderr)
|
|
758
|
+
return 1
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def cmd_auth(args: argparse.Namespace) -> int:
|
|
762
|
+
"""网盘账号认证与多租户管理。"""
|
|
763
|
+
subcmd = getattr(args, "auth_subcmd", None)
|
|
764
|
+
tm = _get_token_manager(args)
|
|
765
|
+
|
|
766
|
+
if subcmd == "switch":
|
|
767
|
+
alias = args.alias
|
|
768
|
+
try:
|
|
769
|
+
tm.switch_account(alias)
|
|
770
|
+
print(f"✅ 成功切换当前活跃账号为: {alias}")
|
|
771
|
+
return 0
|
|
772
|
+
except ValueError as e:
|
|
773
|
+
print(f"❌ 切换失败: {e}", file=sys.stderr)
|
|
774
|
+
return 1
|
|
775
|
+
|
|
776
|
+
if subcmd == "login":
|
|
777
|
+
alias = getattr(args, "account", None) or tm.get_active_account()
|
|
778
|
+
print(f"🔑 正在为账号 [{alias}] 启动设备码授权流程...")
|
|
779
|
+
tm._account = alias
|
|
780
|
+
try:
|
|
781
|
+
tm.get_access_token()
|
|
782
|
+
print(f"🎉 账号 [{alias}] 授权成功!")
|
|
783
|
+
return 0
|
|
784
|
+
except Exception as e:
|
|
785
|
+
print(f"❌ 授权失败: {e}", file=sys.stderr)
|
|
786
|
+
return 1
|
|
787
|
+
|
|
788
|
+
# 默认 list
|
|
789
|
+
accounts = tm.list_accounts()
|
|
790
|
+
active = tm.get_active_account()
|
|
791
|
+
if getattr(args, "json", False):
|
|
792
|
+
return _output_envelope({"current": active, "accounts": accounts})
|
|
793
|
+
print("\n🔑 本地网盘账号认证列表:")
|
|
794
|
+
print("-" * 65)
|
|
795
|
+
if not accounts:
|
|
796
|
+
print(" (暂无保存的账号,请执行 bdpan auth login 进行首次登录)")
|
|
797
|
+
else:
|
|
798
|
+
for acc in accounts:
|
|
799
|
+
marker = "*" if acc["is_current"] else " "
|
|
800
|
+
status = "有效" if acc["valid"] else "已过期"
|
|
801
|
+
exp_str = datetime.fromtimestamp(acc["expires_at"]).strftime("%Y-%m-%d %H:%M:%S") if acc["expires_at"] else "未知"
|
|
802
|
+
print(f" {marker} {acc['account']:<12} | {status:<6} | 到期时间: {exp_str}")
|
|
803
|
+
print("-" * 65)
|
|
804
|
+
print(f"当前活跃账号: {active}")
|
|
805
|
+
print("💡 使用 bdpan auth switch <alias> 切换当前默认账号")
|
|
806
|
+
print("💡 或在任意命令前指定全局参数 bdpan --account <alias> <subcommand>\n")
|
|
807
|
+
return 0
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def cmd_info(args: argparse.Namespace) -> int:
|
|
811
|
+
"""探测视频与多媒体文件的深度元数据。"""
|
|
812
|
+
mc = _get_media_info_client(args)
|
|
813
|
+
path = args.path
|
|
814
|
+
try:
|
|
815
|
+
info = mc.get_media_info(path)
|
|
816
|
+
except MediaInfoError as e:
|
|
817
|
+
if getattr(args, "json", False):
|
|
818
|
+
return _output_envelope(None, success=False, code=1, error=str(e))
|
|
819
|
+
print(f"❌ 解析失败: {e}", file=sys.stderr)
|
|
820
|
+
return 1
|
|
821
|
+
|
|
822
|
+
if getattr(args, "json", False):
|
|
823
|
+
return _output_envelope(info)
|
|
824
|
+
|
|
825
|
+
print(f"\n🎬 媒体元数据 [{info['filename']}]:")
|
|
826
|
+
print(f" 文件路径: {info['path']}")
|
|
827
|
+
print(f" 文件大小: {_format_size(info['size'])}")
|
|
828
|
+
print(f" 媒体分类: {info['category']}")
|
|
829
|
+
if info["is_video"]:
|
|
830
|
+
print(f" 视频分辨率: {info['resolution']}")
|
|
831
|
+
print(f" 视频时长: {info['duration_formatted']} ({info['duration_seconds']} 秒)")
|
|
832
|
+
print(f" 视频编码: {info['video_codec']}")
|
|
833
|
+
print(f" 包含音轨: {info['audio_tracks_count']} 条")
|
|
834
|
+
print(f" MD5 校验值: {info.get('md5') or '-'}\n")
|
|
835
|
+
return 0
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def cmd_stats(args: argparse.Namespace) -> int:
|
|
839
|
+
"""递归聚合统计目录下的容量、文件数与媒体分类分布。"""
|
|
840
|
+
mc = _get_media_info_client(args)
|
|
841
|
+
path = args.path or "/"
|
|
842
|
+
stats = mc.get_dir_stats(path, max_depth=args.depth)
|
|
843
|
+
if getattr(args, "json", False):
|
|
844
|
+
return _output_envelope(stats)
|
|
845
|
+
|
|
846
|
+
print(f"\n📊 目录聚合统计 [{stats['root']}]:")
|
|
847
|
+
print(f" 总容量: {_format_size(stats['total_size'])}")
|
|
848
|
+
print(f" 文件总数: {stats['total_files']} 个")
|
|
849
|
+
print(f" 子目录数: {stats['total_dirs']} 个")
|
|
850
|
+
print("\n 媒体类别分布:")
|
|
851
|
+
for cat, detail in stats["by_category"].items():
|
|
852
|
+
if detail["count"] > 0:
|
|
853
|
+
print(f" - {cat:<10}: {detail['count']:>4d} 个 ({_format_size(detail['size'])})")
|
|
854
|
+
if stats["by_extension"]:
|
|
855
|
+
print("\n 扩展名分布 Top 5:")
|
|
856
|
+
for ext, detail in list(stats["by_extension"].items())[:5]:
|
|
857
|
+
print(f" - {ext:<10}: {detail['count']:>4d} 个 ({_format_size(detail['size'])})")
|
|
858
|
+
print()
|
|
859
|
+
return 0
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def cmd_mcp(args: argparse.Namespace) -> int:
|
|
863
|
+
"""启动原生 Model Context Protocol (MCP) Server。"""
|
|
864
|
+
def _token_getter() -> str:
|
|
865
|
+
return _get_token_manager(args).get_access_token()
|
|
866
|
+
|
|
867
|
+
server = BdpanMcpServer(_token_getter)
|
|
868
|
+
server.run_stdio_server()
|
|
869
|
+
return 0
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
# ---------------------------------------------------------------------------
|
|
873
|
+
# CLI 参数解析与调度
|
|
874
|
+
# ---------------------------------------------------------------------------
|
|
875
|
+
|
|
876
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
877
|
+
parser = argparse.ArgumentParser(
|
|
878
|
+
prog="bdpan",
|
|
879
|
+
description="""百度网盘工程化 CLI 管理工具
|
|
880
|
+
开源仓库: https://github.com/CodingRookie98/bdpan-cli
|
|
881
|
+
作者信息: CodingRookie98 <520whjoker@gmail.com>
|
|
882
|
+
|
|
883
|
+
提供文件组织、大文件断点续传、影视媒体规范化刮削、安全审计流水与原生 MCP 集成。""",
|
|
884
|
+
epilog="""示例:
|
|
885
|
+
bdpan ls /videos/movies
|
|
886
|
+
bdpan upload ./local.mkv /videos/movies/
|
|
887
|
+
bdpan download /videos/movies/Inception.mkv -d ./downloads/
|
|
888
|
+
bdpan scrape foldering /videos/unscraped --dry-run
|
|
889
|
+
bdpan ops list
|
|
890
|
+
bdpan mcp
|
|
891
|
+
|
|
892
|
+
开源仓库与作者:
|
|
893
|
+
开源仓库: https://github.com/CodingRookie98/bdpan-cli
|
|
894
|
+
问题反馈: https://github.com/CodingRookie98/bdpan-cli/issues
|
|
895
|
+
作者信息: CodingRookie98 <520whjoker@gmail.com>
|
|
896
|
+
用户指南: docs/tutorials/user-guide.md""",
|
|
897
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
898
|
+
)
|
|
899
|
+
parser.add_argument(
|
|
900
|
+
"-V",
|
|
901
|
+
"--version",
|
|
902
|
+
action="version",
|
|
903
|
+
version=f"%(prog)s {__version__}",
|
|
904
|
+
help="显示工具版本号并退出",
|
|
905
|
+
)
|
|
906
|
+
parser.add_argument(
|
|
907
|
+
"--account",
|
|
908
|
+
default=None,
|
|
909
|
+
help="指定当前命令所操作的网盘账号别名 (默认使用当前活跃账号)",
|
|
910
|
+
)
|
|
911
|
+
parser.add_argument(
|
|
912
|
+
"--json",
|
|
913
|
+
action="store_true",
|
|
914
|
+
help="以标准化 JSON 格式输出结果 (便于脚本与机器解析)",
|
|
915
|
+
)
|
|
916
|
+
parser.add_argument(
|
|
917
|
+
"-v",
|
|
918
|
+
"--verbose",
|
|
919
|
+
"--debug",
|
|
920
|
+
dest="debug",
|
|
921
|
+
action="store_true",
|
|
922
|
+
help="开启详细调试日志输出 (输出 HTTP 诊断与详细异常追踪)",
|
|
923
|
+
)
|
|
924
|
+
parser.add_argument(
|
|
925
|
+
"-q",
|
|
926
|
+
"--quiet",
|
|
927
|
+
action="store_true",
|
|
928
|
+
help="静音非错误输出",
|
|
929
|
+
)
|
|
930
|
+
subparsers = parser.add_subparsers(dest="command", help="可用子命令")
|
|
931
|
+
|
|
932
|
+
# 1. ls
|
|
933
|
+
p_ls = subparsers.add_parser(
|
|
934
|
+
"ls",
|
|
935
|
+
help="列出网盘指定路径内容",
|
|
936
|
+
description="列出百度网盘指定路径下的文件与子目录列表。",
|
|
937
|
+
epilog="""示例:
|
|
938
|
+
bdpan ls
|
|
939
|
+
bdpan ls /videos/movies
|
|
940
|
+
bdpan ls /videos/movies --limit 500
|
|
941
|
+
bdpan ls /videos/movies --json""",
|
|
942
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
943
|
+
)
|
|
944
|
+
p_ls.add_argument("path", nargs="?", default="/", help="网盘目录路径 (默认: /)")
|
|
945
|
+
p_ls.add_argument("--limit", type=int, default=1000, help="最多读取条目数 (默认: 1000)")
|
|
946
|
+
p_ls.set_defaults(func=cmd_ls)
|
|
947
|
+
|
|
948
|
+
# 2. mkdir
|
|
949
|
+
p_mkdir = subparsers.add_parser(
|
|
950
|
+
"mkdir",
|
|
951
|
+
help="在网盘中创建新目录",
|
|
952
|
+
description="在百度网盘中递归创建新目录(若目录已存在则安全跳过)。",
|
|
953
|
+
epilog="""示例:
|
|
954
|
+
bdpan mkdir /videos/movies/科幻
|
|
955
|
+
bdpan mkdir /backup/2026/10""",
|
|
956
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
957
|
+
)
|
|
958
|
+
p_mkdir.add_argument("path", help="待创建的目录路径")
|
|
959
|
+
p_mkdir.set_defaults(func=cmd_mkdir)
|
|
960
|
+
|
|
961
|
+
# 3. mv
|
|
962
|
+
p_mv = subparsers.add_parser(
|
|
963
|
+
"mv",
|
|
964
|
+
help="移动网盘文件或目录(多源时目标须为已存在目录;单源支持移动改名)",
|
|
965
|
+
description="移动网盘文件或目录(操作自动落盘流水审计,支持 bdpan undo 逆向撤销)。\n多源参数时目标必须为已存在目录;单源参数时支持移动并改名。",
|
|
966
|
+
epilog="""示例:
|
|
967
|
+
bdpan mv /videos/old_name.mkv /videos/movies/new_name.mkv # 单源移动并更名
|
|
968
|
+
bdpan mv /videos/f1.mkv /videos/f2.mkv /videos/movies/ # 多源批量移入已存在目录""",
|
|
969
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
970
|
+
)
|
|
971
|
+
p_mv.add_argument("srcs", nargs="+", help="一个或多个源路径")
|
|
972
|
+
p_mv.add_argument("dest", help="目标目录路径(单源时可为 目标目录/新名)")
|
|
973
|
+
p_mv.set_defaults(func=cmd_mv)
|
|
974
|
+
|
|
975
|
+
# 3.1 cp
|
|
976
|
+
p_cp = subparsers.add_parser(
|
|
977
|
+
"cp",
|
|
978
|
+
help="复制网盘文件或目录(多源时目标须为已存在目录;单源目标为已存在目录时复制入内,否则视为 目标目录/新名)",
|
|
979
|
+
description="复制网盘文件或目录(底层单批次最大 50 项安全防护)。\n多源参数时目标必须为已存在目录;单源目标为已存在目录时复制入内,否则视为 目标目录/新名。",
|
|
980
|
+
epilog="""示例:
|
|
981
|
+
bdpan cp /videos/source.mkv /backup/movies/ # 复制入目录保持原名
|
|
982
|
+
bdpan cp /videos/source.mkv /backup/movies/target.mkv # 复制并指定新名称
|
|
983
|
+
bdpan cp /videos/f1.mkv /videos/f2.mkv /backup/movies/ # 多源批量复制""",
|
|
984
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
985
|
+
)
|
|
986
|
+
p_cp.add_argument("srcs", nargs="+", help="一个或多个源路径")
|
|
987
|
+
p_cp.add_argument("dest", help="目标目录路径,或 目标目录/新名(仅单源)")
|
|
988
|
+
p_cp.set_defaults(func=cmd_cp)
|
|
989
|
+
|
|
990
|
+
# 3.2 rm
|
|
991
|
+
p_rm = subparsers.add_parser(
|
|
992
|
+
"rm",
|
|
993
|
+
help="删除网盘文件或目录(默认交互确认,-y 跳过;服务端移入回收站)",
|
|
994
|
+
description="删除网盘文件或目录。默认开启交互式确认提示,删除后移入百度网盘云端回收站。",
|
|
995
|
+
epilog="""示例:
|
|
996
|
+
bdpan rm /videos/junk.mkv # 交互式提示确认后删除
|
|
997
|
+
bdpan rm /videos/f1.mkv /videos/f2.mkv -y # 免交互确认直接移入回收站""",
|
|
998
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
999
|
+
)
|
|
1000
|
+
p_rm.add_argument("paths", nargs="+", help="待删除的一个或多个路径")
|
|
1001
|
+
p_rm.add_argument("-y", "--yes", action="store_true", help="跳过确认直接删除")
|
|
1002
|
+
p_rm.set_defaults(func=cmd_rm)
|
|
1003
|
+
|
|
1004
|
+
# 3.3 rename
|
|
1005
|
+
p_rename = subparsers.add_parser(
|
|
1006
|
+
"rename",
|
|
1007
|
+
help="原地重命名网盘文件或目录",
|
|
1008
|
+
description="原地重命名网盘文件或目录(操作自动落盘流水审计,支持 bdpan undo 逆向撤销)。",
|
|
1009
|
+
epilog="""示例:
|
|
1010
|
+
bdpan rename /videos/movies/Inception.2010.mkv "盗梦空间 (2010).mkv" """,
|
|
1011
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1012
|
+
)
|
|
1013
|
+
p_rename.add_argument("path", help="待重命名的文件或目录路径")
|
|
1014
|
+
p_rename.add_argument("new_name", help="新名称(仅名称,不含目录)")
|
|
1015
|
+
p_rename.set_defaults(func=cmd_rename)
|
|
1016
|
+
|
|
1017
|
+
# 4. scrape
|
|
1018
|
+
p_scrape = subparsers.add_parser(
|
|
1019
|
+
"scrape",
|
|
1020
|
+
help="媒体库电影刮削与整理系统",
|
|
1021
|
+
description="媒体库电影刮削与自动化整理系统,支持散落文件建目录归集与方案 A / 自定义模板重命名。",
|
|
1022
|
+
epilog="""示例:
|
|
1023
|
+
bdpan scrape foldering /videos/unscraped --dry-run
|
|
1024
|
+
bdpan scrape preview /videos/movies/unwatched -o preview.csv
|
|
1025
|
+
bdpan scrape apply preview.csv --target-dir /videos/movies/unwatched""",
|
|
1026
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1027
|
+
)
|
|
1028
|
+
scrape_sub = p_scrape.add_subparsers(dest="scrape_cmd", help="刮削子命令")
|
|
1029
|
+
|
|
1030
|
+
# 4.1 scrape preview
|
|
1031
|
+
p_preview = scrape_sub.add_parser(
|
|
1032
|
+
"preview",
|
|
1033
|
+
help="扫描并生成方案 A 刮削更名预览",
|
|
1034
|
+
description="扫描目标目录,匹配 TMDB/IMDb 电影元数据,生成重命名预演对照表(支持 --format 自定义模板)。",
|
|
1035
|
+
epilog="""示例:
|
|
1036
|
+
bdpan scrape preview /videos/movies/unwatched -o preview.csv
|
|
1037
|
+
bdpan scrape preview /videos/movies --format "{title} ({year})"
|
|
1038
|
+
bdpan scrape preview /videos/movies --format "{title} ({year}) {{imdb-{imdb_id}}}" """,
|
|
1039
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1040
|
+
)
|
|
1041
|
+
p_preview.add_argument("dir", nargs="?", default="/影视/电影/watched", help="目标目录 (默认: /影视/电影/watched)")
|
|
1042
|
+
p_preview.add_argument("-o", "--output", default="scrape_preview.json", help="输出 json/csv 路径 (默认: scrape_preview.json)")
|
|
1043
|
+
p_preview.add_argument("--all", action="store_true", help="不跳过已合规目录,全量扫描")
|
|
1044
|
+
p_preview.add_argument("--limit", type=int, default=None, help="最大条目限制 (默认: 不限)")
|
|
1045
|
+
p_preview.add_argument(
|
|
1046
|
+
"--format",
|
|
1047
|
+
default=None,
|
|
1048
|
+
help="自定义目录命名模板,支持 {title}, {original_title}, {year}, {id}, {imdb_id}, {tmdb_id}, {edition} (默认方案 A)",
|
|
1049
|
+
)
|
|
1050
|
+
p_preview.set_defaults(func=cmd_scrape_preview)
|
|
1051
|
+
|
|
1052
|
+
# 4.2 scrape apply
|
|
1053
|
+
p_apply = scrape_sub.add_parser(
|
|
1054
|
+
"apply",
|
|
1055
|
+
help="安全应用预览结果并执行网盘批量重命名",
|
|
1056
|
+
description="安全应用预演生成的 CSV/JSON 对照表,批量执行网盘重命名(底层 50 项/批保护)。",
|
|
1057
|
+
epilog="""示例:
|
|
1058
|
+
bdpan scrape apply preview.csv
|
|
1059
|
+
bdpan scrape apply preview.csv --target-dir /videos/movies/unwatched --batch-size 25""",
|
|
1060
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1061
|
+
)
|
|
1062
|
+
p_apply.add_argument("preview_file", nargs="?", default="scrape_preview.json", help="预览 json/csv 文件 (默认: scrape_preview.json)")
|
|
1063
|
+
p_apply.add_argument("--target-dir", default=None, help="覆盖校验目录")
|
|
1064
|
+
p_apply.add_argument("--batch-size", type=int, default=25, help="单批提交大小 (默认: 25)")
|
|
1065
|
+
p_apply.set_defaults(func=cmd_scrape_apply)
|
|
1066
|
+
|
|
1067
|
+
# 4.3 scrape foldering
|
|
1068
|
+
p_folder = scrape_sub.add_parser(
|
|
1069
|
+
"foldering",
|
|
1070
|
+
help="自动为散落媒体文件创建方案 A 独立规范目录并移入",
|
|
1071
|
+
description="自动扫描散落媒体文件与外挂字幕,依据刮削元数据为其创建独立规范目录并原子移入。",
|
|
1072
|
+
epilog="""示例:
|
|
1073
|
+
bdpan scrape foldering /videos/unscraped --dry-run
|
|
1074
|
+
bdpan scrape foldering /videos/unscraped --format "{title} ({year}) {{imdb-{imdb_id}}}"
|
|
1075
|
+
bdpan scrape foldering /videos/unscraped --batch-size 25""",
|
|
1076
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1077
|
+
)
|
|
1078
|
+
p_folder.add_argument("dir", nargs="?", default="/影视/电影/unscraped", help="散落文件所在父目录 (默认: /影视/电影/unscraped)")
|
|
1079
|
+
p_folder.add_argument("--dry-run", action="store_true", help="仅预演规划,不实际修改网盘")
|
|
1080
|
+
p_folder.add_argument("--batch-size", type=int, default=25, help="单批移动大小 (默认: 25)")
|
|
1081
|
+
p_folder.add_argument(
|
|
1082
|
+
"--format",
|
|
1083
|
+
default=None,
|
|
1084
|
+
help="自定义目录命名模板,支持 {title}, {original_title}, {year}, {id}, {imdb_id}, {tmdb_id}, {edition} (默认方案 A)",
|
|
1085
|
+
)
|
|
1086
|
+
p_folder.set_defaults(func=cmd_scrape_foldering)
|
|
1087
|
+
|
|
1088
|
+
# 5. meta (第三阶段预留)
|
|
1089
|
+
p_meta = subparsers.add_parser(
|
|
1090
|
+
"meta",
|
|
1091
|
+
help="元数据与海报抓取上传子系统",
|
|
1092
|
+
description="媒体元数据与海报抓取上传子系统(Kodi/Emby/Jellyfin 兼容性支持)。",
|
|
1093
|
+
epilog="""示例:
|
|
1094
|
+
bdpan meta nfo /videos/movies/watched --dry-run
|
|
1095
|
+
bdpan meta poster /videos/movies/watched --dry-run""",
|
|
1096
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1097
|
+
)
|
|
1098
|
+
meta_sub = p_meta.add_subparsers(dest="meta_cmd", help="元数据子命令")
|
|
1099
|
+
|
|
1100
|
+
p_nfo = meta_sub.add_parser(
|
|
1101
|
+
"nfo",
|
|
1102
|
+
help="批量生成 movie.nfo",
|
|
1103
|
+
description="依据方案 A 目录名末尾的 IMDb/TMDB ID 精准查询电影元数据并生成标准 movie.nfo。",
|
|
1104
|
+
epilog="""示例:
|
|
1105
|
+
bdpan meta nfo /videos/movies/watched --dry-run""",
|
|
1106
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1107
|
+
)
|
|
1108
|
+
p_nfo.add_argument("dir", help="电影目录或父目录")
|
|
1109
|
+
p_nfo.add_argument("--dry-run", action="store_true", help="仅预演生成,不上传")
|
|
1110
|
+
p_nfo.set_defaults(func=cmd_meta_nfo)
|
|
1111
|
+
|
|
1112
|
+
p_poster = meta_sub.add_parser(
|
|
1113
|
+
"poster",
|
|
1114
|
+
help="批量下载并上传 poster.jpg",
|
|
1115
|
+
description="下载 TMDB 官方最高清竖版海报并流式上传至电影根目录的 poster.jpg。",
|
|
1116
|
+
epilog="""示例:
|
|
1117
|
+
bdpan meta poster /videos/movies/watched --dry-run""",
|
|
1118
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1119
|
+
)
|
|
1120
|
+
p_poster.add_argument("dir", help="电影目录或父目录")
|
|
1121
|
+
p_poster.add_argument("--dry-run", action="store_true", help="仅预演抓取,不上传")
|
|
1122
|
+
p_poster.set_defaults(func=cmd_meta_poster)
|
|
1123
|
+
|
|
1124
|
+
# 6. quota
|
|
1125
|
+
p_quota = subparsers.add_parser(
|
|
1126
|
+
"quota",
|
|
1127
|
+
help="查询网盘容量配额",
|
|
1128
|
+
description="查询当前百度网盘账户的存储空间配额(已用空间、总容量、使用百分比及会员到期时间)。",
|
|
1129
|
+
epilog="""示例:
|
|
1130
|
+
bdpan quota
|
|
1131
|
+
bdpan quota --check-expire --check-free
|
|
1132
|
+
bdpan quota --json""",
|
|
1133
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1134
|
+
)
|
|
1135
|
+
p_quota.add_argument("--check-expire", action="store_true", help="同时检查会员容量过期时间")
|
|
1136
|
+
p_quota.add_argument("--check-free", action="store_true", help="同时查询免费容量配额")
|
|
1137
|
+
p_quota.set_defaults(func=cmd_quota)
|
|
1138
|
+
|
|
1139
|
+
# 7. whoami
|
|
1140
|
+
p_whoami = subparsers.add_parser(
|
|
1141
|
+
"whoami",
|
|
1142
|
+
help="查询当前网盘账户信息",
|
|
1143
|
+
description="查询当前登录网盘账户的基本信息(用户名、百度 UID、VIP 会员等级等)。",
|
|
1144
|
+
epilog="""示例:
|
|
1145
|
+
bdpan whoami
|
|
1146
|
+
bdpan whoami --json""",
|
|
1147
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1148
|
+
)
|
|
1149
|
+
p_whoami.set_defaults(func=cmd_whoami)
|
|
1150
|
+
|
|
1151
|
+
# 8. search
|
|
1152
|
+
p_search = subparsers.add_parser(
|
|
1153
|
+
"search",
|
|
1154
|
+
help="按关键词搜索网盘文件",
|
|
1155
|
+
description="按关键词搜索网盘文件,支持限定父目录与非递归单层检索。",
|
|
1156
|
+
epilog="""示例:
|
|
1157
|
+
bdpan search "阿凡达"
|
|
1158
|
+
bdpan search "Matrix" --dir /videos/movies
|
|
1159
|
+
bdpan search "*.mkv" --dir /videos --no-recursion
|
|
1160
|
+
bdpan search "4K" --json""",
|
|
1161
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1162
|
+
)
|
|
1163
|
+
p_search.add_argument("key", help="搜索关键词")
|
|
1164
|
+
p_search.add_argument("--dir", default=None, help="限定搜索目录 (默认: 全盘)")
|
|
1165
|
+
p_search.add_argument("--no-recursion", action="store_true", help="不递归子目录,仅搜当前层")
|
|
1166
|
+
p_search.add_argument("--page", type=int, default=1, help="页码 (默认: 1)")
|
|
1167
|
+
p_search.add_argument("--num", type=int, default=100, help="每页条数 (默认: 100)")
|
|
1168
|
+
p_search.set_defaults(func=cmd_search)
|
|
1169
|
+
|
|
1170
|
+
# 9. tree
|
|
1171
|
+
p_tree = subparsers.add_parser(
|
|
1172
|
+
"tree",
|
|
1173
|
+
help="递归遍历网盘目录树(客户端逐层遍历,可选落地本地索引)",
|
|
1174
|
+
description="客户端深度递归遍历网盘目录树拓扑,可选输出全盘保真 JSON 索引文件。",
|
|
1175
|
+
epilog="""示例:
|
|
1176
|
+
bdpan tree /videos
|
|
1177
|
+
bdpan tree /videos/movies --depth 2
|
|
1178
|
+
bdpan tree /videos --index /tmp/movies_index.json""",
|
|
1179
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1180
|
+
)
|
|
1181
|
+
p_tree.add_argument("path", nargs="?", default="/", help="网盘目录路径 (默认: /)")
|
|
1182
|
+
p_tree.add_argument("--depth", type=int, default=None, help="最大遍历深度 (默认: 不限)")
|
|
1183
|
+
p_tree.add_argument("--index", default=None, metavar="FILE", help="将遍历索引写入本地 JSON 文件")
|
|
1184
|
+
p_tree.set_defaults(func=cmd_tree)
|
|
1185
|
+
|
|
1186
|
+
# 10. upload
|
|
1187
|
+
p_upload = subparsers.add_parser(
|
|
1188
|
+
"upload",
|
|
1189
|
+
help="上传本地文件(预上传→分片→创建,支持断点续传)",
|
|
1190
|
+
description="大文件切片上传引擎,具备首片特征预计算极速秒传、动态最优机房请求与本地断点续传能力。",
|
|
1191
|
+
epilog="""示例:
|
|
1192
|
+
bdpan upload ./movie.mkv /videos/movies/
|
|
1193
|
+
bdpan upload ./movie.mkv /videos/movies/new_name.mkv
|
|
1194
|
+
bdpan upload ./movie.mkv /videos/movies/ --overwrite""",
|
|
1195
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1196
|
+
)
|
|
1197
|
+
p_upload.add_argument("local_path", help="本地文件路径")
|
|
1198
|
+
p_upload.add_argument(
|
|
1199
|
+
"remote_dest",
|
|
1200
|
+
help="网盘目标目录(尾斜杠或已存在目录则保持文件名),或 目标目录/新名",
|
|
1201
|
+
)
|
|
1202
|
+
p_upload.add_argument("--overwrite", action="store_true", help="同名冲突时覆盖(默认自动改名)")
|
|
1203
|
+
p_upload.set_defaults(func=cmd_upload)
|
|
1204
|
+
|
|
1205
|
+
# 11. dedup
|
|
1206
|
+
p_dedup = subparsers.add_parser(
|
|
1207
|
+
"dedup",
|
|
1208
|
+
help="扫描并治理网盘冲突副本与重复项 (1)/(2)(支持隔离与删除)",
|
|
1209
|
+
description="扫描并治理网盘冲突副本 (1)、(2) 与无主孤儿重复项,支持暂存隔离与回收站安全清理。",
|
|
1210
|
+
epilog="""示例:
|
|
1211
|
+
bdpan dedup /videos/movies --dry-run
|
|
1212
|
+
bdpan dedup /videos/movies --staging-dir /videos/pending_delete
|
|
1213
|
+
bdpan dedup /videos/movies --delete -y""",
|
|
1214
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1215
|
+
)
|
|
1216
|
+
p_dedup.add_argument("path", nargs="?", default="/", help="待扫描的网盘目录路径 (默认: /)")
|
|
1217
|
+
action_grp = p_dedup.add_mutually_exclusive_group()
|
|
1218
|
+
action_grp.add_argument(
|
|
1219
|
+
"--staging-dir",
|
|
1220
|
+
default=None,
|
|
1221
|
+
help="将冲突副本移动到指定的隔离暂存目录(推荐,如 /videos/movies/pending_delete)",
|
|
1222
|
+
)
|
|
1223
|
+
action_grp.add_argument(
|
|
1224
|
+
"--delete",
|
|
1225
|
+
action="store_true",
|
|
1226
|
+
help="将冲突副本直接删除(服务端移入回收站)",
|
|
1227
|
+
)
|
|
1228
|
+
p_dedup.add_argument("--dry-run", action="store_true", help="仅预演规划,不实际修改网盘")
|
|
1229
|
+
p_dedup.add_argument("-y", "--yes", action="store_true", help="跳过确认提示直接执行")
|
|
1230
|
+
p_dedup.add_argument("-r", "--recursive", action="store_true", help="递归扫描所有子目录")
|
|
1231
|
+
p_dedup.add_argument("--include-orphans", action="store_true", help="处理无主冲突副本组(保留 (1),清理 (2)+)")
|
|
1232
|
+
p_dedup.add_argument("--include-empty", action="store_true", help="同时扫描并清理空目录")
|
|
1233
|
+
p_dedup.set_defaults(func=cmd_dedup)
|
|
1234
|
+
|
|
1235
|
+
# 12. download
|
|
1236
|
+
p_download = subparsers.add_parser(
|
|
1237
|
+
"download",
|
|
1238
|
+
help="下载网盘文件至本地(支持断点续传与批量下载)",
|
|
1239
|
+
description="网盘大文件断点续传下载引擎,注入 pan.baidu.com User-Agent 防限速,支持多文件并发批量下载。",
|
|
1240
|
+
epilog="""示例:
|
|
1241
|
+
bdpan download /videos/movies/Inception.mkv
|
|
1242
|
+
bdpan download /videos/movies/f1.mkv /videos/movies/f2.mkv -d ./downloads/
|
|
1243
|
+
bdpan download /videos/movies/Inception.mkv -d ./downloads/ --overwrite""",
|
|
1244
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1245
|
+
)
|
|
1246
|
+
p_download.add_argument("paths", nargs="+", help="网盘文件路径 (支持多个)")
|
|
1247
|
+
p_download.add_argument("-d", "--dest", default=".", help="本地保存目标目录或文件路径 (默认: 当前目录)")
|
|
1248
|
+
p_download.add_argument("--overwrite", action="store_true", help="本地同名文件存在时强制覆盖")
|
|
1249
|
+
p_download.set_defaults(func=cmd_download)
|
|
1250
|
+
|
|
1251
|
+
# 13. dlink
|
|
1252
|
+
p_dlink = subparsers.add_parser(
|
|
1253
|
+
"dlink",
|
|
1254
|
+
help="查询网盘文件高速下载直链 (支持导出 aria2c / curl 命令)",
|
|
1255
|
+
description="查询网盘文件的高速下载直链(带防盗链验证),支持直接导出 aria2c 多线程或 curl 脚本。",
|
|
1256
|
+
epilog="""示例:
|
|
1257
|
+
bdpan dlink /videos/movies/Inception.mkv
|
|
1258
|
+
bdpan dlink /videos/movies/Inception.mkv --aria2
|
|
1259
|
+
bdpan dlink /videos/movies/Inception.mkv --curl""",
|
|
1260
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1261
|
+
)
|
|
1262
|
+
p_dlink.add_argument("paths", nargs="+", help="网盘文件路径 (支持多个)")
|
|
1263
|
+
dlink_export_grp = p_dlink.add_mutually_exclusive_group()
|
|
1264
|
+
dlink_export_grp.add_argument("--aria2", action="store_true", help="输出适用于 aria2c 的多线程高速下载命令")
|
|
1265
|
+
dlink_export_grp.add_argument("--curl", action="store_true", help="输出适用于 curl 的断点续传下载命令")
|
|
1266
|
+
p_dlink.set_defaults(func=cmd_dlink)
|
|
1267
|
+
|
|
1268
|
+
# 14. ops
|
|
1269
|
+
p_ops = subparsers.add_parser(
|
|
1270
|
+
"ops",
|
|
1271
|
+
help="查看网盘操作审计流水",
|
|
1272
|
+
description="网盘关键变更操作流水审计日志,记录每次批量改名、移动与归集的映射细节。",
|
|
1273
|
+
epilog="""示例:
|
|
1274
|
+
bdpan ops list
|
|
1275
|
+
bdpan ops list --limit 50
|
|
1276
|
+
bdpan ops show 20261029_095105_mv_7ac7a2""",
|
|
1277
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1278
|
+
)
|
|
1279
|
+
ops_sub = p_ops.add_subparsers(dest="ops_subcmd", help="流水子命令")
|
|
1280
|
+
p_ops_list = ops_sub.add_parser(
|
|
1281
|
+
"list",
|
|
1282
|
+
help="列出历史操作审计流水",
|
|
1283
|
+
description="列出历史操作审计流水概览(流水号、时间戳、命令类型、影响文件数、执行状态)。",
|
|
1284
|
+
epilog="""示例:
|
|
1285
|
+
bdpan ops list
|
|
1286
|
+
bdpan ops list --limit 50""",
|
|
1287
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1288
|
+
)
|
|
1289
|
+
p_ops_list.add_argument("--limit", type=int, default=20, help="最多显示记录数 (默认: 20)")
|
|
1290
|
+
p_ops_list.set_defaults(func=cmd_ops)
|
|
1291
|
+
|
|
1292
|
+
p_ops_show = ops_sub.add_parser(
|
|
1293
|
+
"show",
|
|
1294
|
+
help="查看指定操作流水详情与变更清单",
|
|
1295
|
+
description="查看指定操作流水号的详细映射清单与变更前后真实路径对比。",
|
|
1296
|
+
epilog="""示例:
|
|
1297
|
+
bdpan ops show 20261029_095105_mv_7ac7a2""",
|
|
1298
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1299
|
+
)
|
|
1300
|
+
p_ops_show.add_argument("op_id", help="操作流水号")
|
|
1301
|
+
p_ops_show.set_defaults(func=cmd_ops)
|
|
1302
|
+
p_ops.set_defaults(func=cmd_ops)
|
|
1303
|
+
|
|
1304
|
+
# 15. undo
|
|
1305
|
+
p_undo = subparsers.add_parser(
|
|
1306
|
+
"undo",
|
|
1307
|
+
help="逆向撤销历史批量操作 (基于操作流水)",
|
|
1308
|
+
description="依据操作流水号逆向撤销历史批量操作,原子恢复文件或目录的原有路径(支持 --dry-run 预演)。",
|
|
1309
|
+
epilog="""示例:
|
|
1310
|
+
bdpan undo 20261029_095105_mv_7ac7a2 --dry-run
|
|
1311
|
+
bdpan undo 20261029_095105_mv_7ac7a2 -y""",
|
|
1312
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1313
|
+
)
|
|
1314
|
+
p_undo.add_argument("op_id", help="操作流水号")
|
|
1315
|
+
p_undo.add_argument("--dry-run", action="store_true", help="仅预演逆向回滚方案,不实际修改网盘")
|
|
1316
|
+
p_undo.add_argument("-y", "--yes", action="store_true", help="跳过确认直接执行逆向回滚")
|
|
1317
|
+
p_undo.set_defaults(func=cmd_undo)
|
|
1318
|
+
|
|
1319
|
+
# 16. auth
|
|
1320
|
+
p_auth = subparsers.add_parser(
|
|
1321
|
+
"auth",
|
|
1322
|
+
help="网盘账号认证与多账号管理",
|
|
1323
|
+
description="百度网盘多账号认证管理系统,支持多租户凭证隔离存储、活跃账号切换与单独授权。",
|
|
1324
|
+
epilog="""示例:
|
|
1325
|
+
bdpan auth list
|
|
1326
|
+
bdpan auth switch work_pan
|
|
1327
|
+
bdpan auth login --account personal_pan""",
|
|
1328
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1329
|
+
)
|
|
1330
|
+
auth_sub = p_auth.add_subparsers(dest="auth_subcmd", help="认证子命令")
|
|
1331
|
+
p_auth_list = auth_sub.add_parser(
|
|
1332
|
+
"list",
|
|
1333
|
+
help="列出本地所有网盘账号与到期状态",
|
|
1334
|
+
description="列出本地保存的所有网盘账号别名、UID 以及 Token 剩余有效期与刷新状态。",
|
|
1335
|
+
epilog="""示例:
|
|
1336
|
+
bdpan auth list""",
|
|
1337
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1338
|
+
)
|
|
1339
|
+
p_auth_list.set_defaults(func=cmd_auth)
|
|
1340
|
+
|
|
1341
|
+
p_auth_switch = auth_sub.add_parser(
|
|
1342
|
+
"switch",
|
|
1343
|
+
help="切换当前默认活跃网盘账号",
|
|
1344
|
+
description="切换当前全局默认活跃网盘账号别名。",
|
|
1345
|
+
epilog="""示例:
|
|
1346
|
+
bdpan auth switch work_pan""",
|
|
1347
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1348
|
+
)
|
|
1349
|
+
p_auth_switch.add_argument("alias", help="账号别名")
|
|
1350
|
+
p_auth_switch.set_defaults(func=cmd_auth)
|
|
1351
|
+
|
|
1352
|
+
p_auth_login = auth_sub.add_parser(
|
|
1353
|
+
"login",
|
|
1354
|
+
help="登录或重新授权指定网盘账号",
|
|
1355
|
+
description="登录或重新授权指定网盘账号别名(支持有头浏览器与无头终端授权)。",
|
|
1356
|
+
epilog="""示例:
|
|
1357
|
+
bdpan auth login
|
|
1358
|
+
bdpan auth login --account work_pan""",
|
|
1359
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1360
|
+
)
|
|
1361
|
+
p_auth_login.add_argument("--account", default=None, help="账号别名 (默认: 当前活跃账号)")
|
|
1362
|
+
p_auth_login.set_defaults(func=cmd_auth)
|
|
1363
|
+
p_auth.set_defaults(func=cmd_auth)
|
|
1364
|
+
|
|
1365
|
+
# 17. info
|
|
1366
|
+
p_info = subparsers.add_parser(
|
|
1367
|
+
"info",
|
|
1368
|
+
help="探测视频媒体的深度元数据 (分辨率/时长/编码/音轨)",
|
|
1369
|
+
description="无需下载视频本体,直接基于网盘多媒体元数据探测视频精准分辨率(4K/1080P)、时长、HEVC/H.264编码与音轨。",
|
|
1370
|
+
epilog="""示例:
|
|
1371
|
+
bdpan info /videos/movies/Inception.mkv
|
|
1372
|
+
bdpan info /videos/movies/Inception.mkv --json""",
|
|
1373
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1374
|
+
)
|
|
1375
|
+
p_info.add_argument("path", help="网盘视频/多媒体文件绝对路径")
|
|
1376
|
+
p_info.set_defaults(func=cmd_info)
|
|
1377
|
+
|
|
1378
|
+
# 18. stats
|
|
1379
|
+
p_stats = subparsers.add_parser(
|
|
1380
|
+
"stats",
|
|
1381
|
+
help="递归聚合统计目录下的容量、文件数与媒体分类分布",
|
|
1382
|
+
description="递归聚合统计网盘指定目录下的总容量、总文件数、子目录数及视频/音频/字幕等格式占比分布。",
|
|
1383
|
+
epilog="""示例:
|
|
1384
|
+
bdpan stats /videos/movies
|
|
1385
|
+
bdpan stats /videos/movies --depth 2
|
|
1386
|
+
bdpan stats /videos/movies --json""",
|
|
1387
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1388
|
+
)
|
|
1389
|
+
p_stats.add_argument("path", nargs="?", default="/", help="网盘目录路径 (默认: /)")
|
|
1390
|
+
p_stats.add_argument("--depth", type=int, default=None, help="最大递归深度 (默认: 不限)")
|
|
1391
|
+
p_stats.set_defaults(func=cmd_stats)
|
|
1392
|
+
|
|
1393
|
+
# 19. mcp
|
|
1394
|
+
p_mcp = subparsers.add_parser(
|
|
1395
|
+
"mcp",
|
|
1396
|
+
help="启动原生 Model Context Protocol (MCP) Server (基于 stdio)",
|
|
1397
|
+
description="启动原生 Model Context Protocol (MCP) Server(基于标准 stdio),供 AI 助手直接调用 14 项网盘管理工具。",
|
|
1398
|
+
epilog="""示例:
|
|
1399
|
+
bdpan mcp
|
|
1400
|
+
claude mcp add bdpan uv run bdpan mcp""",
|
|
1401
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1402
|
+
)
|
|
1403
|
+
p_mcp.set_defaults(func=cmd_mcp)
|
|
1404
|
+
|
|
1405
|
+
return parser
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1409
|
+
parser = build_parser()
|
|
1410
|
+
args_list = argv if argv is not None else sys.argv[1:]
|
|
1411
|
+
|
|
1412
|
+
# 向后兼容:如果第一个参数是以 '/' 开头的网盘路径且不是已定义的子命令,则自动重定向为 ls <path>
|
|
1413
|
+
if args_list and args_list[0].startswith("/") and args_list[0] not in parser._subparsers._group_actions[0].choices:
|
|
1414
|
+
args_list = ["ls", args_list[0]] + args_list[1:]
|
|
1415
|
+
|
|
1416
|
+
args = parser.parse_args(args_list)
|
|
1417
|
+
|
|
1418
|
+
setup_logging(debug=args.debug, quiet=args.quiet)
|
|
1419
|
+
|
|
1420
|
+
if not hasattr(args, "func"):
|
|
1421
|
+
parser.print_help()
|
|
1422
|
+
return 0
|
|
1423
|
+
|
|
1424
|
+
return args.func(args)
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
if __name__ == "__main__":
|
|
1428
|
+
sys.exit(main())
|