Coverage for src/config_parser.py: 100%

402 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-08 21:05 +0000

1"""Config file parser. 

2 

3This module provides high-level configuration retrieval functions. 

4Low-level utilities are in config_utils.py, logging in config_logging.py, 

5and filesystem operations in filesystem_utils.py per SRP. 

6""" 

7 

8__author__ = "Mandar Patil (mandarons@pm.me)" 

9 

10import multiprocessing 

11from typing import Any 

12 

13from icloudpy.services.photos import PhotoAsset 

14 

15from src import ( 

16 DEFAULT_DRIVE_DESTINATION, 

17 DEFAULT_ENUMERATION_CHUNK_SIZE, 

18 DEFAULT_OBSOLETE_DELETE_LIMIT_PERCENT, 

19 DEFAULT_PHOTOS_DESTINATION, 

20 DEFAULT_REQUEST_TIMEOUT_SEC, 

21 DEFAULT_RETRY_LOGIN_INTERVAL_SEC, 

22 DEFAULT_ROOT_DESTINATION, 

23 DEFAULT_SYNC_INTERVAL_SEC, 

24 configure_icloudpy_logging, 

25 get_logger, 

26) 

27from src.config_logging import ( 

28 log_config_debug, 

29 log_config_error, 

30 log_config_found_info, 

31 log_config_not_found_warning, 

32 log_invalid_config_value, 

33) 

34from src.config_utils import ( 

35 config_path_to_string, 

36 get_config_value, 

37 get_config_value_or_default, 

38 get_config_value_or_none, 

39 traverse_config_path, 

40) 

41from src.filesystem_utils import ensure_directory_exists, join_and_ensure_path 

42 

43# Configure icloudpy logging immediately after import 

44configure_icloudpy_logging() 

45 

46LOGGER = get_logger() 

47 

48# Cache for config values to prevent repeated warnings 

49_config_warning_cache = set() 

50 

51 

52def _log_config_warning_once(config_path: list, message: str) -> None: 

53 """Log a configuration warning only once for the given config path. 

54 

55 Args: 

56 config_path: Configuration path as list 

57 message: Warning message to log 

58 """ 

59 config_path_key = config_path_to_string(config_path) 

60 if config_path_key not in _config_warning_cache: 

61 _config_warning_cache.add(config_path_key) 

62 log_config_not_found_warning(config_path, message) 

63 

64 

65def clear_config_warning_cache() -> None: 

66 """Clear the configuration warning cache. 

67 

68 This function is primarily intended for testing purposes to ensure 

69 clean test isolation. 

70 """ 

71 _config_warning_cache.clear() 

72 

73 

74# ============================================================================= 

75# String Processing Functions 

76# ============================================================================= 

77 

78 

79def validate_and_strip_username(username: str, config_path: list[str]) -> str | None: 

80 """Validate and strip username string. 

81 

82 Args: 

83 username: Raw username string from config 

84 config_path: Config path for error logging 

85 

86 Returns: 

87 Stripped username if valid, None if empty 

88 """ 

89 username = username.strip() 

90 if len(username) == 0: 

91 log_config_error(config_path, "username is empty") 

92 return None 

93 return username 

94 

95 

96# ============================================================================= 

97# Credential Configuration Functions 

98# ============================================================================= 

99 

100 

101def get_username(config: dict) -> str | None: 

102 """Get username from config. 

103 

104 Args: 

105 config: Configuration dictionary 

106 

107 Returns: 

108 Username string if found and valid, None otherwise 

109 """ 

110 config_path = ["app", "credentials", "username"] 

111 if not traverse_config_path(config=config, config_path=config_path): 

112 log_config_error(config_path, "username is missing. Please set the username.") 

113 return None 

114 

115 username = get_config_value(config=config, config_path=config_path) 

116 return validate_and_strip_username(username, config_path) 

117 

118 

119def get_retry_login_interval(config: dict, log_messages: bool = True) -> int: 

120 """Return retry login interval from config. 

121 

122 Args: 

123 config: Configuration dictionary 

124 log_messages: Whether to log informational messages (default: True). 

125 Callers that reuse this interval for something other than a 

126 login retry pass False, so the log does not claim a login is 

127 being retried when it is not. 

128 

129 Returns: 

130 Retry login interval in seconds 

131 """ 

132 config_path = ["app", "credentials", "retry_login_interval"] 

133 

134 if not traverse_config_path(config=config, config_path=config_path): 

135 retry_login_interval = DEFAULT_RETRY_LOGIN_INTERVAL_SEC 

136 if log_messages: 

137 log_config_not_found_warning( 

138 config_path, 

139 f"not found. Using default {retry_login_interval} seconds ...", 

140 ) 

141 else: 

142 retry_login_interval = get_config_value(config=config, config_path=config_path) 

143 if log_messages: 

144 log_config_found_info(f"Retrying login every {retry_login_interval} seconds.") 

145 

146 return retry_login_interval 

147 

148 

149def get_region(config: dict) -> str: 

150 """Return region from config. 

151 

152 Args: 

153 config: Configuration dictionary 

154 

155 Returns: 

156 Region string ('global' or 'china') 

157 """ 

158 config_path = ["app", "region"] 

159 region = get_config_value_or_default( 

160 config=config, config_path=config_path, default="global", 

161 ) 

162 

163 if region == "global" and not traverse_config_path( 

164 config=config, config_path=config_path, 

165 ): 

166 log_config_not_found_warning( 

167 config_path, "not found. Using default value - global ...", 

168 ) 

169 elif region not in ["global", "china"]: 

170 log_config_error( 

171 config_path, 

172 "is invalid. Valid values are - global or china. Using default value - global ...", 

173 ) 

174 region = "global" 

175 

176 return region 

177 

178 

179# ============================================================================= 

180# Sync Interval Configuration Functions 

181# ============================================================================= 

182 

183 

184def get_sync_interval( 

185 config: dict, config_path: list[str], service_name: str, log_messages: bool = True, 

186) -> int: 

187 """Get sync interval for a service (drive or photos). 

188 

189 Extracted common logic for retrieving sync intervals. 

190 

191 Args: 

192 config: Configuration dictionary 

193 config_path: Path to sync_interval config 

194 service_name: Name of service for logging ("drive" or "photos") 

195 log_messages: Whether to log informational messages (default: True) 

196 

197 Returns: 

198 Sync interval in seconds 

199 """ 

200 sync_interval = get_config_value_or_default( 

201 config=config, 

202 config_path=config_path, 

203 default=DEFAULT_SYNC_INTERVAL_SEC, 

204 ) 

205 

206 # 0 is neither an interval nor one-shot (negative): a service that is 

207 # always due would sync back to back with no sleep, which is the 

208 # fastest way to get throttled by Apple. Treat it as unset. 

209 if sync_interval == 0: 

210 if log_messages: 

211 log_invalid_config_value( 

212 config_path, 

213 sync_interval, 

214 f"a positive number of seconds, or negative for one-shot. Using default {DEFAULT_SYNC_INTERVAL_SEC}", 

215 ) 

216 sync_interval = DEFAULT_SYNC_INTERVAL_SEC 

217 

218 if log_messages: 

219 if sync_interval == DEFAULT_SYNC_INTERVAL_SEC: 

220 log_config_not_found_warning( 

221 config_path, 

222 f"is not found. Using default sync_interval: {sync_interval} seconds ...", 

223 ) 

