返回 douyin-downloader
config_loader.py
根目录 / config / config_loader.py
1 import json
2 import logging
3 import os
4 import sys
5 from copy import deepcopy
6 from pathlib import Path
7 from typing import Any, Dict, List, Optional
8
9 import yaml
10
11 from utils.cookie_utils import parse_cookie_header, sanitize_cookies
12
13 from .default_config import DEFAULT_CONFIG
14
15 logger = logging.getLogger("ConfigLoader")
16
17
18 class ConfigLoader:
19 def __init__(self, config_path: Optional[str] = None):
20 self.config_path = config_path
21 self.config = self._load_config()
22
23 def _load_config(self) -> Dict[str, Any]:
24 config = deepcopy(DEFAULT_CONFIG)
25 override_sources: List[Dict[str, Any]] = []
26
27 if self.config_path and os.path.exists(self.config_path):
28 with open(self.config_path, "r", encoding="utf-8") as f:
29 file_config = yaml.safe_load(f) or {}
30 config = self._merge_config(config, file_config)
31 override_sources.append(file_config)
32
33 env_config = self._load_env_config()
34 if env_config:
35 config = self._merge_config(config, env_config)
36 override_sources.append(env_config)
37
38 return self._normalize_mix_aliases(config, override_sources)
39
40 def _merge_config(self, base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
41 result = base.copy()
42 for key, value in override.items():
43 if key in result and isinstance(result[key], dict) and isinstance(value, dict):
44 result[key] = self._merge_config(result[key], value)
45 else:
46 result[key] = value
47 return result
48
49 def _load_env_config(self) -> Dict[str, Any]:
50 env_config = {}
51 if os.getenv("DOUYIN_COOKIE"):
52 env_config["cookie"] = os.getenv("DOUYIN_COOKIE")
53 if os.getenv("DOUYIN_PATH"):
54 env_config["path"] = os.getenv("DOUYIN_PATH")
55 if os.getenv("DOUYIN_THREAD"):
56 try:
57 env_config["thread"] = int(os.getenv("DOUYIN_THREAD"))
58 except (TypeError, ValueError):
59 logger.warning(
60 "Invalid DOUYIN_THREAD value: %s, ignoring",
61 os.getenv("DOUYIN_THREAD"),
62 )
63 if os.getenv("DOUYIN_PROXY"):
64 env_config["proxy"] = os.getenv("DOUYIN_PROXY")
65 return env_config
66
67 def _normalize_mix_aliases(
68 self, config: Dict[str, Any], override_sources: List[Dict[str, Any]]
69 ) -> Dict[str, Any]:
70 # canonical key 为 mix,allmix 作为兼容别名保留并同步
71 normalization_rules = (
72 ("number", 0),
73 ("increase", False),
74 )
75 for section, default_value in normalization_rules:
76 section_config = config.get(section)
77 if not isinstance(section_config, dict):
78 section_config = {}
79 config[section] = section_config
80
81 mix_value = section_config.get("mix")
82 allmix_value = section_config.get("allmix")
83 section_default = DEFAULT_CONFIG.get(section, {})
84 default_mix_value = (
85 section_default.get("mix", default_value)
86 if isinstance(section_default, dict)
87 else default_value
88 )
89 default_allmix_value = (
90 section_default.get("allmix", default_value)
91 if isinstance(section_default, dict)
92 else default_value
93 )
94
95 mix_is_default = mix_value == default_mix_value
96 allmix_is_default = allmix_value == default_allmix_value
97
98 mix_explicit = self._is_key_explicit_in_sources(override_sources, section, "mix")
99 allmix_explicit = self._is_key_explicit_in_sources(override_sources, section, "allmix")
100
101 if mix_explicit:
102 canonical_value = mix_value
103 if allmix_explicit and allmix_value != mix_value:
104 logger.warning(
105 "mix/allmix conflict detected in %s: mix=%s, allmix=%s; using mix=%s",
106 section,
107 mix_value,
108 allmix_value,
109 mix_value,
110 )
111 elif allmix_explicit:
112 canonical_value = allmix_value
113 elif not mix_is_default:
114 canonical_value = mix_value
115 if not allmix_is_default and allmix_value != mix_value:
116 logger.warning(
117 "mix/allmix conflict detected in %s: mix=%s, allmix=%s; using mix=%s",
118 section,
119 mix_value,
120 allmix_value,
121 mix_value,
122 )
123 elif not allmix_is_default:
124 canonical_value = allmix_value
125 else:
126 canonical_value = default_value
127
128 section_config["mix"] = canonical_value
129 section_config["allmix"] = canonical_value
130
131 return config
132
133 @staticmethod
134 def _is_key_explicit_in_sources(sources: List[Dict[str, Any]], section: str, key: str) -> bool:
135 for source in sources:
136 if not isinstance(source, dict):
137 continue
138 section_value = source.get(section)
139 if isinstance(section_value, dict) and key in section_value:
140 return True
141 return False
142
143 def update(self, **kwargs):
144 for key, value in kwargs.items():
145 if key in self.config:
146 if isinstance(self.config[key], dict) and isinstance(value, dict):
147 self.config[key].update(value)
148 else:
149 self.config[key] = value
150 else:
151 self.config[key] = value
152
153 # Keys that the desktop Settings UI lets the user edit. ``save()`` writes
154 # these back to the YAML config so changes survive a sidecar restart.
155 # Kept explicit rather than dumping everything so we don't accidentally
156 # persist runtime/secret values (cookies, links, etc.) that should stay
157 # out of the on-disk config, and so fields the user added manually to
158 # their config.yml are left untouched.
159 _UI_PERSISTED_KEYS = (
160 "path",
161 "thread",
162 "rate_limit",
163 "cover",
164 "music",
165 "avatar",
166 "json",
167 "download_pinned",
168 "proxy",
169 "retry_times",
170 "folderstyle",
171 "filename_template",
172 "folder_template",
173 "comments",
174 "live",
175 "transcript",
176 "notifications",
177 )
178
179 def save(self) -> bool:
180 """Persist UI-editable keys back to ``self.config_path``.
181
182 Returns True when a file was written, False when no config path is
183 set (e.g. ``ConfigLoader(None)`` in unit tests). Any existing keys
184 the user put in the YAML file manually are preserved by merging
185 the UI keys on top of the previously loaded file contents.
186
187 I/O errors are logged and surfaced via the return value rather than
188 raised, so a read-only disk doesn't take down the HTTP handler that
189 called us.
190 """
191 if not self.config_path:
192 return False
193 target = Path(self.config_path)
194 try:
195 target.parent.mkdir(parents=True, exist_ok=True)
196 except OSError as exc:
197 logger.warning("Cannot create config directory %s: %s", target.parent, exc)
198 return False
199
200 existing: Dict[str, Any] = {}
201 if target.exists():
202 try:
203 with open(target, "r", encoding="utf-8") as handle:
204 loaded = yaml.safe_load(handle)
205 if isinstance(loaded, dict):
206 existing = loaded
207 except (yaml.YAMLError, OSError) as exc:
208 logger.warning(
209 "Failed to read existing config %s for merge: %s; "
210 "falling back to UI-only snapshot",
211 target,
212 exc,
213 )
214 existing = {}
215
216 for key in self._UI_PERSISTED_KEYS:
217 if key in self.config:
218 value = self.config[key]
219 # Defensive copy so a later ``config.update`` on the same
220 # process doesn't mutate what we just serialised.
221 if isinstance(value, dict):
222 value = deepcopy(value)
223 elif isinstance(value, list):
224 value = list(value)
225 existing[key] = value
226
227 try:
228 with open(target, "w", encoding="utf-8") as handle:
229 yaml.safe_dump(existing, handle, allow_unicode=True, sort_keys=False)
230 except OSError as exc:
231 logger.warning("Failed to write config %s: %s", target, exc)
232 return False
233
234 # ``transcript.api_key`` (Requirement 5) and other potentially
235 # sensitive fields land inside this file as plaintext. On POSIX
236 # we tighten permissions to 0o600 (owner read/write only) right
237 # after the write so a co-located malicious process or another
238 # local user can't ``cat`` the key. On Windows we skip — POSIX
239 # mode bits aren't meaningful, ACLs are the right knob, and
240 # changing them belongs to a separate hardening task.
241 if sys.platform != "win32":
242 try:
243 os.chmod(target, 0o600)
244 except OSError as exc:
245 logger.warning(
246 "settings_chmod_failed: path=%s error=%r", target, exc
247 )
248 return True
249
250 def get(self, key: str, default: Any = None) -> Any:
251 return self.config.get(key, default)
252
253 def get_cookies(self) -> Dict[str, str]:
254 cookies_config = self.config.get("cookies") or self.config.get("cookie")
255
256 if isinstance(cookies_config, str):
257 if cookies_config.strip().lower() == "auto":
258 return self._load_auto_cookies()
259 return self._parse_cookie_string(cookies_config)
260 elif isinstance(cookies_config, dict):
261 return sanitize_cookies(cookies_config)
262 if self._auto_cookie_enabled():
263 return self._load_auto_cookies()
264 return {}
265
266 def _parse_cookie_string(self, cookie_str: str) -> Dict[str, str]:
267 return sanitize_cookies(parse_cookie_header(cookie_str))
268
269 def _auto_cookie_enabled(self) -> bool:
270 raw_value = self.config.get("auto_cookie")
271 if isinstance(raw_value, str):
272 return raw_value.strip().lower() in {"1", "true", "yes", "on"}
273 return bool(raw_value)
274
275 def _load_auto_cookies(self) -> Dict[str, str]:
276 for path in self._candidate_auto_cookie_paths():
277 cookies = self._load_cookie_file(path)
278 if cookies is None:
279 continue
280 if cookies:
281 logger.info("Loaded auto cookies from %s", path)
282 return cookies
283 return {}
284
285 def _candidate_auto_cookie_paths(self) -> List[Path]:
286 config_dir = (
287 Path(self.config_path).resolve().parent if self.config_path else Path.cwd().resolve()
288 )
289 search_roots = [
290 config_dir,
291 config_dir.parent,
292 Path.cwd().resolve(),
293 ]
294 candidates: List[Path] = []
295 for root in search_roots:
296 candidates.extend(
297 [
298 root / "config" / "cookies.json",
299 root / ".cookies.json",
300 ]
301 )
302
303 unique: List[Path] = []
304 seen: set[str] = set()
305 for candidate in candidates:
306 resolved = str(candidate.resolve())
307 if resolved in seen:
308 continue
309 seen.add(resolved)
310 unique.append(candidate)
311 return unique
312
313 @staticmethod
314 def _load_cookie_file(path: Path) -> Optional[Dict[str, str]]:
315 if not path.exists():
316 return None
317 try:
318 raw_data = json.loads(path.read_text(encoding="utf-8"))
319 except Exception as exc:
320 logger.warning("Failed to load auto cookie file %s: %s", path, exc)
321 return {}
322
323 if raw_data is None:
324 return {}
325 if not isinstance(raw_data, dict):
326 logger.warning("Auto cookie file %s is not a JSON object", path)
327 return {}
328 return sanitize_cookies(raw_data)
329
330 def get_links(self) -> List[str]:
331 links = self.config.get("link", [])
332 if isinstance(links, str):
333 return [links]
334 return links
335
336 def validate(self) -> bool:
337 if not self.get_links():
338 return False
339 if not self.config.get("path"):
340 return False
341
342 thread = self.config.get("thread")
343 if thread is not None:
344 try:
345 thread_val = int(thread)
346 if thread_val < 1:
347 raise ValueError
348 self.config["thread"] = thread_val
349 except (TypeError, ValueError):
350 logger.warning("Invalid thread value: %s, using default 5", thread)
351 self.config["thread"] = 5
352
353 retry_times = self.config.get("retry_times")
354 if retry_times is not None:
355 try:
356 retry_val = int(retry_times)
357 if retry_val < 0:
358 raise ValueError
359 self.config["retry_times"] = retry_val
360 except (TypeError, ValueError):
361 logger.warning("Invalid retry_times value: %s, using default 3", retry_times)
362 self.config["retry_times"] = 3
363
364 for field in ("start_time", "end_time"):
365 value = self.config.get(field)
366 if value and isinstance(value, str):
367 from datetime import datetime
368
369 try:
370 datetime.strptime(value, "%Y-%m-%d")
371 except ValueError:
372 logger.warning(
373 "Invalid %s format: %s (expected YYYY-MM-DD), clearing", field, value
374 )
375 self.config[field] = ""
376
377 return True
378
378 lines PYTHON