Coverage for src/sync.py: 100%

337 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-27 05:33 +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 

31def get_api_instance( 

32 username: str, 

33 password: str, 

34 cookie_directory: str | None = None, 

35 server_region: str = "global", 

36) -> ICloudPyService: 

37 """ 

38 Create and return an iCloud API client instance. 

39 

40 Args: 

41 username: iCloud username/Apple ID 

42 password: iCloud password 

43 cookie_directory: Directory to store authentication cookies. 

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

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

46 redirect the constant at runtime take effect — the previous 

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

48 constant unmockable post-import. 

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

50 

51 Returns: 

52 Configured ICloudPyService instance 

53 """ 

54 if cookie_directory is None: 

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

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

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

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

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

60 import sys 

61 

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

63 return ( 

64 ICloudPyService( 

65 apple_id=username, 

66 password=password, 

67 cookie_directory=cookie_directory, 

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

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

70 ) 

71 if server_region == "china" 

72 else ICloudPyService( 

73 apple_id=username, 

74 password=password, 

75 cookie_directory=cookie_directory, 

76 ) 

77 ) 

78 

79 

80class SyncState: 

81 """ 

82 Maintains synchronization state for drive and photos. 

83 

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

85 passing multiple variables between functions. 

86 """ 

87 

88 def __init__(self): 

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

90 self.drive_time_remaining = 0 

91 self.photos_time_remaining = 0 

92 self.enable_sync_drive = True 

93 self.enable_sync_photos = True 

94 self.last_send = None 

95 

96 

97def _load_configuration(): 

98 """ 

99 Load configuration from file or environment. 

100 

101 Returns: 

102 Configuration dictionary 

103 """ 

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

105 return read_config(config_path=config_path) 

106 

107 

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

109 """ 

110 Extract drive and photos sync intervals from configuration. 

111 

112 Args: 

113 config: Configuration dictionary 

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

115 

116 Returns: 

117 tuple: (drive_sync_interval, photos_sync_interval) 

118 """ 

119 drive_sync_interval = 0 

120 photos_sync_interval = 0 

121 

122 if config and "drive" in config: 

123 drive_sync_interval = config_parser.get_drive_sync_interval( 

124 config=config, 

125 log_messages=log_messages, 

126 ) 

127 if config and "photos" in config: 

128 photos_sync_interval = config_parser.get_photos_sync_interval( 

129 config=config, 

130 log_messages=log_messages, 

131 ) 

132 

133 return drive_sync_interval, photos_sync_interval 

134 

135 

136def _retrieve_password(username: str): 

137 """ 

138 Retrieve password from environment or keyring. 

139 

140 Args: 

141 username: iCloud username 

142 

143 Returns: 

144 Password string or None if not found 

145 

146 Raises: 

147 ICloudPyNoStoredPasswordAvailableException: If password not available 

148 """ 

149 if ENV_ICLOUD_PASSWORD_KEY in os.environ: 

150 password = os.environ.get(ENV_ICLOUD_PASSWORD_KEY) 

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

152 return password 

153 else: 

154 return utils.get_password_from_keyring(username=username) 

155 

156 

157def _authenticate_and_get_api(config, username: str): 

158 """ 

159 Authenticate user and return iCloud API instance. 

160 

161 Args: 

162 config: Configuration dictionary 

163 username: iCloud username 

164 

165 Returns: 

166 ICloudPyService instance 

167 

168 Raises: 

169 ICloudPyNoStoredPasswordAvailableException: If password not available 

170 """ 

171 server_region = config_parser.get_region(config=config) 

172 password = _retrieve_password(username) 

173 return get_api_instance( 

174 username=username, 

175 password=password, 

176 server_region=server_region, 

177 ) 

178 

179 

180def _check_mount_marker( 

181 destinations: list[str], 

182 marker_filename: str, 

183 required: bool, 

184 service_name: str, 

185) -> bool: 

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

187 

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

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

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

191 an empty container-internal directory. 

192 

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

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

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

196 

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

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

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

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

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

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

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

204 

205 Args: 

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

207 directory is checked independently. An empty list returns 

208 True (nothing to check). 

209 marker_filename: Filename to look for inside each destination 

210 (e.g. ``.mounted``). 

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

212 is a no-op that always returns True. 

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

214 (``Drive`` / ``Photos``). 

215 

216 Returns: 

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