224 else: 

225 log_config_found_info( 

226 f"Syncing {service_name} every {sync_interval} seconds.", 

227 ) 

228 

229 return sync_interval 

230 

231 

232def get_drive_sync_interval(config: dict, log_messages: bool = True) -> int: 

233 """Return drive sync interval from config. 

234 

235 Args: 

236 config: Configuration dictionary 

237 log_messages: Whether to log informational messages (default: True) 

238 

239 Returns: 

240 Drive sync interval in seconds 

241 """ 

242 config_path = ["drive", "sync_interval"] 

243 return get_sync_interval( 

244 config=config, 

245 config_path=config_path, 

246 service_name="drive", 

247 log_messages=log_messages, 

248 ) 

249 

250 

251def get_drive_request_timeout(config: dict) -> int: 

252 """Return drive request timeout from config. 

253 

254 Args: 

255 config: Configuration dictionary 

256 

257 Returns: 

258 Request timeout in seconds (default: DEFAULT_REQUEST_TIMEOUT_SEC) 

259 """ 

260 config_path = ["drive", "request_timeout"] 

261 return get_config_value_or_default( 

262 config=config, 

263 config_path=config_path, 

264 default=DEFAULT_REQUEST_TIMEOUT_SEC, 

265 ) 

266 

267 

268def get_photos_sync_interval(config: dict, log_messages: bool = True) -> int: 

269 """Return photos sync interval from config. 

270 

271 Args: 

272 config: Configuration dictionary 

273 log_messages: Whether to log informational messages (default: True) 

274 

275 Returns: 

276 Photos sync interval in seconds 

277 """ 

278 config_path = ["photos", "sync_interval"] 

279 return get_sync_interval( 

280 config=config, 

281 config_path=config_path, 

282 service_name="photos", 

283 log_messages=log_messages, 

284 ) 

285 

286 

287# ============================================================================= 

288# Thread Configuration Functions 

289# ============================================================================= 

290 

291 

292def calculate_default_max_threads() -> int: 

293 """Calculate default maximum threads based on CPU cores. 

294 

295 Returns: 

296 Default max threads (min of CPU count and 8) 

297 """ 

298 return min(multiprocessing.cpu_count(), 8) 

299 

300 

301def parse_max_threads_value(max_threads_config: Any, default_max_threads: int) -> int: 

302 """Parse and validate max_threads configuration value. 

303 

304 Args: 

305 max_threads_config: Raw config value (string "auto" or integer) 

306 default_max_threads: Default value to use 

307 

308 Returns: 

309 Validated max threads value (capped at 16) 

310 """ 

311 # Handle "auto" value 

312 if isinstance(max_threads_config, str) and max_threads_config.lower() == "auto": 

313 max_threads = default_max_threads 

314 log_config_found_info( 

315 f"Using automatic thread count: {max_threads} threads (based on CPU cores).", 

316 ) 

317 elif isinstance(max_threads_config, int) and max_threads_config >= 1: 

318 max_threads = min( 

319 max_threads_config, 16, 

320 ) # Cap at 16 to avoid overwhelming servers 

321 log_config_found_info(f"Using configured max_threads: {max_threads}.") 

322 else: 

323 log_invalid_config_value( 

324 ["app", "max_threads"], 

325 max_threads_config, 

326 "'auto' or integer >= 1", 

327 ) 

328 max_threads = default_max_threads 

329 

330 return max_threads 

331 

332 

333def get_web_ui_enabled(config: dict) -> bool: 

334 """Return whether the embedded web UI should start on container boot. 

335 

336 Default: **False** — opt-in. Existing mandarons installs see no 

337 behaviour change; only users who explicitly set 

338 ``app.web_ui.enabled: true`` open the port. 

339 """ 

340 return bool( 

341 get_config_value_or_default( 

342 config=config, 

343 config_path=["app", "web_ui", "enabled"], 

344 default=False, 

345 ), 

346 ) 

347 

348 

349def get_web_ui_host(config: dict) -> str: 

350 """Web UI bind address. 

351 

352 Default ``127.0.0.1`` — the web UI accepts the user's Apple ID 

353 password on POST /auth/password with no built-in authentication and 

354 no CSRF token (the feature assumes a reverse-proxy trust boundary 

355 in front of it). Defaulting to loopback means the credential- 

356 accepting form is never exposed to LAN/public on a vanilla install. 

357 Users who run behind a reverse proxy or want explicit LAN exposure 

358 set ``app.web_ui.host: 0.0.0.0`` consciously. 

359 """ 

360 return str( 

361 get_config_value_or_default( 

362 config=config, 

363 config_path=["app", "web_ui", "host"], 

364 default="127.0.0.1", 

365 ), 

366 ) 

367 

368 

369def get_web_ui_port(config: dict) -> int: 

370 """Web UI TCP port. Default ``8080``. Coexists with mandarons' legacy 

371 ``EXPOSE 80`` (unused) — no port collision.""" 

372 return int( 

373 get_config_value_or_default( 

374 config=config, 

375 config_path=["app", "web_ui", "port"], 

376 default=8080, 

377 ), 

378 ) 

379 

380 

381def get_web_ui_public_url(config: dict) -> str | None: 

382 """Public-facing URL for the web UI, used in notifications. 

383 

384 The daemon only knows its local bind host:port; the user-facing URL 

385 (e.g. ``https://icloud.zosia.io`` behind a reverse proxy) must be 

386 declared explicitly. When unset, notifications fall back to 

387 ``http://{host}:{port}`` and log a one-shot warning at startup. 

388 """ 

389 value = get_config_value_or_default( 

390 config=config, 

391 config_path=["app", "web_ui", "public_url"], 

392 default=None, 

393 ) 

394 if value is None: 

395 return None 

396 return str(value).rstrip("/") 

397 

398 

399def get_trust_expiry_warn_days(config: dict) -> int: 

400 """Warn this many days before Apple's trust cookie expires. 

401 

402 Default 7. The check + notification fires once per cookie-lifetime 

403 when ``trust_days_remaining`` first drops below this threshold so 

404 the user can tap refresh-trust *before* the sync loop hits a 

405 failed-auth state. 

406 """ 

407 return int( 

408 get_config_value_or_default( 

409 config=config, 

410 config_path=["app", "trust_expiry_warn_days"], 

411 default=7, 

412 ), 

413 ) 

414 

415 

416def get_trust_refresh_days(config: dict) -> int: 

417 """Proactively re-trust when the cookie has this many days left. 

418 

419 Default 14. Apple's trust window is finite, but ``trust_session`` 

420 mints a fresh token whenever it is called on a live session, so 

421 re-trusting on a schedule keeps the *persisted* token young. That is 

422 what lets a container restart resume without a second factor -- the 

423 common failure mode otherwise is a long-running process holding a 

424 valid in-memory session while the token on disk quietly expires, 

425 stranding the next cold start on a 2FA prompt. 

426 

427 Must exceed ``trust_expiry_warn_days`` for the refresh to get a 

428 chance before the warning fires. Set to 0 to disable. 

429 """ 

430 refresh_days = int( 

431 get_config_value_or_default( 

432 config=config, 

433 config_path=["app", "trust_refresh_days"], 

434 default=14, 

435 ), 

436 ) 

437 _warn_if_refresh_cannot_precede_warning(config, refresh_days) 

