Coverage for src/sync.py: 100%

406 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 17:25 +0000

1"""Sync module.""" 

2 

3__author__ = "Mandar Patil <mandarons@pm.me>" 

4import datetime 

5import os 

6from time import sleep 

7 

8from icloudpy import ICloudPyService, exceptions, utils 

9 

10from src import ( 

11 DEFAULT_CONFIG_FILE_PATH, 

12 ENV_CONFIG_FILE_PATH_KEY, 

13 ENV_ICLOUD_PASSWORD_KEY, 

14 config_parser, 

15 configure_icloudpy_logging, 

16 get_logger, 

17 notify, 

18 read_config, 

19 sync_drive, 

20 sync_photos, 

21) 

22from src.sync_stats import SyncSummary 

23from src.usage import alive 

24 

25# Configure icloudpy logging immediately after import 

26configure_icloudpy_logging() 

27 

28LOGGER = get_logger() 

29 

30 

31_TRUST_COOKIE_NAME = "X-APPLE-WEBAUTH-HSA-TRUST" 

32 

33 

34def _read_trust_cookie_expiry(api) -> datetime.datetime | None: 

35 """Return the expiry datetime of Apple's HSA trust cookie, or None. 

36 

37 The trust window is carried by ``X-APPLE-WEBAUTH-HSA-TRUST`` in 

38 icloudpy's cookie jar (persisted to ``session_data/<username>`` as 

39 LWPCookieJar). Reading it directly avoids hardcoding Apple's trust 

40 duration -- the cookie's own ``expires`` field is the source of 

41 truth, set per-cookie by Apple's server. Returns None if the cookie 

42 isn't present (e.g. account never auth'd with 2FA, or trust cookie 

43 cleared). 

44 """ 

45 try: 

46 cookies = api.session.cookies 

47 except AttributeError: 

48 return None 

49 for cookie in cookies: 

50 if cookie.name == _TRUST_COOKIE_NAME and cookie.expires: 

51 return datetime.datetime.fromtimestamp( 

52 cookie.expires, 

53 tz=datetime.timezone.utc, 

54 ) 

55 return None 

56 

57 

58# Set once the missing-public_url guidance has been logged (see 

59# ``_resolve_dashboard_url``) so the advice appears once per process, 

60# not once per sync-loop iteration. 

61_WEB_UI_PUBLIC_URL_WARNED = False 

62 

63 

64def _resolve_dashboard_url(config) -> str | None: 

65 """Compute the web UI URL to embed in notifications, or None. 

66 

67 Returns ``None`` when ``app.web_ui.enabled`` is False -- callers 

68 fall back to the legacy docker-exec instruction. Otherwise prefers 

69 the explicit ``app.web_ui.public_url`` (e.g. the reverse-proxy 

70 URL); falls back to ``http://{host}:{port}`` with a warning logged 

71 once at startup if the public URL isn't set. 

72 """ 

73 if not config_parser.get_web_ui_enabled(config=config): 

74 return None 

75 public_url = config_parser.get_web_ui_public_url(config=config) 

76 if public_url: 

77 return public_url 

78 host = config_parser.get_web_ui_host(config=config) 

79 port = config_parser.get_web_ui_port(config=config) 

80 # Latch: this resolves on every sync-loop iteration, and while the 

81 # container sits 2FA-pending (default 600s retry) that is ~144x/day 

82 # of identical guidance in the exact scenario the user is watching 

83 # the logs. The advice only needs saying once per process. 

84 global _WEB_UI_PUBLIC_URL_WARNED 

85 if not _WEB_UI_PUBLIC_URL_WARNED: 

86 LOGGER.warning( 

87 "app.web_ui.public_url not set -- notification URLs will use " 

88 "http://%s:%s/, which won't work from outside the container. " 

89 "Set app.web_ui.public_url to your reverse-proxy URL.", 

90 host, 

91 port, 

92 ) 

93 _WEB_UI_PUBLIC_URL_WARNED = True 

94 return f"http://{host}:{port}" 

95 

96 

97def _maybe_warn_trust_expiring(config, api, username: str) -> None: 

98 """Fire the trust-expiring notification once when crossing threshold. 

99 

100 Reads the live trust cookie expiry, compares against 

101 ``app.trust_expiry_warn_days``, and -- if days_remaining is below 

102 the threshold AND we haven't already warned for THIS cookie value -- 

103 fans the warning out through ``notify.send_trust_expiring``. 

104 

105 Debounce key is the cookie expiry ISO string itself. When Apple 

106 refreshes the trust cookie (new expires_at), the stored 

107 ``warned_for_expires_at`` no longer matches and warning eligibility 

108 rearms automatically -- no manual reset needed. 

109 

110 Best-effort: any exception is logged and swallowed so a notification 

111 bug never breaks the sync loop. 

112 """ 

113 try: 

114 from src import notify, web_signals 

115 

116 expires_at = _read_trust_cookie_expiry(api) 

117 expires_at_iso = expires_at.isoformat() if expires_at else None 

118 prior = web_signals.get_trust_state() 

119 web_signals.record_trust_state( 

120 expires_at_iso=expires_at_iso, 

121 warned_for_expires_at=prior.get("warned_for_expires_at"), 

122 ) 

123 if expires_at is None: 

124 return 

125 days_remaining = ( 

126 expires_at - datetime.datetime.now(tz=datetime.timezone.utc) 

127 ).days 

128 threshold = config_parser.get_trust_expiry_warn_days(config=config) 

129 if days_remaining >= threshold: 

