Coverage for src/sync.py: 100%

681 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 16:56 +0000

1"""Sync module.""" 

2 

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

4import datetime 

5import os 

6import re 

7import secrets 

8from time import sleep 

9 

10import requests 

11from icloudpy import ICloudPyService, exceptions, utils 

12 

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 

28 

29# Configure icloudpy logging immediately after import 

30configure_icloudpy_logging() 

31 

32LOGGER = get_logger() 

33 

34 

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

36 

37 

38def _detect_security_key_account(api, username: str) -> bool: 

39 """True when Apple answers this account's second factor with an fsaChallenge. 

40 

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. 

48 

49 Recording the method also means ``notify`` picks the security-key 

50 wording on this same pass, since it reads the same signal. 

51 

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 

68 

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 

73 

74 

75def _log_trust_revocation_hint(api) -> None: 

76 """Say so when Apple rejected a trust token that has not expired. 

77 

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. 

84 

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. 

88 

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}") 

108 

109 

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

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

112 

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 

132 

133 

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 

138 

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 

142 

143 

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

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

146 

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}" 

175 

176 

177def _publish_auth_blocked(blocked: bool, reason: str | None = None) -> None: 

178 """Tell the web UI whether the loop can authenticate. 

179 

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. 

183 

184 Best-effort -- signalling must never break syncing. 

185 """ 

186 try: 

187 from src import web_signals 

188 

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}") 

192 

193 

194def _maybe_refresh_trust(config, api) -> None: 

195 """Re-trust the session before its token ages out. 

196 

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. 

202 

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). 

206 

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}") 

250 

251 

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

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

254 

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``. 

259 

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. 

264 

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 

270 

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}") 

300 

301 

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) 

308 

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}) 

317 

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) 

323 

324 

325class SessionOnlyICloudPyService(ICloudPyService): 

326 """An iCloud client allowed to resume a saved session and nothing else. 

327 

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 """ 

337 

338 def authenticate(self, force_refresh=False, service=None): 

339 """Validate the saved session; never sign in with credentials. 

340 

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. 

344 

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) 

354 

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 

365 

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) 

374 

375 self._webservices = self.data["webservices"] 

376 LOGGER.debug("Resumed the saved session without a password") 

377 

378 

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. 

387 

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") 

401 

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 

412 

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 ) 

438 

439 

440class SyncState: 

441 """ 

442 Maintains synchronization state for drive and photos. 

443 

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

445 passing multiple variables between functions. 

446 """ 

447 

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 

464 

465 

466def _load_configuration(): 

467 """ 

468 Load configuration from file or environment. 

469 

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) 

475 

476 

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

478 """ 

479 Extract drive and photos sync intervals from configuration. 

480 

481 Args: 

482 config: Configuration dictionary 

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

484 

485 Returns: 

486 tuple: (drive_sync_interval, photos_sync_interval) 

487 """ 

488 drive_sync_interval = 0 

489 photos_sync_interval = 0 

490 

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 ) 

501 

502 return drive_sync_interval, photos_sync_interval 

503 

504 

505def _retrieve_password(username: str): 

506 """ 

507 Retrieve password from environment or keyring. 

508 

509 Args: 

510 username: iCloud username 

511 

512 Returns: 

513 Password string or None if not found 

514 

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 

531 

532 

533def _authenticate_and_get_api(config, username: str, sync_state: SyncState | None = None): 

534 """ 

535 Authenticate user and return iCloud API instance. 

536 

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 

542 

543 Returns: 

544 ICloudPyService instance 

545 

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 ) 

573 

574 

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. 

582 

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. 

587 

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. 

591 

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. 

599 

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``). 

610 

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 

628 

629 

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

631 """ 

632 Execute drive synchronization if enabled. 

633 

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 

639 

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 

645 

646 from src.sync_stats import DriveStats 

647 

648 start_time = time.time() 

649 stats = DriveStats() 

650 

651 destination_path = config_parser.prepare_drive_destination(config=config) 

652 

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 

668 

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 

678 

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

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

681 LOGGER.info("Drive synced") 

682 

683 # Calculate statistics 

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

685 

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) 

691 

692 # Count skipped files 

693 stats.files_skipped = len(files_before & files_after) 

694 

695 # Count removed files 

696 if config_parser.get_drive_remove_obsolete(config=config): 

697 stats.files_removed = len(files_before - files_after) 

698 

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 

706 

707 # Reset countdown timer to the configured interval 

708 sync_state.drive_time_remaining = drive_sync_interval 

709 return stats 

710 return None 

711 

712 

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

714 """ 

715 Execute photos synchronization if enabled. 

716 

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 

722 

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 

728 

729 from src.sync_stats import PhotoStats 

730 

731 start_time = time.time() 

732 stats = PhotoStats() 

733 

734 destination_path = config_parser.prepare_photos_destination(config=config) 

735 

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 

750 

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 

760 

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

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

763 LOGGER.info("Photos synced") 

764 

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 

774 

775 # Calculate statistics 

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

777 

778 # Count newly downloaded files 

779 new_files = files_after - files_before 

780 stats.photos_downloaded = len(new_files) 

781 

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 ) 

792 

793 # Count skipped photos 

794 stats.photos_skipped = len(files_before & files_after) 

795 

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) 

801 

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 

809 

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") 

815 

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 

824 

825 # Reset countdown timer to the configured interval 

826 sync_state.photos_time_remaining = photos_sync_interval 

827 return stats 

828 return None 

829 

830 

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

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

833 

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. 

838 

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``). 

844 

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). 

852 

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.") 

857 

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 ) 

879 

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 ) 

904 

905 if check_files is not None: 

906 from src import migration_check 

907 

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}") 

945 

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}") 

980 

981 LOGGER.info( 

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

983 ) 

984 

985 

986def _check_services_configured(config): 

987 """ 