438 return refresh_days 

439 

440 

441def _warn_if_refresh_cannot_precede_warning(config: dict, refresh_days: int) -> None: 

442 """Say so once when the refresh threshold is at or below the warn threshold. 

443 

444 The refresh exists so the expiry warning never has to fire. With 

445 ``trust_refresh_days <= trust_expiry_warn_days`` the warning always fires 

446 first, and the user is told to re-authenticate a session the container 

447 was about to renew on its own. Not an error -- both still work -- so it 

448 is a one-time warning rather than a rejected config. 

449 """ 

450 if refresh_days <= 0: 

451 return 

452 warn_days = get_trust_expiry_warn_days(config=config) 

453 key = "app > trust_refresh_days <= trust_expiry_warn_days" 

454 if refresh_days <= warn_days and key not in _config_warning_cache: 

455 _config_warning_cache.add(key) 

456 LOGGER.warning( 

457 f"app.trust_refresh_days ({refresh_days}) is not above " 

458 f"app.trust_expiry_warn_days ({warn_days}), so the expiry warning " 

459 f"will fire before the proactive refresh gets a chance. Raise " 

460 f"trust_refresh_days, or lower trust_expiry_warn_days.", 

461 ) 

462 

463 

464def get_app_max_threads(config: dict) -> int: 

465 """Return app-level max threads from config with support for 'auto' value. 

466 

467 Args: 

468 config: Configuration dictionary 

469 

470 Returns: 

471 Maximum number of threads for parallel operations 

472 """ 

473 default_max_threads = calculate_default_max_threads() 

474 config_path = ["app", "max_threads"] 

475 

476 if not traverse_config_path(config=config, config_path=config_path): 

477 log_config_debug( 

478 f"max_threads is not found in {config_path_to_string(config_path=config_path)}. " 

479 f"Using default max_threads: {default_max_threads} (auto) ...", 

480 ) 

481 return default_max_threads 

482 

483 max_threads_config = get_config_value(config=config, config_path=config_path) 

484 return parse_max_threads_value(max_threads_config, default_max_threads) 

485 

486 

487def get_mount_marker_filename(config: dict) -> str: 

488 """Return the filename used as the mount-failsafe marker (default `.mounted`). 

489 

490 The marker is the empty/sentinel file the user touches in a destination 

491 directory to assert "this path is correctly bind-mounted". The mount 

492 checker (``sync._check_mount_marker``) refuses to sync into a destination 

493 that lacks this file when ``require_mount_marker`` is enabled. 

494 

495 Args: 

496 config: Configuration dictionary 

497 

498 Returns: 

499 Filename string (relative to each destination directory). 

500 """ 

501 config_path = ["app", "mount_marker_filename"] 

502 return str(get_config_value_or_default(config=config, config_path=config_path, default=".mounted")) 

503 

504 

505def get_usage_tracking_enabled(config: dict) -> bool: 

506 """Get usage tracking enabled setting from configuration. 

507 

508 Args: 

509 config: Configuration dictionary 

510 

511 Returns: 

512 True if usage tracking is enabled (default), False if disabled 

513 """ 

514 config_path = ["app", "usage_tracking", "enabled"] 

515 if not traverse_config_path(config=config, config_path=config_path): 

516 return True # Default to enabled if not configured 

517 

518 value = get_config_value(config=config, config_path=config_path) 

519 if isinstance(value, bool): 

520 return value 

521 

522 # Handle string values for backwards compatibility 

523 if isinstance(value, str): 

524 return value.lower() not in ("false", "no", "0", "disabled", "off") 

525 

526 # Default to enabled for any other type 

527 return True 

528 

529 

530# ============================================================================= 

531# Root Destination Functions 

532# ============================================================================= 

533 

534 

535def get_root_destination_path(config: dict) -> str: 

536 """Get root destination path from config without creating directory. 

537 

538 Args: 

539 config: Configuration dictionary 

540 

541 Returns: 

542 Root destination path string 

543 """ 

544 config_path = ["app", "root"] 

545 root_destination = get_config_value_or_default( 

546 config=config, 

547 config_path=config_path, 

548 default=DEFAULT_ROOT_DESTINATION, 

549 ) 

550 

551 if not traverse_config_path(config=config, config_path=config_path): 

552 log_config_not_found_warning( 

553 config_path, 

554 f"root destination is missing. Using default root destination: {root_destination}", 

555 ) 

556 

557 return root_destination 

558 

559 

560def prepare_root_destination(config: dict) -> str: 

561 """Prepare root destination by creating directory if needed. 

562 

563 Args: 

564 config: Configuration dictionary 

565 

566 Returns: 

567 Absolute path to root destination directory 

568 """ 

569 log_config_debug("Checking root destination ...") 

570 root_destination = get_root_destination_path(config) 

571 return ensure_directory_exists(root_destination) 

572 

573 

574# ============================================================================= 

575# Drive Configuration Functions 

576# ============================================================================= 

577 

578 

579def get_drive_destination_path(config: dict) -> str: 

580 """Get drive destination path from config without creating directory. 

581 

582 Args: 

583 config: Configuration dictionary 

584 

585 Returns: 

586 Drive destination path string 

587 """ 

588 config_path = ["drive", "destination"] 

589 drive_destination = get_config_value_or_default( 

590 config=config, 

591 config_path=config_path, 

592 default=DEFAULT_DRIVE_DESTINATION, 

593 ) 

594 

595 if not traverse_config_path(config=config, config_path=config_path): 

596 log_config_not_found_warning( 

597 config_path, 

598 f"destination is missing. Using default drive destination: {drive_destination}.", 

599 ) 

600 

601 return drive_destination 

602 

603 

604def prepare_drive_destination(config: dict) -> str: 

605 """Prepare drive destination path by creating directory if needed. 

606 

607 Args: 

608 config: Configuration dictionary 

609 

610 Returns: 

611 Absolute path to drive destination directory 

612 """ 

613 log_config_debug("Checking drive destination ...") 

614 root_path = prepare_root_destination(config=config) 

615 drive_destination = get_drive_destination_path(config) 

616 return join_and_ensure_path(root_path, drive_destination) 

617 

618 

619def get_drive_remove_obsolete(config: dict) -> bool: 

620 """Return drive remove obsolete flag from config. 

621 

622 Args: 

623 config: Configuration dictionary 

624 

625 Returns: 

626 True if obsolete files should be removed, False otherwise 

627 """ 

628 config_path = ["drive", "remove_obsolete"] 

629 drive_remove_obsolete = get_config_value_or_default( 

630 config=config, config_path=config_path, default=False, 

631 ) 

632 

633 if not drive_remove_obsolete: 

634 _log_config_warning_once( 

635 config_path, 

636 "remove_obsolete is not found. Not removing the obsolete files and folders.", 

637 ) 

638 else: 

639 log_config_debug( 

640 f"{'R' if drive_remove_obsolete else 'Not R'}emoving obsolete files and folders ...", 

641 ) 

642 

643 return drive_remove_obsolete 

644 

645 

646def get_drive_flatten_packages(config: dict | None) -> bool: 

