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
bdpan_cli/auth.py ADDED
@@ -0,0 +1,218 @@
1
+ """access_token 的获取与缓存(设备码模式)。
2
+
3
+ access_token 有效期 30 天,refresh_token 有效期 10 年。
4
+ 缓存文件不提交到 git(见 .gitignore)。
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import time
11
+ from typing import Any
12
+
13
+ from bdpan_cli._vendor import openapi_client
14
+ from bdpan_cli._vendor.openapi_client.api.auth_api import AuthApi
15
+ from bdpan_cli._vendor.openapi_client.rest import ApiException
16
+ from bdpan_cli.config import AppConfig
17
+
18
+ # 默认用户端 Token 凭据路径遵循 XDG 规范 (~/.config/bdpan/tokens.json)
19
+ DEFAULT_CONFIG_DIR = Path.home() / ".config" / "bdpan"
20
+ DEFAULT_TOKEN_FILE = DEFAULT_CONFIG_DIR / "tokens.json"
21
+ LEGACY_TOKEN_CACHE = Path(__file__).resolve().parents[2] / ".baidupan_tokens.json"
22
+
23
+ # 向后兼容常量导出
24
+ TOKEN_CACHE = DEFAULT_TOKEN_FILE
25
+ SCOPE = "basic,netdisk"
26
+ REFRESH_BEFORE_EXPIRY = 24 * 3600 # 剩余不足一天时先刷新
27
+
28
+
29
+ class AuthorizationNeeded(Exception):
30
+ """需要用户完成设备码授权。"""
31
+
32
+
33
+ class TokenManager:
34
+ """网盘授权凭证管理器,支持多账号别名存储与动态切换。"""
35
+
36
+ def __init__(
37
+ self,
38
+ config: AppConfig,
39
+ account: str | None = None,
40
+ cache_path: Path | None = None,
41
+ ) -> None:
42
+ self._config = config
43
+ self._account = account
44
+ if cache_path:
45
+ self._cache_path = Path(cache_path)
46
+ else:
47
+ env_path = os.environ.get("BDPAN_TOKEN_PATH")
48
+ self._cache_path = Path(env_path) if env_path else DEFAULT_TOKEN_FILE
49
+
50
+ def _normalize_token_data(self, raw: dict[str, Any]) -> dict[str, Any]:
51
+ """标准化 token 字典,平滑兼容旧单账号形态与多账号形态。"""
52
+ if "access_token" in raw:
53
+ return {
54
+ "current": "default",
55
+ "accounts": {
56
+ "default": {
57
+ "access_token": raw.get("access_token"),
58
+ "refresh_token": raw.get("refresh_token"),
59
+ "expires_at": raw.get("expires_at"),
60
+ }
61
+ },
62
+ }
63
+ if "accounts" not in raw or not isinstance(raw["accounts"], dict):
64
+ raw["accounts"] = {}
65
+ if "current" not in raw:
66
+ raw["current"] = "default"
67
+ return raw
68
+
69
+ def _load_all(self) -> dict[str, Any]:
70
+ """读取并兼容迁移缓存数据,返回统一的多账号结构字典。"""
71
+ # 若目标文件不存在,但存在旧项目级 .baidupan_tokens.json,则自动无缝迁移至 ~/.config/bdpan/tokens.json
72
+ if not self._cache_path.exists():
73
+ if self._cache_path == DEFAULT_TOKEN_FILE and LEGACY_TOKEN_CACHE.exists():
74
+ try:
75
+ legacy_data = json.loads(LEGACY_TOKEN_CACHE.read_text(encoding="utf-8"))
76
+ migrated = self._normalize_token_data(legacy_data)
77
+ self._save_all(migrated)
78
+ return migrated
79
+ except (OSError, json.JSONDecodeError):
80
+ pass
81
+ return {"current": "default", "accounts": {}}
82
+ try:
83
+ raw = json.loads(self._cache_path.read_text(encoding="utf-8"))
84
+ if not isinstance(raw, dict):
85
+ return {"current": "default", "accounts": {}}
86
+ if "access_token" in raw:
87
+ migrated = self._normalize_token_data(raw)
88
+ self._save_all(migrated)
89
+ return migrated
90
+ return self._normalize_token_data(raw)
91
+ except (OSError, json.JSONDecodeError):
92
+ return {"current": "default", "accounts": {}}
93
+
94
+ def _save_all(self, data: dict[str, Any]) -> None:
95
+ self._cache_path.parent.mkdir(parents=True, exist_ok=True)
96
+ self._cache_path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
97
+ try:
98
+ self._cache_path.chmod(0o600)
99
+ except OSError:
100
+ pass
101
+
102
+ def get_active_account(self) -> str:
103
+ """返回当前生效的账号别名。"""
104
+ if self._account:
105
+ return self._account
106
+ all_data = self._load_all()
107
+ return all_data.get("current") or "default"
108
+
109
+ def get_access_token(self) -> str:
110
+ account = self.get_active_account()
111
+ all_data = self._load_all()
112
+ cached = all_data.get("accounts", {}).get(account)
113
+ if cached and "expires_at" in cached and "access_token" in cached:
114
+ if cached["expires_at"] - time.time() > REFRESH_BEFORE_EXPIRY:
115
+ return cached["access_token"]
116
+ refreshed = self._refresh(cached.get("refresh_token", ""))
117
+ if refreshed:
118
+ return refreshed["access_token"]
119
+ return self._device_flow()["access_token"]
120
+
121
+ def _load_cache(self) -> dict[str, Any] | None:
122
+ all_data = self._load_all()
123
+ account = self.get_active_account()
124
+ return all_data.get("accounts", {}).get(account)
125
+
126
+ def _save_cache(self, token: dict[str, Any]) -> dict[str, Any]:
127
+ account = self.get_active_account()
128
+ all_data = self._load_all()
129
+ if "accounts" not in all_data or not isinstance(all_data["accounts"], dict):
130
+ all_data["accounts"] = {}
131
+ all_data["accounts"][account] = token
132
+ all_data["current"] = account
133
+ self._save_all(all_data)
134
+ return token
135
+
136
+ def list_accounts(self) -> list[dict[str, Any]]:
137
+ """列出本地所有保存的网盘账号信息。"""
138
+ all_data = self._load_all()
139
+ curr = all_data.get("current") or "default"
140
+ accounts = all_data.get("accounts") or {}
141
+ res = []
142
+ for name, acc in accounts.items():
143
+ expires_at = acc.get("expires_at") or 0
144
+ is_valid = expires_at > time.time()
145
+ res.append({
146
+ "account": name,
147
+ "is_current": (name == curr),
148
+ "expires_at": expires_at,
149
+ "valid": is_valid,
150
+ })
151
+ return res
152
+
153
+ def switch_account(self, account: str) -> None:
154
+ """切换默认活跃账号。"""
155
+ all_data = self._load_all()
156
+ if account not in all_data.get("accounts", {}):
157
+ raise ValueError(f"账号 [{account}] 不存在,请先使用设备码授权登录该账号")
158
+ all_data["current"] = account
159
+ self._save_all(all_data)
160
+
161
+ def remove_account(self, account: str) -> None:
162
+ """移除指定账号凭证。"""
163
+ all_data = self._load_all()
164
+ accounts = all_data.get("accounts", {})
165
+ if account in accounts:
166
+ del accounts[account]
167
+ if all_data.get("current") == account:
168
+ all_data["current"] = next(iter(accounts.keys())) if accounts else "default"
169
+ self._save_all(all_data)
170
+
171
+ def _to_cache(self, response) -> dict:
172
+ return {
173
+ "access_token": response.access_token,
174
+ "refresh_token": response.refresh_token,
175
+ "expires_at": int(time.time()) + int(response.expires_in),
176
+ }
177
+
178
+ def _refresh(self, refresh_token: str) -> dict | None:
179
+ if not refresh_token:
180
+ return None
181
+ try:
182
+ with openapi_client.ApiClient() as client:
183
+ response = AuthApi(client).oauth_token_refresh_token(
184
+ refresh_token, self._config.app_key, self._config.secret_key
185
+ )
186
+ except ApiException:
187
+ return None
188
+ return self._save_cache(self._to_cache(response))
189
+
190
+ def _device_flow(self) -> dict:
191
+ with openapi_client.ApiClient() as client:
192
+ auth = AuthApi(client)
193
+ device = auth.oauth_token_device_code(self._config.app_key, SCOPE)
194
+ interval = max(int(device.interval or 5), 3)
195
+ deadline = time.time() + int(device.expires_in or 300)
196
+ print(
197
+ f"请打开 {device.verification_url} ,输入用户码 {device.user_code} 并确认授权",
198
+ flush=True,
199
+ )
200
+ while time.time() < deadline:
201
+ time.sleep(interval)
202
+ try:
203
+ response = auth.oauth_token_device_token(
204
+ device.device_code,
205
+ self._config.app_key,
206
+ self._config.secret_key,
207
+ )
208
+ except ApiException as e:
209
+ body = getattr(e, "body", "") or ""
210
+ if "authorization_pending" in body:
211
+ continue
212
+ if "slow_down" in body:
213
+ interval += 5
214
+ continue
215
+ raise RuntimeError(f"设备码授权失败:{body}") from e
216
+ print("授权成功", flush=True)
217
+ return self._save_cache(self._to_cache(response))
218
+ raise AuthorizationNeeded("设备码已过期,请重新运行")