130 return 

131 if prior.get("warned_for_expires_at") == expires_at_iso: 

132 return # already warned for this cookie value 

133 notify.send_trust_expiring( 

134 config=config, 

135 username=username, 

136 days_remaining=days_remaining, 

137 dashboard_url=_resolve_dashboard_url(config), 

138 ) 

139 web_signals.record_trust_state( 

140 expires_at_iso=expires_at_iso, 

141 warned_for_expires_at=expires_at_iso, 

142 ) 

143 except Exception as e: # pragma: no cover - guarded so notify bugs don't break sync 

144 LOGGER.warning(f"trust-expiring check failed: {e!s}") 

145 

146 

147def get_api_instance( 

148 username: str, 

149 password: str, 

150 cookie_directory: str | None = None, 

151 server_region: str = "global", 

152) -> ICloudPyService: 

153 """ 

154 Create and return an iCloud API client instance. 

155 

156 Args: 

157 username: iCloud username/Apple ID 

158 password: iCloud password 

159 cookie_directory: Directory to store authentication cookies. 

160 When ``None`` (the default), resolved late from 

161 ``src.DEFAULT_COOKIE_DIRECTORY`` so test fixtures that 

162 redirect the constant at runtime take effect — the previous 

163 ``= DEFAULT_COOKIE_DIRECTORY`` default-arg capture made the 

164 constant unmockable post-import. 

165 server_region: Server region ("china" or "global") 

166 

167 Returns: 

168 Configured ICloudPyService instance 

169 """ 

170 if cookie_directory is None: 

171 # Read through the src module so monkey-patches of 

172 # ``src.DEFAULT_COOKIE_DIRECTORY`` (e.g. by tests/conftest.py) 

173 # are honoured. ``src`` is this function's parent package and 

174 # already imported; using ``sys.modules`` avoids a per-call 

175 # ``import src`` and makes the data flow explicit. 

176 import sys 

177 

178 cookie_directory = sys.modules["src"].DEFAULT_COOKIE_DIRECTORY 

179 return ( 

180 ICloudPyService( 

181 apple_id=username, 

182 password=password, 

183 cookie_directory=cookie_directory, 

184 home_endpoint="https://www.icloud.com.cn", 

185 setup_endpoint="https://setup.icloud.com.cn/setup/ws/1", 

186 ) 

187 if server_region == "china" 

188 else ICloudPyService( 

189 apple_id=username, 

190 password=password, 

191 cookie_directory=cookie_directory, 

192 ) 

193 ) 

194 

195 

196class SyncState: 

197 """ 

198 Maintains synchronization state for drive and photos. 

199 

200 This class encapsulates the countdown timers and sync flags to avoid 

201 passing multiple variables between functions. 

202 """ 

203 

204 def __init__(self): 

205 """Initialize sync state with default values.""" 

206 self.drive_time_remaining = 0 

207 self.photos_time_remaining = 0 

208 self.enable_sync_drive = True 

209 self.enable_sync_photos = True 

210 self.last_send = None 

211 

212 

213def _load_configuration(): 

214 """ 

215 Load configuration from file or environment. 

216 

217 Returns: 

218 Configuration dictionary 

219 """ 

220 config_path = os.environ.get(ENV_CONFIG_FILE_PATH_KEY, DEFAULT_CONFIG_FILE_PATH) 

221 return read_config(config_path=config_path) 

222 

223 

224def _extract_sync_intervals(config, log_messages: bool = False): 

225 """ 

226 Extract drive and photos sync intervals from configuration. 

227 

228 Args: 

229 config: Configuration dictionary 

230 log_messages: Whether to log informational messages (default: False for loop usage) 

231 

232 Returns: 

233 tuple: (drive_sync_interval, photos_sync_interval) 

234 """ 

235 drive_sync_interval = 0 

236 photos_sync_interval = 0 

237 

238 if config and "drive" in config: 

239 drive_sync_interval = config_parser.get_drive_sync_interval( 

240 config=config, 

241 log_messages=log_messages, 

242 ) 

243 if config and "photos" in config: 

244 photos_sync_interval = config_parser.get_photos_sync_interval( 

245 config=config, 

246 log_messages=log_messages, 

247 ) 

248 

249 return drive_sync_interval, photos_sync_interval 

250 

251 

252def _retrieve_password(username: str): 

253 """ 

254 Retrieve password from environment or keyring. 

255 

256 Args: 

257 username: iCloud username 

258 

259 Returns: 

260 Password string or None if not found 

261 

262 Raises: 

263 ICloudPyNoStoredPasswordAvailableException: If password not available 

264 """ 

265 if ENV_ICLOUD_PASSWORD_KEY in os.environ: 

266 password = os.environ.get(ENV_ICLOUD_PASSWORD_KEY) 

267 utils.store_password_in_keyring(username=username, password=password) 

268 return password 

269 else: 

270 return utils.get_password_from_keyring(username=username) 

271 

272 

273def _authenticate_and_get_api(config, username: str): 

274 """ 

275 Authenticate user and return iCloud API instance. 

276 

277 Args: 

278 config: Configuration dictionary 

279 username: iCloud username 

280 

281 Returns: 

282 ICloudPyService instance 

283 

284 Raises: 

285 ICloudPyNoStoredPasswordAvailableException: If password not available 

286 """ 

287 server_region = config_parser.get_region(config=config) 

288 password = _retrieve_password(username) 

289 return get_api_instance( 

290 username=username, 

291 password=password, 

292 server_region=server_region, 

293 ) 

294 

295 