218 """ 

219 if not required: 

220 return True 

221 all_present = True 

222 for destination_path in destinations: 

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

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

225 LOGGER.error( 

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

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

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

229 f"next sync cycle will proceed.", 

230 ) 

231 all_present = False 

232 return all_present 

233 

234 

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

236 """ 

237 Execute drive synchronization if enabled. 

238 

239 Args: 

240 config: Configuration dictionary 

241 api: iCloud API instance 

242 sync_state: Current sync state 

243 drive_sync_interval: Drive sync interval in seconds 

244 

245 Returns: 

246 DriveStats object if sync was performed, None otherwise 

247 """ 

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

249 import time 

250 

251 from src.sync_stats import DriveStats 

252 

253 start_time = time.time() 

254 stats = DriveStats() 

255 

256 destination_path = config_parser.prepare_drive_destination(config=config) 

257 

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

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

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

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

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

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

264 # burns CPU until the user touches the marker. 

265 if not _check_mount_marker( 

266 destinations=[destination_path], 

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

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

269 service_name="Drive", 

270 ): 

271 sync_state.drive_time_remaining = drive_sync_interval 

272 return None 

273 

274 # Count files before sync 

275 files_before = set() 

276 if os.path.exists(destination_path): 

277 try: 

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

279 for file in file_list: 

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

281 except Exception: 

282 pass 

283 

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

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

286 LOGGER.info("Drive synced") 

287 

288 # Calculate statistics 

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

290 

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

292 if files_after is not None: 

293 # Count newly downloaded files 

294 new_files = files_after - files_before 

295 stats.files_downloaded = len(new_files) 

296 

297 # Count skipped files 

298 stats.files_skipped = len(files_before & files_after) 

299 

300 # Count removed files 

301 if config_parser.get_drive_remove_obsolete(config=config): 

302 stats.files_removed = len(files_before - files_after) 

303 

304 # Calculate bytes downloaded 

305 try: 

306 for file_path in new_files: 

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

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

309 except Exception: 

310 pass 

311 

312 # Reset countdown timer to the configured interval 

313 sync_state.drive_time_remaining = drive_sync_interval 

314 return stats 

315 return None 

316 

317 

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

319 """ 

320 Execute photos synchronization if enabled. 

321 

322 Args: 

323 config: Configuration dictionary 

324 api: iCloud API instance 

325 sync_state: Current sync state 

326 photos_sync_interval: Photos sync interval in seconds 

327 

328 Returns: 

329 PhotoStats object if sync was performed, None otherwise 

330 """ 

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

332 import time 

333 

334 from src.sync_stats import PhotoStats 

335 

336 start_time = time.time() 

337 stats = PhotoStats() 

338 

339 destination_path = config_parser.prepare_photos_destination(config=config) 

340 

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

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

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

344 if not _check_mount_marker( 

345 destinations=[destination_path], 

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

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

348 service_name="Photos", 

349 ): 

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

351 # countdown so the next cycle waits the configured interval 

352 # before re-checking the marker. 

353 sync_state.photos_time_remaining = photos_sync_interval 

354 return None 

355 

356 # Count files before sync 

357 files_before = set() 

358 if os.path.exists(destination_path): 

359 try: 

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

361 for file in file_list: 

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

363 except Exception: 

364 pass 

365 

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

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

368 LOGGER.info("Photos synced") 

369 

370 # Count files after sync 

371 files_after = set() 

372 if os.path.exists(destination_path): 

373 try: 

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

375 for file in file_list: 

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

377 except Exception: 

378 pass 

379 

380 # Calculate statistics 

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

382 

383 # Count newly downloaded files 

384 new_files = files_after - files_before 

385 stats.photos_downloaded = len(new_files) 

386 

387 # Estimate hardlinked photos (approximate) 

388 use_hardlinks = config_parser.get_photos_use_hardlinks( 

389 config=config, 

390 log_messages=False, 

391 ) 

392 if use_hardlinks: 

393 stats.photos_hardlinked = max( 

394 0, 

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

396 ) 

397 

398 # Count skipped photos 

399 stats.photos_skipped = len(files_before & files_after) 

400 

401 # Calculate bytes downloaded 

402 try: 

403 for file_path in new_files: 

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

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

406 

407 # Estimate bytes saved by hardlinks 

408 if use_hardlinks and stats.photos_hardlinked > 0: 

409 for file_path in files_after: 

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

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

412 except Exception: 

413 pass 

414 

415 # Track failed downloads so notifications reflect errors 

416 if isinstance(sync_result, tuple): 

417 _, failed_downloads = sync_result 

418 if failed_downloads > 0: 

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

420 

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

422 try: 

423 for item in os.listdir(destination_path): 

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

425 if os.path.isdir(item_path): 

426 stats.albums_synced.append(item) 

427 except Exception: 

428 pass 

429 

430 # Reset countdown timer to the configured interval 

431 sync_state.photos_time_remaining = photos_sync_interval 

432 return stats 

433 return None 

434 

435 

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

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

438 

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

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

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

442 sync loop loose on a new install. 

443 

444 Logs (at INFO level): 

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

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

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

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

449 

450 Args: 

451 config: Configuration dictionary 

452 api: Authenticated iCloud API instance 

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

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

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

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

457 

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

459 sync loop itself are all skipped. 

460 """ 

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

