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.
Files changed (44) hide show
  1. bdpan_cli/__init__.py +15 -0
  2. bdpan_cli/_vendor/__init__.py +9 -0
  3. bdpan_cli/_vendor/openapi_client/__init__.py +27 -0
  4. bdpan_cli/_vendor/openapi_client/api/__init__.py +6 -0
  5. bdpan_cli/_vendor/openapi_client/api/auth_api.py +649 -0
  6. bdpan_cli/_vendor/openapi_client/api/fileinfo_api.py +737 -0
  7. bdpan_cli/_vendor/openapi_client/api/filemanager_api.py +669 -0
  8. bdpan_cli/_vendor/openapi_client/api/fileupload_api.py +599 -0
  9. bdpan_cli/_vendor/openapi_client/api/multimediafile_api.py +391 -0
  10. bdpan_cli/_vendor/openapi_client/api/userinfo_api.py +315 -0
  11. bdpan_cli/_vendor/openapi_client/api_client.py +864 -0
  12. bdpan_cli/_vendor/openapi_client/configuration.py +454 -0
  13. bdpan_cli/_vendor/openapi_client/exceptions.py +197 -0
  14. bdpan_cli/_vendor/openapi_client/model/__init__.py +5 -0
  15. bdpan_cli/_vendor/openapi_client/model/oauth_token_authorization_code_response.py +276 -0
  16. bdpan_cli/_vendor/openapi_client/model/oauth_token_device_code_response.py +276 -0
  17. bdpan_cli/_vendor/openapi_client/model/oauth_token_device_token_response.py +276 -0
  18. bdpan_cli/_vendor/openapi_client/model/oauth_token_refresh_token_response.py +276 -0
  19. bdpan_cli/_vendor/openapi_client/model/quotaresponse.py +276 -0
  20. bdpan_cli/_vendor/openapi_client/model/uinforesponse.py +284 -0
  21. bdpan_cli/_vendor/openapi_client/model_utils.py +2039 -0
  22. bdpan_cli/_vendor/openapi_client/rest.py +385 -0
  23. bdpan_cli/_version.py +3 -0
  24. bdpan_cli/account.py +78 -0
  25. bdpan_cli/apply_scrape_rename.py +99 -0
  26. bdpan_cli/auth.py +218 -0
  27. bdpan_cli/cli.py +1428 -0
  28. bdpan_cli/config.py +26 -0
  29. bdpan_cli/dedup.py +310 -0
  30. bdpan_cli/downloader.py +178 -0
  31. bdpan_cli/file_manager.py +389 -0
  32. bdpan_cli/foldering.py +181 -0
  33. bdpan_cli/logger.py +101 -0
  34. bdpan_cli/mcp_server.py +547 -0
  35. bdpan_cli/media_info.py +251 -0
  36. bdpan_cli/ops.py +173 -0
  37. bdpan_cli/organize.py +108 -0
  38. bdpan_cli/preview_scrape.py +182 -0
  39. bdpan_cli/scraper.py +505 -0
  40. bdpan_cli/uploader.py +391 -0
  41. bdpan_cli-0.1.0.dist-info/METADATA +167 -0
  42. bdpan_cli-0.1.0.dist-info/RECORD +44 -0
  43. bdpan_cli-0.1.0.dist-info/WHEEL +4 -0
  44. bdpan_cli-0.1.0.dist-info/entry_points.txt +5 -0