296def _check_mount_marker( 

297 destinations: list[str], 

298 marker_filename: str, 

299 required: bool, 

300 service_name: str, 

301) -> bool: 

302 """Verify the failsafe marker file is present in every write destination. 

303 

304 Mirrors boredazfcuk/docker-icloudpd's ``.mounted`` pattern: protects 

305 against silent bind-mount failures (typo in the host path, missing 

306 share, wrong permissions) that would otherwise dump iCloud data into 

307 an empty container-internal directory. 

308 

309 Takes a list of destinations because a single sync may write to more 

310 than one bind-mounted directory; the marker is required in EACH write 

311 destination because any one of them could be the failed mount. 

312 

313 Returns True when it is safe to proceed (marker not required, or 

314 marker required and present in every destination). Returns False when 

315 the marker is required and is missing from at least one destination — 

316 in which case the caller should skip this sync cycle without 

317 advancing the countdown so the next interval re-checks. Every 

318 missing-marker failure is logged so the user can fix all of them in 

319 one pass rather than discovering them one cycle at a time. 

320 

321 Args: 

322 destinations: List of sync destination directories to check. Each 

323 directory is checked independently. An empty list returns 

324 True (nothing to check). 

325 marker_filename: Filename to look for inside each destination 

326 (e.g. ``.mounted``). 

327 required: Whether the marker is required at all. When False this 

328 is a no-op that always returns True. 

329 service_name: Human-readable label used in the error log 

330 (``Drive`` / ``Photos``). 

331 

332 Returns: 

333 True if it is safe to proceed; False to skip this sync cycle. 

334 """ 

335 if not required: 

336 return True 

337 all_present = True 

338 for destination_path in destinations: 

339 marker_path = os.path.join(destination_path, marker_filename) 

340 if not os.path.isfile(marker_path): 

341 LOGGER.error( 

342 f"{service_name} mount marker missing: {marker_path} not found — " 

343 f"refusing to sync. Create the marker file (`touch {marker_path}`) " 

344 f"after confirming the destination is correctly mounted, then the " 

345 f"next sync cycle will proceed.", 

346 ) 

347 all_present = False 

348 return all_present 

349 

350 

351def _perform_drive_sync(config, api, sync_state: SyncState, drive_sync_interval: int): 

352 """ 

353 Execute drive synchronization if enabled. 

354 

355 Args: 

356 config: Configuration dictionary 

357 api: iCloud API instance 

358 sync_state: Current sync state 

359 drive_sync_interval: Drive sync interval in seconds 

360 

361 Returns: 

362 DriveStats object if sync was performed, None otherwise 

363 """ 

364 if config and "drive" in config and sync_state.enable_sync_drive: 

365 import time 

366 

367 from src.sync_stats import DriveStats 

368 

369 start_time = time.time() 

370 stats = DriveStats() 

371 

372 destination_path = config_parser.prepare_drive_destination(config=config) 

373 

374 # Mount-marker failsafe (see _check_mount_marker). Skip this 

375 # cycle when the marker isn't present. Reset the countdown to 

376 # the full interval so ``_calculate_next_sync_schedule`` waits 

377 # before re-checking -- without the reset, on startup 

378 # ``drive_time_remaining`` is 0 and the next iteration spins 

379 # at zero sleep into a tight busy loop that floods logs and 

380 # burns CPU until the user touches the marker. 

381 if not _check_mount_marker( 

382 destinations=[destination_path], 

383 marker_filename=config_parser.get_mount_marker_filename(config=config), 

384 required=config_parser.get_drive_require_mount_marker(config=config), 

385 service_name="Drive", 

386 ): 

387 sync_state.drive_time_remaining = drive_sync_interval 

388 return None 

389 

390 # Count files before sync 

391 files_before = set() 

392 if os.path.exists(destination_path): 

393 try: 

394 for root, _dirs, file_list in os.walk(destination_path): 

395 for file in file_list: 

396 files_before.add(os.path.join(root, file)) 

397 except Exception: 

398 pass 

399 

400 LOGGER.info("Syncing drive...") 

401 files_after = sync_drive.sync_drive(config=config, drive=api.drive) 

402 LOGGER.info("Drive synced") 

403 

404 # Calculate statistics 

405 stats.duration_seconds = time.time() - start_time 

406 

407 # Handle case where sync_drive returns None (e.g., in tests) 

408 if files_after is not None: 

409 # Count newly downloaded files 

410 new_files = files_after - files_before 

411 stats.files_downloaded = len(new_files) 

412 

413 # Count skipped files 

414 stats.files_skipped = len(files_before & files_after) 

415 

416 # Count removed files 

417 if config_parser.get_drive_remove_obsolete(config=config): 

418 stats.files_removed = len(files_before - files_after) 

419 

420 # Calculate bytes downloaded 

421 try: 

422 for file_path in new_files: 

423 if os.path.exists(file_path) and os.path.isfile(file_path): 

424 stats.bytes_downloaded += os.path.getsize(file_path) 

425 except Exception: 

426 pass 

427 

428 # Reset countdown timer to the configured interval 

429 sync_state.drive_time_remaining = drive_sync_interval 

430 return stats 

431 return None 

432 

433 

434def _perform_photos_sync(config, api, sync_state: SyncState, photos_sync_interval: int): 

435 """ 

436 Execute photos synchronization if enabled. 

437 

438 Args: 

439 config: Configuration dictionary 

440 api: iCloud API instance 

441 sync_state: Current sync state 

442 photos_sync_interval: Photos sync interval in seconds 

443 

444 Returns: 

445 PhotoStats object if sync was performed, None otherwise 

446 """ 