462 

463 if config and "drive" in config: 

464 try: 

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

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

467 # how migration_check builds its base path. 

468 drive_destination = os.path.join( 

469 config_parser.get_root_destination_path(config=config), 

470 config_parser.get_drive_destination_path(config=config), 

471 ) 

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

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

474 LOGGER.info( 

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

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

477 ) 

478 except Exception as e: 

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

480 else: 

481 LOGGER.info( 

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

483 ) 

484 

485 if config and "photos" in config: 

486 try: 

487 photos_destination = os.path.join( 

488 config_parser.get_root_destination_path(config=config), 

489 config_parser.get_photos_destination_path(config=config), 

490 ) 

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

492 libraries = ( 

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

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

495 else [] 

496 ) 

497 if libraries: 

498 LOGGER.info( 

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

500 ) 

501 else: 

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

503 except Exception as e: 

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

505 else: 

506 LOGGER.info( 

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

508 ) 

509 

510 if check_files is not None: 

511 from src import migration_check 

512 

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

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

515 if config and "photos" in config: 

516 try: 

517 LOGGER.info( 

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

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

520 ) 

521 results = migration_check.check_migration( 

522 api=api, 

523 config=config, 

524 sample=check_files, 

525 ) 

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

527 stats = result["stats"] 

528 LOGGER.info( 

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

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

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

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

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

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

535 ) 

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

537 for item in items: 

538 if status == "size_mismatch": 

539 path, expected, actual = item 

540 LOGGER.info( 

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

542 ) 

543 else: 

544 path, expected = item 

545 LOGGER.info( 

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

547 ) 

548 except Exception as e: 

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

550 

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

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

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

554 if config and "drive" in config: 

555 try: 

556 drive_result = migration_check.check_drive_migration( 

557 api=api, 

558 config=config, 

559 sample=check_files, 

560 ) 

561 if drive_result is not None: 

562 stats = drive_result["stats"] 

563 LOGGER.info( 

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

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

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

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

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

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

570 ) 

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

572 for item in items: 

573 if status == "size_mismatch": 

574 path, expected, actual = item 

575 LOGGER.info( 

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

577 ) 

578 else: 

579 path, expected = item 

580 LOGGER.info( 

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

582 ) 

583 except Exception as e: 

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

585 

586 LOGGER.info( 

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

588 ) 

589 

590 

591def _check_services_configured(config): 

592 """ 

593 Check if any sync services are configured. 

594 

595 Args: 

596 config: Configuration dictionary 

597 

598 Returns: 

599 bool: True if at least one service is configured 

600 """ 

601 

602 return "drive" in config or "photos" in config 

603 

604 

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

606 """Send anonymized usage statistics. 

607 

608 Args: 

609 config: Configuration dictionary 

610 summary: Sync summary with statistics 

611 """ 

612 

613 # Create anonymized usage data 

614 usage_data = { 

615 "sync_duration": ( 

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

617 if summary.sync_end_time 

618 else 0 

619 ), 

620 "has_drive_activity": bool( 

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

622 ), 

623 "has_photos_activity": bool( 

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

625 ), 

626 "has_errors": summary.has_errors(), 

627 "timestamp": ( 

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

629 ), 

630 } 

631 

632 # Add aggregated statistics (no personal data) 

633 if summary.drive_stats: 

634 usage_data["drive"] = { 

635 "files_count": summary.drive_stats.files_downloaded, 

636 "bytes_count": summary.drive_stats.bytes_downloaded, 

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

638 } 

