Coverage for src/sync.py: 100%

707 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-08 21:05 +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 PHOTOS_INDEXING_RETRY_SEC, 

19 config_parser, 

20 configure_icloudpy_logging, 

21 get_logger, 

22 notify, 

23 read_config, 

24 sync_drive, 

25 sync_photos, 

26) 

27from src.sync_stats import SyncSummary 

28from src.usage import alive 

29 

30# Configure icloudpy logging immediately after import 

31configure_icloudpy_logging() 

32 

33LOGGER = get_logger() 

34 

35 

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

37 

38 

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

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

41 

42 Once security keys are enrolled Apple stops issuing 6-digit codes 

43 altogether, so requesting a push sends nothing and listening for a 

44 replied code waits for something that cannot arrive -- the loop did 

45 both, every cycle, and told the user over Telegram to "reply the 

46 6-digit code here". Detecting it here rather than waiting for someone 

47 to open the dashboard is what lets the very first notification tell 

48 the truth. 

49 

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

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

52 

53 Best-effort in both directions: icloudpy without security-key support 

54 has no such attribute, and no probe failure may break the retry loop. 

55 """ 

56 try: 

57 challenge = getattr(api, "security_key_challenge", None) 

58 except Exception as e: # noqa: BLE001 - a probe must never break the loop 

59 LOGGER.debug(f"security-key probe failed: {e!s}") 

60 return False 

61 # Shape, not truthiness. icloudpy returns a mapping carrying "challenge" 

62 # and "keyHandles", or None -- anything else (a stub, a changed API, a 

63 # sentinel) must not be mistaken for Apple demanding a key, because the 

64 # cost of a false positive is suppressing the real code flow. 

65 if not (isinstance(challenge, dict) and challenge.get("challenge")): 

66 return False 

67 try: 

68 from src import web_signals 

69 

70 web_signals.record_auth_method(username=username, method="security_key") 

71 except Exception as e: # noqa: BLE001 - wording is not worth an outage 

72 LOGGER.debug(f"could not record auth method: {e!s}") 

73 return True 

74 

75 

76def _log_trust_revocation_hint(api) -> None: 

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

78 

79 Expiry and revocation are indistinguishable from the logs -- both 

80 surface as 421 and both end with a 2FA prompt -- but they mean 

81 opposite things for what to do next. A refresh schedule prevents the 

82 first and can do nothing about the second: Apple drops trust on 

83 security events, typically a new trusted device, a password change, 

84 or a change to security keys. 

85 

86 Without this, the obvious reading of "valid trust token, 2FA demanded 

87 anyway" is that the refresh logic is broken, and the time goes into 

88 auditing code that behaved correctly. 

89 

90 Best-effort: never raises into the retry path. 

91 """ 

92 try: 

93 expires_at = _read_trust_cookie_expiry(api) 

94 if expires_at is None: 

95 return 

96 remaining = ( 

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

98 ).days 

99 if remaining <= 0: 

100 return 

101 LOGGER.error( 

102 f"The trust token had not expired -- it is valid for {remaining} " 

103 f"more days (until {expires_at.date()}). Apple revoked it " 

104 f"server-side, which typically follows a new trusted device, a " 

105 f"password change, or a change to security keys.", 

106 ) 

107 except Exception as e: # noqa: BLE001 - diagnostics never break the retry 

108 LOGGER.debug(f"trust revocation hint failed: {e!s}") 

109 

110 

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

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

113 

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

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

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

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

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

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

120 cleared). 

121 """ 

122 try: 

123 cookies = api.session.cookies 

124 except AttributeError: 

125 return None 

126 for cookie in cookies: 

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

128 return datetime.datetime.fromtimestamp( 

129 cookie.expires, 

130 tz=datetime.timezone.utc, 

131 ) 

132 return None 

133 

134 

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

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

137# not once per sync-loop iteration. 

138_WEB_UI_PUBLIC_URL_WARNED = False 

139 

140# Minimum wait after a failed sign-in. Apple answers a throttled account 

141# with 409 on /signin/init; retrying sooner just extends the lockout. 

142_AUTH_BACKOFF_FLOOR_SEC = 1800 

143 

144 

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

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

147 

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

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

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

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

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

153 """ 

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

155 return None 

156 public_url = config_parser.get_web_ui_public_url(config=config) 

157 if public_url: 

158 return public_url 

159 host = config_parser.get_web_ui_host(config=config) 

160 port = config_parser.get_web_ui_port(config=config) 

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

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

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

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

165 global _WEB_UI_PUBLIC_URL_WARNED 

166 if not _WEB_UI_PUBLIC_URL_WARNED: 

167 LOGGER.warning( 

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

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

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

171 host, 

172 port, 

173 ) 

174 _WEB_UI_PUBLIC_URL_WARNED = True 

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

176 

177 

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

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

180 

181 Only this loop knows: every on-disk signal the dashboard can check by 

182 itself (username configured, password in the keyring) still looks 

183 healthy while an account sits stuck on a second factor. 

184 

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

186 """ 

187 try: 

188 from src import web_signals 

189 

190 web_signals.record_auth_blocked(blocked=blocked, reason=reason) 

191 except Exception as e: # pragma: no cover - signalling is advisory 

192 LOGGER.warning(f"Could not publish auth state: {e!s}") 

193 

194 

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

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

197 

198 ``trust_session`` asks Apple for a new ``X-Apple-TwoSV-Trust-Token`` 

199 and icloudpy persists it to ``session_data``. Calling it while the 

200 session is still healthy therefore rolls the window forward, so a 

201 restart at any later point finds a young token and resumes without 

202 prompting for a second factor. 

203 

204 This matters most for accounts where the second factor is expensive 

205 or impossible to satisfy headlessly (hardware security keys, where 

206 Apple refuses to send a 6-digit code at all). 

207 

208 Best-effort: never raises into the sync loop. 

209 """ 

210 try: 

211 threshold = config_parser.get_trust_refresh_days(config=config) 

212 if threshold <= 0: 

213 return 

214 expires_at = _read_trust_cookie_expiry(api) 

215 if expires_at is None: 

216 return 

217 days_remaining = ( 

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

219 ).days 

220 if days_remaining > threshold: 

221 return 

222 LOGGER.info( 

223 f"Trust token has {days_remaining}d left (threshold {threshold}d) -- refreshing.", 

224 ) 

225 if api.trust_session(): 

226 refreshed = _read_trust_cookie_expiry(api) 

227 expires_at_iso = refreshed.isoformat() if refreshed else None 

228 LOGGER.info( 

229 f"Trust refreshed; now expires {expires_at_iso or 'unknown'}.", 

230 ) 

231 # Webhook-only: no other transport reports a refresh, and a 

232 # receiver tracking the trust window needs the new expiry as 

233 # much as it needs the warning that preceded it. 

234 refresh_data = {"days_remaining_before": days_remaining} 

235 if expires_at_iso: 

236 # Omitted rather than null when the cookie is unreadable. 

237 refresh_data["expires_at"] = expires_at_iso 

238 notify.post_event_to_webhook( 

239 config, 

240 "trust_refreshed", 

241 f"iCloud trust token refreshed; now expires {expires_at_iso or 'unknown'}", 

242 refresh_data, 

243 ) 

244 else: 

245 LOGGER.warning( 

246 "Proactive trust refresh was declined by Apple -- a second " 

247 "factor will be needed at the next cold start.", 

248 ) 

249 except Exception as e: # pragma: no cover - never break sync over a refresh 

250 LOGGER.warning(f"trust refresh failed: {e!s}") 

251 

252 

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

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

255 

256 Reads the live trust cookie expiry, compares against 

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

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

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

260 

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

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

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

264 rearms automatically -- no manual reset needed. 

265 

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

267 bug never breaks the sync loop. 