447 if config and "photos" in config and sync_state.enable_sync_photos: 

448 import time 

449 

450 from src.sync_stats import PhotoStats 

451 

452 start_time = time.time() 

453 stats = PhotoStats() 

454 

455 destination_path = config_parser.prepare_photos_destination(config=config) 

456 

457 # Mount-marker failsafe (see _check_mount_marker). Skip this cycle 

458 # without advancing the countdown so the next interval re-checks 

459 # once the user fixes the mount + touches the marker file. 

460 if not _check_mount_marker( 

461 destinations=[destination_path], 

462 marker_filename=config_parser.get_mount_marker_filename(config=config), 

463 required=config_parser.get_photos_require_mount_marker(config=config), 

464 service_name="Photos", 

465 ): 

466 # Same busy-loop guard as the Drive branch above: reset the 

467 # countdown so the next cycle waits the configured interval 

468 # before re-checking the marker. 

469 sync_state.photos_time_remaining = photos_sync_interval 

470 return None 

471 

472 # Count files before sync 

473 files_before = set() 

474 if os.path.exists(destination_path): 

475 try: 

476 for root, _dirs, file_list in os.walk(destination_path): 

477 for file in file_list: 

478 files_before.add(os.path.join(root, file)) 

479 except Exception: 

480 pass 

481 

482 LOGGER.info("Syncing photos...") 

483 sync_result = sync_photos.sync_photos(config=config, photos=api.photos) 

484 LOGGER.info("Photos synced") 

485 

486 # Count files after sync 

487 files_after = set() 

488 if os.path.exists(destination_path): 

489 try: 

490 for root, _dirs, file_list in os.walk(destination_path): 

491 for file in file_list: 

492 files_after.add(os.path.join(root, file)) 

493 except Exception: 

494 pass 

495 

496 # Calculate statistics 

497 stats.duration_seconds = time.time() - start_time 

498 

499 # Count newly downloaded files 

500 new_files = files_after - files_before 

501 stats.photos_downloaded = len(new_files) 

502 

503 # Estimate hardlinked photos (approximate) 

504 use_hardlinks = config_parser.get_photos_use_hardlinks( 

505 config=config, 

506 log_messages=False, 

507 ) 

508 if use_hardlinks: 

509 stats.photos_hardlinked = max( 

510 0, 

511 len(files_after) - len(files_before) - stats.photos_downloaded, 

512 ) 

513 

514 # Count skipped photos 

515 stats.photos_skipped = len(files_before & files_after) 

516 

517 # Calculate bytes downloaded 

518 try: 

519 for file_path in new_files: 

520 if os.path.exists(file_path) and os.path.isfile(file_path): 

521 stats.bytes_downloaded += os.path.getsize(file_path) 

522 

523 # Estimate bytes saved by hardlinks 

524 if use_hardlinks and stats.photos_hardlinked > 0: 

525 for file_path in files_after: 

526 if file_path not in new_files and os.path.isfile(file_path): 

527 stats.bytes_saved_by_hardlinks += os.path.getsize(file_path) 

528 except Exception: 

529 pass 

530 

531 # Track failed downloads so notifications reflect errors 

532 if isinstance(sync_result, tuple): 

533 _, failed_downloads = sync_result 

534 if failed_downloads > 0: 

535 stats.errors.append(f"{failed_downloads} photo download(s) failed") 

536 

537 # Get list of synced albums (simple approximation based on directories) 

538 try: 

539 for item in os.listdir(destination_path): 

540 item_path = os.path.join(destination_path, item) 

541 if os.path.isdir(item_path): 

542 stats.albums_synced.append(item) 

543 except Exception: 

544 pass 

545 

546 # Reset countdown timer to the configured interval 

547 sync_state.photos_time_remaining = photos_sync_interval 

548 return stats 

549 return None 

550 

551 

552def _perform_dry_run(config, api, check_files: int | None = None) -> None: 

553 """Authenticate-and-enumerate path used when ``--dry-run`` is passed. 

554 

555 Verifies that the configured credentials, mount paths, and iCloud-side 

556 state are all in working order WITHOUT writing or downloading any 

557 files. Designed as the safety check users run before letting the real 

558 sync loop loose on a new install. 

559 

560 Logs (at INFO level): 

561 - Drive destination path + root-level item count (when Drive is configured) 

562 - Photos destination path + library names (when Photos is configured) 

563 - When ``check_files`` is not None: per-library would-skip / 

564 size-mismatch / not-found counts (see ``migration_check``). 

565 

566 Args: 

567 config: Configuration dictionary 

568 api: Authenticated iCloud API instance 

569 check_files: When set (``--check-files=N``), additionally walks 

570 up to N photos per library and reports what a real sync 

571 would do per file. ``0`` walks every photo. ``None`` skips 

572 this check (cheap default for ``--dry-run`` alone). 

573 

574 Notifications, usage statistics, file writes, file deletions, and the 

575 sync loop itself are all skipped. 

576 """ 

577 LOGGER.info("DRY RUN: authentication succeeded — verifying configured services.") 

578 

579 if config and "drive" in config: 

580 try: 

581 # Resolved absolute path (root + destination), computed without 

582 # creating anything, so users can verify the mount point. Mirrors 

583 # how migration_check builds its base path. 

584 drive_destination = os.path.join( 

585 config_parser.get_root_destination_path(config=config), 

586 config_parser.get_drive_destination_path(config=config), 

587 ) 