639 

640 if summary.photo_stats: 

641 usage_data["photos"] = { 

642 "photos_count": summary.photo_stats.photos_downloaded, 

643 "bytes_count": summary.photo_stats.bytes_downloaded, 

644 "hardlinks_count": summary.photo_stats.photos_hardlinked, 

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

646 } 

647 

648 # Send to usage tracking 

649 alive(config=config, data=usage_data) 

650 

651 

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

653 """ 

654 Handle 2FA authentication requirement. 

655 

656 Args: 

657 config: Configuration dictionary 

658 username: iCloud username 

659 sync_state: Current sync state 

660 

661 Returns: 

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

663 """ 

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

665 sleep_for = config_parser.get_retry_login_interval(config=config) 

666 

667 if sleep_for < 0: 

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

669 return False 

670 

671 _log_retry_time(sleep_for) 

672 server_region = config_parser.get_region(config=config) 

673 sync_state.last_send = notify.send( 

674 config=config, 

675 username=username, 

676 last_send=sync_state.last_send, 

677 region=server_region, 

678 ) 

679 sleep(sleep_for) 

680 return True 

681 

682 

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

684 """ 

685 Handle password not available error. 

686 

687 Args: 

688 config: Configuration dictionary 

689 username: iCloud username 

690 sync_state: Current sync state 

691 

692 Returns: 

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

694 """ 

695 LOGGER.error( 

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

697 ) 

698 sleep_for = config_parser.get_retry_login_interval(config=config) 

699 

700 if sleep_for < 0: 

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

702 return False 

703 

704 _log_retry_time(sleep_for) 

705 server_region = config_parser.get_region(config=config) 

706 sync_state.last_send = notify.send( 

707 config=config, 

708 username=username, 

709 last_send=sync_state.last_send, 

710 region=server_region, 

711 ) 

712 sleep(sleep_for) 

713 return True 

714 

715 

716def _log_retry_time(sleep_for: int): 

717 """ 

718 Log the next retry time. 

719 

720 Args: 

721 sleep_for: Sleep duration in seconds 

722 """ 

723 next_sync = ( 

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

725 ).strftime("%c") 

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

727 

728 

729def _calculate_next_sync_schedule(config, sync_state: SyncState): 

730 """ 

731 Calculate next sync schedule and update sync state. 

732 

733 This function implements the adaptive scheduling algorithm that determines 

734 which service should sync next based on countdown timers. 

735 

736 Args: 

737 config: Configuration dictionary 

738 sync_state: Current sync state 

739 

740 Returns: 

741 int: Sleep duration in seconds 

742 """ 

743 has_drive = config and "drive" in config 

744 has_photos = config and "photos" in config 

745 

746 if not has_drive and has_photos: 

747 sleep_for = sync_state.photos_time_remaining 

748 sync_state.enable_sync_drive = False 

749 sync_state.enable_sync_photos = True 

750 elif has_drive and not has_photos: 

751 sleep_for = sync_state.drive_time_remaining 

752 sync_state.enable_sync_drive = True 

753 sync_state.enable_sync_photos = False 

754 elif ( 

755 has_drive 

756 and has_photos 

757 and sync_state.drive_time_remaining <= sync_state.photos_time_remaining 

758 ): 

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

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

761 if ( 

762 sync_state.drive_time_remaining == sync_state.photos_time_remaining 

763 and sync_state.drive_time_remaining > 10 

764 ): 

765 sleep_for = sync_state.drive_time_remaining 

766 sync_state.enable_sync_drive = True 

767 sync_state.enable_sync_photos = True 

768 else: 

769 sleep_for = ( 

770 sync_state.photos_time_remaining - sync_state.drive_time_remaining 

771 ) 

772 sync_state.photos_time_remaining -= sync_state.drive_time_remaining 

773 sync_state.enable_sync_drive = True 

774 sync_state.enable_sync_photos = False 

775 else: 

776 sleep_for = sync_state.drive_time_remaining - sync_state.photos_time_remaining 

777 sync_state.drive_time_remaining -= sync_state.photos_time_remaining 

778 sync_state.enable_sync_drive = False 

779 sync_state.enable_sync_photos = True 

780 

781 return sleep_for 

782 

783 

784def _log_next_sync_time(sleep_for: int): 