@@ -0,0 +1,251 @@
1
+ """MediaInfo: 百度网盘视频/媒体深度元数据探测与目录聚合统计。"""
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.multimediafile_api import MultimediafileApi
12
+ from bdpan_cli._vendor.openapi_client.rest import ApiException
13
+ from bdpan_cli.file_manager import FileManager
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # 常见媒体格式分类表
18
+ EXTENSION_CATEGORY_MAP = {
19
+ # 视频
20
+ ".mkv": "video",
21
+ ".mp4": "video",
22
+ ".avi": "video",
23
+ ".mov": "video",
24
+ ".wmv": "video",
25
+ ".flv": "video",
26
+ ".rmvb": "video",
27
+ ".ts": "video",
28
+ ".m2ts": "video",
29
+ ".iso": "video",
30
+ # 字幕
31
+ ".srt": "subtitle",
32
+ ".ass": "subtitle",
33
+ ".sub": "subtitle",
34
+ ".vtt": "subtitle",
35
+ ".idx": "subtitle",
36
+ ".sup": "subtitle",
37
+ # 图片/海报
38
+ ".jpg": "image",
39
+ ".jpeg": "image",
40
+ ".png": "image",
41
+ ".webp": "image",
42
+ ".bmp": "image",
43
+ ".gif": "image",
44
+ # 音频
45
+ ".mp3": "audio",
46
+ ".flac": "audio",
47
+ ".wav": "audio",
48
+ ".aac": "audio",
49
+ ".m4a": "audio",
50
+ ".ogg": "audio",
51
+ ".ape": "audio",
52
+ # 压缩包
53
+ ".zip": "archive",
54
+ ".rar": "archive",
55
+ ".7z": "archive",
56
+ ".tar": "archive",
57
+ ".gz": "archive",
58
+ # 文档/NFO
59
+ ".nfo": "doc",
60
+ ".txt": "doc",
61
+ ".pdf": "doc",
62
+ }
63
+
64
+
65
+ class MediaInfoError(RuntimeError):
66
+ """媒体元数据解析或统计异常基类。"""
67
+
68
+
69
+ def format_duration(seconds: int | float | None) -> str:
70
+ """将秒数格式化为 HH:MM:SS。"""
71
+ if not seconds or seconds < 0:
72
+ return "00:00:00"
73
+ total_sec = int(seconds)
74
+ hours = total_sec // 3600
75
+ minutes = (total_sec % 3600) // 60
76
+ secs = total_sec % 60
77
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
78
+
79
+
80
+ def normalize_resolution(width: int | None, height: int | None) -> str:
81
+ """归一化视频分辨率描述。"""
82
+ if not width or not height:
83
+ return "未知分辨率"
84
+ w, h = int(width), int(height)
85
+ if w >= 3840 or h >= 2160:
86
+ return f"{w}x{h} (4K UHD)"
87
+ if w >= 2560 or h >= 1440:
88
+ return f"{w}x{h} (2K QHD)"
89
+ if w >= 1920 or h >= 1080:
90
+ return f"{w}x{h} (1080P FHD)"
91
+ if w >= 1280 or h >= 720:
92
+ return f"{w}x{h} (720P HD)"
93
+ return f"{w}x{h}"
94
+
95
+
96
+ def normalize_codec(codec: str | None) -> str:
97
+ """归一化编码名称。"""
98
+ if not codec:
99
+ return "未知编码"
100
+ c = str(codec).strip().lower()
101
+ if c in ("hevc", "h265", "h.265"):
102
+ return "HEVC"
103
+ if c in ("h264", "h.264", "avc"):
104
+ return "H.264"
105
+ if c in ("av01", "av1"):
106
+ return "AV1"
107
+ if c in ("vc1", "vc-1"):
108
+ return "VC-1"
109
+ return codec.upper()
110
+
111
+
112
+ class MediaInfoClient:
113
+ """网盘媒体元数据探测与聚合统计客户端。"""
114
+
115
+ def __init__(self, access_token: str, api_client: openapi_client.ApiClient | None = None) -> None:
116
+ self.access_token = access_token
117
+ self.api_client = api_client or openapi_client.ApiClient()
118
+ self._multimedia_api = MultimediafileApi(self.api_client)
119
+ self._file_manager = FileManager(access_token, self.api_client)
120
+
121
+ def get_media_info(self, remote_path: str) -> dict[str, Any]:
122
+ """获取单个视频/多媒体文件的深度媒体元数据。
123
+
124
+ Args:
125
+ remote_path: 网盘文件路径。
126
+
127
+ Returns:
128
+ 包含分辨率、时长、编码、音轨、文件大小等元数据的结构化字典。
129
+
130
+ Raises:
131
+ MediaInfoError: 当文件不存在、为目录或接口异常时抛出。
132
+ """
133
+ info = self._file_manager.get_file_info(remote_path)
134
+ if not info:
135
+ raise MediaInfoError(f"网盘文件不存在: {remote_path}")
136
+ if info.get("isdir") == 1:
137
+ raise MediaInfoError(f"目标为目录,无法解析媒体深度元数据: {remote_path}")
138
+
139
+ fs_id = info["fs_id"]
140
+ try:
141
+ res = self._multimedia_api.xpanmultimediafilemetas(
142
+ access_token=self.access_token,
143
+ fsids=json.dumps([fs_id]),
144
+ extra="1",
145
+ needmedia=1,
146
+ )
147
+ except ApiException as e:
148
+ raise MediaInfoError(f"获取媒体元数据 API 异常 [{remote_path}]: {e.status} {e.body}") from e
149
+
150
+ if not isinstance(res, dict) or res.get("errno") != 0:
151
+ raise MediaInfoError(f"获取媒体元数据失败 [{remote_path}]: errno={res.get('errno', '?')}")
152
+
153
+ entries = res.get("list") or []
154
+ entry = entries[0] if entries else {}
155
+
156
+ # 提取字段
157
+ size = int(entry.get("size") or info.get("size") or 0)
158
+ filename = entry.get("server_filename") or info.get("server_filename") or os.path.basename(remote_path)
159
+ ext = os.path.splitext(filename)[1].lower()
160
+ is_video = EXTENSION_CATEGORY_MAP.get(ext) == "video"
161
+
162
+ # 尝试从 media_info 或直接字段提取宽/高/时长/编码
163
+ media_info_block = entry.get("media_info") or {}
164
+ video_track = (media_info_block.get("video") or [{}])[0] if isinstance(media_info_block.get("video"), list) and media_info_block.get("video") else {}
165
+
166
+ duration = entry.get("duration") or video_track.get("duration") or 0
167
+ width = entry.get("width") or video_track.get("width")
168
+ height = entry.get("height") or video_track.get("height")
169
+ raw_codec = entry.get("video_codec") or video_track.get("codec")
170
+
171
+ audio_tracks = media_info_block.get("audio") or []
172
+
173
+ return {
174
+ "path": remote_path,
175
+ "filename": filename,
176
+ "fs_id": fs_id,
177
+ "size": size,
178
+ "md5": entry.get("md5") or info.get("md5"),
179
+ "category": EXTENSION_CATEGORY_MAP.get(ext, "other"),
180
+ "is_video": is_video,
181
+ "duration_seconds": int(duration),
182
+ "duration_formatted": format_duration(duration),
183
+ "width": width,
184
+ "height": height,
185
+ "resolution": normalize_resolution(width, height),
186
+ "video_codec": normalize_codec(raw_codec),
187
+ "audio_tracks_count": len(audio_tracks),
188
+ "audio_tracks": audio_tracks,
189
+ "raw_media_info": media_info_block,
190
+ }
191
+
192
+ def get_dir_stats(self, remote_path: str, max_depth: int | None = None) -> dict[str, Any]:
193
+ """递归统计指定目录下的容量、文件数及扩展名与类别分布。
194
+
195
+ Args:
196
+ remote_path: 网盘目录绝对路径。
197
+ max_depth: 最大扫描深度。
198
+
199
+ Returns:
200
+ 统计结果字典。
201
+ """
202
+ raw_path = remote_path or "/"
203
+ target_dir = "/" + raw_path.strip("/") if raw_path != "/" else "/"
204
+ entries, errors = self._file_manager.walk(target_dir, max_depth=max_depth)
205
+
206
+ total_files = 0
207
+ total_dirs = 0
208
+ total_size = 0
209
+ by_extension: dict[str, dict[str, int]] = {}
210
+ by_category: dict[str, dict[str, int]] = {
211
+ "video": {"count": 0, "size": 0},
212
+ "subtitle": {"count": 0, "size": 0},
213
+ "image": {"count": 0, "size": 0},
214
+ "audio": {"count": 0, "size": 0},
215
+ "archive": {"count": 0, "size": 0},
216
+ "doc": {"count": 0, "size": 0},
217
+ "other": {"count": 0, "size": 0},
218
+ }
219
+
220
+ for _, e in entries:
221
+ if e.get("isdir") == 1:
222
+ total_dirs += 1
223
+ else:
224
+ total_files += 1
225
+ size = int(e.get("size") or 0)
226
+ total_size += size
227
+ fname = e.get("server_filename") or ""
228
+ ext = os.path.splitext(fname)[1].lower() or "(no-ext)"
229
+
230
+ if ext not in by_extension:
231
+ by_extension[ext] = {"count": 0, "size": 0}
232
+ by_extension[ext]["count"] += 1
233
+ by_extension[ext]["size"] += size
234
+
235
+ cat = EXTENSION_CATEGORY_MAP.get(ext, "other")
236
+ by_category[cat]["count"] += 1
237
+ by_category[cat]["size"] += size
238
+
239
+ # 按大小降序排序扩展名
240
+ sorted_ext = dict(sorted(by_extension.items(), key=lambda item: item[1]["size"], reverse=True))
241
+
242
+ return {
243
+ "root": target_dir,
244
+ "max_depth": max_depth,
245
+ "total_files": total_files,
246
+ "total_dirs": total_dirs,
247
+ "total_size": total_size,
248
+ "by_extension": sorted_ext,
249
+ "by_category": by_category,
250
+ "errors": [{"path": p, "error": msg} for p, msg in errors],
251
+ }
bdpan_cli/ops.py ADDED
@@ -0,0 +1,173 @@
1
+ """操作流水审计与逆向撤销体系 (Operation Audit Logging & Undo Engine - BK-0034)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import time
9
+ import uuid
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from bdpan_cli.file_manager import FileManager, FileManagerError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ DEFAULT_OPS_DIR = Path(__file__).resolve().parents[2] / ".bdpan_ops"
19
+
20
+
21
+ class UndoError(RuntimeError):
22
+ """撤销操作异常基类。"""
23
+
24
+
25
+ class OperationLogger:
26
+ """操作审计流水记录器,将每一次破坏性/批量操作落盘为结构化 JSON 日志。"""
27
+
28
+ def __init__(self, ops_dir: str | Path | None = None) -> None:
29
+ self.ops_dir = Path(ops_dir).resolve() if ops_dir else DEFAULT_OPS_DIR
30
+ self.ops_dir.mkdir(parents=True, exist_ok=True)
31
+
32
+ def record_operation(
33
+ self,
34
+ command: str,
35
+ items: list[dict[str, Any]],
36
+ account: str = "default",
37
+ ) -> str:
38
+ """记录一次批量操作流水。
39
+
40
+ Args:
41
+ command: 命令类型,如 mv, rename, foldering, dedup 等。
42
+ items: 操作的映射项列表,每项形如 {"src": "/old/path", "dest": "/new/path"}。
43
+ account: 操作所使用的网盘账号标识。
44
+
45
+ Returns:
46
+ 分配的唯一操作批次 ID (op_id)。
47
+ """
48
+ now = datetime.now()
49
+ op_id = f"{now.strftime('%Y%m%d_%H%M%S')}_{command}_{uuid.uuid4().hex[:6]}"
50
+ record = {
51
+ "op_id": op_id,
52
+ "command": command,
53
+ "timestamp": int(time.time()),
54
+ "created_at": now.isoformat(),
55
+ "account": account,
56
+ "undone": False,
57
+ "undone_at": None,
58
+ "items": items,
59
+ }
60
+ file_path = self.ops_dir / f"{op_id}.json"
61
+ with open(file_path, "w", encoding="utf-8") as f:
62
+ json.dump(record, f, ensure_ascii=False, indent=2)
63
+
64
+ logger.debug("已记录操作流水 [%s]: %d 项映射", op_id, len(items))
65
+ return op_id
66
+
67
+ def list_operations(self, limit: int = 20) -> list[dict[str, Any]]:
68
+ """按时间倒序列出最近的操作审计流水。"""
69
+ if not self.ops_dir.exists():
70
+ return []
71
+
72
+ ops: list[dict[str, Any]] = []
73
+ # 按文件名排序,文件名以时间戳开头,因此逆序即为最新在前
74
+ for f in sorted(self.ops_dir.glob("*.json"), reverse=True):
75
+ try:
76
+ with open(f, "r", encoding="utf-8") as fp:
77
+ data = json.load(fp)
78
+ if isinstance(data, dict) and "op_id" in data:
79
+ ops.append(data)
80
+ if len(ops) >= limit:
81
+ break
82
+ except (OSError, json.JSONDecodeError) as e:
83
+ logger.warning("跳过不可读流水文件 [%s]: %s", f, e)
84
+
85
+ return ops
86
+
87
+ def get_operation(self, op_id: str) -> dict[str, Any] | None:
88
+ """读取指定 op_id 的完整流水记录。"""
89
+ file_path = self.ops_dir / f"{op_id}.json"
90
+ if not file_path.exists():
91
+ return None
92
+ try:
93
+ with open(file_path, "r", encoding="utf-8") as f:
94
+ return json.load(f)
95
+ except (OSError, json.JSONDecodeError) as e:
96
+ logger.error("读取流水失败 [%s]: %s", op_id, e)
97
+ return None
98
+
99
+ def mark_undone(self, op_id: str) -> None:
100
+ """标记指定操作为已撤销。"""
101
+ file_path = self.ops_dir / f"{op_id}.json"
102
+ if not file_path.exists():
103
+ raise UndoError(f"流水文件不存在: {op_id}")
104
+
105
+ with open(file_path, "r", encoding="utf-8") as f:
106
+ record = json.load(f)
107
+
108
+ record["undone"] = True
109
+ record["undone_at"] = datetime.now().isoformat()
110
+
111
+ with open(file_path, "w", encoding="utf-8") as f:
112
+ json.dump(record, f, ensure_ascii=False, indent=2)
113
+
114
+
115
+ class UndoEngine:
116
+ """逆向撤销执行引擎,将历史操作中的 dest 反向移回原 src。"""
117
+
118
+ def __init__(self, file_manager: FileManager, ops_logger: OperationLogger | None = None) -> None:
119
+ self.file_manager = file_manager
120
+ self.ops_logger = ops_logger or OperationLogger()
121
+
122
+ def plan_undo(self, op_id: str) -> dict[str, Any]:
123
+ """为指定操作生成逆向回滚计划。"""
124
+ op = self.ops_logger.get_operation(op_id)
125
+ if not op:
126
+ raise UndoError(f"未找到对应的操作流水: {op_id}")
127
+ if op.get("undone"):
128
+ raise UndoError(f"操作 [{op_id}] 此前已被撤销过 (撤销时间: {op.get('undone_at')}),禁止重复撤销")
129
+
130
+ items = op.get("items") or []
131
+ reversals: list[dict[str, str]] = []
132
+ for item in items:
133
+ src = item.get("src")
134
+ dest = item.get("dest")
135
+ if not src or not dest:
136
+ continue
137
+ # 逆向:现在的源是以前的 dest,现在的目标目录是以前 src 的父目录,文件名是以前 src 的文件名
138
+ dest_dir = os.path.dirname(src) or "/"
139
+ newname = os.path.basename(src)
140
+ reversals.append({
141
+ "src": dest,
142
+ "dest_dir": dest_dir,
143
+ "newname": newname,
144
+ })
145
+
146
+ return {
147
+ "op_id": op_id,
148
+ "command": op.get("command"),
149
+ "account": op.get("account"),
150
+ "created_at": op.get("created_at"),
151
+ "reversals": reversals,
152
+ }
153
+
154
+ def execute_undo(self, op_id: str, dry_run: bool = False) -> dict[str, Any]:
155
+ """执行逆向回滚。"""
156
+ plan = self.plan_undo(op_id)
157
+ reversals = plan["reversals"]
158
+ if not reversals:
159
+ return {"op_id": op_id, "undone_count": 0, "dry_run": dry_run}
160
+
161
+ if dry_run:
162
+ return {"op_id": op_id, "undone_count": len(reversals), "dry_run": True, "plan": reversals}
163
+
164
+ # 转换为 batch_move 所需的三元组列表:(src, dest_dir, newname)
165
+ move_tuples = [(r["src"], r["dest_dir"], r["newname"]) for r in reversals]
166
+ try:
167
+ self.file_manager.batch_move(move_tuples, batch_size=50)
168
+ except FileManagerError as e:
169
+ raise UndoError(f"执行回滚移动失败: {e}") from e
170
+
171
+ # 标记已撤销
172
+ self.ops_logger.mark_undone(op_id)
173
+ return {"op_id": op_id, "undone_count": len(reversals), "dry_run": False}
bdpan_cli/organize.py ADDED
@@ -0,0 +1,108 @@
1
+ """电影目录规整与迁移任务脚本。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from typing import Any
8
+
9
+ from bdpan_cli.auth import TokenManager
10
+ from bdpan_cli.config import load_config
11
+ from bdpan_cli.file_manager import FileManager
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ ROOT_DIR = "/影视/电影"
16
+ UNWATCHED_OLD = f"{ROOT_DIR}/0_notWatch"
17
+ UNWATCHED_NEW_NAME = "unwatched"
18
+ UNWATCHED_NEW_PATH = f"{ROOT_DIR}/{UNWATCHED_NEW_NAME}"
19
+ WATCHED_DIR = f"{ROOT_DIR}/watched"
20
+ TRIAGE_DIR = f"{ROOT_DIR}/pending_triage"
21
+
22
+
23
+ def run_organization() -> None:
24
+ config = load_config()
25
+ token = TokenManager(config).get_access_token()
26
+ mgr = FileManager(token)
27
+
28
+ logger.info("=== 阶段 0: 检查当前目录状态 ===")
29
+ entries = mgr.list_dir(ROOT_DIR, limit=1000)
30
+ name_map = {e.get("server_filename"): e for e in entries}
31
+ logger.info("当前 %s 下共有 %d 个条目", ROOT_DIR, len(entries))
32
+
33
+ # 1. 待看目录重命名
34
+ logger.info("=== 阶段 1: 重命名待看目录 ===")
35
+ if "0_notWatch" in name_map:
36
+ logger.info("发现 0_notWatch,开始重命名为 %s ...", UNWATCHED_NEW_NAME)
37
+ res_rename = mgr.rename(UNWATCHED_OLD, UNWATCHED_NEW_NAME)
38
+ logger.info("重命名成功: %s", res_rename)
39
+ elif UNWATCHED_NEW_NAME in name_map:
40
+ logger.info("目录 %s 已存在,跳过重命名", UNWATCHED_NEW_NAME)
41
+ else:
42
+ logger.warning("未找到 0_notWatch 或 %s,请确认目录状态", UNWATCHED_NEW_NAME)
43
+
44
+ # 2. 创建目标目录
45
+ logger.info("=== 阶段 2: 确保目标目录存在 ===")
46
+ logger.info("创建/确保已看目录: %s", WATCHED_DIR)
47
+ mgr.mkdir(WATCHED_DIR)
48
+ logger.info("创建/确保待整理目录: %s", TRIAGE_DIR)
49
+ mgr.mkdir(TRIAGE_DIR)
50
+
51
+ # 3. 重新扫描根目录以获取最新条目清单
52
+ logger.info("=== 阶段 3: 扫描待归类文件与目录 ===")
53
+ current_entries = mgr.list_dir(ROOT_DIR, limit=1000)
54
+
55
+ # 保留在根目录的三个核心文件夹
56
+ preserved_names = {UNWATCHED_NEW_NAME, "0_notWatch", "watched", "pending_triage"}
57
+
58
+ items_to_triage: list[tuple[str, str, str]] = [] # (src_path, dest_dir, new_name)
59
+ items_to_watched: list[tuple[str, str, str]] = []
60
+
61
+ for item in current_entries:
62
+ fname = item.get("server_filename")
63
+ if fname in preserved_names:
64
+ continue
65
+ fpath = item.get("path")
66
+ isdir = item.get("isdir") == 1
67
+
68
+ if isdir:
69
+ items_to_watched.append((fpath, WATCHED_DIR, fname))
70
+ else:
71
+ # 种子、压缩包、单视频、字幕等全部非目录文件移至 pending_triage
72
+ items_to_triage.append((fpath, TRIAGE_DIR, fname))
73
+
74
+ logger.info("待迁移到 watched (电影目录): %d 项", len(items_to_watched))
75
+ logger.info("待迁移到 pending_triage (杂项文件): %d 项", len(items_to_triage))
76
+
77
+ # 4. 执行迁移到 pending_triage
78
+ if items_to_triage:
79
+ logger.info("开始分批迁移杂项文件到 %s (批次大小 50)...", TRIAGE_DIR)
80
+ res_triage = mgr.batch_move(items_to_triage, batch_size=50)
81
+ logger.info("杂项文件迁移完成,共调用 %d 个批次", len(res_triage))
82
+
83
+ # 5. 执行迁移到 watched
84
+ if items_to_watched:
85
+ logger.info("开始分批迁移电影目录到 %s (批次大小 50)...", WATCHED_DIR)
86
+ res_watched = mgr.batch_move(items_to_watched, batch_size=50)
87
+ logger.info("电影目录迁移完成,共调用 %d 个批次", len(res_watched))
88
+
89
+ # 6. 对账与最终验证
90
+ logger.info("=== 阶段 4: 迁移后对账审计 ===")
91
+ final_root_entries = mgr.list_dir(ROOT_DIR, limit=1000)
92
+ root_names = [e.get("server_filename") for e in final_root_entries]
93
+ logger.info("%s 根目录最终条目数: %d -> %s", ROOT_DIR, len(root_names), root_names)
94
+
95
+ watched_entries = mgr.list_dir(WATCHED_DIR, limit=1000)
96
+ logger.info("%s 下最终条目数: %d", WATCHED_DIR, len(watched_entries))
97
+
98
+ triage_entries = mgr.list_dir(TRIAGE_DIR, limit=1000)
99
+ logger.info("%s 下最终条目数: %d", TRIAGE_DIR, len(triage_entries))
100
+
101
+ unwatched_entries = mgr.list_dir(UNWATCHED_NEW_PATH, limit=1000)
102
+ logger.info("%s 下最终条目数: %d", UNWATCHED_NEW_PATH, len(unwatched_entries))
103
+
104
+ logger.info("=== 迁移完成! ===")
105
+
106
+
107
+ if __name__ == "__main__":
108
+ run_organization()