Coverage for src/sync.py: 100%
681 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 16:56 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 16:56 +0000
1"""Sync module."""
3__author__ = "Mandar Patil <mandarons@pm.me>"
4import datetime
5import os
6import re
7import secrets
8from time import sleep
10import requests
11from icloudpy import ICloudPyService, exceptions, utils
13from src import (
14 DEFAULT_CONFIG_FILE_PATH,
15 DEFAULT_RETRY_LOGIN_INTERVAL_SEC,
16 ENV_CONFIG_FILE_PATH_KEY,
17 ENV_ICLOUD_PASSWORD_KEY,
18 config_parser,
19 configure_icloudpy_logging,
20 get_logger,
21 notify,
22 read_config,
23 sync_drive,
24 sync_photos,
25)
26from src.sync_stats import SyncSummary
27from src.usage import alive
29# Configure icloudpy logging immediately after import
30configure_icloudpy_logging()
32LOGGER = get_logger()
35_TRUST_COOKIE_NAME = "X-APPLE-WEBAUTH-HSA-TRUST"
38def _detect_security_key_account(api, username: str) -> bool:
39 """True when Apple answers this account's second factor with an fsaChallenge.
41 Once security keys are enrolled Apple stops issuing 6-digit codes
42 altogether, so requesting a push sends nothing and listening for a
43 replied code waits for something that cannot arrive -- the loop did
44 both, every cycle, and told the user over Telegram to "reply the
45 6-digit code here". Detecting it here rather than waiting for someone
46 to open the dashboard is what lets the very first notification tell
47 the truth.
49 Recording the method also means ``notify`` picks the security-key
50 wording on this same pass, since it reads the same signal.
52 Best-effort in both directions: icloudpy without security-key support
53 has no such attribute, and no probe failure may break the retry loop.
54 """
55 try:
56 challenge = getattr(api, "security_key_challenge", None)
57 except Exception as e: # noqa: BLE001 - a probe must never break the loop
58 LOGGER.debug(f"security-key probe failed: {e!s}")
59 return False
60 # Shape, not truthiness. icloudpy returns a mapping carrying "challenge"
61 # and "keyHandles", or None -- anything else (a stub, a changed API, a
62 # sentinel) must not be mistaken for Apple demanding a key, because the
63 # cost of a false positive is suppressing the real code flow.
64 if not (isinstance(challenge, dict) and challenge.get("challenge")):
65 return False
66 try:
67 from src import web_signals
69 web_signals.record_auth_method(username=username, method="security_key")
70 except Exception as e: # noqa: BLE001 - wording is not worth an outage
71 LOGGER.debug(f"could not record auth method: {e!s}")
72 return True
75def _log_trust_revocation_hint(api) -> None:
76 """Say so when Apple rejected a trust token that has not expired.
78 Expiry and revocation are indistinguishable from the logs -- both
79 surface as 421 and both end with a 2FA prompt -- but they mean
80 opposite things for what to do next. A refresh schedule prevents the
81 first and can do nothing about the second: Apple drops trust on
82 security events, typically a new trusted device, a password change,
83 or a change to security keys.
85 Without this, the obvious reading of "valid trust token, 2FA demanded
86 anyway" is that the refresh logic is broken, and the time goes into
87 auditing code that behaved correctly.
89 Best-effort: never raises into the retry path.
90 """
91 try:
92 expires_at = _read_trust_cookie_expiry(api)
93 if expires_at is None:
94 return
95 remaining = (
96 expires_at - datetime.datetime.now(tz=datetime.timezone.utc)
97 ).days
98 if remaining <= 0:
99 return
100 LOGGER.error(
101 f"The trust token had not expired -- it is valid for {remaining} "
102 f"more days (until {expires_at.date()}). Apple revoked it "
103 f"server-side, which typically follows a new trusted device, a "
104 f"password change, or a change to security keys.",
105 )
106 except Exception as e: # noqa: BLE001 - diagnostics never break the retry
107 LOGGER.debug(f"trust revocation hint failed: {e!s}")
110def _read_trust_cookie_expiry(api) -> datetime.datetime | None:
111 """Return the expiry datetime of Apple's HSA trust cookie, or None.
113 The trust window is carried by ``X-APPLE-WEBAUTH-HSA-TRUST`` in
114 icloudpy's cookie jar (persisted to ``session_data/<username>`` as
115 LWPCookieJar). Reading it directly avoids hardcoding Apple's trust
116 duration -- the cookie's own ``expires`` field is the source of
117 truth, set per-cookie by Apple's server. Returns None if the cookie
118 isn't present (e.g. account never auth'd with 2FA, or trust cookie
119 cleared).
120 """
121 try:
122 cookies = api.session.cookies
123 except AttributeError:
124 return None
125 for cookie in cookies:
126 if cookie.name == _TRUST_COOKIE_NAME and cookie.expires:
127 return datetime.datetime.fromtimestamp(
128 cookie.expires,
129 tz=datetime.timezone.utc,
130 )
131 return None
134# Set once the missing-public_url guidance has been logged (see
135# ``_resolve_dashboard_url``) so the advice appears once per process,
136# not once per sync-loop iteration.
137_WEB_UI_PUBLIC_URL_WARNED = False
139# Minimum wait after a failed sign-in. Apple answers a throttled account
140# with 409 on /signin/init; retrying sooner just extends the lockout.
141_AUTH_BACKOFF_FLOOR_SEC = 1800
144def _resolve_dashboard_url(config) -> str | None:
145 """Compute the web UI URL to embed in notifications, or None.
147 Returns ``None`` when ``app.web_ui.enabled`` is False -- callers
148 fall back to the legacy docker-exec instruction. Otherwise prefers
149 the explicit ``app.web_ui.public_url`` (e.g. the reverse-proxy
150 URL); falls back to ``http://{host}:{port}`` with a warning logged
151 once at startup if the public URL isn't set.
152 """
153 if not config_parser.get_web_ui_enabled(config=config):
154 return None
155 public_url = config_parser.get_web_ui_public_url(config=config)
156 if public_url:
157 return public_url
158 host = config_parser.get_web_ui_host(config=config)
159 port = config_parser.get_web_ui_port(config=config)
160 # Latch: this resolves on every sync-loop iteration, and while the
161 # container sits 2FA-pending (default 600s retry) that is ~144x/day
162 # of identical guidance in the exact scenario the user is watching
163 # the logs. The advice only needs saying once per process.
164 global _WEB_UI_PUBLIC_URL_WARNED
165 if not _WEB_UI_PUBLIC_URL_WARNED:
166 LOGGER.warning(
167 "app.web_ui.public_url not set -- notification URLs will use "
168 "http://%s:%s/, which won't work from outside the container. "
169 "Set app.web_ui.public_url to your reverse-proxy URL.",
170 host,
171 port,
172 )
173 _WEB_UI_PUBLIC_URL_WARNED = True
174 return f"http://{host}:{port}"
177def _publish_auth_blocked(blocked: bool, reason: str | None = None) -> None:
178 """Tell the web UI whether the loop can authenticate.
180 Only this loop knows: every on-disk signal the dashboard can check by
181 itself (username configured, password in the keyring) still looks
182 healthy while an account sits stuck on a second factor.
184 Best-effort -- signalling must never break syncing.
185 """
186 try:
187 from src import web_signals
189 web_signals.record_auth_blocked(blocked=blocked, reason=reason)
190 except Exception as e: # pragma: no cover - signalling is advisory
191 LOGGER.warning(f"Could not publish auth state: {e!s}")
194def _maybe_refresh_trust(config, api) -> None:
195 """Re-trust the session before its token ages out.
197 ``trust_session`` asks Apple for a new ``X-Apple-TwoSV-Trust-Token``
198 and icloudpy persists it to ``session_data``. Calling it while the
199 session is still healthy therefore rolls the window forward, so a
200 restart at any later point finds a young token and resumes without
201 prompting for a second factor.
203 This matters most for accounts where the second factor is expensive
204 or impossible to satisfy headlessly (hardware security keys, where
205 Apple refuses to send a 6-digit code at all).
207 Best-effort: never raises into the sync loop.
208 """
209 try:
210 threshold = config_parser.get_trust_refresh_days(config=config)
211 if threshold <= 0:
212 return
213 expires_at = _read_trust_cookie_expiry(api)
214 if expires_at is None:
215 return
216 days_remaining = (
217 expires_at - datetime.datetime.now(tz=datetime.timezone.utc)
218 ).days
219 if days_remaining > threshold:
220 return
221 LOGGER.info(
222 f"Trust token has {days_remaining}d left (threshold {threshold}d) -- refreshing.",
223 )
224 if api.trust_session():
225 refreshed = _read_trust_cookie_expiry(api)
226 expires_at_iso = refreshed.isoformat() if refreshed else None
227 LOGGER.info(
228 f"Trust refreshed; now expires {expires_at_iso or 'unknown'}.",
229 )
230 # Webhook-only: no other transport reports a refresh, and a
231 # receiver tracking the trust window needs the new expiry as
232 # much as it needs the warning that preceded it.
233 refresh_data = {"days_remaining_before": days_remaining}
234 if expires_at_iso:
235 # Omitted rather than null when the cookie is unreadable.
236 refresh_data["expires_at"] = expires_at_iso
237 notify.post_event_to_webhook(
238 config,
239 "trust_refreshed",
240 f"iCloud trust token refreshed; now expires {expires_at_iso or 'unknown'}",
241 refresh_data,
242 )
243 else:
244 LOGGER.warning(
245 "Proactive trust refresh was declined by Apple -- a second "
246 "factor will be needed at the next cold start.",
247 )
248 except Exception as e: # pragma: no cover - never break sync over a refresh
249 LOGGER.warning(f"trust refresh failed: {e!s}")
252def _maybe_warn_trust_expiring(config, api, username: str) -> None:
253 """Fire the trust-expiring notification once when crossing threshold.
255 Reads the live trust cookie expiry, compares against
256 ``app.trust_expiry_warn_days``, and -- if days_remaining is below
257 the threshold AND we haven't already warned for THIS cookie value --
258 fans the warning out through ``notify.send_trust_expiring``.
260 Debounce key is the cookie expiry ISO string itself. When Apple
261 refreshes the trust cookie (new expires_at), the stored
262 ``warned_for_expires_at`` no longer matches and warning eligibility
263 rearms automatically -- no manual reset needed.
265 Best-effort: any exception is logged and swallowed so a notification
266 bug never breaks the sync loop.
267 """
268 try:
269 from src import notify, web_signals
271 expires_at = _read_trust_cookie_expiry(api)
272 expires_at_iso = expires_at.isoformat() if expires_at else None
273 prior = web_signals.get_trust_state()
274 web_signals.record_trust_state(
275 expires_at_iso=expires_at_iso,
276 warned_for_expires_at=prior.get("warned_for_expires_at"),
277 )
278 if expires_at is None:
279 return
280 days_remaining = (
281 expires_at - datetime.datetime.now(tz=datetime.timezone.utc)
282 ).days
283 threshold = config_parser.get_trust_expiry_warn_days(config=config)
284 if days_remaining >= threshold:
285 return
286 if prior.get("warned_for_expires_at") == expires_at_iso:
287 return # already warned for this cookie value
288 notify.send_trust_expiring(
289 config=config,
290 username=username,
291 days_remaining=days_remaining,
292 dashboard_url=_resolve_dashboard_url(config),
293 )
294 web_signals.record_trust_state(
295 expires_at_iso=expires_at_iso,
296 warned_for_expires_at=expires_at_iso,
297 )
298 except Exception as e: # pragma: no cover - guarded so notify bugs don't break sync
299 LOGGER.warning(f"trust-expiring check failed: {e!s}")
302_SESSION_ONLY_HELP = (
303 "Store an Apple ID password to let the container re-authenticate on its "
304 "own, or create a new session by signing in from the web dashboard or "
305 "with the documented `icloud --username=... --session-directory=...` "
306 "command."
307)
309# Apple answers a session it will no longer accept with one of these, and
310# icloudpy surfaces the status as the exception's ``code``. Anything else --
311# a 500, a 503, a bad gateway -- is an outage that says nothing about the
312# session, so it belongs to the loop's transport handler rather than being
313# reported to the user as "sign in again". (icloudpy itself lumps 500 in
314# with the re-auth statuses when it rewrites the reason; that conflation
315# must not reach the user as an instruction.)
316_SESSION_REJECTED_CODES = frozenset({401, 421, 450})
318# icloudpy reads the keyring for a ``None`` password -- the very lookup
319# session-only mode exists to avoid -- and, given ``""``, its log filter
320# rewrites every log line with asterisks between each character. A random
321# placeholder avoids both; ``SessionOnlyICloudPyService`` never sends it.
322_SESSION_ONLY_PLACEHOLDER_PASSWORD = secrets.token_urlsafe(32)
325class SessionOnlyICloudPyService(ICloudPyService):
326 """An iCloud client allowed to resume a saved session and nothing else.
328 icloudpy's ``authenticate()`` tries the saved session token first and
329 falls back to a full SRP sign-in when it does not validate. With no
330 password that fallback cannot succeed, and letting it run would post a
331 placeholder credential to Apple's sign-in endpoint on every retry --
332 the fastest way to get an Apple ID throttled or locked. So this
333 override stops after the session check and reports the missing
334 password instead, which the loop's existing handler already turns into
335 a notification and a backoff.
336 """
338 def authenticate(self, force_refresh=False, service=None):
339 """Validate the saved session; never sign in with credentials.
341 Raises ``ICloudPyNoStoredPasswordAvailableException`` when the
342 session is missing or Apple has rejected it; any other API error
343 propagates untouched, since an outage is not an expired session.
345 ``force_refresh`` is refused as defence in depth rather than
346 because anything here asks for it: icloudpy's only caller is its
347 Find My 450 handler, which this app never reaches. Honouring it
348 would mean a credential sign-in, which is the one thing this class
349 exists to prevent, so it is refused wherever it came from.
350 """
351 if force_refresh or not self.session_data.get("session_token"):
352 msg = f"No Apple ID password is stored and there is no saved session to resume. {_SESSION_ONLY_HELP}"
353 raise exceptions.ICloudPyNoStoredPasswordAvailableException(msg)
355 try:
356 self.data = self._validate_token()
357 except exceptions.ICloudPyAPIResponseException as error:
358 if error.code not in _SESSION_REJECTED_CODES:
359 # An outage leaves the session perfectly good; reporting it as
360 # expired would send the user off to re-authenticate for
361 # nothing. Let the loop's transport handler have it.
362 raise
363 msg = f"No Apple ID password is stored and the saved session is no longer valid. {_SESSION_ONLY_HELP}"
364 raise exceptions.ICloudPyNoStoredPasswordAvailableException(msg) from error
366 if "webservices" not in self.data:
367 # icloudpy returns the response untouched when a failed /validate
368 # carries no recognised error field, so ``self.data`` can be an
369 # error body. Upstream would raise KeyError here and kill the
370 # process -- with `restart: unless-stopped` that is a restart loop
371 # hitting Apple on every boot.
372 msg = f"No Apple ID password is stored and Apple did not accept the saved session. {_SESSION_ONLY_HELP}"
373 raise exceptions.ICloudPyNoStoredPasswordAvailableException(msg)
375 self._webservices = self.data["webservices"]
376 LOGGER.debug("Resumed the saved session without a password")
379def get_api_instance(
380 username: str,
381 password: str | None,
382 cookie_directory: str | None = None,
383 server_region: str = "global",
384) -> ICloudPyService:
385 """
386 Create and return an iCloud API client instance.
388 Args:
389 username: iCloud username/Apple ID
390 password: iCloud password, or ``None`` to run in session-only
391 mode -- the client then resumes the saved session in
392 ``cookie_directory`` and refuses to sign in with credentials
393 (see ``SessionOnlyICloudPyService``).
394 cookie_directory: Directory to store authentication cookies.
395 When ``None`` (the default), resolved late from
396 ``src.DEFAULT_COOKIE_DIRECTORY`` so test fixtures that
397 redirect the constant at runtime take effect — the previous
398 ``= DEFAULT_COOKIE_DIRECTORY`` default-arg capture made the
399 constant unmockable post-import.
400 server_region: Server region ("china" or "global")
402 Returns:
403 Configured ICloudPyService instance
404 """
405 if cookie_directory is None:
406 # Read through the src module so monkey-patches of
407 # ``src.DEFAULT_COOKIE_DIRECTORY`` (e.g. by tests/conftest.py)
408 # are honoured. ``src`` is this function's parent package and
409 # already imported; using ``sys.modules`` avoids a per-call
410 # ``import src`` and makes the data flow explicit.
411 import sys
413 cookie_directory = sys.modules["src"].DEFAULT_COOKIE_DIRECTORY
414 service_class = ICloudPyService
415 if password is None:
416 service_class = SessionOnlyICloudPyService
417 password = _SESSION_ONLY_PLACEHOLDER_PASSWORD
418 # Apple can report a photo library as still indexing for weeks while
419 # listing it in full. Open it anyway: sync_photos downloads what it lists
420 # and keeps it out of obsolete-file cleanup until the index finishes.
421 return (
422 service_class(
423 apple_id=username,
424 password=password,
425 cookie_directory=cookie_directory,
426 home_endpoint="https://www.icloud.com.cn",
427 setup_endpoint="https://setup.icloud.com.cn/setup/ws/1",
428 photos_require_finished_index=False,
429 )
430 if server_region == "china"
431 else service_class(
432 apple_id=username,
433 password=password,
434 cookie_directory=cookie_directory,
435 photos_require_finished_index=False,
436 )
437 )
440class SyncState:
441 """
442 Maintains synchronization state for drive and photos.
444 This class encapsulates the countdown timers and sync flags to avoid
445 passing multiple variables between functions.
446 """
448 def __init__(self):
449 """Initialize sync state with default values."""
450 self.drive_time_remaining = 0
451 self.photos_time_remaining = 0
452 self.enable_sync_drive = True
453 self.enable_sync_photos = True
454 self.last_send = None
455 # Whether a 2FA push has already been requested for the current
456 # re-auth episode. Reset to False on each successful authentication so
457 # a fresh episode triggers exactly one push (see _handle_2fa_required).
458 self.two_fa_triggered = False
459 # Whether this cycle is running without a stored password, i.e. off
460 # the saved session alone. Set by _authenticate_and_get_api; the error
461 # handlers need it because nothing in that mode can be fixed by
462 # retrying with a password the container does not have.
463 self.session_only = False
466def _load_configuration():
467 """
468 Load configuration from file or environment.
470 Returns:
471 Configuration dictionary
472 """
473 config_path = os.environ.get(ENV_CONFIG_FILE_PATH_KEY, DEFAULT_CONFIG_FILE_PATH)
474 return read_config(config_path=config_path)
477def _extract_sync_intervals(config, log_messages: bool = False):
478 """
479 Extract drive and photos sync intervals from configuration.
481 Args:
482 config: Configuration dictionary
483 log_messages: Whether to log informational messages (default: False for loop usage)
485 Returns:
486 tuple: (drive_sync_interval, photos_sync_interval)
487 """
488 drive_sync_interval = 0
489 photos_sync_interval = 0
491 if config and "drive" in config:
492 drive_sync_interval = config_parser.get_drive_sync_interval(
493 config=config,
494 log_messages=log_messages,
495 )
496 if config and "photos" in config:
497 photos_sync_interval = config_parser.get_photos_sync_interval(
498 config=config,
499 log_messages=log_messages,
500 )
502 return drive_sync_interval, photos_sync_interval
505def _retrieve_password(username: str):
506 """
507 Retrieve password from environment or keyring.
509 Args:
510 username: iCloud username
512 Returns:
513 Password string or None if not found
515 Raises:
516 ICloudPyNoStoredPasswordAvailableException: If password not available
517 """
518 # A blank value counts as no password. Compose substitutes "" for an
519 # unset ${VAR}, and storing it would also overwrite a real keyring
520 # entry; returning it would skip session-only mode and send an empty
521 # password to Apple's sign-in on every retry.
522 password = os.environ.get(ENV_ICLOUD_PASSWORD_KEY)
523 if password:
524 utils.store_password_in_keyring(username=username, password=password)
525 return password
526 password = utils.get_password_from_keyring(username=username)
527 if not password:
528 msg = f"The stored password for {username} is empty."
529 raise exceptions.ICloudPyNoStoredPasswordAvailableException(msg)
530 return password
533def _authenticate_and_get_api(config, username: str, sync_state: SyncState | None = None):
534 """
535 Authenticate user and return iCloud API instance.
537 Args:
538 config: Configuration dictionary
539 username: iCloud username
540 sync_state: Current sync state, whose ``session_only`` flag is set
541 here so the error handlers can tell the two modes apart
543 Returns:
544 ICloudPyService instance
546 Raises:
547 ICloudPyNoStoredPasswordAvailableException: If no password is
548 configured *and* the saved session cannot be resumed.
549 """
550 server_region = config_parser.get_region(config=config)
551 try:
552 password = _retrieve_password(username)
553 except exceptions.ICloudPyNoStoredPasswordAvailableException:
554 # No password anywhere is a choice, not necessarily a misconfiguration:
555 # an operator who would rather not keep an Apple ID password on disk
556 # can run unattended off the saved session alone until Apple's trust
557 # window closes. Resuming it is worth attempting before declaring
558 # failure -- the loop used to report "password is not stored" without
559 # ever looking at a perfectly valid session.
560 LOGGER.debug(
561 "No Apple ID password is configured -- resuming the saved session.",
562 )
563 password = None
564 if sync_state is not None:
565 # Set before the client is built, so it is already right if building
566 # it is what fails.
567 sync_state.session_only = password is None
568 return get_api_instance(
569 username=username,
570 password=password,
571 server_region=server_region,
572 )
575def _check_mount_marker(
576 destinations: list[str],
577 marker_filename: str,
578 required: bool,
579 service_name: str,
580) -> bool:
581 """Verify the failsafe marker file is present in every write destination.
583 Mirrors boredazfcuk/docker-icloudpd's ``.mounted`` pattern: protects
584 against silent bind-mount failures (typo in the host path, missing
585 share, wrong permissions) that would otherwise dump iCloud data into
586 an empty container-internal directory.
588 Takes a list of destinations because a single sync may write to more
589 than one bind-mounted directory; the marker is required in EACH write
590 destination because any one of them could be the failed mount.
592 Returns True when it is safe to proceed (marker not required, or
593 marker required and present in every destination). Returns False when
594 the marker is required and is missing from at least one destination —
595 in which case the caller should skip this sync cycle without
596 advancing the countdown so the next interval re-checks. Every
597 missing-marker failure is logged so the user can fix all of them in
598 one pass rather than discovering them one cycle at a time.
600 Args:
601 destinations: List of sync destination directories to check. Each
602 directory is checked independently. An empty list returns
603 True (nothing to check).
604 marker_filename: Filename to look for inside each destination
605 (e.g. ``.mounted``).
606 required: Whether the marker is required at all. When False this
607 is a no-op that always returns True.
608 service_name: Human-readable label used in the error log
609 (``Drive`` / ``Photos``).
611 Returns:
612 True if it is safe to proceed; False to skip this sync cycle.
613 """
614 if not required:
615 return True
616 all_present = True
617 for destination_path in destinations:
618 marker_path = os.path.join(destination_path, marker_filename)
619 if not os.path.isfile(marker_path):
620 LOGGER.error(
621 f"{service_name} mount marker missing: {marker_path} not found — "
622 f"refusing to sync. Create the marker file (`touch {marker_path}`) "
623 f"after confirming the destination is correctly mounted, then the "
624 f"next sync cycle will proceed.",
625 )
626 all_present = False
627 return all_present
630def _perform_drive_sync(config, api, sync_state: SyncState, drive_sync_interval: int):
631 """
632 Execute drive synchronization if enabled.
634 Args:
635 config: Configuration dictionary
636 api: iCloud API instance
637 sync_state: Current sync state
638 drive_sync_interval: Drive sync interval in seconds
640 Returns:
641 DriveStats object if sync was performed, None otherwise
642 """
643 if config and "drive" in config and sync_state.enable_sync_drive:
644 import time
646 from src.sync_stats import DriveStats
648 start_time = time.time()
649 stats = DriveStats()
651 destination_path = config_parser.prepare_drive_destination(config=config)
653 # Mount-marker failsafe (see _check_mount_marker). Skip this
654 # cycle when the marker isn't present. Reset the countdown to
655 # the full interval so ``_calculate_next_sync_schedule`` waits
656 # before re-checking -- without the reset, on startup
657 # ``drive_time_remaining`` is 0 and the next iteration spins
658 # at zero sleep into a tight busy loop that floods logs and
659 # burns CPU until the user touches the marker.
660 if not _check_mount_marker(
661 destinations=[destination_path],
662 marker_filename=config_parser.get_mount_marker_filename(config=config),
663 required=config_parser.get_drive_require_mount_marker(config=config),
664 service_name="Drive",
665 ):
666 sync_state.drive_time_remaining = drive_sync_interval
667 return None
669 # Count files before sync
670 files_before = set()
671 if os.path.exists(destination_path):
672 try:
673 for root, _dirs, file_list in os.walk(destination_path):
674 for file in file_list:
675 files_before.add(os.path.join(root, file))
676 except Exception:
677 pass
679 LOGGER.info("Syncing drive...")
680 files_after = sync_drive.sync_drive(config=config, drive=api.drive)
681 LOGGER.info("Drive synced")
683 # Calculate statistics
684 stats.duration_seconds = time.time() - start_time
686 # Handle case where sync_drive returns None (e.g., in tests)
687 if files_after is not None:
688 # Count newly downloaded files
689 new_files = files_after - files_before
690 stats.files_downloaded = len(new_files)
692 # Count skipped files
693 stats.files_skipped = len(files_before & files_after)
695 # Count removed files
696 if config_parser.get_drive_remove_obsolete(config=config):
697 stats.files_removed = len(files_before - files_after)
699 # Calculate bytes downloaded
700 try:
701 for file_path in new_files:
702 if os.path.exists(file_path) and os.path.isfile(file_path):
703 stats.bytes_downloaded += os.path.getsize(file_path)
704 except Exception:
705 pass
707 # Reset countdown timer to the configured interval
708 sync_state.drive_time_remaining = drive_sync_interval
709 return stats
710 return None
713def _perform_photos_sync(config, api, sync_state: SyncState, photos_sync_interval: int):
714 """
715 Execute photos synchronization if enabled.
717 Args:
718 config: Configuration dictionary
719 api: iCloud API instance
720 sync_state: Current sync state
721 photos_sync_interval: Photos sync interval in seconds
723 Returns:
724 PhotoStats object if sync was performed, None otherwise
725 """
726 if config and "photos" in config and sync_state.enable_sync_photos:
727 import time
729 from src.sync_stats import PhotoStats
731 start_time = time.time()
732 stats = PhotoStats()
734 destination_path = config_parser.prepare_photos_destination(config=config)
736 # Mount-marker failsafe (see _check_mount_marker). Skip this cycle
737 # without advancing the countdown so the next interval re-checks
738 # once the user fixes the mount + touches the marker file.
739 if not _check_mount_marker(
740 destinations=[destination_path],
741 marker_filename=config_parser.get_mount_marker_filename(config=config),
742 required=config_parser.get_photos_require_mount_marker(config=config),
743 service_name="Photos",
744 ):
745 # Same busy-loop guard as the Drive branch above: reset the
746 # countdown so the next cycle waits the configured interval
747 # before re-checking the marker.
748 sync_state.photos_time_remaining = photos_sync_interval
749 return None
751 # Count files before sync
752 files_before = set()
753 if os.path.exists(destination_path):
754 try:
755 for root, _dirs, file_list in os.walk(destination_path):
756 for file in file_list:
757 files_before.add(os.path.join(root, file))
758 except Exception:
759 pass
761 LOGGER.info("Syncing photos...")
762 sync_result = sync_photos.sync_photos(config=config, photos=api.photos)
763 LOGGER.info("Photos synced")
765 # Count files after sync
766 files_after = set()
767 if os.path.exists(destination_path):
768 try:
769 for root, _dirs, file_list in os.walk(destination_path):
770 for file in file_list:
771 files_after.add(os.path.join(root, file))
772 except Exception:
773 pass
775 # Calculate statistics
776 stats.duration_seconds = time.time() - start_time
778 # Count newly downloaded files
779 new_files = files_after - files_before
780 stats.photos_downloaded = len(new_files)
782 # Estimate hardlinked photos (approximate)
783 use_hardlinks = config_parser.get_photos_use_hardlinks(
784 config=config,
785 log_messages=False,
786 )
787 if use_hardlinks:
788 stats.photos_hardlinked = max(
789 0,
790 len(files_after) - len(files_before) - stats.photos_downloaded,
791 )
793 # Count skipped photos
794 stats.photos_skipped = len(files_before & files_after)
796 # Calculate bytes downloaded
797 try:
798 for file_path in new_files:
799 if os.path.exists(file_path) and os.path.isfile(file_path):
800 stats.bytes_downloaded += os.path.getsize(file_path)
802 # Estimate bytes saved by hardlinks
803 if use_hardlinks and stats.photos_hardlinked > 0:
804 for file_path in files_after:
805 if file_path not in new_files and os.path.isfile(file_path):
806 stats.bytes_saved_by_hardlinks += os.path.getsize(file_path)
807 except Exception:
808 pass
810 # Track failed downloads so notifications reflect errors
811 if isinstance(sync_result, tuple):
812 _, failed_downloads = sync_result
813 if failed_downloads > 0:
814 stats.errors.append(f"{failed_downloads} photo download(s) failed")
816 # Get list of synced albums (simple approximation based on directories)
817 try:
818 for item in os.listdir(destination_path):
819 item_path = os.path.join(destination_path, item)
820 if os.path.isdir(item_path):
821 stats.albums_synced.append(item)
822 except Exception:
823 pass
825 # Reset countdown timer to the configured interval
826 sync_state.photos_time_remaining = photos_sync_interval
827 return stats
828 return None
831def _perform_dry_run(config, api, check_files: int | None = None) -> None:
832 """Authenticate-and-enumerate path used when ``--dry-run`` is passed.
834 Verifies that the configured credentials, mount paths, and iCloud-side
835 state are all in working order WITHOUT writing or downloading any
836 files. Designed as the safety check users run before letting the real
837 sync loop loose on a new install.
839 Logs (at INFO level):
840 - Drive destination path + root-level item count (when Drive is configured)
841 - Photos destination path + library names (when Photos is configured)
842 - When ``check_files`` is not None: per-library would-skip /
843 size-mismatch / not-found counts (see ``migration_check``).
845 Args:
846 config: Configuration dictionary
847 api: Authenticated iCloud API instance
848 check_files: When set (``--check-files=N``), additionally walks
849 up to N photos per library and reports what a real sync
850 would do per file. ``0`` walks every photo. ``None`` skips
851 this check (cheap default for ``--dry-run`` alone).
853 Notifications, usage statistics, file writes, file deletions, and the
854 sync loop itself are all skipped.
855 """
856 LOGGER.info("DRY RUN: authentication succeeded — verifying configured services.")
858 if config and "drive" in config:
859 try:
860 # Resolved absolute path (root + destination), computed without
861 # creating anything, so users can verify the mount point. Mirrors
862 # how migration_check builds its base path.
863 drive_destination = os.path.join(
864 config_parser.get_root_destination_path(config=config),
865 config_parser.get_drive_destination_path(config=config),
866 )
867 LOGGER.info(f"DRY RUN: Drive destination: {drive_destination}")
868 root_items = list(api.drive.dir())
869 LOGGER.info(
870 f"DRY RUN: Drive root contains {len(root_items)} item(s) — "
871 "real sync would walk this tree per `drive.filters`.",
872 )
873 except Exception as e:
874 LOGGER.warning(f"DRY RUN: Drive enumeration failed: {e!s}")
875 else:
876 LOGGER.info(
877 "DRY RUN: no `drive:` section in config — Drive sync would be skipped.",
878 )
880 if config and "photos" in config:
881 try:
882 photos_destination = os.path.join(
883 config_parser.get_root_destination_path(config=config),
884 config_parser.get_photos_destination_path(config=config),
885 )
886 LOGGER.info(f"DRY RUN: Photos destination: {photos_destination}")
887 libraries = (
888 list(api.photos.libraries.keys())
889 if hasattr(api.photos, "libraries")
890 else []
891 )
892 if libraries:
893 LOGGER.info(
894 f"DRY RUN: Photos libraries available: {', '.join(libraries)}",
895 )
896 else:
897 LOGGER.info("DRY RUN: Photos libraries: (none reported by iCloud)")
898 except Exception as e:
899 LOGGER.warning(f"DRY RUN: Photos enumeration failed: {e!s}")
900 else:
901 LOGGER.info(
902 "DRY RUN: no `photos:` section in config — Photos sync would be skipped.",
903 )
905 if check_files is not None:
906 from src import migration_check
908 # Photos walker — per-library counts using mandarons' real path/size
909 # logic so the report mirrors what a real sync would skip vs download.
910 if config and "photos" in config:
911 try:
912 LOGGER.info(
913 f"DRY RUN: walking photos for file-existence check "
914 f"(--check-files={'all' if check_files == 0 else check_files} per library) ...",
915 )
916 results = migration_check.check_migration(
917 api=api,
918 config=config,
919 sample=check_files,
920 )
921 for library_name, result in results.items():
922 stats = result["stats"]
923 LOGGER.info(
924 f"DRY RUN: {library_name} (dest {result['library_dest']}): "
925 f"sampled={result['checked']} "
926 f"would_skip={stats['would_skip']} "
927 f"size_mismatch={stats['size_mismatch']} "
928 f"not_found={stats['not_found']} "
929 f"errors={stats['error']}",
930 )
931 for status, items in result["samples"].items():
932 for item in items:
933 if status == "size_mismatch":
934 path, expected, actual = item
935 LOGGER.info(
936 f"DRY RUN: sample {status}: {path} (have {actual:,}b, want {expected:,}b)",
937 )
938 else:
939 path, expected = item
940 LOGGER.info(
941 f"DRY RUN: sample {status}: {path} ({expected:,}b)",
942 )
943 except Exception as e:
944 LOGGER.warning(f"DRY RUN: photos check-files walk failed: {e!s}")
946 # Drive walker — same per-file would_skip/size_mismatch/not_found
947 # report, but walking the Drive tree (no library_destinations,
948 # mirror-tree layout). Catches misconfigured drive.destination.
949 if config and "drive" in config:
950 try:
951 drive_result = migration_check.check_drive_migration(
952 api=api,
953 config=config,
954 sample=check_files,
955 )
956 if drive_result is not None:
957 stats = drive_result["stats"]
958 LOGGER.info(
959 f"DRY RUN: Drive (dest {drive_result['drive_destination']}): "
960 f"sampled={drive_result['checked']} "
961 f"would_skip={stats['would_skip']} "
962 f"size_mismatch={stats['size_mismatch']} "
963 f"not_found={stats['not_found']} "
964 f"errors={stats['error']}",
965 )
966 for status, items in drive_result["samples"].items():
967 for item in items:
968 if status == "size_mismatch":
969 path, expected, actual = item
970 LOGGER.info(
971 f"DRY RUN: sample {status}: {path} (have {actual:,}b, want {expected:,}b)",
972 )
973 else:
974 path, expected = item
975 LOGGER.info(
976 f"DRY RUN: sample {status}: {path} ({expected:,}b)",
977 )
978 except Exception as e:
979 LOGGER.warning(f"DRY RUN: drive check-files walk failed: {e!s}")
981 LOGGER.info(
982 "DRY RUN complete — no files were written. Re-run without --dry-run to sync.",
983 )
986def _check_services_configured(config):
987 """
988 Check if any sync services are configured.
990 Args:
991 config: Configuration dictionary
993 Returns:
994 bool: True if at least one service is configured
995 """
997 return "drive" in config or "photos" in config
1000def _cycle_nothing_synced_reason(config, drive_stats, photos_stats) -> str | None:
1001 """Why this cycle synced no service at all, or None if one did.
1003 Such a cycle is indistinguishable from a clean one by its stats -- no
1004 errors, no counts -- so reporting it as a success is how a monitor stays
1005 green while nothing whatsoever is being downloaded. It happens two ways:
1006 nothing is configured to sync, or every service that was due got skipped
1007 by the mount-marker failsafe.
1009 Not to be confused with a service that simply was not due. With unequal
1010 intervals ``_calculate_next_sync_schedule`` enables only the service
1011 whose timer expired, so one of the two returning None is the ordinary
1012 case -- hence "no stats at all" rather than "any stats missing".
1014 Args:
1015 config: Configuration dictionary
1016 drive_stats: Result of ``_perform_drive_sync``
1017 photos_stats: Result of ``_perform_photos_sync``
1019 Returns:
1020 ``nothing_synced``, ``mount_marker_missing``, or None if a service ran
1021 """
1022 if not _check_services_configured(config):
1023 return "nothing_synced"
1024 if drive_stats is None and photos_stats is None:
1025 return "mount_marker_missing"
1026 return None
1029def _cycle_end_message(has_errors: bool, nothing_reason: str | None) -> str:
1030 """The human text for the end-of-cycle event.
1032 Kept out of ``sync()`` only because the branch it sits in is already four
1033 levels deep.
1034 """
1035 if nothing_reason == "nothing_synced":
1036 return "iCloud sync cycle synced nothing: no drive or photos section is configured"
1037 if nothing_reason:
1038 return "iCloud sync cycle synced nothing: the mount marker is missing"
1039 if has_errors:
1040 return "iCloud sync cycle completed with errors"
1041 return "iCloud sync cycle completed"
1044def _send_usage_statistics(config, summary: SyncSummary) -> None:
1045 """Send anonymized usage statistics.
1047 Args:
1048 config: Configuration dictionary
1049 summary: Sync summary with statistics
1050 """
1052 # Create anonymized usage data
1053 usage_data = {
1054 "sync_duration": (
1055 (summary.sync_end_time - summary.sync_start_time).total_seconds()
1056 if summary.sync_end_time
1057 else 0
1058 ),
1059 "has_drive_activity": bool(
1060 summary.drive_stats and summary.drive_stats.has_activity(),
1061 ),
1062 "has_photos_activity": bool(
1063 summary.photo_stats and summary.photo_stats.has_activity(),
1064 ),
1065 "has_errors": summary.has_errors(),
1066 "timestamp": (
1067 summary.sync_end_time.isoformat() if summary.sync_end_time else None
1068 ),
1069 }
1071 # Add aggregated statistics (no personal data)
1072 if summary.drive_stats:
1073 usage_data["drive"] = {
1074 "files_count": summary.drive_stats.files_downloaded,
1075 "bytes_count": summary.drive_stats.bytes_downloaded,
1076 "has_errors": summary.drive_stats.has_errors(),
1077 }
1079 if summary.photo_stats:
1080 usage_data["photos"] = {
1081 "photos_count": summary.photo_stats.photos_downloaded,
1082 "bytes_count": summary.photo_stats.bytes_downloaded,
1083 "hardlinks_count": summary.photo_stats.photos_hardlinked,
1084 "has_errors": summary.photo_stats.has_errors(),
1085 }
1087 # Send to usage tracking
1088 alive(config=config, data=usage_data)
1091def _auth_retry_sleep(total_seconds: int) -> None:
1092 """Wait between sign-in attempts, ending early on a completed re-auth.
1094 The loop backs off for ``retry_login_interval`` between attempts and
1095 cannot otherwise see that the session was fixed underneath it, so
1096 someone who signs in through the web UI watches the dashboard keep
1097 saying the sync is stopped until an interval that began *before* the
1098 problem was solved finally runs out.
1100 Polls only the re-auth signal, not the force-sync sentinel behind
1101 "Sync now": that button means "sync everything now", and a re-auth
1102 should end a wait without also queueing a full re-enumeration.
1103 """
1104 _CHUNK = 2
1105 try:
1106 from src import web_signals as _ws
1107 except ImportError: # pragma: no cover - module is optional
1108 sleep(total_seconds)
1109 return
1111 if total_seconds <= _CHUNK:
1112 sleep(total_seconds)
1113 return
1115 remaining = total_seconds
1116 while remaining > 0:
1117 chunk = min(_CHUNK, remaining)
1118 sleep(chunk)
1119 remaining -= chunk
1120 if _ws.consume_reauth_completed():
1121 LOGGER.info("Re-auth completed -- ending the retry wait early.")
1122 return
1125def _request_2fa_push_once(api, sync_state: SyncState) -> None:
1126 """Ask Apple to push a 2FA code to the trusted devices, once per episode.
1128 Without this call the loop notified the user that re-auth was needed but
1129 never requested a code, so nothing was ever sent.
1131 The latch is set only when Apple accepts the request.
1132 ``trigger_2fa_push_notification`` returns False rather than raising for
1133 most failures, and latching regardless meant one transient error
1134 forfeited the push for the whole episode -- the user told "2FA is
1135 required" while no code ever arrived, which is the bug this exists to
1136 fix. Retrying on the next cycle is bounded by ``retry_login_interval``
1137 (600s by default), far below anything that trips Apple's limits.
1139 Best-effort: a failure here never stops the retry loop.
1140 """
1141 if sync_state.two_fa_triggered:
1142 return
1143 try:
1144 pushed = api.trigger_2fa_push_notification()
1145 except Exception as e: # noqa: BLE001
1146 LOGGER.warning(f"Failed to request 2FA push notification; will retry next cycle: {e!s}")
1147 return
1148 if pushed:
1149 LOGGER.info("Requested a 2FA push notification to your trusted devices.")
1150 sync_state.two_fa_triggered = True
1151 else:
1152 LOGGER.warning("Apple did not accept the 2FA push request; will retry next cycle.")
1155def _handle_2fa_required(config, username: str, sync_state: SyncState, api):
1156 """
1157 Handle 2FA authentication requirement.
1159 Args:
1160 config: Configuration dictionary
1161 username: iCloud username
1162 sync_state: Current sync state
1163 api: Live ``ICloudPyService`` still in its 2FA-required state. Used to
1164 request a push notification to the user's trusted devices. When
1165 ``app.telegram.listen`` is true, the retry sleep is replaced with a
1166 Telegram poll: the user replies the auth keyword to have Apple push a
1167 code, then replies the 6 digits -- completing re-authentication from a
1168 phone, headless, with no web UI.
1170 Returns:
1171 bool: True if should continue (retry), False if should exit
1172 """
1173 LOGGER.error("Error: 2FA is required. Please log in.")
1174 _publish_auth_blocked(True, reason="2fa_required")
1175 # Decided before anything is sent: it selects the notification wording,
1176 # the reason a receiver sees, and suppresses two steps that cannot
1177 # succeed on such an account.
1178 security_key = _detect_security_key_account(api, username)
1179 notify.send_cycle_event(
1180 config=config,
1181 boundary="failure",
1182 message="iCloud sync cycle aborted: 2FA is required",
1183 data={"reason": "security_key_required" if security_key else "two_factor_required"},
1184 )
1185 if security_key:
1186 LOGGER.error(
1187 "This account signs in with a security key, so Apple will not "
1188 "send a 6-digit code. Complete the ceremony on the dashboard "
1189 "(/auth); the push request and the code listener are skipped.",
1190 )
1191 # Ask Apple to push a code before anything else -- including the exit
1192 # below: retry_login_interval < 0 is the mode where an operator is about
1193 # to intervene by hand, and a code on their devices is what they need.
1194 # Not for a security-key account: Apple sends those no code at all.
1195 if not security_key:
1196 _request_2fa_push_once(api, sync_state)
1198 sleep_for = config_parser.get_retry_login_interval(config=config)
1200 if sleep_for < 0:
1201 LOGGER.info("retry_login_interval is < 0, exiting ...")
1202 return False
1204 _log_retry_time(sleep_for)
1205 server_region = config_parser.get_region(config=config)
1206 # notify.send is throttled (once per 24h) and listen-aware: in listen mode the
1207 # Telegram channel gets the actionable reply prompt, other channels the standard
1208 # alert. Throttling here is what prevents a "reply auth" message every retry cycle.
1209 sync_state.last_send = notify.send(
1210 config=config,
1211 username=username,
1212 last_send=sync_state.last_send,
1213 region=server_region,
1214 dashboard_url=_resolve_dashboard_url(config),
1215 # A security-key account cannot finish sign-in from a Telegram code,
1216 # so it gets the standard alert pointing at the dashboard.
1217 reply_prompt=not security_key,
1218 event="security_key_required" if security_key else "two_factor_required",
1219 )
1220 if not security_key and config_parser.get_telegram_listen_enabled(config=config):
1221 _wait_for_telegram_code(config=config, api=api, timeout_seconds=sleep_for)
1222 else:
1223 _auth_retry_sleep(sleep_for)
1224 return True
1227def _wait_for_telegram_code(config, api, timeout_seconds: int) -> bool:
1228 """Drive 2FA over Telegram with a manual, user-initiated trigger.
1230 The reply prompt itself is sent by ``notify.send`` (throttled); this function
1231 drains any stale replies, then polls for the user's actions:
1232 1. On the auth keyword -> ``api.trigger_2fa_push_notification()`` so Apple
1233 actually pushes a code to the trusted devices. (The headless path
1234 previously waited for a code it never requested -- this missing trigger
1235 is the core bug this fixes.)
1236 2. On a 6-digit reply (spaces/dashes tolerated) -> ``validate_2fa_code``
1237 + ``trust_session``.
1239 Returns True once a code validates and trust succeeds within
1240 ``timeout_seconds``, or once the web UI completes the re-auth first;
1241 False on timeout. Best-effort throughout. The Telegram
1242 ``getUpdates`` offset is held in-memory for the duration of this wait.
1243 """
1244 from src import web_signals
1246 poll_interval = 5
1247 bot_token = config_parser.get_telegram_bot_token(config=config)
1248 chat_id = config_parser.get_telegram_chat_id(config=config)
1249 auth_keyword = config_parser.get_telegram_auth_keyword(config=config)
1250 if not bot_token or not chat_id:
1251 LOGGER.warning(
1252 "Telegram listen enabled but bot_token/chat_id not configured; falling back to plain sleep.",
1253 )
1254 sleep(timeout_seconds)
1255 return False
1257 # Drain any messages already pending so a stale reply from a previous
1258 # session does not get acted on; start listening for genuinely new replies.
1259 # Deliberate trade-off: a code typed while the loop is between wait
1260 # windows is dropped too. Acting on an old code is worse than asking for
1261 # a fresh one, and the window is the whole retry interval (600s default).
1262 _, offset = notify.poll_telegram_for_text(
1263 bot_token=bot_token, chat_id=chat_id, offset=0,
1264 )
1265 while True:
1266 _, drained = notify.poll_telegram_for_text(
1267 bot_token=bot_token, chat_id=chat_id, offset=offset,
1268 )
1269 if drained == offset:
1270 break
1271 offset = drained
1273 LOGGER.info(
1274 f"Listening on Telegram for '{auth_keyword}' trigger or 6-digit code (timeout {timeout_seconds}s).",
1275 )
1276 elapsed = 0
1277 while elapsed < timeout_seconds:
1278 chunk = min(poll_interval, timeout_seconds - elapsed)
1279 sleep(chunk)
1280 elapsed += chunk
1281 # The web UI and Telegram can both complete a re-auth. If the web UI
1282 # got there first, stop listening -- otherwise the loop sits out the
1283 # rest of the window holding a session that is already fixed.
1284 if web_signals.consume_reauth_completed():
1285 LOGGER.info("Re-auth completed in the web UI -- ending the Telegram wait.")
1286 return True
1287 text, offset = notify.poll_telegram_for_text(
1288 bot_token=bot_token,
1289 chat_id=chat_id,
1290 offset=offset,
1291 )
1292 if not text:
1293 continue
1294 norm = text.strip().lower()
1295 if norm == auth_keyword:
1296 LOGGER.info("Telegram auth trigger received -- requesting 2FA push.")
1297 try:
1298 pushed = api.trigger_2fa_push_notification()
1299 except Exception as e: # noqa: BLE001
1300 LOGGER.warning(f"trigger_2fa_push_notification raised: {e!s}")
1301 pushed = False
1302 notify.post_message_to_telegram(
1303 bot_token,
1304 chat_id,
1305 (
1306 "✅ 2FA code sent to your Apple devices -- reply the 6-digit code here."
1307 if pushed
1308 else "⚠️ Couldn't request a code (no trusted device, or auth state off). Try again shortly."
1309 ),
1310 )
1311 continue
1312 code = norm.replace(" ", "").replace("-", "")
1313 if re.fullmatch(r"\d{6}", code):
1314 LOGGER.info("Received 6-digit code via Telegram -- validating.")
1315 try:
1316 accepted = api.validate_2fa_code(code)
1317 except Exception as e: # noqa: BLE001
1318 LOGGER.warning(
1319 f"validate_2fa_code raised: {e!s} -- waiting for another code.",
1320 )
1321 continue
1322 if not accepted:
1323 notify.post_message_to_telegram(
1324 bot_token,
1325 chat_id,
1326 "❌ Apple rejected that code -- reply a fresh one.",
1327 )
1328 LOGGER.warning(
1329 "Apple rejected the Telegram-supplied code -- waiting for another.",
1330 )
1331 continue
1332 try:
1333 api.trust_session()
1334 except Exception as e: # noqa: BLE001
1335 LOGGER.warning(f"trust_session raised (non-fatal): {e!s}")
1336 notify.post_message_to_telegram(
1337 bot_token,
1338 chat_id,
1339 "✅ Re-authenticated. iCloud sync resumed.",
1340 )
1341 LOGGER.info("Telegram-driven 2FA succeeded; resuming sync.")
1342 return True
1343 LOGGER.info("Telegram listen timeout reached with no usable code; retrying auth.")
1344 return False
1347def _needs_a_human(sync_state: SyncState, error) -> bool:
1348 """Whether a sign-in failure warrants the "re-auth required" alert.
1350 A rejected password always does: retrying cannot fix it.
1352 In session-only mode one more case does, because it cannot be told
1353 apart from a rejected session. icloudpy keeps the HTTP status on the
1354 exception only for a non-JSON body or its own re-auth statuses
1355 (``_SESSION_REJECTED_CODES``), so a JSON-bodied 401 arrives with
1356 ``code`` as ``None``. Those are the ones that would otherwise leave a
1357 container only a human can revive retrying in silence forever.
1359 Everything else is an outage -- a 5xx that kept its status, a dropped
1360 connection, a DNS failure -- and says nothing about the session. Those
1361 are logged and retried. Waking someone daily to re-authenticate over
1362 Apple having a bad hour is the alert this function exists to withhold.
1363 """
1364 if isinstance(error, exceptions.ICloudPyFailedLoginException):
1365 return True
1366 return (
1367 sync_state.session_only
1368 and isinstance(error, exceptions.ICloudPyAPIResponseException)
1369 and error.code is None
1370 )
1373def _handle_auth_transport_error(config, username: str, sync_state: SyncState, error):
1374 """Back off after a sign-in failure Apple did not express as a 2FA prompt.
1376 Uses at least ``_AUTH_BACKOFF_FLOOR_SEC`` regardless of the configured
1377 retry interval: the errors that land here (notably Apple's 409 on
1378 ``/signin/init``) mean "you are trying too often", so honouring a short
1379 interval would make it worse.
1381 Returns True to keep looping, False to exit.
1382 """
1383 LOGGER.error(f"Sign-in failed and will be retried: {error!s}")
1384 # sign_in_failed is reserved for what the alert below treats as needing
1385 # a human. A network fault or an Apple service error means the attempt
1386 # never completed, which needs no one to intervene.
1387 rejected = _needs_a_human(sync_state, error)
1388 notify.send_cycle_event(
1389 config=config,
1390 boundary="failure",
1391 message=(
1392 "iCloud sync cycle aborted: sign-in failed"
1393 if rejected
1394 else "iCloud sync cycle aborted: sign-in did not complete"
1395 ),
1396 data={"reason": "sign_in_failed" if rejected else "sign_in_error"},
1397 )
1398 sleep_for = config_parser.get_retry_login_interval(config=config)
1399 if sleep_for < 0:
1400 LOGGER.info("retry_login_interval is < 0, exiting ...")
1401 return False
1402 sleep_for = max(sleep_for, _AUTH_BACKOFF_FLOOR_SEC)
1403 _log_retry_time(sleep_for)
1404 if sync_state.session_only:
1405 # The loop is not syncing and only a human can change that, so the
1406 # dashboard is told -- its wording for this reason allows for an
1407 # outage and asks for nothing.
1408 _publish_auth_blocked(True, reason="sign_in_failed")
1409 if rejected:
1410 # notify.send's text is "iCloud re-auth required", so it is reserved
1411 # for the cases where that is actually true: a rejected password,
1412 # which never heals by retrying, and -- in session-only mode -- a
1413 # failure that cannot be told apart from a rejected session. It is
1414 # throttled, so this is not a message per retry.
1415 sync_state.last_send = notify.send(
1416 config=config,
1417 username=username,
1418 last_send=sync_state.last_send,
1419 region=config_parser.get_region(config=config),
1420 dashboard_url=_resolve_dashboard_url(config),
1421 event="sign_in_failed",
1422 )
1423 # Ends early on a completed web-UI re-auth, but not on "Sync now":
1424 # nothing a button does may shorten a throttle backoff.
1425 _auth_retry_sleep(sleep_for)
1426 return True
1429def _handle_sync_error(config, error, drive_sync_interval, photos_sync_interval):
1430 """Back off after a failure that happened *after* a successful sign-in.
1432 Anything raised once ``api`` exists is a service problem, not an auth
1433 problem: a zone that is unavailable, a 5xx on a download, a connection
1434 dropped mid-transfer. Reporting those as sign-in failures sends the user
1435 to re-authenticate for no reason.
1437 The interval matters as much as the wording. Every handler here ends in
1438 ``continue``, which skips ``_calculate_next_sync_schedule`` -- so the
1439 countdown timers never advance and both services stay enabled. Retrying
1440 on the short login interval therefore re-enumerates the whole library
1441 every few minutes. Wait at least as long as the shortest configured sync
1442 interval instead: never poll a broken service faster than a working one.
1444 Returns True to keep looping, False to exit.
1445 """
1446 LOGGER.error(f"Sync failed and will be retried: {error!s}")
1447 notify.send_cycle_event(
1448 config=config,
1449 boundary="failure",
1450 message="iCloud sync cycle failed and will be retried",
1451 data={"reason": "sync_error"},
1452 )
1453 # log_messages=False: this is not a login retry, and the getter's
1454 # "Retrying login every N seconds." would say otherwise.
1455 sleep_for = config_parser.get_retry_login_interval(config=config, log_messages=False)
1456 if sleep_for < 0:
1457 LOGGER.info("retry_login_interval is < 0, exiting ...")
1458 return False
1459 configured = [i for i in (drive_sync_interval, photos_sync_interval) if i > 0]
1460 if configured:
1461 sleep_for = max(sleep_for, min(configured))
1462 _log_retry_time(sleep_for, what="sync")
1463 # This can be a whole sync interval; the "Sync now" button must still
1464 # cut it short, as it does on the normal scheduling path.
1465 _interruptible_sleep(sleep_for)
1466 return True
1469def _handle_password_error(config, username: str, sync_state: SyncState, error):
1470 """
1471 Handle password not available error.
1473 Args:
1474 config: Configuration dictionary
1475 username: iCloud username
1476 sync_state: Current sync state
1477 error: The raised exception, whose message names which way
1478 session-only mode failed -- no session at all, or a session
1479 Apple no longer accepts.
1481 Returns:
1482 bool: True if should continue (retry), False if should exit
1483 """
1484 LOGGER.error(str(error))
1485 # The dashboard's own on-disk check reads an empty keyring as "setup
1486 # needed", which is the wrong instruction here: the session, not the
1487 # password, is what has to be replaced.
1488 _publish_auth_blocked(True, reason="session_unusable")
1489 notify.send_cycle_event(
1490 config=config,
1491 boundary="failure",
1492 message="iCloud sync cycle aborted: no stored password and no usable saved session",
1493 data={"reason": "password_missing"},
1494 )
1495 sleep_for = config_parser.get_retry_login_interval(config=config)
1497 if sleep_for < 0:
1498 LOGGER.info("retry_login_interval is < 0, exiting ...")
1499 return False
1501 _log_retry_time(sleep_for)
1502 server_region = config_parser.get_region(config=config)
1503 sync_state.last_send = notify.send(
1504 config=config,
1505 username=username,
1506 last_send=sync_state.last_send,
1507 region=server_region,
1508 dashboard_url=_resolve_dashboard_url(config),
1509 event="password_missing",
1510 )
1511 _auth_retry_sleep(sleep_for)
1512 return True
1515def _log_retry_time(sleep_for: int, what: str = "login"):
1516 """
1517 Log the next retry time.
1519 Args:
1520 sleep_for: Sleep duration in seconds
1521 what: What is being retried; a failed sync is not a failed login
1522 """
1523 next_sync = (
1524 datetime.datetime.now() + datetime.timedelta(seconds=sleep_for)
1525 ).strftime("%c")
1526 LOGGER.info(f"Retrying {what} at {next_sync} ...")
1529def _calculate_next_sync_schedule(config, sync_state: SyncState):
1530 """
1531 Calculate next sync schedule and update sync state.
1533 This function implements the adaptive scheduling algorithm that determines
1534 which service should sync next based on countdown timers.
1536 Args:
1537 config: Configuration dictionary
1538 sync_state: Current sync state
1540 Returns:
1541 int: Sleep duration in seconds
1542 """
1543 has_drive = config and "drive" in config
1544 has_photos = config and "photos" in config
1546 if not has_drive and has_photos:
1547 sleep_for = sync_state.photos_time_remaining
1548 sync_state.enable_sync_drive = False
1549 sync_state.enable_sync_photos = True
1550 elif has_drive and not has_photos:
1551 sleep_for = sync_state.drive_time_remaining
1552 sync_state.enable_sync_drive = True
1553 sync_state.enable_sync_photos = False
1554 else:
1555 # Sleep until the sooner of the two is due, and take that time off
1556 # both countdowns. Whichever reaches zero syncs next; equal timers
1557 # sync together. A negative countdown is a one-shot service
1558 # (sync_interval < 0) that has already run: it is never due again,
1559 # so it takes no part, and must never become a negative sleep.
1560 pending = [t for t in (sync_state.drive_time_remaining, sync_state.photos_time_remaining) if t >= 0]
1561 sleep_for = min(pending) if pending else 0
1562 if sync_state.drive_time_remaining >= 0:
1563 sync_state.drive_time_remaining -= sleep_for
1564 if sync_state.photos_time_remaining >= 0:
1565 sync_state.photos_time_remaining -= sleep_for
1566 sync_state.enable_sync_drive = sync_state.drive_time_remaining == 0
1567 sync_state.enable_sync_photos = sync_state.photos_time_remaining == 0
1569 return sleep_for
1572def _log_next_sync_time(sleep_for: int):
1573 """
1574 Log the next scheduled sync time.
1576 Args:
1577 sleep_for: Sleep duration in seconds
1578 """
1579 next_sync = (
1580 datetime.datetime.now() + datetime.timedelta(seconds=sleep_for)
1581 ).strftime("%c")
1582 LOGGER.info(f"Resyncing at {next_sync} ...")
1585def _log_sync_intervals_at_startup(config):
1586 """
1587 Log sync intervals once at startup.
1589 Args:
1590 config: Configuration dictionary
1591 """
1592 if config and "drive" in config:
1593 config_parser.get_drive_sync_interval(config=config, log_messages=True)
1594 if config and "photos" in config:
1595 config_parser.get_photos_sync_interval(config=config, log_messages=True)
1598def _should_exit_oneshot_mode(config):
1599 """
1600 Check if should exit in oneshot mode.
1602 Oneshot mode exits when ALL configured sync intervals are negative.
1604 Args:
1605 config: Configuration dictionary
1607 Returns:
1608 bool: True if should exit
1609 """
1611 should_exit_drive = ("drive" not in config) or (
1612 config_parser.get_drive_sync_interval(config=config, log_messages=False) < 0
1613 )
1614 should_exit_photos = ("photos" not in config) or (
1615 config_parser.get_photos_sync_interval(config=config, log_messages=False) < 0
1616 )
1618 return should_exit_drive and should_exit_photos
1621def sync(dry_run: bool = False, check_files: int | None = None):
1622 """
1623 Main synchronization loop.
1625 Orchestrates the entire sync process by delegating specific responsibilities
1626 to focused helper functions. This function coordinates the high-level flow
1627 while each helper handles a single concern.
1629 Args:
1630 dry_run: When True, authenticate and summarise what would be synced,
1631 then exit without writing files, sending notifications, or
1632 entering the sync loop. Useful for verifying credentials, mount
1633 paths, and config before the real loop starts downloading.
1634 check_files: Optional sample size for the per-photo file-existence
1635 check during dry-run. Only meaningful with ``dry_run=True``.
1636 ``None`` skips the check (cheap default). ``0`` walks every
1637 photo (slow on large libraries). Positive N walks N
1638 stride-sampled photos per library.
1639 """
1640 sync_state = SyncState()
1641 startup_logged = False
1643 while True:
1644 # A config that cannot be read must not kill the daemon. On a NAS the
1645 # volume holding config.yaml can lag behind container start or go away
1646 # mid-run, and a half-written file (the user editing it live) raises
1647 # out of the YAML parser. Either way the traceback escapes the loop,
1648 # and `restart: unless-stopped` turns that into a restart loop.
1649 try:
1650 config = _load_configuration()
1651 except Exception as e: # noqa: BLE001 -- any parse fault must not be fatal
1652 LOGGER.error(f"Config file could not be read, retrying: {e!s}")
1653 config = None
1654 if config is None:
1655 if dry_run:
1656 # A dry run is a one-shot check with nothing to wait for.
1657 LOGGER.error("DRY RUN: no readable config, nothing to check.")
1658 return
1659 sleep(DEFAULT_RETRY_LOGIN_INTERVAL_SEC)
1660 continue
1662 # Log sync intervals once at startup
1663 if not startup_logged:
1664 _log_sync_intervals_at_startup(config)
1665 notify.warn_unknown_webhook_events(config)
1666 # The state file outlives the container, so a restart mid-library
1667 # would leave the dashboard showing it as still syncing. Nothing
1668 # can legitimately be in flight here.
1669 try:
1670 from src import web_signals as _ws
1672 _ws.clear_stale_library_states()
1673 except Exception as e: # noqa: BLE001 -- never block startup
1674 LOGGER.debug(f"web_signals: clear_stale_library_states raised: {e!s}")
1675 startup_logged = True
1677 drive_sync_interval, photos_sync_interval = _extract_sync_intervals(
1678 config,
1679 log_messages=False,
1680 )
1681 username = config_parser.get_username(config=config) if config else None
1683 # Web UI "Sync now" requests: ``src.web_signals`` writes a
1684 # sentinel file when the user taps the button; we delete it and
1685 # zero the countdown so the next pass through the sync calls
1686 # runs immediately. Best-effort import so vanilla mandarons
1687 # builds without the web-UI module still work.
1688 try:
1689 from src import web_signals as _ws
1691 if _ws.consume_force_sync("drive"):
1692 LOGGER.info("Force-sync requested for Drive — running immediately")
1693 sync_state.drive_time_remaining = 0
1694 if _ws.consume_force_sync("photos"):
1695 LOGGER.info("Force-sync requested for Photos — running immediately")
1696 sync_state.photos_time_remaining = 0
1697 except (
1698 ImportError
1699 ): # pragma: no cover — best-effort fallback for builds without web_signals
1700 pass
1702 if username:
1703 authenticated = False
1704 try:
1705 api = _authenticate_and_get_api(config, username, sync_state)
1706 authenticated = True
1708 # Dry-run path: authenticate, enumerate, log, exit.
1709 # Skips the entire sync + notification + retry pipeline.
1710 if dry_run:
1711 if api.requires_2sa:
1712 LOGGER.info(
1713 "DRY RUN: 2FA required — finish interactive auth first "
1714 "(see README), then re-run with --dry-run.",
1715 )
1716 else:
1717 _perform_dry_run(config, api, check_files=check_files)
1718 return
1720 _publish_auth_blocked(False)
1721 if not api.requires_2sa:
1722 # Trust-window check: record current cookie expiry and
1723 # fire a pre-emptive warning once if it's about to lapse.
1724 # Best-effort: any failure is logged + swallowed inside.
1725 _maybe_refresh_trust(config, api)
1726 _maybe_warn_trust_expiring(config, api, username)
1728 # Authenticated: clear the 2FA trigger latch so a future
1729 # re-auth episode requests a fresh push exactly once.
1730 sync_state.two_fa_triggered = False
1732 # Signed in and about to sync: open the cycle on any
1733 # configured webhook. Fire-and-forget, and a no-op when
1734 # none is configured (see notify.send_cycle_event).
1735 notify.send_cycle_event(
1736 config=config,
1737 boundary="start",
1738 message="iCloud sync cycle started",
1739 )
1741 # Create summary for this sync cycle
1742 summary = SyncSummary()
1744 # Perform syncs and collect statistics
1745 drive_stats = _perform_drive_sync(
1746 config,
1747 api,
1748 sync_state,
1749 drive_sync_interval,
1750 )
1751 photos_stats = _perform_photos_sync(
1752 config,
1753 api,
1754 sync_state,
1755 photos_sync_interval,
1756 )
1758 # Populate summary with statistics
1759 summary.drive_stats = drive_stats
1760 summary.photo_stats = photos_stats
1761 summary.sync_end_time = datetime.datetime.now()
1763 # Close the cycle on the webhooks. Failed downloads
1764 # counted in the stats make this a failure even though
1765 # the cycle itself ran to completion -- a monitor that
1766 # reported success here would stay green while the
1767 # library silently fell behind. So does a cycle that
1768 # synced nothing at all. The statistics ride along, so a
1769 # POST receiver sees them without app.notifications.
1770 has_errors = summary.has_errors()
1771 nothing_reason = _cycle_nothing_synced_reason(
1772 config,
1773 drive_stats,
1774 photos_stats,
1775 )
1776 # Every sync_failed names a reason, including this one: a
1777 # receiver should never have to infer why from the
1778 # statistics. (Only Photos counts failed downloads, so
1779 # has_errors never means a Drive failure today.)
1780 reason = nothing_reason or ("download_errors" if has_errors else None)
1781 cycle_data = notify.summary_event_data(summary)
1782 if reason:
1783 cycle_data["reason"] = reason
1784 notify.send_cycle_event(
1785 config=config,
1786 boundary="failure" if reason else "success",
1787 message=_cycle_end_message(has_errors, nothing_reason),
1788 data=cycle_data,
1789 )
1791 # Persist per-service last-sync state for the web
1792 # dashboard. Best-effort — if the JSON write fails
1793 # the sync itself is unaffected.
1794 try:
1795 from src import web_signals as _ws
1797 if drive_stats is not None:
1798 _ws.record_sync_completion(
1799 service="drive",
1800 files_downloaded=drive_stats.files_downloaded,
1801 files_skipped=drive_stats.files_skipped,
1802 files_removed=drive_stats.files_removed,
1803 errors=len(drive_stats.errors),
1804 duration_seconds=drive_stats.duration_seconds,
1805 )
1806 if photos_stats is not None:
1807 _ws.record_sync_completion(
1808 service="photos",
1809 files_downloaded=photos_stats.photos_downloaded,
1810 files_skipped=photos_stats.photos_skipped,
1811 errors=len(photos_stats.errors),
1812 duration_seconds=photos_stats.duration_seconds,
1813 )
1814 except (
1815 ImportError
1816 ): # pragma: no cover — best-effort fallback for builds without web_signals
1817 pass
1818 except Exception as e:
1819 LOGGER.debug(
1820 f"web_signals: record_sync_completion raised: {e!s}",
1821 )
1823 # Send usage statistics (anonymized summary data)
1824 try:
1825 _send_usage_statistics(config, summary)
1826 except Exception as e:
1827 LOGGER.debug(f"Failed to send usage statistics: {e!s}")
1829 # Send sync summary notification if configured
1830 # Only send notification when both enabled services have synced in this cycle
1831 # Gracefully handle notification failures to not break sync
1832 has_drive_config = config and "drive" in config
1833 has_photos_config = config and "photos" in config
1835 should_send_notification = False
1836 if has_drive_config and has_photos_config:
1837 # Both services configured - send notification only when both have synced
1838 should_send_notification = (
1839 drive_stats is not None and photos_stats is not None
1840 )
1841 elif has_drive_config and not has_photos_config:
1842 # Only drive configured - send when drive synced
1843 should_send_notification = drive_stats is not None
1844 elif has_photos_config and not has_drive_config:
1845 # Only photos configured - send when photos synced
1846 should_send_notification = photos_stats is not None
1848 if should_send_notification:
1849 try:
1850 notify.send_sync_summary(config=config, summary=summary)
1851 except Exception as e:
1852 LOGGER.debug(
1853 f"Failed to send sync summary notification: {e!s}",
1854 )
1856 if not _check_services_configured(config):
1857 LOGGER.warning(
1858 "Nothing to sync. Please add drive: and/or photos: section in config.yaml file.",
1859 )
1860 else:
1861 _log_trust_revocation_hint(api)
1862 if not _handle_2fa_required(config, username, sync_state, api):
1863 break
1864 continue
1866 except exceptions.ICloudPyNoStoredPasswordAvailableException as e:
1867 if not _handle_password_error(config, username, sync_state, e):
1868 break
1869 continue
1870 except exceptions.ICloudPyFailedLoginException as e:
1871 # icloudpy catches most sign-in errors -- a 409 or 401 with a
1872 # reason, any 5xx -- and re-raises them as this, which
1873 # subclasses ICloudPyException directly and so is not caught by
1874 # the API-response clause below. It only arises while signing
1875 # in, so it always takes the auth backoff.
1876 if not _handle_auth_transport_error(config, username, sync_state, e):
1877 break
1878 continue
1879 except exceptions.ICloudPyServiceNotActivatedException as e:
1880 # A zone or service being unavailable says nothing about the
1881 # sign-in whenever it surfaces, so it never earns the
1882 # rate-limit backoff. Listed ahead of the broader catch
1883 # below because it subclasses it.
1884 if not _handle_sync_error(
1885 config,
1886 e,
1887 drive_sync_interval,
1888 photos_sync_interval,
1889 ):
1890 break
1891 continue
1892 except (
1893 exceptions.ICloudPyAPIResponseException,
1894 requests.exceptions.RequestException,
1895 ) as e:
1896 # Any other failure raised after the sign-in succeeded is a
1897 # service problem, not an auth problem -- and must not earn
1898 # the backoff meant for "you are trying too often" either.
1899 if authenticated:
1900 if not _handle_sync_error(
1901 config,
1902 e,
1903 drive_sync_interval,
1904 photos_sync_interval,
1905 ):
1906 break
1907 continue
1908 # Apple refuses sign-in for reasons other than "2FA needed":
1909 # 409 when it is throttling the account, 5xx when it is
1910 # having a bad day, plus ordinary network faults. None of
1911 # these are fatal, but letting them escape kills the process
1912 # -- and with `restart: unless-stopped` that becomes a crash
1913 # loop that re-authenticates every few seconds, which is the
1914 # fastest possible way to deepen a throttle.
1915 if not _handle_auth_transport_error(config, username, sync_state, e):
1916 break
1917 continue
1919 sleep_for = _calculate_next_sync_schedule(config, sync_state)
1920 _log_next_sync_time(sleep_for)
1922 if _should_exit_oneshot_mode(config):
1923 LOGGER.info(
1924 "All configured sync intervals are negative, exiting oneshot mode...",
1925 )
1926 break
1928 # Interruptible sleep -- poll the web-signal force-sync sentinels
1929 # every few seconds so the "Sync now" button stays responsive even
1930 # mid-long-interval. Without this, a user tap during a multi-hour
1931 # drive sleep would wait the full remaining duration.
1932 _interruptible_sleep(sleep_for)
1935def _interruptible_sleep(total_seconds: int) -> None:
1936 """Sleep up to ``total_seconds`` in short chunks, returning early
1937 when ``web_signals.pending_force_syncs()`` reports any sentinel.
1939 The ``import src.web_signals`` is best-effort so a vanilla mandarons
1940 build without the web-UI module still runs (it falls back to a
1941 single ``sleep(total_seconds)``).
1942 """
1943 _CHUNK = 2 # seconds — tradeoff: shorter = more responsive, more wakeups
1944 try:
1945 from src import web_signals as _ws
1946 except ImportError: # pragma: no cover — vanilla-mandarons fallback
1947 sleep(total_seconds)
1948 return
1950 # Short intervals (<= one chunk) sleep in a single call so the
1951 # existing tests that count sleep invocations still match. The
1952 # chunking only matters for long intervals where the user might
1953 # tap "Sync now" mid-sleep -- those become multiple short sleeps
1954 # with a sentinel poll between each.
1955 if total_seconds <= _CHUNK:
1956 sleep(total_seconds)
1957 return
1959 remaining = total_seconds
1960 while remaining > 0:
1961 chunk = min(_CHUNK, remaining)
1962 sleep(chunk)
1963 remaining -= chunk
1964 if _ws.pending_force_syncs():
1965 return