588 LOGGER.info(f"DRY RUN: Drive destination: {drive_destination}") 

589 root_items = list(api.drive.dir()) 

590 LOGGER.info( 

591 f"DRY RUN: Drive root contains {len(root_items)} item(s) — " 

592 "real sync would walk this tree per `drive.filters`.", 

593 ) 

594 except Exception as e: 

595 LOGGER.warning(f"DRY RUN: Drive enumeration failed: {e!s}") 

596 else: 

597 LOGGER.info( 

598 "DRY RUN: no `drive:` section in config — Drive sync would be skipped.", 

599 ) 

600 

601 if config and "photos" in config: 

602 try: 

603 photos_destination = os.path.join( 

604 config_parser.get_root_destination_path(config=config), 

605 config_parser.get_photos_destination_path(config=config), 

606 ) 

607 LOGGER.info(f"DRY RUN: Photos destination: {photos_destination}") 

608 libraries = ( 

609 list(api.photos.libraries.keys()) 

610 if hasattr(api.photos, "libraries") 

611 else [] 

612 ) 

613 if libraries: 

614 LOGGER.info( 

615 f"DRY RUN: Photos libraries available: {', '.join(libraries)}", 

616 ) 

617 else: 

618 LOGGER.info("DRY RUN: Photos libraries: (none reported by iCloud)") 

619 except Exception as e: 

620 LOGGER.warning(f"DRY RUN: Photos enumeration failed: {e!s}") 

621 else: 

622 LOGGER.info( 

623 "DRY RUN: no `photos:` section in config — Photos sync would be skipped.", 

624 ) 

625 

626 if check_files is not None: 

627 from src import migration_check 

628 

629 # Photos walker — per-library counts using mandarons' real path/size 

630 # logic so the report mirrors what a real sync would skip vs download. 

631 if config and "photos" in config: 

632 try: 

633 LOGGER.info( 

634 f"DRY RUN: walking photos for file-existence check " 

635 f"(--check-files={'all' if check_files == 0 else check_files} per library) ...", 

636 ) 

637 results = migration_check.check_migration( 

638 api=api, 

639 config=config, 

640 sample=check_files, 

641 ) 

642 for library_name, result in results.items(): 

643 stats = result["stats"] 

644 LOGGER.info( 

645 f"DRY RUN: {library_name} (dest {result['library_dest']}): " 

646 f"sampled={result['checked']} " 

647 f"would_skip={stats['would_skip']} " 

648 f"size_mismatch={stats['size_mismatch']} " 

649 f"not_found={stats['not_found']} " 

650 f"errors={stats['error']}", 

651 ) 

652 for status, items in result["samples"].items(): 

653 for item in items: 

654 if status == "size_mismatch": 

655 path, expected, actual = item 

656 LOGGER.info( 

657 f"DRY RUN: sample {status}: {path} (have {actual:,}b, want {expected:,}b)", 

658 ) 

659 else: 

660 path, expected = item 

661 LOGGER.info( 

662 f"DRY RUN: sample {status}: {path} ({expected:,}b)", 

663 ) 

664 except Exception as e: 

665 LOGGER.warning(f"DRY RUN: photos check-files walk failed: {e!s}") 

666 

667 # Drive walker — same per-file would_skip/size_mismatch/not_found 

668 # report, but walking the Drive tree (no library_destinations, 

669 # mirror-tree layout). Catches misconfigured drive.destination. 

670 if config and "drive" in config: 

671 try: 

672 drive_result = migration_check.check_drive_migration( 

673 api=api, 

674 config=config, 

675 sample=check_files, 

676 ) 

677 if drive_result is not None: 

678 stats = drive_result["stats"] 

679 LOGGER.info( 

680 f"DRY RUN: Drive (dest {drive_result['drive_destination']}): " 

681 f"sampled={drive_result['checked']} " 

682 f"would_skip={stats['would_skip']} " 

683 f"size_mismatch={stats['size_mismatch']} " 

684 f"not_found={stats['not_found']} " 

685 f"errors={stats['error']}", 

686 ) 

687 for status, items in drive_result["samples"].items(): 

688 for item in items: 

689 if status == "size_mismatch": 

690 path, expected, actual = item 

691 LOGGER.info( 

692 f"DRY RUN: sample {status}: {path} (have {actual:,}b, want {expected:,}b)", 

693 ) 

694 else: 

695 path, expected = item 

696 LOGGER.info( 

697 f"DRY RUN: sample {status}: {path} ({expected:,}b)", 

698 ) 

699 except Exception as e: 

700 LOGGER.warning(f"DRY RUN: drive check-files walk failed: {e!s}") 

701 

702 LOGGER.info( 

703 "DRY RUN complete — no files were written. Re-run without --dry-run to sync.", 

704 ) 

705 

706 

707def _check_services_configured(config): 

708 """ 

709 Check if any sync services are configured. 

710 

711 Args: 

712 config: Configuration dictionary 

713 

714 Returns: 

715 bool: True if at least one service is configured 

716 """ 

717 

718 return "drive" in config or "photos" in config 

719 

720 

721def _send_usage_statistics(config, summary: SyncSummary) -> None: 

722 """Send anonymized usage statistics. 

723 

724 Args: 

725 config: Configuration dictionary 

726 summary: Sync summary with statistics 

727 """ 

728 

729 # Create anonymized usage data 