268 """ 

269 try: 

270 from src import notify, web_signals 

271 

272 expires_at = _read_trust_cookie_expiry(api) 

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

274 prior = web_signals.get_trust_state() 

275 web_signals.record_trust_state( 

276 expires_at_iso=expires_at_iso, 

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

278 ) 

279 if expires_at is None: 

280 return 

281 days_remaining = ( 

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

283 ).days 

284 threshold = config_parser.get_trust_expiry_warn_days(config=config) 

285 if days_remaining >= threshold: 

286 return 

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

288 return # already warned for this cookie value 

289 notify.send_trust_expiring( 

290 config=config, 

291 username=username, 

292 days_remaining=days_remaining, 

293 dashboard_url=_resolve_dashboard_url(config), 

294 ) 

295 web_signals.record_trust_state( 

296 expires_at_iso=expires_at_iso, 

297 warned_for_expires_at=expires_at_iso, 

298 ) 

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

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

301 

302 

303_SESSION_ONLY_HELP = ( 

304 "Store an Apple ID password to let the container re-authenticate on its " 

305 "own, or create a new session by signing in from the web dashboard or " 

306 "with the documented `icloud --username=... --session-directory=...` " 

307 "command." 

308) 

309 

310# Apple answers a session it will no longer accept with one of these, and 

311# icloudpy surfaces the status as the exception's ``code``. Anything else -- 

312# a 500, a 503, a bad gateway -- is an outage that says nothing about the 

313# session, so it belongs to the loop's transport handler rather than being 

314# reported to the user as "sign in again". (icloudpy itself lumps 500 in 

315# with the re-auth statuses when it rewrites the reason; that conflation 

316# must not reach the user as an instruction.) 

317_SESSION_REJECTED_CODES = frozenset({401, 421, 450}) 

318 

319# icloudpy reads the keyring for a ``None`` password -- the very lookup 

320# session-only mode exists to avoid -- and, given ``""``, its log filter 

321# rewrites every log line with asterisks between each character. A random 

322# placeholder avoids both; ``SessionOnlyICloudPyService`` never sends it. 

323_SESSION_ONLY_PLACEHOLDER_PASSWORD = secrets.token_urlsafe(32) 

324 

325 

326class SessionOnlyICloudPyService(ICloudPyService): 

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

328 

329 icloudpy's ``authenticate()`` tries the saved session token first and 

330 falls back to a full SRP sign-in when it does not validate. With no 

331 password that fallback cannot succeed, and letting it run would post a 

332 placeholder credential to Apple's sign-in endpoint on every retry -- 

333 the fastest way to get an Apple ID throttled or locked. So this 

334 override stops after the session check and reports the missing 

335 password instead, which the loop's existing handler already turns into 

336 a notification and a backoff. 

337 """ 

338 

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

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

341 

342 Raises ``ICloudPyNoStoredPasswordAvailableException`` when the 

343 session is missing or Apple has rejected it; any other API error 

344 propagates untouched, since an outage is not an expired session. 

345 

346 ``force_refresh`` is refused as defence in depth rather than 

347 because anything here asks for it: icloudpy's only caller is its 

348 Find My 450 handler, which this app never reaches. Honouring it 

349 would mean a credential sign-in, which is the one thing this class 

350 exists to prevent, so it is refused wherever it came from. 

351 """ 

352 if force_refresh or not self.session_data.get("session_token"): 

353 msg = f"No Apple ID password is stored and there is no saved session to resume. {_SESSION_ONLY_HELP}" 

354 raise exceptions.ICloudPyNoStoredPasswordAvailableException(msg) 

355 

356 try: 

357 self.data = self._validate_token() 

358 except exceptions.ICloudPyAPIResponseException as error: 

359 if error.code not in _SESSION_REJECTED_CODES: 

360 # An outage leaves the session perfectly good; reporting it as 

361 # expired would send the user off to re-authenticate for 

362 # nothing. Let the loop's transport handler have it. 

363 raise 

364 msg = f"No Apple ID password is stored and the saved session is no longer valid. {_SESSION_ONLY_HELP}" 

365 raise exceptions.ICloudPyNoStoredPasswordAvailableException(msg) from error 

366 

367 if "webservices" not in self.data: 

368 # icloudpy returns the response untouched when a failed /validate 

369 # carries no recognised error field, so ``self.data`` can be an 

370 # error body. Upstream would raise KeyError here and kill the 

371 # process -- with `restart: unless-stopped` that is a restart loop 

372 # hitting Apple on every boot. 

373 msg = f"No Apple ID password is stored and Apple did not accept the saved session. {_SESSION_ONLY_HELP}" 

374 raise exceptions.ICloudPyNoStoredPasswordAvailableException(msg) 

375 

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

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

378 

379 

380def get_api_instance( 

381 username: str, 

382 password: str | None, 

383 cookie_directory: str | None = None, 

384 server_region: str = "global", 

385) -> ICloudPyService: 

386 """ 

387 Create and return an iCloud API client instance. 

388 

389 Args: 

390 username: iCloud username/Apple ID 

391 password: iCloud password, or ``None`` to run in session-only 

392 mode -- the client then resumes the saved session in 

393 ``cookie_directory`` and refuses to sign in with credentials 

394 (see ``SessionOnlyICloudPyService``). 

395 cookie_directory: Directory to store authentication cookies. 

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

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

398 redirect the constant at runtime take effect — the previous 

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

400 constant unmockable post-import. 

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

402 

403 Returns: 

404 Configured ICloudPyService instance 

405 """ 

406 if cookie_directory is None: 

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

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

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

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

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

412 import sys 

413 

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

415 service_class = ICloudPyService 

416 if password is None: 

417 service_class = SessionOnlyICloudPyService 

418 password = _SESSION_ONLY_PLACEHOLDER_PASSWORD 

419 return ( 

420 service_class( 

421 apple_id=username, 

422 password=password, 

423 cookie_directory=cookie_directory, 

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

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

426 ) 

427 if server_region == "china" 

428 else service_class( 

429 apple_id=username, 

430 password=password, 

431 cookie_directory=cookie_directory, 

432 ) 

433 ) 

434 

435 

436class SyncState: 

437 """ 

438 Maintains synchronization state for drive and photos. 

439 

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

441 passing multiple variables between functions. 

442 """ 

443 

444 def __init__(self): 

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

446 self.drive_time_remaining = 0 

447 self.photos_time_remaining = 0 

448 self.enable_sync_drive = True 

449 self.enable_sync_photos = True 

450 self.last_send = None 

451 # Whether a 2FA push has already been requested for the current 

452 # re-auth episode. Reset to False on each successful authentication so 

453 # a fresh episode triggers exactly one push (see _handle_2fa_required). 

454 self.two_fa_triggered = False 

455 # Whether this cycle is running without a stored password, i.e. off 

456 # the saved session alone. Set by _authenticate_and_get_api; the error 

457 # handlers need it because nothing in that mode can be fixed by 

458 # retrying with a password the container does not have. 

459 self.session_only = False 

460 # Set when this cycle skipped Photos because Apple is still indexing 

461 # it, so the cycle's webhook can say so rather than blame the mount. 

462 self.photos_indexing = False 

463 

464 

465def _load_configuration(): 

466 """ 

467 Load configuration from file or environment. 

468 

469 Returns: 

470 Configuration dictionary 

471 """ 

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

473 return read_config(config_path=config_path) 

474 

475 

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

477 """ 

478 Extract drive and photos sync intervals from configuration. 

479 

480 Args: 

481 config: Configuration dictionary 

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

483 

484 Returns: 

485 tuple: (drive_sync_interval, photos_sync_interval) 

486 """ 

487 drive_sync_interval = 0 

488 photos_sync_interval = 0 

489 

490 if config and "drive" in config: 

491 drive_sync_interval = config_parser.get_drive_sync_interval( 

492 config=config, 

493 log_messages=log_messages, 

494 ) 

495 if config and "photos" in config: 

496 photos_sync_interval = config_parser.get_photos_sync_interval( 

497 config=config, 

498 log_messages=log_messages, 

499 ) 

500 

501 return drive_sync_interval, photos_sync_interval 

502 

503 

504def _retrieve_password(username: str): 

505 """ 

506 Retrieve password from environment or keyring. 

507 

508 Args: 

509 username: iCloud username 

510 

511 Returns: 

512 Password string or None if not found 

513 

514 Raises: 

515 ICloudPyNoStoredPasswordAvailableException: If password not available 