647 """Return whether iCloud Drive package downloads should be kept on 

648 disk as single binary files (zip / gzip bytes) instead of being 

649 unpacked into bundle directories. 

650 

651 Default ``False`` — preserves the historical mandarons behaviour 

652 of unpacking ``.band``-style bundles so the local representation 

653 mirrors macOS's directory-bundle semantics. 

654 

655 Setting this to ``True`` is appropriate for backup-style deployments 

656 (NAS, cold storage) where the operator prefers: 

657 

658 - **Single-file storage** — one mtime / size per package for 

659 simpler dedup, restoration, and round-trip back to iCloud. 

660 - **No internal name collisions** — two iWork files in the same 

661 folder normally share bare-pathed internal entries like 

662 ``Data/Document.iwa``; unpacking both into the same parent dir 

663 raises ``FileExistsError``. Skipping unpack avoids this entirely. 

664 - **Lower inode footprint** — large bundles often expand to 

665 hundreds of small files on disk. 

666 

667 Args: 

668 config: Configuration dictionary (None ok). 

669 

670 Returns: 

671 bool — True if packages should be kept as single files. 

672 """ 

673 if not config: 

674 return False 

675 config_path = ["drive", "flatten_packages"] 

676 return bool( 

677 get_config_value_or_default( 

678 config=config, config_path=config_path, default=False, 

679 ), 

680 ) 

681 

682 

683def get_drive_require_mount_marker(config: dict) -> bool: 

684 """Return whether Drive sync requires the mount-failsafe marker file. 

685 

686 When True, ``sync._check_mount_marker`` refuses to start a Drive sync 

687 until the marker file (see ``get_mount_marker_filename``) exists in 

688 the Drive destination directory. Protects against silent bind-mount 

689 failures dumping iCloud Drive content into the wrong place. 

690 

691 Default: False (preserves historical behaviour — no breaking change). 

692 

693 Args: 

694 config: Configuration dictionary 

695 

696 Returns: 

697 True if the marker is required before each Drive sync. 

698 """ 

699 config_path = ["drive", "require_mount_marker"] 

700 return bool(get_config_value_or_default(config=config, config_path=config_path, default=False)) 

701 

702 

703# ============================================================================= 

704# Photos Configuration Functions 

705# ============================================================================= 

706 

707 

708def get_photos_enumeration_chunk_size(config: dict | None) -> int: 

709 """Tasks to buffer before draining via execute_parallel_downloads. 

710 

711 Smaller = lower peak memory, more per-chunk HTTP setup overhead. 

712 Larger = higher peak memory, fewer chunks. Default 1000 keeps 

713 resident set at ~10 MB on typical libraries while still amortising 

714 connection setup. Tested empirically on a 111K-photo library: 

715 1000 sustained < 1 GB resident through the full enumeration. 

716 

717 Args: 

718 config: Configuration dictionary (None falls back to default). 

719 

720 Returns: 

721 Positive integer chunk size, defaulting to 1000. 

722 """ 

723 raw = get_config_value_or_default( 

724 config=config or {}, 

725 config_path=["photos", "enumeration_chunk_size"], 

726 default=DEFAULT_ENUMERATION_CHUNK_SIZE, 

727 ) 

728 try: 

729 value = int(raw) 

730 except (TypeError, ValueError): 

731 return DEFAULT_ENUMERATION_CHUNK_SIZE 

732 return value if value > 0 else DEFAULT_ENUMERATION_CHUNK_SIZE 

733 

734 

735def get_photos_destination_path(config: dict) -> str: 

736 """Get photos destination path from config without creating directory. 

737 

738 Args: 

739 config: Configuration dictionary 

740 

741 Returns: 

742 Photos destination path string 

743 """ 

744 config_path = ["photos", "destination"] 

745 photos_destination = get_config_value_or_default( 

746 config=config, 

747 config_path=config_path, 

748 default=DEFAULT_PHOTOS_DESTINATION, 

749 ) 

750 

751 if not traverse_config_path(config=config, config_path=config_path): 

752 log_config_not_found_warning( 

753 config_path, 

754 f"destination is missing. Using default photos destination: {config_path_to_string(config_path)}", 

755 ) 

756 

757 return photos_destination 

758 

759 

760def prepare_photos_destination(config: dict) -> str: 

761 """Prepare photos destination path by creating directory if needed. 

762 

763 Args: 

764 config: Configuration dictionary 

765 

766 Returns: 

767 Absolute path to photos destination directory 

768 """ 

769 log_config_debug("Checking photos destination ...") 

770 root_path = prepare_root_destination(config=config) 

771 photos_destination = get_photos_destination_path(config) 

772 return join_and_ensure_path(root_path, photos_destination) 

773 

774 

775def get_photos_all_albums(config: dict) -> bool: 

776 """Return flag to download all albums from config. 

777 

778 Args: 

779 config: Configuration dictionary 

780 

781 Returns: 

782 True if all albums should be synced, False otherwise 

783 """ 

784 config_path = ["photos", "all_albums"] 

785 download_all = get_config_value_or_default( 

786 config=config, config_path=config_path, default=False, 

787 ) 

788 

789 if download_all: 

790 log_config_found_info("Syncing all albums.") 

791 

792 return download_all 

793 

794 

795def get_photos_use_hardlinks(config: dict, log_messages: bool = True) -> bool: 

796 """Return flag to use hard links for duplicate photos from config. 

797 

798 Args: 

799 config: Configuration dictionary 

800 log_messages: Whether to log informational messages (default: True) 

801 

802 Returns: 

803 True if hard links should be used, False otherwise 

804 """ 

805 config_path = ["photos", "use_hardlinks"] 

806 use_hardlinks = get_config_value_or_default( 

807 config=config, config_path=config_path, default=False, 

808 ) 

809 

810 if use_hardlinks and log_messages: 

811 log_config_found_info("Using hard links for duplicate photos.") 

812 

813 return use_hardlinks 

814 

815 

816def get_photos_remove_obsolete(config: dict) -> bool: 

817 """Return photos remove obsolete flag from config. 

818 

819 Args: 

820 config: Configuration dictionary 

821 

822 Returns: 

823 True if obsolete files should be removed, False otherwise 

824 """ 

825 config_path = ["photos", "remove_obsolete"] 

826 photos_remove_obsolete = get_config_value_or_default( 

827 config=config, config_path=config_path, default=False, 

828 ) 

829 

830 if not photos_remove_obsolete: 

831 _log_config_warning_once( 

832 config_path, 

833 "remove_obsolete is not found. Not removing the obsolete files and folders.", 

834 ) 

835 else: 

836 log_config_debug( 

837 f"{'R' if photos_remove_obsolete else 'Not R'}emoving obsolete files and folders ...", 

838 ) 

839 

840 return photos_remove_obsolete 

841 

842 

843def get_photos_require_mount_marker(config: dict) -> bool: 

844 """Return whether Photos sync requires the mount-failsafe marker file. 

845 

846 When True, ``sync._check_mount_marker`` refuses to start a Photos sync 

847 until the marker file (see ``get_mount_marker_filename``) exists in 

848 the Photos destination directory. Protects against silent bind-mount 

849 failures dumping the entire iCloud photo library into the wrong place. 

850 

851 Default: False (preserves historical behaviour — no breaking change). 

852 

853 Args: 

854 config: Configuration dictionary 

855 

856 Returns: 

857 True if the marker is required before each Photos sync. 

858 """ 

859 config_path = ["photos", "require_mount_marker"] 

860 return bool(get_config_value_or_default(config=config, config_path=config_path, default=False)) 

