Coverage for src/sync.py: 100%
406 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 17:25 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 17:25 +0000
1"""Sync module."""
3__author__ = "Mandar Patil <mandarons@pm.me>"
4import datetime
5import os
6from time import sleep
8from icloudpy import ICloudPyService, exceptions, utils
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
25# Configure icloudpy logging immediately after import
26configure_icloudpy_logging()
28LOGGER = get_logger()
31_TRUST_COOKIE_NAME = "X-APPLE-WEBAUTH-HSA-TRUST"
34def _read_trust_cookie_expiry(api) -> datetime.datetime | None:
35 """Return the expiry datetime of Apple's HSA trust cookie, or None.
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
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
64def _resolve_dashboard_url(config) -> str | None:
65 """Compute the web UI URL to embed in notifications, or None.
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}"
97def _maybe_warn_trust_expiring(config, api, username: str) -> None:
98 """Fire the trust-expiring notification once when crossing threshold.
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``.
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.
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
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}")
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.
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")
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
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 )
196class SyncState:
197 """
198 Maintains synchronization state for drive and photos.
200 This class encapsulates the countdown timers and sync flags to avoid
201 passing multiple variables between functions.
202 """
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
213def _load_configuration():
214 """
215 Load configuration from file or environment.
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)
224def _extract_sync_intervals(config, log_messages: bool = False):
225 """
226 Extract drive and photos sync intervals from configuration.
228 Args:
229 config: Configuration dictionary
230 log_messages: Whether to log informational messages (default: False for loop usage)
232 Returns:
233 tuple: (drive_sync_interval, photos_sync_interval)
234 """
235 drive_sync_interval = 0
236 photos_sync_interval = 0
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 )
249 return drive_sync_interval, photos_sync_interval
252def _retrieve_password(username: str):
253 """
254 Retrieve password from environment or keyring.
256 Args:
257 username: iCloud username
259 Returns:
260 Password string or None if not found
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)
273def _authenticate_and_get_api(config, username: str):
274 """
275 Authenticate user and return iCloud API instance.
277 Args:
278 config: Configuration dictionary
279 username: iCloud username
281 Returns:
282 ICloudPyService instance
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 )
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.
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.
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.
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.
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``).
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
351def _perform_drive_sync(config, api, sync_state: SyncState, drive_sync_interval: int):
352 """
353 Execute drive synchronization if enabled.
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
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
367 from src.sync_stats import DriveStats
369 start_time = time.time()
370 stats = DriveStats()
372 destination_path = config_parser.prepare_drive_destination(config=config)
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
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
400 LOGGER.info("Syncing drive...")
401 files_after = sync_drive.sync_drive(config=config, drive=api.drive)
402 LOGGER.info("Drive synced")
404 # Calculate statistics
405 stats.duration_seconds = time.time() - start_time
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)
413 # Count skipped files
414 stats.files_skipped = len(files_before & files_after)
416 # Count removed files
417 if config_parser.get_drive_remove_obsolete(config=config):
418 stats.files_removed = len(files_before - files_after)
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
428 # Reset countdown timer to the configured interval
429 sync_state.drive_time_remaining = drive_sync_interval
430 return stats
431 return None
434def _perform_photos_sync(config, api, sync_state: SyncState, photos_sync_interval: int):
435 """
436 Execute photos synchronization if enabled.
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
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
450 from src.sync_stats import PhotoStats
452 start_time = time.time()
453 stats = PhotoStats()
455 destination_path = config_parser.prepare_photos_destination(config=config)
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
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
482 LOGGER.info("Syncing photos...")
483 sync_result = sync_photos.sync_photos(config=config, photos=api.photos)
484 LOGGER.info("Photos synced")
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
496 # Calculate statistics
497 stats.duration_seconds = time.time() - start_time
499 # Count newly downloaded files
500 new_files = files_after - files_before
501 stats.photos_downloaded = len(new_files)
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 )
514 # Count skipped photos
515 stats.photos_skipped = len(files_before & files_after)
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)
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
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")
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
546 # Reset countdown timer to the configured interval
547 sync_state.photos_time_remaining = photos_sync_interval
548 return stats
549 return None
552def _perform_dry_run(config, api, check_files: int | None = None) -> None:
553 """Authenticate-and-enumerate path used when ``--dry-run`` is passed.
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.
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``).
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).
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.")
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 )
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 )
626 if check_files is not None:
627 from src import migration_check
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}")
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}")
702 LOGGER.info(
703 "DRY RUN complete — no files were written. Re-run without --dry-run to sync.",
704 )
707def _check_services_configured(config):
708 """
709 Check if any sync services are configured.
711 Args:
712 config: Configuration dictionary
714 Returns:
715 bool: True if at least one service is configured
716 """
718 return "drive" in config or "photos" in config
721def _send_usage_statistics(config, summary: SyncSummary) -> None:
722 """Send anonymized usage statistics.
724 Args:
725 config: Configuration dictionary
726 summary: Sync summary with statistics
727 """
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 }
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 }
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 }
764 # Send to usage tracking
765 alive(config=config, data=usage_data)
768def _handle_2fa_required(config, username: str, sync_state: SyncState):
769 """
770 Handle 2FA authentication requirement.
772 Args:
773 config: Configuration dictionary
774 username: iCloud username
775 sync_state: Current sync state
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)
783 if sleep_for < 0:
784 LOGGER.info("retry_login_interval is < 0, exiting ...")
785 return False
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
800def _handle_password_error(config, username: str, sync_state: SyncState):
801 """
802 Handle password not available error.
804 Args:
805 config: Configuration dictionary
806 username: iCloud username
807 sync_state: Current sync state
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)
817 if sleep_for < 0:
818 LOGGER.info("retry_login_interval is < 0, exiting ...")
819 return False
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
834def _log_retry_time(sleep_for: int):
835 """
836 Log the next retry time.
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} ...")
847def _calculate_next_sync_schedule(config, sync_state: SyncState):
848 """
849 Calculate next sync schedule and update sync state.
851 This function implements the adaptive scheduling algorithm that determines
852 which service should sync next based on countdown timers.
854 Args:
855 config: Configuration dictionary
856 sync_state: Current sync state
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
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
899 return sleep_for
902def _log_next_sync_time(sleep_for: int):
903 """
904 Log the next scheduled sync time.
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} ...")
915def _log_sync_intervals_at_startup(config):
916 """
917 Log sync intervals once at startup.
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)
928def _should_exit_oneshot_mode(config):
929 """
930 Check if should exit in oneshot mode.
932 Oneshot mode exits when ALL configured sync intervals are negative.
934 Args:
935 config: Configuration dictionary
937 Returns:
938 bool: True if should exit
939 """
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 )
948 return should_exit_drive and should_exit_photos
951def sync(dry_run: bool = False, check_files: int | None = None):
952 """
953 Main synchronization loop.
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.
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
973 while True:
974 config = _load_configuration()
976 # Log sync intervals once at startup
977 if not startup_logged:
978 _log_sync_intervals_at_startup(config)
979 startup_logged = True
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
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
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
1006 if username:
1007 try:
1008 api = _authenticate_and_get_api(config, username)
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
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)
1028 # Create summary for this sync cycle
1029 summary = SyncSummary()
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 )
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()
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
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 )
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}")
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
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
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 )
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
1124 except exceptions.ICloudPyNoStoredPasswordAvailableException:
1125 if not _handle_password_error(config, username, sync_state):
1126 break
1127 continue
1129 sleep_for = _calculate_next_sync_schedule(config, sync_state)
1130 _log_next_sync_time(sleep_for)
1132 if _should_exit_oneshot_mode(config):
1133 LOGGER.info(
1134 "All configured sync intervals are negative, exiting oneshot mode...",
1135 )
1136 break
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)
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.
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
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
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