516 """ 

517 # A blank value counts as no password. Compose substitutes "" for an 

518 # unset ${VAR}, and storing it would also overwrite a real keyring 

519 # entry; returning it would skip session-only mode and send an empty 

520 # password to Apple's sign-in on every retry. 

521 password = os.environ.get(ENV_ICLOUD_PASSWORD_KEY) 

522 if password: 

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

524 return password 

525 password = utils.get_password_from_keyring(username=username) 

526 if not password: 

527 msg = f"The stored password for {username} is empty." 

528 raise exceptions.ICloudPyNoStoredPasswordAvailableException(msg) 

529 return password 

530 

531 

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

533 """ 

534 Authenticate user and return iCloud API instance. 

535 

536 Args: 

537 config: Configuration dictionary 

538 username: iCloud username 

539 sync_state: Current sync state, whose ``session_only`` flag is set 

540 here so the error handlers can tell the two modes apart 

541 

542 Returns: 

543 ICloudPyService instance 

544 

545 Raises: 

546 ICloudPyNoStoredPasswordAvailableException: If no password is 

547 configured *and* the saved session cannot be resumed. 

548 """ 

549 server_region = config_parser.get_region(config=config) 

550 try: 

551 password = _retrieve_password(username) 

552 except exceptions.ICloudPyNoStoredPasswordAvailableException: 

553 # No password anywhere is a choice, not necessarily a misconfiguration: 

554 # an operator who would rather not keep an Apple ID password on disk 

555 # can run unattended off the saved session alone until Apple's trust 

556 # window closes. Resuming it is worth attempting before declaring 

557 # failure -- the loop used to report "password is not stored" without 

558 # ever looking at a perfectly valid session. 

559 LOGGER.debug( 

560 "No Apple ID password is configured -- resuming the saved session.", 

561 ) 

562 password = None 

563 if sync_state is not None: 

564 # Set before the client is built, so it is already right if building 

565 # it is what fails. 

566 sync_state.session_only = password is None 

567 return get_api_instance( 

568 username=username, 

569 password=password, 

570 server_region=server_region, 

571 ) 

572 

573 

574def _check_mount_marker( 

575 destinations: list[str], 

576 marker_filename: str, 

577 required: bool, 

578 service_name: str, 

579) -> bool: 

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

581 

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

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

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

585 an empty container-internal directory. 

586 

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

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

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

590 

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

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

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

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

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

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

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

598 

599 Args: 

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

601 directory is checked independently. An empty list returns 

602 True (nothing to check). 

603 marker_filename: Filename to look for inside each destination 

604 (e.g. ``.mounted``). 

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

606 is a no-op that always returns True. 

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

608 (``Drive`` / ``Photos``). 

609 

610 Returns: 

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

612 """ 

613 if not required: 

614 return True 

615 all_present = True 

616 for destination_path in destinations: 

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

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

619 LOGGER.error( 

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

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

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

623 f"next sync cycle will proceed.", 

624 ) 

625 all_present = False 

626 return all_present 

627 

628 

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

630 """ 

631 Execute drive synchronization if enabled. 

632 

633 Args: 

634 config: Configuration dictionary 

635 api: iCloud API instance 

636 sync_state: Current sync state 

637 drive_sync_interval: Drive sync interval in seconds 

638 

639 Returns: 

640 DriveStats object if sync was performed, None otherwise 

641 """ 

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

643 import time 

644 

645 from src.sync_stats import DriveStats 

646 

647 start_time = time.time() 

648 stats = DriveStats() 

649 

650 destination_path = config_parser.prepare_drive_destination(config=config) 

651 

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

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

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

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

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

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

658 # burns CPU until the user touches the marker. 

659 if not _check_mount_marker( 

660 destinations=[destination_path], 

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

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

663 service_name="Drive", 

664 ): 

665 sync_state.drive_time_remaining = drive_sync_interval 

666 return None 

667 

668 # Count files before sync 

669 files_before = set() 

670 if os.path.exists(destination_path): 

671 try: 

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

673 for file in file_list: 

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

675 except Exception: 

676 pass 

677 

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

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

680 LOGGER.info("Drive synced") 

681 

682 # Calculate statistics 

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

684 

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

686 if files_after is not None: 

687 # Count newly downloaded files 

688 new_files = files_after - files_before 

689 stats.files_downloaded = len(new_files) 

690 

691 # Count skipped files 

692 stats.files_skipped = len(files_before & files_after) 

693 

694 # Count removed files 

695 if config_parser.get_drive_remove_obsolete(config=config): 

696 stats.files_removed = len(files_before - files_after) 

697 

698 # Calculate bytes downloaded 

699 try: 

700 for file_path in new_files: 

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

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

703 except Exception: 

704 pass 

705 

706 # Reset countdown timer to the configured interval 

707 sync_state.drive_time_remaining = drive_sync_interval 

708 return stats 

709 return None 

710 

711 

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

713 """ 

714 Execute photos synchronization if enabled. 

715 

716 Args: 

717 config: Configuration dictionary 

718 api: iCloud API instance 

719 sync_state: Current sync state 

720 photos_sync_interval: Photos sync interval in seconds 

721 

722 Returns: 

723 PhotoStats object if sync was performed, None otherwise 

724 """ 

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

726 import time 

727 

728 from src.sync_stats import PhotoStats 

729 

730 start_time = time.time() 

731 stats = PhotoStats() 

732 

733 destination_path = config_parser.prepare_photos_destination(config=config) 

734 

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

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

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

738 if not _check_mount_marker( 

739 destinations=[destination_path], 

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

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

742 service_name="Photos", 

743 ): 

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

745 # countdown so the next cycle waits the configured interval 

746 # before re-checking the marker. 

747 sync_state.photos_time_remaining = photos_sync_interval 

748 return None 

749 

750 # Count files before sync 

751 files_before = set() 

752 if os.path.exists(destination_path): 

753 try: 

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

755 for file in file_list: 

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

757 except Exception: 

758 pass 

759 

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

761 try: 

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

763 except exceptions.ICloudPyServiceNotActivatedException as e: 

764 # With no interval there is no next cycle to wait for -- a 

765 # one-shot run must still report this through the normal error 

766 # path rather than schedule a retry it will never take. 

767 if not sync_photos.is_photos_indexing(e) or photos_sync_interval <= 0: 

768 raise 

769 _wait_for_photos_indexing(sync_state, photos_sync_interval) 

770 return None 

771 LOGGER.info("Photos synced") 

772 _signal_photos_indexing(waiting=False) 

773 

774 # Count files after sync 

775 files_after = set() 

776 if os.path.exists(destination_path): 

777 try: 

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

779 for file in file_list: 

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

781 except Exception: 

782 pass 

783 

784 # Calculate statistics 

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

786 

787 # Count newly downloaded files 

788 new_files = files_after - files_before 

789 stats.photos_downloaded = len(new_files) 

790 

791 # Estimate hardlinked photos (approximate) 

792 use_hardlinks = config_parser.get_photos_use_hardlinks( 

793 config=config, 

794 log_messages=False, 

795 ) 

796 if use_hardlinks: 

797 stats.photos_hardlinked = max( 

798 0, 

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

800 ) 

801 

802 # Count skipped photos 

803 stats.photos_skipped = len(files_before & files_after) 

804 

805 # Calculate bytes downloaded 

806 try: 

807 for file_path in new_files: 

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

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

810 

811 # Estimate bytes saved by hardlinks 

812 if use_hardlinks and stats.photos_hardlinked > 0: 

813 for file_path in files_after: 

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

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

816 except Exception: 

817 pass 

818 

819 # Track failed downloads so notifications reflect errors 

820 if isinstance(sync_result, tuple): 

821 _, failed_downloads = sync_result 

822 if failed_downloads > 0: 

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

824 

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

826 try: 

827 for item in os.listdir(destination_path): 

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

829 if os.path.isdir(item_path): 

830 stats.albums_synced.append(item) 

831 except Exception: 

832 pass 

833 

834 # Reset countdown timer to the configured interval 

835 sync_state.photos_time_remaining = photos_sync_interval 

836 return stats 

837 return None 

838 

839 

840def _wait_for_photos_indexing(sync_state: SyncState, photos_sync_interval: int) -> None: 

841 """Skip Photos this cycle because Apple has not finished indexing it. 

842 

843 Nothing could be listed, so nothing may be cleaned up either -- an 

844 empty listing and an unreadable one look identical to obsolete-file 

845 cleanup. Drive is unaffected and carries on. 

846 

847 The retry only ever shortens the wait (``min`` with the configured 

848 interval). It is not a login retry: the session is resumed per cycle 

849 like any other, and ``retry_login_interval`` is not involved. 

850 """ 