861 

862 

863def get_photos_folder_format(config: dict) -> str | None: 

864 """Return filename format or None. 

865 

866 Args: 

867 config: Configuration dictionary 

868 

869 Returns: 

870 Folder format string if configured, None otherwise 

871 """ 

872 config_path = ["photos", "folder_format"] 

873 fmt = get_config_value_or_none(config=config, config_path=config_path) 

874 

875 if fmt: 

876 log_config_found_info(f"Using format {fmt}.") 

877 

878 return fmt 

879 

880 

881def get_photos_request_timeout(config: dict) -> int: 

882 """Return the HTTP read timeout for photo downloads, in seconds. 

883 

884 Drive downloads have had ``drive.request_timeout`` for a while; photo 

885 downloads had no timeout at all, so a stalled connection to Apple's CDN 

886 blocked its worker thread forever. The process stays alive and the 

887 container stays "healthy" while the sync never finishes. 

888 

889 This is a between-bytes timeout, not a total-transfer budget, so large 

890 videos are unaffected -- it only fires when the connection goes quiet. 

891 """ 

892 config_path = ["photos", "request_timeout"] 

893 

894 if not traverse_config_path(config=config, config_path=config_path): 

895 return DEFAULT_REQUEST_TIMEOUT_SEC 

896 

897 return get_config_value(config=config, config_path=config_path) 

898 

899 

900def get_photos_obsolete_delete_limit_percent(config: dict) -> int: 

901 """Share of a destination obsolete-cleanup may delete in one run. 

902 

903 Cleanup is the only destructive step in a sync and it infers deletions 

904 from the absence of a path in the run's tracked-file set, so any bug that 

905 leaves paths untracked is acted on at full speed. Above this share the 

906 run reports instead of deleting. 

907 

908 Args: 

909 config: Configuration dictionary 

910 

911 Returns: 

912 Percentage of a destination a single cleanup run may delete. 

913 ``DEFAULT_OBSOLETE_DELETE_LIMIT_PERCENT`` when unset; 0 disables 

914 the limit entirely. 

915 """ 

916 config_path = ["photos", "obsolete_delete_limit_percent"] 

917 

918 if not traverse_config_path(config=config, config_path=config_path): 

919 return DEFAULT_OBSOLETE_DELETE_LIMIT_PERCENT 

920 

921 return get_config_value(config=config, config_path=config_path) 

922 

923 

924# ============================================================================= 

925# Photos Filter Configuration Functions 

926# ============================================================================= 

927 

928 

929def validate_file_sizes(file_sizes: list[str]) -> list[str]: 

930 """Validate and filter file sizes against valid options. 

931 

932 Accepts any key in ``PhotoAsset.PHOTO_VERSION_LOOKUP``, including the 

933 ``live_video_*`` keys: add ``live_video_original`` to ``file_sizes`` to 

934 download the paired ``.mov`` of a Live Photo (and ``live_video_medium`` / 

935 ``live_video_thumb`` for smaller variants). Photos that aren't Live Photos 

936 simply don't have those versions and are skipped. 

937 

938 Args: 

939 file_sizes: List of file size strings to validate 

940 

941 Returns: 

942 List of valid file sizes (defaults to ["original"] if all invalid) 

943 """ 

944 valid_file_sizes = list(PhotoAsset.PHOTO_VERSION_LOOKUP.keys()) 

945 validated_sizes = [] 

946 

947 for file_size in file_sizes: 

948 if file_size not in valid_file_sizes: 

949 log_invalid_config_value( 

950 ["photos", "filters", "file_sizes"], 

951 file_size, 

952 ",".join(valid_file_sizes), 

953 ) 

954 else: 

955 validated_sizes.append(file_size) 

956 

957 return validated_sizes if validated_sizes else ["original"] 

958 

959 

960def get_photos_filename_format(config: dict) -> str: 

961 """Filename naming convention for downloaded photos. 

962 

963 - ``"metadata"`` (default, backward-compatible): ``name__filesize__base64id.ext`` 

964 — mandarons' historical format. Encodes the CloudKit asset ID into the 

965 filename so the same source photo can be unambiguously round-tripped to 

966 its iCloud record from the local file alone. 

967 - ``"simple"``: ``name.ext`` — the boredazfcuk/docker-icloudpd convention 

968 (and Apple's own download-from-icloud.com convention). Drops the 

969 metadata suffix entirely. Trade-off: rename collisions are possible 

970 (two distinct iCloud photos sharing the same human filename land on 

971 the same path on disk; ``collect_download_task`` detects this and 

972 falls back to the metadata-suffix name for the colliding photo so 

973 both files coexist). 

974 Useful for users migrating from boredazfcuk who want to point 

975 mandarons at their existing photo tree without triggering a full 

976 re-download — ``check_photo_exists`` compares by file size, not by 

977 filename suffix, so matching simple-format files are correctly 

978 treated as already-present. 

979 

980 Returns: 

981 Either ``"metadata"`` (default) or ``"simple"``. 

982 """ 

983 config_path = ["photos", "filename_format"] 

984 value = get_config_value_or_none(config=config, config_path=config_path) 

985 if value is None: 

986 return "metadata" 

987 value = str(value).lower().strip() 

988 if value not in ("metadata", "simple"): 

989 log_config_not_found_warning( 

990 config_path, f"unknown value {value!r}; falling back to 'metadata'", 

991 ) 

992 return "metadata" 

993 return value 

994 

995 

996def get_photos_file_format(config: dict) -> str | None: 

997 """Single filename template applied to all versions (``photos.file_format``). 

998 

999 Mirrors ``folder_format`` but for the filename. Uses ``${photo.*}`` tokens 

1000 (see ``photo_path_utils.render_filename_template``). When set it overrides 

1001 ``filename_format``. Returns ``None`` (use ``filename_format``) when unset. 

1002 """ 

1003 config_path = ["photos", "file_format"] 

1004 value = get_config_value_or_none(config=config, config_path=config_path) 

1005 if value is None: 

1006 return None 

1007 if not isinstance(value, str) or not value.strip(): 

1008 log_config_not_found_warning(config_path, "must be a non-empty template string; ignoring") 

1009 return None 

1010 return value 

1011 

1012 

1013def get_photos_variant_separator(config: dict) -> str: 

1014 """Separator inserted before the variant in ``${photo.variant_suffix}``. 

1015 

1016 Defaults to ``"_"``. Only appears when a version is non-primary (not 

1017 original/full), so primaries stay un-suffixed. 

1018 """ 

1019 config_path = ["photos", "variant_separator"] 

1020 value = get_config_value_or_none(config=config, config_path=config_path) 

1021 return str(value) if value is not None else "_" 

1022 

1023 

1024def get_photos_preserve_originals_as_bak(config: dict) -> bool: 

1025 """Whether to hide untouched-original copies of edited photos via ``.original.bak``. 

1026 

1027 When True AND ``photos.filters.file_sizes`` contains BOTH ``original`` 

1028 AND ``original_alt``, an edited photo lands as TWO files on disk: 

1029 

1030 - ``IMG_1234.JPG`` — the edited "current view" (visible) 

1031 - ``IMG_1234.HEIC.original.bak`` — the untouched original (invisible 

1032 to Plex / Photos.app / Synology Photos / any photo browser, since 

1033 no app recognises ``.bak`` as an image extension), but filesystem- 

1034 recoverable 

1035 

1036 Unedited photos (no ``original_alt`` for them) are unaffected. 

1037 

1038 Default False (backward-compatible — no behaviour change for existing 

1039 mandarons users). 

1040 """ 