730 usage_data = { 

731 "sync_duration": ( 

732 (summary.sync_end_time - summary.sync_start_time).total_seconds() 

733 if summary.sync_end_time 

734 else 0 

735 ), 

736 "has_drive_activity": bool( 

737 summary.drive_stats and summary.drive_stats.has_activity(), 

738 ), 

739 "has_photos_activity": bool( 

740 summary.photo_stats and summary.photo_stats.has_activity(), 

741 ), 

742 "has_errors": summary.has_errors(), 

743 "timestamp": ( 

744 summary.sync_end_time.isoformat() if summary.sync_end_time else None 

745 ), 

746 } 

747 

748 # Add aggregated statistics (no personal data) 

749 if summary.drive_stats: 

750 usage_data["drive"] = { 

751 "files_count": summary.drive_stats.files_downloaded, 

752 "bytes_count": summary.drive_stats.bytes_downloaded, 

753 "has_errors": summary.drive_stats.has_errors(), 

754 } 

755 

756 if summary.photo_stats: 

757 usage_data["photos"] = { 

758 "photos_count": summary.photo_stats.photos_downloaded, 

759 "bytes_count": summary.photo_stats.bytes_downloaded, 

760 "hardlinks_count": summary.photo_stats.photos_hardlinked, 

761 "has_errors": summary.photo_stats.has_errors(), 

762 } 

763 

764 # Send to usage tracking 

765 alive(config=config, data=usage_data) 

766 

767 

768def _handle_2fa_required(config, username: str, sync_state: SyncState): 

769 """ 

770 Handle 2FA authentication requirement. 

771 

772 Args: 

773 config: Configuration dictionary 

774 username: iCloud username 

775 sync_state: Current sync state 

776 

777 Returns: 

778 bool: True if should continue (retry), False if should exit 

779 """ 

780 LOGGER.error("Error: 2FA is required. Please log in.") 

781 sleep_for = config_parser.get_retry_login_interval(config=config) 

782 

783 if sleep_for < 0: 

784 LOGGER.info("retry_login_interval is < 0, exiting ...") 

785 return False 

786 

787 _log_retry_time(sleep_for) 

788 server_region = config_parser.get_region(config=config) 

789 sync_state.last_send = notify.send( 

790 config=config, 

791 username=username, 

792 last_send=sync_state.last_send, 

793 region=server_region, 

794 dashboard_url=_resolve_dashboard_url(config), 

795 ) 

796 sleep(sleep_for) 

797 return True 

798 

799 

800def _handle_password_error(config, username: str, sync_state: SyncState): 

801 """ 

802 Handle password not available error. 

803 

804 Args: 

805 config: Configuration dictionary 

806 username: iCloud username 

807 sync_state: Current sync state 

808 

809 Returns: 

810 bool: True if should continue (retry), False if should exit 

811 """ 

812 LOGGER.error( 

813 "Password is not stored in keyring. Please save the password in keyring.", 

814 ) 

815 sleep_for = config_parser.get_retry_login_interval(config=config) 

816 

817 if sleep_for < 0: 

818 LOGGER.info("retry_login_interval is < 0, exiting ...") 

819 return False 

820 

821 _log_retry_time(sleep_for) 

822 server_region = config_parser.get_region(config=config) 

823 sync_state.last_send = notify.send( 

824 config=config, 

825 username=username, 

826 last_send=sync_state.last_send, 

827 region=server_region, 

828 dashboard_url=_resolve_dashboard_url(config), 

829 ) 

830 sleep(sleep_for) 

831 return True 

832 

833 

834def _log_retry_time(sleep_for: int): 

835 """ 

836 Log the next retry time. 

837 

838 Args: 

839 sleep_for: Sleep duration in seconds 

840 """ 

841 next_sync = ( 

842 datetime.datetime.now() + datetime.timedelta(seconds=sleep_for) 

843 ).strftime("%c") 

844 LOGGER.info(f"Retrying login at {next_sync} ...") 

845 

846 

847def _calculate_next_sync_schedule(config, sync_state: SyncState): 

848 """ 

849 Calculate next sync schedule and update sync state. 

850 

851 This function implements the adaptive scheduling algorithm that determines 

852 which service should sync next based on countdown timers. 

853 

854 Args: 

855 config: Configuration dictionary 

856 sync_state: Current sync state 

857 

858 Returns: 

859 int: Sleep duration in seconds 

860 """ 

861 has_drive = config and "drive" in config 

862 has_photos = config and "photos" in config 

863 

864 if not has_drive and has_photos: 

865 sleep_for = sync_state.photos_time_remaining 

866 sync_state.enable_sync_drive = False 

867 sync_state.enable_sync_photos = True 

868 elif has_drive and not has_photos: 

869 sleep_for = sync_state.drive_time_remaining 

870 sync_state.enable_sync_drive = True 

871 sync_state.enable_sync_photos = False 

872 elif ( 

873 has_drive 

874 and has_photos 

875 and sync_state.drive_time_remaining <= sync_state.photos_time_remaining 

876 ): 

877 # Special case: if both timers are equal and large (> 10 seconds), wait for the full interval 

878 # This fixes the bug where equal large intervals cause immediate re-sync 

879 if ( 

880 sync_state.drive_time_remaining == sync_state.photos_time_remaining 

881 and sync_state.drive_time_remaining > 10 

882 ): 

883 sleep_for = sync_state.drive_time_remaining 

884 sync_state.enable_sync_drive = True 

885 sync_state.enable_sync_photos = True 

886 else: 

887 sleep_for = ( 

888 sync_state.photos_time_remaining - sync_state.drive_time_remaining 

889 ) 