785 """ 

786 Log the next scheduled sync time. 

787 

788 Args: 

789 sleep_for: Sleep duration in seconds 

790 """ 

791 next_sync = ( 

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

793 ).strftime("%c") 

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

795 

796 

797def _log_sync_intervals_at_startup(config): 

798 """ 

799 Log sync intervals once at startup. 

800 

801 Args: 

802 config: Configuration dictionary 

803 """ 

804 if config and "drive" in config: 

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

806 if config and "photos" in config: 

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

808 

809 

810def _should_exit_oneshot_mode(config): 

811 """ 

812 Check if should exit in oneshot mode. 

813 

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

815 

816 Args: 

817 config: Configuration dictionary 

818 

819 Returns: 

820 bool: True if should exit 

821 """ 

822 

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

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

825 ) 

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

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

828 ) 

829 

830 return should_exit_drive and should_exit_photos 

831 

832 

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

834 """ 

835 Main synchronization loop. 

836 

837 Orchestrates the entire sync process by delegating specific responsibilities 

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

839 while each helper handles a single concern. 

840 

841 Args: 

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

843 then exit without writing files, sending notifications, or 

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

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

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

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

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

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

850 stride-sampled photos per library. 

851 """ 

852 sync_state = SyncState() 

853 startup_logged = False 

854 

855 while True: 

856 config = _load_configuration() 

857 # Skip telemetry on dry-run: ``alive()`` registers the installation 

858 # and saves the usage cache to disk, both of which violate the 

859 # "no side effects" dry-run contract. (The real sync loop still 

860 # calls it on every iteration as before.) 

861 if not dry_run: 

862 alive(config=config) 

863 

864 # Log sync intervals once at startup 

865 if not startup_logged: 

866 _log_sync_intervals_at_startup(config) 

867 startup_logged = True 

868 

869 drive_sync_interval, photos_sync_interval = _extract_sync_intervals( 

870 config, 

871 log_messages=False, 

872 ) 

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

874 

875 if username: 

876 try: 

877 api = _authenticate_and_get_api(config, username) 

878 

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

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

881 if dry_run: 

882 if api.requires_2sa: 

883 LOGGER.info( 

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

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

886 ) 

887 else: 

888 _perform_dry_run(config, api, check_files=check_files) 

889 return 

890 

891 if not api.requires_2sa: 

892 # Create summary for this sync cycle 

893 summary = SyncSummary() 

894 

895 # Perform syncs and collect statistics 

896 drive_stats = _perform_drive_sync( 

897 config, 

898 api, 

899 sync_state, 

900 drive_sync_interval, 

901 ) 

902 photos_stats = _perform_photos_sync( 

903 config, 

904 api, 

905 sync_state, 

906 photos_sync_interval, 

907 ) 

908 

909 # Populate summary with statistics 

910 summary.drive_stats = drive_stats 

911 summary.photo_stats = photos_stats 

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

913 

914 # Send usage statistics (anonymized summary data) 

915 try: 

916 _send_usage_statistics(config, summary) 

917 except Exception as e: 

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

919 

920 # Send sync summary notification if configured 

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

922 # Gracefully handle notification failures to not break sync 

923 has_drive_config = config and "drive" in config 

924 has_photos_config = config and "photos" in config 

925 

926 should_send_notification = False 

927 if has_drive_config and has_photos_config: 

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

929 should_send_notification = ( 

930 drive_stats is not None and photos_stats is not None 

931 ) 

932 elif has_drive_config and not has_photos_config: 

933 # Only drive configured - send when drive synced 

934 should_send_notification = drive_stats is not None 

935 elif has_photos_config and not has_drive_config: 

936 # Only photos configured - send when photos synced 

937 should_send_notification = photos_stats is not None 

938 

939 if should_send_notification: 

940 try: 

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

942 except Exception as e: 

943 LOGGER.debug( 

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

945 ) 

946 

947 if not _check_services_configured(config): 

948 LOGGER.warning( 

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

950 ) 

951 else: 

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

953 break 

954 continue 

955 

956 except exceptions.ICloudPyNoStoredPasswordAvailableException: 

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

958 break 

959 continue 

960 

961 sleep_for = _calculate_next_sync_schedule(config, sync_state) 

962 _log_next_sync_time(sleep_for) 

963 

964 if _should_exit_oneshot_mode(config): 

965 LOGGER.info( 

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

967 ) 

968 break 

969 

970 sleep(sleep_for)