1041 config_path = ["photos", "preserve_originals_as_bak"] 

1042 value = get_config_value_or_none(config=config, config_path=config_path) 

1043 return bool(value) if value is not None else False 

1044 

1045 

1046def get_recycle_bin_enabled(config: dict) -> bool: 

1047 """Whether obsolete-file cleanup moves things to the recycle bin. 

1048 

1049 ``app.recycle_bin.enabled``. Off by default: cleanup deletes, as before. 

1050 The bin never widens what cleanup removes; ``remove_obsolete`` still 

1051 decides that. 

1052 """ 

1053 value = get_config_value_or_none(config=config, config_path=["app", "recycle_bin", "enabled"]) 

1054 return bool(value) if value is not None else False 

1055 

1056 

1057def get_recycle_bin_retention_days(config: dict) -> int | None: 

1058 """Days the recycle bin keeps what cleanup moved there; None keeps forever. 

1059 

1060 ``app.recycle_bin.retention_days``. Anything but a positive whole number 

1061 is reported and treated as unset -- keeping forever is the safe reading 

1062 of a value that cannot be trusted. 

1063 """ 

1064 config_path = ["app", "recycle_bin", "retention_days"] 

1065 value = get_config_value_or_none(config=config, config_path=config_path) 

1066 if value is None: 

1067 return None 

1068 if isinstance(value, bool) or not isinstance(value, int) or value <= 0: 

1069 log_invalid_config_value(config_path, value, "a positive number of days; keeping deleted files forever") 

1070 return None 

1071 return value 

1072 

1073 

1074def get_photos_library_destinations(config: dict) -> dict[str, str]: 

1075 """Get per-library destination subdirectory mapping from photos config. 

1076 

1077 Optional config block (under top-level ``photos``): 

1078 

1079 .. code-block:: yaml 

1080 

1081 photos: 

1082 destination: photos 

1083 library_destinations: 

1084 PrimarySync: personal 

1085 SharedLibrary: shared 

1086 

1087 When set, photos from each library are written to 

1088 ``<photos.destination>/<library_destinations[library]>/...`` instead of 

1089 sharing one destination tree. When unset (the default), all libraries 

1090 share the single ``photos.destination`` path — preserving the historical 

1091 behaviour of mandarons/icloud-docker. 

1092 

1093 Returns: 

1094 Dict mapping library name → subdirectory relative to ``photos.destination``. 

1095 Returns ``{}`` if not configured (backward-compatible default). 

1096 """ 

1097 config_path = ["photos", "library_destinations"] 

1098 mapping = get_config_value_or_none(config=config, config_path=config_path) 

1099 if not mapping or not isinstance(mapping, dict): 

1100 return {} 

1101 return {str(k): str(v) for k, v in mapping.items()} 

1102 

1103 

1104def get_photos_libraries_filter(config: dict, base_config_path: list[str]) -> list[str] | None: 

1105 """Get libraries filter from photos config. 

1106 

1107 Args: 

1108 config: Configuration dictionary 

1109 base_config_path: Base path to filters section 

1110 

1111 Returns: 

1112 List of library names if configured, None otherwise 

1113 """ 

1114 config_path = base_config_path + ["libraries"] 

1115 libraries = get_config_value_or_none(config=config, config_path=config_path) 

1116 

1117 if not libraries or len(libraries) == 0: 

1118 log_config_not_found_warning( 

1119 config_path, "not found. Downloading all libraries ...", 

1120 ) 

1121 return None 

1122 

1123 return libraries 

1124 

1125 

1126def get_photos_albums_filter( 

1127 config: dict, base_config_path: list[str], 

1128) -> list[str] | None: 

1129 """Get albums filter from photos config. 

1130 

1131 Args: 

1132 config: Configuration dictionary 

1133 base_config_path: Base path to filters section 

1134 

1135 Returns: 

1136 List of album names if configured, None otherwise 

1137 """ 

1138 config_path = base_config_path + ["albums"] 

1139 albums = get_config_value_or_none(config=config, config_path=config_path) 

1140 

1141 if not albums or len(albums) == 0: 

1142 log_config_not_found_warning( 

1143 config_path, "not found. Downloading all albums ...", 

1144 ) 

1145 return None 

1146 

1147 return albums 

1148 

1149 

1150def get_photos_file_sizes_filter( 

1151 config: dict, base_config_path: list[str], 

1152) -> list[str]: 

1153 """Get file sizes filter from photos config. 

1154 

1155 Args: 

1156 config: Configuration dictionary 

1157 base_config_path: Base path to filters section 

1158 

1159 Returns: 

1160 List of file size options (defaults to ["original"]) 

1161 """ 

1162 config_path = base_config_path + ["file_sizes"] 

1163 

1164 if not traverse_config_path(config=config, config_path=config_path): 

1165 log_config_not_found_warning( 

1166 config_path, "not found. Downloading original size photos ...", 

1167 ) 

1168 return ["original"] 

1169 

1170 file_sizes = get_config_value(config=config, config_path=config_path) 

1171 return validate_file_sizes(file_sizes) 

1172 

1173 

1174def get_photos_extensions_filter( 

1175 config: dict, base_config_path: list[str], 

1176) -> list[str] | None: 

1177 """Get extensions filter from photos config. 

1178 

1179 Args: 

1180 config: Configuration dictionary 

1181 base_config_path: Base path to filters section 

1182 

1183 Returns: 

1184 List of file extensions if configured, None otherwise 

1185 """ 

1186 config_path = base_config_path + ["extensions"] 

1187 extensions = get_config_value_or_none(config=config, config_path=config_path) 

1188 

1189 if not extensions or len(extensions) == 0: 

1190 log_config_not_found_warning( 

1191 config_path, "not found. Downloading all extensions ...", 

1192 ) 

1193 return None 

1194 

1195 return extensions 

1196 

1197 

1198def get_photos_filters(config: dict) -> dict[str, Any]: 

1199 """Return photos filters from config. 

1200 

1201 Args: 

1202 config: Configuration dictionary 

1203 

1204 Returns: 

1205 Dictionary containing filter configuration for photos 

1206 """ 

1207 photos_filters = { 

1208 "libraries": None, 

1209 "albums": None, 

1210 "file_sizes": ["original"], 

1211 "extensions": None, 

1212 } 

1213 

1214 base_config_path = ["photos", "filters"] 

1215 

1216 # Check for filters section existence 

1217 if not traverse_config_path(config=config, config_path=base_config_path): 

1218 log_config_not_found_warning( 

1219 base_config_path, 

1220 "not found. Downloading all libraries and albums with original size ...", 

1221 ) 

1222 return photos_filters 

1223 

1224 # Parse individual filter components 

1225 photos_filters["libraries"] = get_photos_libraries_filter(config, base_config_path) 

1226 photos_filters["albums"] = get_photos_albums_filter(config, base_config_path) 

1227 photos_filters["file_sizes"] = get_photos_file_sizes_filter( 

1228 config, base_config_path, 

1229 ) 

1230 photos_filters["extensions"] = get_photos_extensions_filter( 

1231 config, base_config_path, 

1232 ) 