851 retry = min(PHOTOS_INDEXING_RETRY_SEC, photos_sync_interval) 

852 again = f"{retry // 60} minutes" if retry >= 120 else f"{retry} seconds" 

853 LOGGER.warning( 

854 "Apple has not finished indexing this iCloud Photos library, so it cannot " 

855 f"be read yet. Nothing on disk was changed. Trying Photos again in {again}.", 

856 ) 

857 sync_state.photos_time_remaining = retry 

858 sync_state.photos_indexing = True 

859 _signal_photos_indexing(waiting=True) 

860 

861 

862def _signal_photos_indexing(*, waiting: bool) -> None: 

863 """Publish (or clear) the dashboard's "waiting for Apple" note. 

864 

865 Recorded against the Photos service, not a library: no library can be 

866 read while this is true, so no library row could carry it. 

867 """ 

868 try: 

869 from src import web_signals as _ws 

870 

871 _ws.record_photos_indexing(waiting=waiting) 

872 if waiting: 

873 # The library being opened when this surfaced is still marked 

874 # "syncing now" and never got started. Left alone it reads as 

875 # a library syncing forever, next to a note saying Photos 

876 # cannot be read at all. 

877 _ws.clear_stale_library_states() 

878 except Exception as e: # noqa: BLE001 -- dashboard state must never break sync 

879 LOGGER.debug(f"web_signals: record_photos_indexing raised: {e!s}") 

880 

881 

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

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

884 

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

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

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

888 sync loop loose on a new install. 

889 

890 Logs (at INFO level): 

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

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

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

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

895 

896 Args: 

897 config: Configuration dictionary 

898 api: Authenticated iCloud API instance 

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

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

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

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

903 

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

905 sync loop itself are all skipped. 

906 """ 

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

908 

909 if config and "drive" in config: 

910 try: 

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

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

913 # how migration_check builds its base path. 

914 drive_destination = os.path.join( 

915 config_parser.get_root_destination_path(config=config), 

916 config_parser.get_drive_destination_path(config=config), 

917 ) 

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

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

920 LOGGER.info( 

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

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

923 ) 

924 except Exception as e: 

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

926 else: 

927 LOGGER.info( 

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

929 ) 

930 

931 if config and "photos" in config: 

932 try: 

933 photos_destination = os.path.join( 

934 config_parser.get_root_destination_path(config=config), 

935 config_parser.get_photos_destination_path(config=config), 

936 ) 

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

938 libraries = ( 

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

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

941 else [] 

942 ) 

943 if libraries: 

944 LOGGER.info( 

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

946 ) 

947 else: 

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

949 except Exception as e: 

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

951 else: 

952 LOGGER.info( 

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

954 ) 

955 

956 if check_files is not None: 

957 from src import migration_check 

958 

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

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

961 if config and "photos" in config: 

962 try: 

963 LOGGER.info( 

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

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

966 ) 

967 results = migration_check.check_migration( 

968 api=api, 

969 config=config, 

970 sample=check_files, 

971 ) 

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

973 stats = result["stats"] 

974 LOGGER.info( 

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

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

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

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

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

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

981 ) 

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

983 for item in items: 

984 if status == "size_mismatch": 

985 path, expected, actual = item 

986 LOGGER.info( 

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

988 ) 

989 else: 

990 path, expected = item 

991 LOGGER.info( 

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

993 ) 

994 except Exception as e: 

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

996 

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

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

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

1000 if config and "drive" in config: 

1001 try: 

1002 drive_result = migration_check.check_drive_migration( 

1003 api=api, 

1004 config=config, 

1005 sample=check_files, 

1006 ) 

1007 if drive_result is not None: 

1008 stats = drive_result["stats"] 

1009 LOGGER.info( 

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

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

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

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

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

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

1016 ) 

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

1018 for item in items: 

1019 if status == "size_mismatch": 

1020 path, expected, actual = item 

1021 LOGGER.info( 

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

1023 ) 

1024 else: 

1025 path, expected = item 

1026 LOGGER.info( 

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

1028 ) 

1029 except Exception as e: 

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

1031 

1032 LOGGER.info( 

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

1034 ) 

1035 

1036 

1037def _check_services_configured(config): 

1038 """ 

1039 Check if any sync services are configured. 

1040 

1041 Args: 

1042 config: Configuration dictionary 

1043 

1044 Returns: 

1045 bool: True if at least one service is configured 

1046 """ 

1047 

1048 return "drive" in config or "photos" in config 

1049 

1050 

1051def _cycle_nothing_synced_reason(config, drive_stats, photos_stats, photos_indexing=False) -> str | None: 

1052 """Why this cycle synced no service at all, or None if one did. 

1053 

1054 Such a cycle is indistinguishable from a clean one by its stats -- no 

1055 errors, no counts -- so reporting it as a success is how a monitor stays 

1056 green while nothing whatsoever is being downloaded. It happens two ways: 

1057 nothing is configured to sync, or every service that was due got skipped 

1058 -- by the mount-marker failsafe, or (Photos) because Apple has not 

1059 finished indexing the library. 

1060 

1061 Not to be confused with a service that simply was not due. With unequal 

1062 intervals ``_calculate_next_sync_schedule`` enables only the service 

1063 whose timer expired, so one of the two returning None is the ordinary 

1064 case -- hence "no stats at all" rather than "any stats missing". 

1065 

1066 Args: 

1067 config: Configuration dictionary 

1068 drive_stats: Result of ``_perform_drive_sync`` 

1069 photos_stats: Result of ``_perform_photos_sync`` 

1070 photos_indexing: Whether this cycle skipped Photos for indexing 

1071 

1072 Returns: 

1073 ``nothing_synced``, ``photos_indexing``, ``mount_marker_missing``, 

1074 or None if a service ran 

1075 """ 

1076 if not _check_services_configured(config): 

1077 return "nothing_synced" 

1078 if drive_stats is None and photos_stats is None: 

1079 return "photos_indexing" if photos_indexing else "mount_marker_missing" 

1080 return None 

1081 

1082 

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

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

1085 

1086 Kept out of ``sync()`` only because the branch it sits in is already four 

1087 levels deep. 

1088 """ 

1089 if nothing_reason == "nothing_synced": 

1090 return "iCloud sync cycle synced nothing: no drive or photos section is configured" 

1091 if nothing_reason == "photos_indexing": 

1092 return "iCloud sync cycle synced nothing: Apple has not finished indexing Photos" 

1093 if nothing_reason: 

1094 return "iCloud sync cycle synced nothing: the mount marker is missing" 

1095 if has_errors: 

1096 return "iCloud sync cycle completed with errors" 

1097 return "iCloud sync cycle completed" 

1098 

1099 

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

1101 """Send anonymized usage statistics. 

1102 

1103 Args: 

1104 config: Configuration dictionary 

1105 summary: Sync summary with statistics 

1106 """ 

1107 

1108 # Create anonymized usage data 

1109 usage_data = { 

1110 "sync_duration": ( 

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

1112 if summary.sync_end_time 

1113 else 0 

1114 ), 

1115 "has_drive_activity": bool( 

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

1117 ), 

1118 "has_photos_activity": bool( 

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

1120 ), 

1121 "has_errors": summary.has_errors(), 

1122 "timestamp": ( 

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

1124 ), 

1125 } 

1126 

1127 # Add aggregated statistics (no personal data) 

1128 if summary.drive_stats: 

1129 usage_data["drive"] = { 

1130 "files_count": summary.drive_stats.files_downloaded, 

1131 "bytes_count": summary.drive_stats.bytes_downloaded, 

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

1133 } 

1134 

1135 if summary.photo_stats: 

1136 usage_data["photos"] = { 

1137 "photos_count": summary.photo_stats.photos_downloaded, 

1138 "bytes_count": summary.photo_stats.bytes_downloaded, 

1139 "hardlinks_count": summary.photo_stats.photos_hardlinked, 

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

1141 } 

1142 

1143 # Send to usage tracking 

1144 alive(config=config, data=usage_data) 

1145 

1146 

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

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

1149 

1150 The loop backs off for ``retry_login_interval`` between attempts and 