890 sync_state.photos_time_remaining -= sync_state.drive_time_remaining 

891 sync_state.enable_sync_drive = True 

892 sync_state.enable_sync_photos = False 

893 else: 

894 sleep_for = sync_state.drive_time_remaining - sync_state.photos_time_remaining 

895 sync_state.drive_time_remaining -= sync_state.photos_time_remaining 

896 sync_state.enable_sync_drive = False 

897 sync_state.enable_sync_photos = True 

898 

899 return sleep_for 

900 

901 

902def _log_next_sync_time(sleep_for: int): 

903 """ 

904 Log the next scheduled sync time. 

905 

906 Args: 

907 sleep_for: Sleep duration in seconds 

908 """ 

909 next_sync = ( 

910 datetime.datetime.now() + datetime.timedelta(seconds=sleep_for) 

911 ).strftime("%c") 

912 LOGGER.info(f"Resyncing at {next_sync} ...") 

913 

914 

915def _log_sync_intervals_at_startup(config): 

916 """ 

917 Log sync intervals once at startup. 

918 

919 Args: 

920 config: Configuration dictionary 

921 """ 

922 if config and "drive" in config: 

923 config_parser.get_drive_sync_interval(config=config, log_messages=True) 

924 if config and "photos" in config: 

925 config_parser.get_photos_sync_interval(config=config, log_messages=True) 

926 

927 

928def _should_exit_oneshot_mode(config): 

929 """ 

930 Check if should exit in oneshot mode. 

931 

932 Oneshot mode exits when ALL configured sync intervals are negative. 

933 

934 Args: 

935 config: Configuration dictionary 

936 

937 Returns: 

938 bool: True if should exit 

939 """ 

940 

941 should_exit_drive = ("drive" not in config) or ( 

942 config_parser.get_drive_sync_interval(config=config, log_messages=False) < 0 

943 ) 

944 should_exit_photos = ("photos" not in config) or ( 

945 config_parser.get_photos_sync_interval(config=config, log_messages=False) < 0 

946 ) 

947 

948 return should_exit_drive and should_exit_photos 

949 

950 

951def sync(dry_run: bool = False, check_files: int | None = None): 

952 """ 

953 Main synchronization loop. 

954 

955 Orchestrates the entire sync process by delegating specific responsibilities 

956 to focused helper functions. This function coordinates the high-level flow 

957 while each helper handles a single concern. 

958 

959 Args: 

960 dry_run: When True, authenticate and summarise what would be synced, 

961 then exit without writing files, sending notifications, or 

962 entering the sync loop. Useful for verifying credentials, mount 

963 paths, and config before the real loop starts downloading. 

964 check_files: Optional sample size for the per-photo file-existence 

965 check during dry-run. Only meaningful with ``dry_run=True``. 

966 ``None`` skips the check (cheap default). ``0`` walks every 

967 photo (slow on large libraries). Positive N walks N 

968 stride-sampled photos per library. 

969 """ 

970 sync_state = SyncState() 

971 startup_logged = False 

972 

973 while True: 

974 config = _load_configuration() 

975 

976 # Log sync intervals once at startup 

977 if not startup_logged: 

978 _log_sync_intervals_at_startup(config) 

979 startup_logged = True 

980 

981 drive_sync_interval, photos_sync_interval = _extract_sync_intervals( 

982 config, 

983 log_messages=False, 

984 ) 

985 username = config_parser.get_username(config=config) if config else None 

986 

987 # Web UI "Sync now" requests: ``src.web_signals`` writes a 

988 # sentinel file when the user taps the button; we delete it and 

989 # zero the countdown so the next pass through the sync calls 

990 # runs immediately. Best-effort import so vanilla mandarons 

991 # builds without the web-UI module still work. 

992 try: 

993 from src import web_signals as _ws 

994 

995 if _ws.consume_force_sync("drive"): 

996 LOGGER.info("Force-sync requested for Drive — running immediately") 

997 sync_state.drive_time_remaining = 0 

998 if _ws.consume_force_sync("photos"): 

999 LOGGER.info("Force-sync requested for Photos — running immediately") 

1000 sync_state.photos_time_remaining = 0 

1001 except ( 

1002 ImportError 

1003 ): # pragma: no cover — best-effort fallback for builds without web_signals 

1004 pass 

1005 

1006 if username: 

1007 try: 

1008 api = _authenticate_and_get_api(config, username) 

1009 

1010 # Dry-run path: authenticate, enumerate, log, exit. 

1011 # Skips the entire sync + notification + retry pipeline. 

1012 if dry_run: 

1013 if api.requires_2sa: 

1014 LOGGER.info( 

1015 "DRY RUN: 2FA required — finish interactive auth first " 

1016 "(see README), then re-run with --dry-run.", 

1017 ) 

1018 else: 

1019 _perform_dry_run(config, api, check_files=check_files) 

1020 return 

1021 

1022 if not api.requires_2sa: 

1023 # Trust-window check: record current cookie expiry and 

1024 # fire a pre-emptive warning once if it's about to lapse. 

1025 # Best-effort: any failure is logged + swallowed inside. 

1026 _maybe_warn_trust_expiring(config, api, username) 

1027 

1028 # Create summary for this sync cycle 

1029 summary = SyncSummary() 

1030 

1031 # Perform syncs and collect statistics 

1032 drive_stats = _perform_drive_sync( 

1033 config, 

1034 api, 

1035 sync_state, 

1036 drive_sync_interval, 

1037 ) 

1038 photos_stats = _perform_photos_sync( 

1039 config, 

1040 api, 

1041 sync_state, 

1042 photos_sync_interval, 

1043 ) 