1233 

1234 return photos_filters 

1235 

1236 

1237# ============================================================================= 

1238# SMTP Configuration Functions 

1239# ============================================================================= 

1240 

1241 

1242def get_smtp_config_value( 

1243 config: dict, key: str, warn_if_missing: bool = True, 

1244) -> str | None: 

1245 """Get SMTP configuration value with optional warning. 

1246 

1247 Common helper for SMTP config retrieval to reduce duplication. 

1248 

1249 Args: 

1250 config: Configuration dictionary 

1251 key: SMTP config key name 

1252 warn_if_missing: Whether to log warning if not found 

1253 

1254 Returns: 

1255 Config value if found, None otherwise 

1256 """ 

1257 config_path = ["app", "smtp", key] 

1258 value = get_config_value_or_none(config=config, config_path=config_path) 

1259 

1260 if value is None and warn_if_missing: 

1261 log_config_not_found_warning(config_path, f"{key} is not found.") 

1262 

1263 return value 

1264 

1265 

1266def get_smtp_email(config: dict) -> str | None: 

1267 """Return smtp from email from config. 

1268 

1269 Args: 

1270 config: Configuration dictionary 

1271 

1272 Returns: 

1273 SMTP email address if configured, None otherwise 

1274 """ 

1275 return get_smtp_config_value(config, "email", warn_if_missing=False) 

1276 

1277 

1278def get_smtp_username(config: dict) -> str | None: 

1279 """Return smtp username from the config, if set. 

1280 

1281 Args: 

1282 config: Configuration dictionary 

1283 

1284 Returns: 

1285 SMTP username if configured, None otherwise 

1286 """ 

1287 return get_smtp_config_value(config, "username", warn_if_missing=False) 

1288 

1289 

1290def get_smtp_to_email(config: dict) -> str | None: 

1291 """Return smtp to email from config, defaults to from email. 

1292 

1293 Args: 

1294 config: Configuration dictionary 

1295 

1296 Returns: 

1297 SMTP 'to' email address, falling back to 'from' email if not specified 

1298 """ 

1299 to_email = get_smtp_config_value(config, "to", warn_if_missing=False) 

1300 return to_email if to_email else get_smtp_email(config=config) 

1301 

1302 

1303def get_smtp_password(config: dict) -> str | None: 

1304 """Return smtp password from config. 

1305 

1306 Args: 

1307 config: Configuration dictionary 

1308 

1309 Returns: 

1310 SMTP password if configured, None otherwise 

1311 """ 

1312 return get_smtp_config_value(config, "password", warn_if_missing=True) 

1313 

1314 

1315def get_smtp_host(config: dict) -> str | None: 

1316 """Return smtp host from config. 

1317 

1318 Args: 

1319 config: Configuration dictionary 

1320 

1321 Returns: 

1322 SMTP host if configured, None otherwise 

1323 """ 

1324 return get_smtp_config_value(config, "host", warn_if_missing=True) 

1325 

1326 

1327def get_smtp_port(config: dict) -> int | None: 

1328 """Return smtp port from config. 

1329 

1330 Args: 

1331 config: Configuration dictionary 

1332 

1333 Returns: 

1334 SMTP port if configured, None otherwise 

1335 """ 

1336 return get_smtp_config_value(config, "port", warn_if_missing=True) # type: ignore[return-value] 

1337 

1338 

1339def get_smtp_no_tls(config: dict) -> bool: 

1340 """Return smtp no_tls flag from config. 

1341 

1342 Args: 

1343 config: Configuration dictionary 

1344 

1345 Returns: 

1346 True if TLS should be disabled, False otherwise 

1347 """ 

1348 no_tls = get_smtp_config_value(config, "no_tls", warn_if_missing=True) 

1349 return no_tls if no_tls is not None else False # type: ignore[return-value] 

1350 

1351 

1352# ============================================================================= 

1353# Notification Service Configuration Functions 

1354# ============================================================================= 

1355 

1356 

1357def get_notification_config_value(config: dict, service: str, key: str) -> str | None: 

1358 """Get notification service configuration value. 

1359 

1360 Common helper for notification service config retrieval. 

1361 

1362 Args: 

1363 config: Configuration dictionary 

1364 service: Service name (telegram, discord, pushover) 

1365 key: Config key name 

1366 

1367 Returns: 

1368 Config value if found, None otherwise 

1369 """ 

1370 config_path = ["app", service, key] 

1371 value = get_config_value_or_none(config=config, config_path=config_path) 

1372 

1373 if value is None: 

1374 log_config_not_found_warning(config_path, f"{key} is not found.") 

1375 

1376 return value 

1377 

1378 

1379def get_telegram_bot_token(config: dict) -> str | None: 

1380 """Return telegram bot token from config. 

1381 

1382 Args: 

1383 config: Configuration dictionary 

1384 

1385 Returns: 

1386 Telegram bot token if configured, None otherwise 

1387 """ 

1388 return get_notification_config_value(config, "telegram", "bot_token") 

1389 

1390 

1391def get_telegram_chat_id(config: dict) -> str | None: 

1392 """Return telegram chat id from config. 

1393 

1394 Args: 

1395 config: Configuration dictionary 

1396 

1397 Returns: 

1398 Telegram chat ID if configured, None otherwise 

1399 """ 

1400 return get_notification_config_value(config, "telegram", "chat_id") 

1401 

1402 

1403def get_telegram_listen_enabled(config: dict) -> bool: 

1404 """Whether to poll Telegram for inbound replies during a 2FA wait. 

1405 

1406 Opt-in (default False). When True, the 2FA wait gap is replaced with a 

1407 Telegram ``getUpdates`` poll: the user replies the auth keyword to have a 

1408 code pushed to their Apple devices, then replies the 6-digit code -- so 

1409 re-authentication can be completed from a phone, headless. 

1410 

1411 Reuses ``app.telegram.bot_token`` / ``chat_id`` from the existing outbound 

1412 config; ``chat_id`` filtering means only messages from the user's own chat 

1413 are honoured (no separate auth surface). 

1414 """ 

1415 return bool( 

1416 get_config_value_or_none( 

1417 config=config, 

1418 config_path=["app", "telegram", "listen"], 

1419 ), 

1420 ) 

1421 

1422 

1423def get_telegram_auth_keyword(config: dict) -> str: 

1424 """The reply keyword that triggers a 2FA push (default ``auth``). 

1425 

1426 Customisable via ``app.telegram.auth_keyword`` so users running multiple 

1427 containers in one chat can target a specific one (e.g. ``auth-photos``). 

1428 Matched case-insensitively; returns the lowercased word. 

1429 """ 

1430 # Optional key with a sane default -- quiet lookup so the absent case does 

1431 # not log a "not found" warning on every 2FA wait. 

1432 value = get_config_value_or_none( 

1433 config=config, 

1434 config_path=["app", "telegram", "auth_keyword"], 

1435 ) 

1436 return str(value).strip().lower() if value else "auth" 

1437 

1438 

1439def get_discord_webhook_url(config: dict) -> str | None: 

1440 """Return discord webhook_url from config. 

1441 

1442 Args: 

1443 config: Configuration dictionary 

1444 

1445 Returns: 

1446 Discord webhook URL if configured, None otherwise 

1447 """ 

1448 return get_notification_config_value(config, "discord", "webhook_url") 