1151 cannot otherwise see that the session was fixed underneath it, so 

1152 someone who signs in through the web UI watches the dashboard keep 

1153 saying the sync is stopped until an interval that began *before* the 

1154 problem was solved finally runs out. 

1155 

1156 Polls only the re-auth signal, not the force-sync sentinel behind 

1157 "Sync now": that button means "sync everything now", and a re-auth 

1158 should end a wait without also queueing a full re-enumeration. 

1159 """ 

1160 _CHUNK = 2 

1161 try: 

1162 from src import web_signals as _ws 

1163 except ImportError: # pragma: no cover - module is optional 

1164 sleep(total_seconds) 

1165 return 

1166 

1167 if total_seconds <= _CHUNK: 

1168 sleep(total_seconds) 

1169 return 

1170 

1171 remaining = total_seconds 

1172 while remaining > 0: 

1173 chunk = min(_CHUNK, remaining) 

1174 sleep(chunk) 

1175 remaining -= chunk 

1176 if _ws.consume_reauth_completed(): 

1177 LOGGER.info("Re-auth completed -- ending the retry wait early.") 

1178 return 

1179 

1180 

1181def _request_2fa_push_once(api, sync_state: SyncState) -> None: 

1182 """Ask Apple to push a 2FA code to the trusted devices, once per episode. 

1183 

1184 Without this call the loop notified the user that re-auth was needed but 

1185 never requested a code, so nothing was ever sent. 

1186 

1187 The latch is set only when Apple accepts the request. 

1188 ``trigger_2fa_push_notification`` returns False rather than raising for 

1189 most failures, and latching regardless meant one transient error 

1190 forfeited the push for the whole episode -- the user told "2FA is 

1191 required" while no code ever arrived, which is the bug this exists to 

1192 fix. Retrying on the next cycle is bounded by ``retry_login_interval`` 

1193 (600s by default), far below anything that trips Apple's limits. 

1194 

1195 Best-effort: a failure here never stops the retry loop. 

1196 """ 

1197 if sync_state.two_fa_triggered: 

1198 return 

1199 try: 

1200 pushed = api.trigger_2fa_push_notification() 

1201 except Exception as e: # noqa: BLE001 

1202 LOGGER.warning(f"Failed to request 2FA push notification; will retry next cycle: {e!s}") 

1203 return 

1204 if pushed: 

1205 LOGGER.info("Requested a 2FA push notification to your trusted devices.") 

1206 sync_state.two_fa_triggered = True 

1207 else: 

1208 LOGGER.warning("Apple did not accept the 2FA push request; will retry next cycle.") 

1209 

1210 

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

1212 """ 

1213 Handle 2FA authentication requirement. 

1214 

1215 Args: 

1216 config: Configuration dictionary 

1217 username: iCloud username 

1218 sync_state: Current sync state 

1219 api: Live ``ICloudPyService`` still in its 2FA-required state. Used to 

1220 request a push notification to the user's trusted devices. When 

1221 ``app.telegram.listen`` is true, the retry sleep is replaced with a 

1222 Telegram poll: the user replies the auth keyword to have Apple push a 

1223 code, then replies the 6 digits -- completing re-authentication from a 

1224 phone, headless, with no web UI. 

1225 

1226 Returns: 

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

1228 """ 

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

1230 _publish_auth_blocked(True, reason="2fa_required") 

1231 # Decided before anything is sent: it selects the notification wording, 

1232 # the reason a receiver sees, and suppresses two steps that cannot 

1233 # succeed on such an account. 

1234 security_key = _detect_security_key_account(api, username) 

1235 notify.send_cycle_event( 

1236 config=config, 

1237 boundary="failure", 

1238 message="iCloud sync cycle aborted: 2FA is required", 

1239 data={"reason": "security_key_required" if security_key else "two_factor_required"}, 

1240 ) 

1241 if security_key: 

1242 LOGGER.error( 

1243 "This account signs in with a security key, so Apple will not " 

1244 "send a 6-digit code. Complete the ceremony on the dashboard " 

1245 "(/auth); the push request and the code listener are skipped.", 

1246 ) 

1247 # Ask Apple to push a code before anything else -- including the exit 

1248 # below: retry_login_interval < 0 is the mode where an operator is about 

1249 # to intervene by hand, and a code on their devices is what they need. 

1250 # Not for a security-key account: Apple sends those no code at all. 

1251 if not security_key: 

1252 _request_2fa_push_once(api, sync_state) 

1253 

1254 sleep_for = config_parser.get_retry_login_interval(config=config) 

1255 

1256 if sleep_for < 0: 

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

1258 return False 

1259 

1260 _log_retry_time(sleep_for) 

1261 server_region = config_parser.get_region(config=config) 

1262 # notify.send is throttled (once per 24h) and listen-aware: in listen mode the 

1263 # Telegram channel gets the actionable reply prompt, other channels the standard 

1264 # alert. Throttling here is what prevents a "reply auth" message every retry cycle. 

1265 sync_state.last_send = notify.send( 

1266 config=config, 

1267 username=username, 

1268 last_send=sync_state.last_send, 

1269 region=server_region, 

1270 dashboard_url=_resolve_dashboard_url(config), 

1271 # A security-key account cannot finish sign-in from a Telegram code, 

1272 # so it gets the standard alert pointing at the dashboard. 

1273 reply_prompt=not security_key, 

1274 event="security_key_required" if security_key else "two_factor_required", 

1275 ) 

1276 if not security_key and config_parser.get_telegram_listen_enabled(config=config): 

1277 _wait_for_telegram_code(config=config, api=api, timeout_seconds=sleep_for) 

1278 else: 

1279 _auth_retry_sleep(sleep_for) 

1280 return True 

1281 

1282 

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

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

1285 

1286 The reply prompt itself is sent by ``notify.send`` (throttled); this function 

1287 drains any stale replies, then polls for the user's actions: 

1288 1. On the auth keyword -> ``api.trigger_2fa_push_notification()`` so Apple 

1289 actually pushes a code to the trusted devices. (The headless path 

1290 previously waited for a code it never requested -- this missing trigger 

1291 is the core bug this fixes.) 

1292 2. On a 6-digit reply (spaces/dashes tolerated) -> ``validate_2fa_code`` 

1293 + ``trust_session``. 

1294 

1295 Returns True once a code validates and trust succeeds within 

1296 ``timeout_seconds``, or once the web UI completes the re-auth first; 

1297 False on timeout. Best-effort throughout. The Telegram 

1298 ``getUpdates`` offset is held in-memory for the duration of this wait. 

1299 """ 

1300 from src import web_signals 

1301 

1302 poll_interval = 5 

1303 bot_token = config_parser.get_telegram_bot_token(config=config) 

1304 chat_id = config_parser.get_telegram_chat_id(config=config) 

1305 auth_keyword = config_parser.get_telegram_auth_keyword(config=config) 

1306 if not bot_token or not chat_id: 

1307 LOGGER.warning( 

1308 "Telegram listen enabled but bot_token/chat_id not configured; falling back to plain sleep.", 

1309 ) 

1310 sleep(timeout_seconds) 

1311 return False 

1312 

1313 # Drain any messages already pending so a stale reply from a previous 

1314 # session does not get acted on; start listening for genuinely new replies. 

1315 # Deliberate trade-off: a code typed while the loop is between wait 

1316 # windows is dropped too. Acting on an old code is worse than asking for 

1317 # a fresh one, and the window is the whole retry interval (600s default). 

1318 _, offset = notify.poll_telegram_for_text( 

1319 bot_token=bot_token, chat_id=chat_id, offset=0, 

1320 ) 

1321 while True: 

1322 _, drained = notify.poll_telegram_for_text( 

1323 bot_token=bot_token, chat_id=chat_id, offset=offset, 

1324 ) 

1325 if drained == offset: 

1326 break 

1327 offset = drained 

1328 

1329 LOGGER.info( 

1330 f"Listening on Telegram for '{auth_keyword}' trigger or 6-digit code (timeout {timeout_seconds}s).", 

1331 ) 

1332 elapsed = 0 

1333 while elapsed < timeout_seconds: 

1334 chunk = min(poll_interval, timeout_seconds - elapsed) 

1335 sleep(chunk) 

1336 elapsed += chunk 