1044 

1045 # Populate summary with statistics 

1046 summary.drive_stats = drive_stats 

1047 summary.photo_stats = photos_stats 

1048 summary.sync_end_time = datetime.datetime.now() 

1049 

1050 # Persist per-service last-sync state for the web 

1051 # dashboard. Best-effort — if the JSON write fails 

1052 # the sync itself is unaffected. 

1053 try: 

1054 from src import web_signals as _ws 

1055 

1056 if drive_stats is not None: 

1057 _ws.record_sync_completion( 

1058 service="drive", 

1059 files_downloaded=drive_stats.files_downloaded, 

1060 files_skipped=drive_stats.files_skipped, 

1061 files_removed=drive_stats.files_removed, 

1062 errors=len(drive_stats.errors), 

1063 duration_seconds=drive_stats.duration_seconds, 

1064 ) 

1065 if photos_stats is not None: 

1066 _ws.record_sync_completion( 

1067 service="photos", 

1068 files_downloaded=photos_stats.photos_downloaded, 

1069 files_skipped=photos_stats.photos_skipped, 

1070 errors=len(photos_stats.errors), 

1071 duration_seconds=photos_stats.duration_seconds, 

1072 ) 

1073 except ( 

1074 ImportError 

1075 ): # pragma: no cover — best-effort fallback for builds without web_signals 

1076 pass 

1077 except Exception as e: 

1078 LOGGER.debug( 

1079 f"web_signals: record_sync_completion raised: {e!s}", 

1080 ) 

1081 

1082 # Send usage statistics (anonymized summary data) 

1083 try: 

1084 _send_usage_statistics(config, summary) 

1085 except Exception as e: 

1086 LOGGER.debug(f"Failed to send usage statistics: {e!s}") 

1087 

1088 # Send sync summary notification if configured 

1089 # Only send notification when both enabled services have synced in this cycle 

1090 # Gracefully handle notification failures to not break sync 

1091 has_drive_config = config and "drive" in config 

1092 has_photos_config = config and "photos" in config 

1093 

1094 should_send_notification = False 

1095 if has_drive_config and has_photos_config: 

1096 # Both services configured - send notification only when both have synced 

1097 should_send_notification = ( 

1098 drive_stats is not None and photos_stats is not None 

1099 ) 

1100 elif has_drive_config and not has_photos_config: 

1101 # Only drive configured - send when drive synced 

1102 should_send_notification = drive_stats is not None 

1103 elif has_photos_config and not has_drive_config: 

1104 # Only photos configured - send when photos synced 

1105 should_send_notification = photos_stats is not None 

1106 

1107 if should_send_notification: 

1108 try: 

1109 notify.send_sync_summary(config=config, summary=summary) 

1110 except Exception as e: 

1111 LOGGER.debug( 

1112 f"Failed to send sync summary notification: {e!s}", 

1113 ) 

1114 

1115 if not _check_services_configured(config): 

1116 LOGGER.warning( 

1117 "Nothing to sync. Please add drive: and/or photos: section in config.yaml file.", 

1118 ) 

1119 else: 

1120 if not _handle_2fa_required(config, username, sync_state): 

1121 break 

1122 continue 

1123 

1124 except exceptions.ICloudPyNoStoredPasswordAvailableException: 

1125 if not _handle_password_error(config, username, sync_state): 

1126 break 

1127 continue 

1128 

1129 sleep_for = _calculate_next_sync_schedule(config, sync_state) 

1130 _log_next_sync_time(sleep_for) 

1131 

1132 if _should_exit_oneshot_mode(config): 

1133 LOGGER.info( 

1134 "All configured sync intervals are negative, exiting oneshot mode...", 

1135 ) 

1136 break 

1137 

1138 # Interruptible sleep -- poll the web-signal force-sync sentinels 

1139 # every few seconds so the "Sync now" button stays responsive even 

1140 # mid-long-interval. Without this, a user tap during a multi-hour 

1141 # drive sleep would wait the full remaining duration. 

1142 _interruptible_sleep(sleep_for) 

1143 

1144 

1145def _interruptible_sleep(total_seconds: int) -> None: 

1146 """Sleep up to ``total_seconds`` in short chunks, returning early 

1147 when ``web_signals.pending_force_syncs()`` reports any sentinel. 

1148 

1149 The ``import src.web_signals`` is best-effort so a vanilla mandarons 

1150 build without the web-UI module still runs (it falls back to a 

1151 single ``sleep(total_seconds)``). 

1152 """ 

1153 _CHUNK = 2 # seconds — tradeoff: shorter = more responsive, more wakeups 

1154 try: 

1155 from src import web_signals as _ws 

1156 except ImportError: # pragma: no cover — vanilla-mandarons fallback 

1157 sleep(total_seconds) 

1158 return 

1159 

1160 # Short intervals (<= one chunk) sleep in a single call so the 

1161 # existing tests that count sleep invocations still match. The 

1162 # chunking only matters for long intervals where the user might 

1163 # tap "Sync now" mid-sleep -- those become multiple short sleeps 

1164 # with a sentinel poll between each. 

1165 if total_seconds <= _CHUNK: 

1166 sleep(total_seconds) 

1167 return 

1168 

1169 remaining = total_seconds 

1170 while remaining > 0: 

1171 chunk = min(_CHUNK, remaining) 

1172 sleep(chunk) 

1173 remaining -= chunk 

1174 if _ws.pending_force_syncs(): 

1175 return