1449 

1450 

1451def get_discord_username(config: dict) -> str | None: 

1452 """Return discord username from config. 

1453 

1454 Args: 

1455 config: Configuration dictionary 

1456 

1457 Returns: 

1458 Discord username if configured, None otherwise 

1459 """ 

1460 return get_notification_config_value(config, "discord", "username") 

1461 

1462 

1463def get_pushover_user_key(config: dict) -> str | None: 

1464 """Return Pushover user key from config. 

1465 

1466 Args: 

1467 config: Configuration dictionary 

1468 

1469 Returns: 

1470 Pushover user key if configured, None otherwise 

1471 """ 

1472 return get_notification_config_value(config, "pushover", "user_key") 

1473 

1474 

1475def get_pushover_api_token(config: dict) -> str | None: 

1476 """Return Pushover API token from config. 

1477 

1478 Args: 

1479 config: Configuration dictionary 

1480 

1481 Returns: 

1482 Pushover API token if configured, None otherwise 

1483 """ 

1484 return get_notification_config_value(config, "pushover", "api_token") 

1485 

1486 

1487def get_pushover_notification_priority(config: dict) -> int | None: 

1488 """Return Pushover notification priority from config. 

1489 

1490 Args: 

1491 config: Configuration dictionary 

1492 

1493 Returns: 

1494 Pushover notification priority if configured, None otherwise 

1495 """ 

1496 config_path = ["app", "pushover", "priority"] 

1497 return get_config_value_or_none(config=config, config_path=config_path) 

1498 

1499 

1500def get_webhook_url(config: dict, event: str) -> str | None: 

1501 """Return the ping URL configured for a sync-lifecycle event. 

1502 

1503 ``app.webhooks.start`` / ``success`` / ``failure`` are each optional and 

1504 independent. Looked up quietly rather than through 

1505 ``get_notification_config_value``: every event is consulted on every sync 

1506 cycle, so the "not found" warning the other notification getters log would 

1507 become three lines per cycle for anyone not using webhooks. 

1508 

1509 Args: 

1510 config: Configuration dictionary 

1511 event: Event name -- ``start``, ``success`` or ``failure`` 

1512 

1513 Returns: 

1514 The URL if configured and non-blank, None otherwise 

1515 """ 

1516 config_path = ["app", "webhooks", event] 

1517 value = get_config_value_or_none(config=config, config_path=config_path) 

1518 if not value: 

1519 return None 

1520 return str(value).strip() or None 

1521 

1522 

1523def get_webhook_post_url(config: dict) -> str | None: 

1524 """Return ``app.webhooks.url`` -- the endpoint every event is POSTed to. 

1525 

1526 Distinct from the ``start``/``success``/``failure`` GET pings: those report 

1527 a cycle boundary to a monitor, this one receives the whole notification 

1528 stream as JSON. Either, both or neither may be configured. 

1529 

1530 Args: 

1531 config: Configuration dictionary 

1532 

1533 Returns: 

1534 The URL if configured and non-blank, None otherwise 

1535 """ 

1536 return get_webhook_url(config=config, event="url") 

1537 

1538 

1539def get_webhook_events(config: dict) -> list[str] | None: 

1540 """Return the ``app.webhooks.events`` allow-list, or None for "all". 

1541 

1542 None and a configured list mean different things: no key at all sends 

1543 every event, while an explicit (even empty) list sends only what it 

1544 names. A non-list value is treated as absent rather than as an empty 

1545 filter, so a typo cannot silently mute the endpoint. 

1546 

1547 Args: 

1548 config: Configuration dictionary 

1549 

1550 Returns: 

1551 List of event names, or None when unfiltered 

1552 """ 

1553 value = get_config_value_or_none(config=config, config_path=["app", "webhooks", "events"]) 

1554 if not isinstance(value, list): 

1555 return None 

1556 return [str(event).strip() for event in value] 

1557 

1558 

1559def get_webhook_headers(config: dict) -> dict[str, str]: 

1560 """Return the extra request headers for ``app.webhooks.url``. 

1561 

1562 Receivers behind an authenticating proxy need a bearer token or an API 

1563 key, which has no other home in the config. Values are credentials and 

1564 are never logged. 

1565 

1566 Args: 

1567 config: Configuration dictionary 

1568 

1569 A key left without a value in YAML parses as None; such an entry is 

1570 dropped rather than sent as the string "None". 

1571 

1572 Returns: 

1573 Mapping of header name to value; empty when unconfigured 

1574 """ 

1575 value = get_config_value_or_none(config=config, config_path=["app", "webhooks", "headers"]) 

1576 if not isinstance(value, dict): 

1577 return {} 

1578 return { 

1579 str(name): str(header_value) 

1580 for name, header_value in value.items() 

1581 if header_value is not None 

1582 } 

1583 

1584 

1585# ============================================================================= 

1586# Sync Summary Notification Configuration Functions 

1587# ============================================================================= 

1588 

1589 

1590def get_sync_summary_enabled(config: dict) -> bool: 

1591 """Return whether sync summary notifications are enabled. 

1592 

1593 Args: 

1594 config: Configuration dictionary 

1595 

1596 Returns: 

1597 True if sync summary is enabled, False otherwise (default: False) 

1598 """ 

1599 config_path = ["app", "notifications", "sync_summary", "enabled"] 

1600 if not traverse_config_path(config=config, config_path=config_path): 

1601 return False 

1602 

1603 value = get_config_value(config=config, config_path=config_path) 

1604 return bool(value) if value is not None else False 

1605 

1606 

1607def get_sync_summary_on_success(config: dict) -> bool: 

1608 """Return whether to send summary on successful syncs. 

1609 

1610 Args: 

1611 config: Configuration dictionary 

1612 

1613 Returns: 

1614 True if should send on success, False otherwise (default: True) 

1615 """ 

1616 config_path = ["app", "notifications", "sync_summary", "on_success"] 

1617 if not traverse_config_path(config=config, config_path=config_path): 

1618 return True # Default to True if not configured 

1619 

1620 value = get_config_value(config=config, config_path=config_path) 

1621 return bool(value) if value is not None else True 

1622 

1623 

1624def get_sync_summary_on_error(config: dict) -> bool: 

1625 """Return whether to send summary when errors occur. 

1626 

1627 Args: 

1628 config: Configuration dictionary 

1629 

1630 Returns: 

1631 True if should send on error, False otherwise (default: True) 

1632 """ 

1633 config_path = ["app", "notifications", "sync_summary", "on_error"] 

1634 if not traverse_config_path(config=config, config_path=config_path): 

1635 return True # Default to True if not configured 

1636 

1637 value = get_config_value(config=config, config_path=config_path) 

1638 return bool(value) if value is not None else True 

1639 

1640 

1641def get_sync_summary_min_downloads(config: dict) -> int: 

1642 """Return minimum downloads required to trigger notification. 

1643 

1644 Args: 

1645 config: Configuration dictionary 

1646 

1647 Returns: 

1648 Minimum downloads threshold (default: 1) 

1649 """ 

1650 config_path = ["app", "notifications", "sync_summary", "min_downloads"] 

1651 if not traverse_config_path(config=config, config_path=config_path): 

1652 return 1 # Default to 1 if not configured 

1653 

1654 value = get_config_value(config=config, config_path=config_path) 

1655 return int(value) if value is not None else 1