1337 # The web UI and Telegram can both complete a re-auth. If the web UI 

1338 # got there first, stop listening -- otherwise the loop sits out the 

1339 # rest of the window holding a session that is already fixed. 

1340 if web_signals.consume_reauth_completed(): 

1341 LOGGER.info("Re-auth completed in the web UI -- ending the Telegram wait.") 

1342 return True 

1343 text, offset = notify.poll_telegram_for_text( 

1344 bot_token=bot_token, 

1345 chat_id=chat_id, 

1346 offset=offset, 

1347 ) 

1348 if not text: 

1349 continue 

1350 norm = text.strip().lower() 

1351 if norm == auth_keyword: 

1352 LOGGER.info("Telegram auth trigger received -- requesting 2FA push.") 

1353 try: 

1354 pushed = api.trigger_2fa_push_notification() 

1355 except Exception as e: # noqa: BLE001 

1356 LOGGER.warning(f"trigger_2fa_push_notification raised: {e!s}") 

1357 pushed = False 

1358 notify.post_message_to_telegram( 

1359 bot_token, 

1360 chat_id, 

1361 ( 

1362 "✅ 2FA code sent to your Apple devices -- reply the 6-digit code here." 

1363 if pushed 

1364 else "⚠️ Couldn't request a code (no trusted device, or auth state off). Try again shortly." 

1365 ), 

1366 ) 

1367 continue 

1368 code = norm.replace(" ", "").replace("-", "") 

1369 if re.fullmatch(r"\d{6}", code): 

1370 LOGGER.info("Received 6-digit code via Telegram -- validating.") 

1371 try: 

1372 accepted = api.validate_2fa_code(code) 

1373 except Exception as e: # noqa: BLE001 

1374 LOGGER.warning( 

1375 f"validate_2fa_code raised: {e!s} -- waiting for another code.", 

1376 ) 

1377 continue 

1378 if not accepted: 

1379 notify.post_message_to_telegram( 

1380 bot_token, 

1381 chat_id, 

1382 "❌ Apple rejected that code -- reply a fresh one.", 

1383 ) 

1384 LOGGER.warning( 

1385 "Apple rejected the Telegram-supplied code -- waiting for another.", 

1386 ) 

1387 continue 

1388 try: 

1389 api.trust_session() 

1390 except Exception as e: # noqa: BLE001 

1391 LOGGER.warning(f"trust_session raised (non-fatal): {e!s}") 

1392 notify.post_message_to_telegram( 

1393 bot_token, 

1394 chat_id, 

1395 "✅ Re-authenticated. iCloud sync resumed.", 

1396 ) 

1397 LOGGER.info("Telegram-driven 2FA succeeded; resuming sync.") 

1398 return True 

1399 LOGGER.info("Telegram listen timeout reached with no usable code; retrying auth.") 

1400 return False 

1401 

1402 

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

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

1405 

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

1407 

1408 In session-only mode one more case does, because it cannot be told 

1409 apart from a rejected session. icloudpy keeps the HTTP status on the 

1410 exception only for a non-JSON body or its own re-auth statuses 

1411 (``_SESSION_REJECTED_CODES``), so a JSON-bodied 401 arrives with 

1412 ``code`` as ``None``. Those are the ones that would otherwise leave a 

1413 container only a human can revive retrying in silence forever. 

1414 

1415 Everything else is an outage -- a 5xx that kept its status, a dropped 

1416 connection, a DNS failure -- and says nothing about the session. Those 

1417 are logged and retried. Waking someone daily to re-authenticate over 

1418 Apple having a bad hour is the alert this function exists to withhold. 

1419 """ 

1420 if isinstance(error, exceptions.ICloudPyFailedLoginException): 

1421 return True 

1422 return ( 

1423 sync_state.session_only 

1424 and isinstance(error, exceptions.ICloudPyAPIResponseException) 

1425 and error.code is None 

1426 ) 

1427 

1428 

1429def _handle_auth_transport_error(config, username: str, sync_state: SyncState, error): 

1430 """Back off after a sign-in failure Apple did not express as a 2FA prompt. 

1431 

1432 Uses at least ``_AUTH_BACKOFF_FLOOR_SEC`` regardless of the configured 

1433 retry interval: the errors that land here (notably Apple's 409 on 

1434 ``/signin/init``) mean "you are trying too often", so honouring a short 

1435 interval would make it worse. 

1436 

1437 Returns True to keep looping, False to exit. 

1438 """ 

1439 LOGGER.error(f"Sign-in failed and will be retried: {error!s}") 

1440 # sign_in_failed is reserved for what the alert below treats as needing 

1441 # a human. A network fault or an Apple service error means the attempt 

1442 # never completed, which needs no one to intervene. 

1443 rejected = _needs_a_human(sync_state, error) 

1444 notify.send_cycle_event( 

1445 config=config, 

1446 boundary="failure", 

1447 message=( 

1448 "iCloud sync cycle aborted: sign-in failed" 

1449 if rejected 

1450 else "iCloud sync cycle aborted: sign-in did not complete" 

1451 ), 

1452 data={"reason": "sign_in_failed" if rejected else "sign_in_error"}, 

1453 ) 

1454 sleep_for = config_parser.get_retry_login_interval(config=config) 

1455 if sleep_for < 0: 

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

1457 return False 

1458 sleep_for = max(sleep_for, _AUTH_BACKOFF_FLOOR_SEC) 

1459 _log_retry_time(sleep_for) 

1460 if sync_state.session_only: 

1461 # The loop is not syncing and only a human can change that, so the 

1462 # dashboard is told -- its wording for this reason allows for an 

1463 # outage and asks for nothing. 

1464 _publish_auth_blocked(True, reason="sign_in_failed") 

1465 if rejected: 

1466 # notify.send's text is "iCloud re-auth required", so it is reserved 

1467 # for the cases where that is actually true: a rejected password, 

1468 # which never heals by retrying, and -- in session-only mode -- a 

1469 # failure that cannot be told apart from a rejected session. It is 

1470 # throttled, so this is not a message per retry. 

1471 sync_state.last_send = notify.send( 

1472 config=config, 

1473 username=username, 

1474 last_send=sync_state.last_send, 

1475 region=config_parser.get_region(config=config), 

1476 dashboard_url=_resolve_dashboard_url(config), 

1477 event="sign_in_failed", 

1478 ) 

1479 # Ends early on a completed web-UI re-auth, but not on "Sync now": 

1480 # nothing a button does may shorten a throttle backoff. 

1481 _auth_retry_sleep(sleep_for) 

1482 return True 

1483 

1484 

1485def _handle_sync_error(config, error, drive_sync_interval, photos_sync_interval): 

1486 """Back off after a failure that happened *after* a successful sign-in. 

1487 

1488 Anything raised once ``api`` exists is a service problem, not an auth 

1489 problem: a zone that is unavailable, a 5xx on a download, a connection 

1490 dropped mid-transfer. Reporting those as sign-in failures sends the user 

1491 to re-authenticate for no reason. 

1492 

1493 The interval matters as much as the wording. Every handler here ends in 

1494 ``continue``, which skips ``_calculate_next_sync_schedule`` -- so the 

1495 countdown timers never advance and both services stay enabled. Retrying 

1496 on the short login interval therefore re-enumerates the whole library 

1497 every few minutes. Wait at least as long as the shortest configured sync 

1498 interval instead: never poll a broken service faster than a working one. 

1499 

1500 Returns True to keep looping, False to exit. 

1501 """ 

1502 LOGGER.error(f"Sync failed and will be retried: {error!s}") 

1503 notify.send_cycle_event( 

1504 config=config, 

1505 boundary="failure", 

1506 message="iCloud sync cycle failed and will be retried", 

1507 data={"reason": "sync_error"}, 

1508 ) 

1509 # log_messages=False: this is not a login retry, and the getter's 

1510 # "Retrying login every N seconds." would say otherwise. 

1511 sleep_for = config_parser.get_retry_login_interval(config=config, log_messages=False) 

1512 if sleep_for < 0: 

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

1514 return False 

1515 configured = [i for i in (drive_sync_interval, photos_sync_interval) if i > 0] 

1516 if configured: 

1517 sleep_for = max(sleep_for, min(configured)) 

1518 _log_retry_time(sleep_for, what="sync") 

1519 # This can be a whole sync interval; the "Sync now" button must still 

1520 # cut it short, as it does on the normal scheduling path. 

1521 _interruptible_sleep(sleep_for) 

1522 return True 

1523 

1524 

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

1526 """ 