988 Check if any sync services are configured. 

989 

990 Args: 

991 config: Configuration dictionary 

992 

993 Returns: 

994 bool: True if at least one service is configured 

995 """ 

996 

997 return "drive" in config or "photos" in config 

998 

999 

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. 

1002 

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. 

1008 

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". 

1013 

1014 Args: 

1015 config: Configuration dictionary 

1016 drive_stats: Result of ``_perform_drive_sync`` 

1017 photos_stats: Result of ``_perform_photos_sync`` 

1018 

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 

1027 

1028 

1029def _cycle_end_message(has_errors: bool, nothing_reason: str | None) -> str: 

1030 """The human text for the end-of-cycle event. 

1031 

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" 

1042 

1043 

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

1045 """Send anonymized usage statistics. 

1046 

1047 Args: 

1048 config: Configuration dictionary 

1049 summary: Sync summary with statistics 

1050 """ 

1051 

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 } 

1070 

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 } 

1078 

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 } 

1086 

1087 # Send to usage tracking 

1088 alive(config=config, data=usage_data) 

1089 

1090 

1091def _auth_retry_sleep(total_seconds: int) -> None: 

1092 """Wait between sign-in attempts, ending early on a completed re-auth. 

1093 

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. 

1099 

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 

1110 

1111 if total_seconds <= _CHUNK: 

1112 sleep(total_seconds) 

1113 return 

1114 

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 

1123 

1124 

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. 

1127 

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. 

1130 

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. 

1138 

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.") 

1153 

1154 

1155def _handle_2fa_required(config, username: str, sync_state: SyncState, api): 

1156 """ 

1157 Handle 2FA authentication requirement. 

1158 

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. 

1169 

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) 

1197 

1198 sleep_for = config_parser.get_retry_login_interval(config=config) 

1199 

1200 if sleep_for < 0: 

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

1202 return False 

1203 

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 

1225 

1226 

1227def _wait_for_telegram_code(config, api, timeout_seconds: int) -> bool: 

1228 """Drive 2FA over Telegram with a manual, user-initiated trigger. 

1229 

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``. 

1238 

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 

1245 

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 

1256 

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 

1272 

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 

1345 

1346 

1347def _needs_a_human(sync_state: SyncState, error) -> bool: 

1348 """Whether a sign-in failure warrants the "re-auth required" alert. 

1349 

1350 A rejected password always does: retrying cannot fix it. 

1351 

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. 

1358 

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 ) 

1371 

1372 

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. 

1375 

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. 

1380 

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 

1427 

1428 

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. 

1431 

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. 

1436 

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. 

1443 

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 

1467 

1468 

1469def _handle_password_error(config, username: str, sync_state: SyncState, error): 

1470 """ 

1471 Handle password not available error. 

1472 

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. 

1480 

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) 

1496 

1497 if sleep_for < 0: 

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

1499 return False 

1500 

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 

1513 

1514 

1515def _log_retry_time(sleep_for: int, what: str = "login"): 

1516 """ 

1517 Log the next retry time. 

1518 

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} ...") 

1527 

1528 

1529def _calculate_next_sync_schedule(config, sync_state: SyncState): 

1530 """ 

1531 Calculate next sync schedule and update sync state. 

1532 

1533 This function implements the adaptive scheduling algorithm that determines 

1534 which service should sync next based on countdown timers. 

1535 

1536 Args: 

1537 config: Configuration dictionary 

1538 sync_state: Current sync state 

1539 

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 

1545 

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 

1568 

1569 return sleep_for 

1570 

1571 

1572def _log_next_sync_time(sleep_for: int): 

1573 """ 

1574 Log the next scheduled sync time. 

1575 

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} ...") 

1583 

1584 

1585def _log_sync_intervals_at_startup(config): 

1586 """ 

1587 Log sync intervals once at startup. 

1588 

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) 

1596 

1597 

1598def _should_exit_oneshot_mode(config): 

1599 """ 

1600 Check if should exit in oneshot mode. 

1601 

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

1603 

1604 Args: 

1605 config: Configuration dictionary 

1606 

1607 Returns: 

1608 bool: True if should exit 

1609 """ 

1610 

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 ) 

1617 

1618 return should_exit_drive and should_exit_photos 

1619 

1620 

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

1622 """ 

1623 Main synchronization loop. 

1624 

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. 

1628 

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 

1642 

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 

1661 

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 

1671 

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 

1676 

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 

1682 

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 

1690 

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 

1701 

1702 if username: 

1703 authenticated = False 

1704 try: 

1705 api = _authenticate_and_get_api(config, username, sync_state) 

1706 authenticated = True 

1707 

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 

1719 

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) 

1727 

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 

1731 

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 ) 

1740 

1741 # Create summary for this sync cycle 

1742 summary = SyncSummary() 

1743 

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 ) 

1757 

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() 

1762 

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 ) 

1790 

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 

1796 

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 ) 

1822 

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}") 

1828 

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 

1834 

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 

1847 

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 ) 

1855 

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 

1865 

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 

1918 

1919 sleep_for = _calculate_next_sync_schedule(config, sync_state) 

1920 _log_next_sync_time(sleep_for) 

1921 

1922 if _should_exit_oneshot_mode(config): 

1923 LOGGER.info( 

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

1925 ) 

1926 break 

1927 

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) 

1933 

1934 

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. 

1938 

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 

1949 

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 

1958 

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