1527 Handle password not available error. 

1528 

1529 Args: 

1530 config: Configuration dictionary 

1531 username: iCloud username 

1532 sync_state: Current sync state 

1533 error: The raised exception, whose message names which way 

1534 session-only mode failed -- no session at all, or a session 

1535 Apple no longer accepts. 

1536 

1537 Returns: 

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

1539 """ 

1540 LOGGER.error(str(error)) 

1541 # The dashboard's own on-disk check reads an empty keyring as "setup 

1542 # needed", which is the wrong instruction here: the session, not the 

1543 # password, is what has to be replaced. 

1544 _publish_auth_blocked(True, reason="session_unusable") 

1545 notify.send_cycle_event( 

1546 config=config, 

1547 boundary="failure", 

1548 message="iCloud sync cycle aborted: no stored password and no usable saved session", 

1549 data={"reason": "password_missing"}, 

1550 ) 

1551 sleep_for = config_parser.get_retry_login_interval(config=config) 

1552 

1553 if sleep_for < 0: 

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

1555 return False 

1556 

1557 _log_retry_time(sleep_for) 

1558 server_region = config_parser.get_region(config=config) 

1559 sync_state.last_send = notify.send( 

1560 config=config, 

1561 username=username, 

1562 last_send=sync_state.last_send, 

1563 region=server_region, 

1564 dashboard_url=_resolve_dashboard_url(config), 

1565 event="password_missing", 

1566 ) 

1567 _auth_retry_sleep(sleep_for) 

1568 return True 

1569 

1570 

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

1572 """ 

1573 Log the next retry time. 

1574 

1575 Args: 

1576 sleep_for: Sleep duration in seconds 

1577 what: What is being retried; a failed sync is not a failed login 

1578 """ 

1579 next_sync = ( 

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

1581 ).strftime("%c") 

1582 LOGGER.info(f"Retrying {what} at {next_sync} ...") 

1583 

1584 

1585def _calculate_next_sync_schedule(config, sync_state: SyncState): 

1586 """ 

1587 Calculate next sync schedule and update sync state. 

1588 

1589 This function implements the adaptive scheduling algorithm that determines 

1590 which service should sync next based on countdown timers. 

1591 

1592 Args: 

1593 config: Configuration dictionary 

1594 sync_state: Current sync state 

1595 

1596 Returns: 

1597 int: Sleep duration in seconds 

1598 """ 

1599 has_drive = config and "drive" in config 

1600 has_photos = config and "photos" in config 

1601 

1602 if not has_drive and has_photos: 

1603 sleep_for = sync_state.photos_time_remaining 

1604 sync_state.enable_sync_drive = False 

1605 sync_state.enable_sync_photos = True 

1606 elif has_drive and not has_photos: 

1607 sleep_for = sync_state.drive_time_remaining 

1608 sync_state.enable_sync_drive = True 

1609 sync_state.enable_sync_photos = False 

1610 else: 

1611 # Sleep until the sooner of the two is due, and take that time off 

1612 # both countdowns. Whichever reaches zero syncs next; equal timers 

1613 # sync together. A negative countdown is a one-shot service 

1614 # (sync_interval < 0) that has already run: it is never due again, 

1615 # so it takes no part, and must never become a negative sleep. 

1616 pending = [t for t in (sync_state.drive_time_remaining, sync_state.photos_time_remaining) if t >= 0] 

1617 sleep_for = min(pending) if pending else 0 

1618 if sync_state.drive_time_remaining >= 0: 

1619 sync_state.drive_time_remaining -= sleep_for 

1620 if sync_state.photos_time_remaining >= 0: 

1621 sync_state.photos_time_remaining -= sleep_for 

1622 sync_state.enable_sync_drive = sync_state.drive_time_remaining == 0 

1623 sync_state.enable_sync_photos = sync_state.photos_time_remaining == 0 

1624 

1625 return sleep_for 

1626 

1627 

1628def _log_next_sync_time(sleep_for: int): 

1629 """ 

1630 Log the next scheduled sync time. 

1631 

1632 Args: 

1633 sleep_for: Sleep duration in seconds 

1634 """ 

1635 next_sync = ( 

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

1637 ).strftime("%c") 

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

1639 

1640 

1641def _log_sync_intervals_at_startup(config): 

1642 """ 

1643 Log sync intervals once at startup. 

1644 

1645 Args: 

1646 config: Configuration dictionary 

1647 """ 

1648 if config and "drive" in config: 

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

1650 if config and "photos" in config: 

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

1652 

1653 

1654def _should_exit_oneshot_mode(config): 

1655 """ 

1656 Check if should exit in oneshot mode. 

1657 

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

1659 

1660 Args: 

1661 config: Configuration dictionary 

1662 

1663 Returns: 

1664 bool: True if should exit 

1665 """ 

1666 

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

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

1669 ) 

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

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

1672 ) 

1673 

1674 return should_exit_drive and should_exit_photos 

1675 

1676 

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

1678 """ 

1679 Main synchronization loop. 

1680 

1681 Orchestrates the entire sync process by delegating specific responsibilities 

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

1683 while each helper handles a single concern. 

1684 

1685 Args: 

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

1687 then exit without writing files, sending notifications, or 

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

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

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

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

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

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

1694 stride-sampled photos per library. 

1695 """ 

1696 sync_state = SyncState() 

1697 startup_logged = False 

1698 

1699 while True: 

1700 # A config that cannot be read must not kill the daemon. On a NAS the 

1701 # volume holding config.yaml can lag behind container start or go away 

1702 # mid-run, and a half-written file (the user editing it live) raises 

1703 # out of the YAML parser. Either way the traceback escapes the loop, 

1704 # and `restart: unless-stopped` turns that into a restart loop. 

1705 try: 

1706 config = _load_configuration() 

1707 except Exception as e: # noqa: BLE001 -- any parse fault must not be fatal 

1708 LOGGER.error(f"Config file could not be read, retrying: {e!s}") 

1709 config = None 

1710 if config is None: 

1711 if dry_run: 

1712 # A dry run is a one-shot check with nothing to wait for. 

1713 LOGGER.error("DRY RUN: no readable config, nothing to check.") 

1714 return 

1715 sleep(DEFAULT_RETRY_LOGIN_INTERVAL_SEC) 

1716 continue 

1717 

1718 # Log sync intervals once at startup 

1719 if not startup_logged: 

1720 _log_sync_intervals_at_startup(config) 

1721 notify.warn_unknown_webhook_events(config) 

1722 # The state file outlives the container, so a restart mid-library 

1723 # would leave the dashboard showing it as still syncing. Nothing 

1724 # can legitimately be in flight here. 

1725 try: 

1726 from src import web_signals as _ws 

1727 

1728 _ws.clear_stale_library_states() 

1729 except Exception as e: # noqa: BLE001 -- never block startup 

1730 LOGGER.debug(f"web_signals: clear_stale_library_states raised: {e!s}") 

1731 startup_logged = True 

1732 

1733 drive_sync_interval, photos_sync_interval = _extract_sync_intervals( 

1734 config, 

1735 log_messages=False, 

1736 ) 

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

1738 

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

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

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

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

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

1744 try: 

1745 from src import web_signals as _ws 

1746 

1747 if _ws.consume_force_sync("drive"): 

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

1749 sync_state.drive_time_remaining = 0 

1750 if _ws.consume_force_sync("photos"): 

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

1752 sync_state.photos_time_remaining = 0 

1753 except ( 

1754 ImportError 

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

1756 pass 

1757 

1758 if username: 

1759 authenticated = False 

1760 try: 

1761 api = _authenticate_and_get_api(config, username, sync_state) 

1762 authenticated = True 

1763 

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

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

1766 if dry_run: 

1767 if api.requires_2sa: 

1768 LOGGER.info( 

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

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

1771 ) 

1772 else: 

1773 _perform_dry_run(config, api, check_files=check_files) 

1774 return 

1775 

1776 _publish_auth_blocked(False) 

1777 if not api.requires_2sa: 

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

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

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

1781 _maybe_refresh_trust(config, api) 

1782 _maybe_warn_trust_expiring(config, api, username) 

1783 

1784 # Authenticated: clear the 2FA trigger latch so a future 

1785 # re-auth episode requests a fresh push exactly once. 

1786 sync_state.two_fa_triggered = False 

1787 

1788 # Signed in and about to sync: open the cycle on any 

1789 # configured webhook. Fire-and-forget, and a no-op when 

1790 # none is configured (see notify.send_cycle_event). 

1791 notify.send_cycle_event( 

1792 config=config, 

1793 boundary="start", 

1794 message="iCloud sync cycle started", 

1795 ) 

1796 

1797 # Create summary for this sync cycle 

1798 summary = SyncSummary() 

1799 

1800 # Perform syncs and collect statistics 

1801 sync_state.photos_indexing = False 

1802 drive_stats = _perform_drive_sync( 

1803 config, 

1804 api, 

1805 sync_state, 

1806 drive_sync_interval, 

1807 ) 

1808 photos_stats = _perform_photos_sync( 

1809 config, 

1810 api, 

1811 sync_state, 

1812 photos_sync_interval, 

1813 ) 

1814 

1815 # Populate summary with statistics 

1816 summary.drive_stats = drive_stats 

1817 summary.photo_stats = photos_stats 

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

1819 

1820 # Close the cycle on the webhooks. Failed downloads 

1821 # counted in the stats make this a failure even though 

1822 # the cycle itself ran to completion -- a monitor that 

1823 # reported success here would stay green while the 

1824 # library silently fell behind. So does a cycle that 

1825 # synced nothing at all. The statistics ride along, so a 

1826 # POST receiver sees them without app.notifications. 

1827 has_errors = summary.has_errors() 

1828 nothing_reason = _cycle_nothing_synced_reason( 

1829 config, 

1830 drive_stats, 

1831 photos_stats, 

1832 sync_state.photos_indexing, 

1833 ) 

1834 # Every sync_failed names a reason, including this one: a 

1835 # receiver should never have to infer why from the 

1836 # statistics. (Only Photos counts failed downloads, so 

1837 # has_errors never means a Drive failure today.) 

1838 reason = nothing_reason or ("download_errors" if has_errors else None) 

1839 cycle_data = notify.summary_event_data(summary) 

1840 if reason: 

1841 cycle_data["reason"] = reason 

1842 notify.send_cycle_event( 

1843 config=config, 

1844 boundary="failure" if reason else "success", 

1845 message=_cycle_end_message(has_errors, nothing_reason), 

1846 data=cycle_data, 

1847 ) 

1848 

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

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

1851 # the sync itself is unaffected. 

1852 try: 

1853 from src import web_signals as _ws 

1854 

1855 if drive_stats is not None: 

1856 _ws.record_sync_completion( 

1857 service="drive", 

1858 files_downloaded=drive_stats.files_downloaded, 

1859 files_skipped=drive_stats.files_skipped, 

1860 files_removed=drive_stats.files_removed, 

1861 errors=len(drive_stats.errors), 

1862 duration_seconds=drive_stats.duration_seconds, 

1863 ) 

1864 if photos_stats is not None: 

1865 _ws.record_sync_completion( 

1866 service="photos", 

1867 files_downloaded=photos_stats.photos_downloaded, 

1868 files_skipped=photos_stats.photos_skipped, 

1869 errors=len(photos_stats.errors), 

1870 duration_seconds=photos_stats.duration_seconds, 

1871 ) 

1872 except ( 

1873 ImportError 

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

1875 pass 

1876 except Exception as e: 

1877 LOGGER.debug( 

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

1879 ) 

1880 

1881 # Send usage statistics (anonymized summary data) 

1882 try: 

1883 _send_usage_statistics(config, summary) 

1884 except Exception as e: 

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

1886 

1887 # Send sync summary notification if configured 

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

1889 # Gracefully handle notification failures to not break sync 

1890 has_drive_config = config and "drive" in config 

1891 has_photos_config = config and "photos" in config 

1892 

1893 should_send_notification = False 

1894 if has_drive_config and has_photos_config: 

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

1896 should_send_notification = ( 

1897 drive_stats is not None and photos_stats is not None 

1898 ) 

1899 elif has_drive_config and not has_photos_config: 

1900 # Only drive configured - send when drive synced 

1901 should_send_notification = drive_stats is not None 

1902 elif has_photos_config and not has_drive_config: 

1903 # Only photos configured - send when photos synced 

1904 should_send_notification = photos_stats is not None 

1905 

1906 if should_send_notification: 

1907 try: 

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

1909 except Exception as e: 

1910 LOGGER.debug( 

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

1912 ) 

1913 

1914 if not _check_services_configured(config): 

1915 LOGGER.warning( 

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

1917 ) 

1918 else: 

1919 _log_trust_revocation_hint(api) 

1920 if not _handle_2fa_required(config, username, sync_state, api): 

1921 break 

1922 continue 

1923 

1924 except exceptions.ICloudPyNoStoredPasswordAvailableException as e: 

1925 if not _handle_password_error(config, username, sync_state, e): 

1926 break 

1927 continue 

1928 except exceptions.ICloudPyFailedLoginException as e: 

1929 # icloudpy catches most sign-in errors -- a 409 or 401 with a 

1930 # reason, any 5xx -- and re-raises them as this, which 

1931 # subclasses ICloudPyException directly and so is not caught by 

1932 # the API-response clause below. It only arises while signing 

1933 # in, so it always takes the auth backoff. 

1934 if not _handle_auth_transport_error(config, username, sync_state, e): 

1935 break 

1936 continue 

1937 except exceptions.ICloudPyServiceNotActivatedException as e: 

1938 # A zone or service being unavailable says nothing about the 

1939 # sign-in whenever it surfaces, so it never earns the 

1940 # rate-limit backoff. Listed ahead of the broader catch 

1941 # below because it subclasses it. 

1942 if not _handle_sync_error( 

1943 config, 

1944 e, 

1945 drive_sync_interval, 

1946 photos_sync_interval, 

1947 ): 

1948 break 

1949 continue 

1950 except ( 

1951 exceptions.ICloudPyAPIResponseException, 

1952 requests.exceptions.RequestException, 

1953 ) as e: 

1954 # Any other failure raised after the sign-in succeeded is a 

1955 # service problem, not an auth problem -- and must not earn 

1956 # the backoff meant for "you are trying too often" either. 

1957 if authenticated: 

1958 if not _handle_sync_error( 

1959 config, 

1960 e, 

1961 drive_sync_interval, 

1962 photos_sync_interval, 

1963 ): 

1964 break 

1965 continue 

1966 # Apple refuses sign-in for reasons other than "2FA needed": 

1967 # 409 when it is throttling the account, 5xx when it is 

1968 # having a bad day, plus ordinary network faults. None of 

1969 # these are fatal, but letting them escape kills the process 

1970 # -- and with `restart: unless-stopped` that becomes a crash 

1971 # loop that re-authenticates every few seconds, which is the 

1972 # fastest possible way to deepen a throttle. 

1973 if not _handle_auth_transport_error(config, username, sync_state, e): 

1974 break 

1975 continue 

1976 

1977 sleep_for = _calculate_next_sync_schedule(config, sync_state) 

1978 _log_next_sync_time(sleep_for) 

1979 

1980 if _should_exit_oneshot_mode(config): 

1981 LOGGER.info( 

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

1983 ) 

1984 break 

1985 

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

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

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

1989 # drive sleep would wait the full remaining duration. 

1990 _interruptible_sleep(sleep_for) 

1991 

1992 

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

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

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

1996 

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

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

1999 single ``sleep(total_seconds)``). 

2000 """ 

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

2002 try: 

2003 from src import web_signals as _ws 

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

2005 sleep(total_seconds) 

2006 return 

2007 

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

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

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

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

2012 # with a sentinel poll between each. 

2013 if total_seconds <= _CHUNK: 

2014 sleep(total_seconds) 

2015 return 

2016 

2017 remaining = total_seconds 

2018 while remaining > 0: 

2019 chunk = min(_CHUNK, remaining) 

2020 sleep(chunk) 

2021 remaining -= chunk 

2022 if _ws.pending_force_syncs(): 

2023 return