Coverage for src/photo_file_utils.py: 100%

137 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-08 21:05 +0000

1"""Photo file operations module. 

2 

3This module contains utilities for photo file operations including 

4downloading, hardlink creation, and file existence checking. 

5""" 

6 

7___author___ = "Mandar Patil <mandarons@pm.me>" 

8 

9import contextlib 

10import json 

11import os 

12import shutil 

13import threading 

14from datetime import timezone 

15from urllib.parse import urlencode 

16 

17from src import DEFAULT_REQUEST_TIMEOUT_SEC, get_logger 

18 

19LOGGER = get_logger() 

20 

21# Module-level lock to protect thread-safe mutation of photo._versions during retries 

22_versions_refresh_lock = threading.Lock() 

23 

24# Consecutive download-URL refresh failures, and how often to escalate them to WARNING. 

25# Downloads run in parallel, so the counter is guarded by its own lock. 

26_refresh_failure_lock = threading.Lock() 

27_consecutive_refresh_failures = 0 

28_REFRESH_FAILURE_WARN_INTERVAL = 3 

29 

30# CloudKit fields to request when re-fetching a photo record for fresh download URLs. 

31# Mirrors the desiredKeys list used by icloudpy's PhotoAlbum._list_query_gen(). 

32_DESIRED_KEYS = [ 

33 "resJPEGFullWidth", 

34 "resJPEGFullHeight", 

35 "resJPEGFullFileType", 

36 "resJPEGFullFingerprint", 

37 "resJPEGFullRes", 

38 "resJPEGLargeWidth", 

39 "resJPEGLargeHeight", 

40 "resJPEGLargeFileType", 

41 "resJPEGLargeFingerprint", 

42 "resJPEGLargeRes", 

43 "resJPEGMedWidth", 

44 "resJPEGMedHeight", 

45 "resJPEGMedFileType", 

46 "resJPEGMedFingerprint", 

47 "resJPEGMedRes", 

48 "resJPEGThumbWidth", 

49 "resJPEGThumbHeight", 

50 "resJPEGThumbFileType", 

51 "resJPEGThumbFingerprint", 

52 "resJPEGThumbRes", 

53 "resVidFullWidth", 

54 "resVidFullHeight", 

55 "resVidFullFileType", 

56 "resVidFullFingerprint", 

57 "resVidFullRes", 

58 "resVidMedWidth", 

59 "resVidMedHeight", 

60 "resVidMedFileType", 

61 "resVidMedFingerprint", 

62 "resVidMedRes", 

63 "resVidSmallWidth", 

64 "resVidSmallHeight", 

65 "resVidSmallFileType", 

66 "resVidSmallFingerprint", 

67 "resVidSmallRes", 

68 "resSidecarWidth", 

69 "resSidecarHeight", 

70 "resSidecarFileType", 

71 "resSidecarFingerprint", 

72 "resSidecarRes", 

73 "itemType", 

74 "dataClassType", 

75 "filenameEnc", 

76 "originalOrientation", 

77 "resOriginalWidth", 

78 "resOriginalHeight", 

79 "resOriginalFileType", 

80 "resOriginalFingerprint", 

81 "resOriginalRes", 

82 "resOriginalAltWidth", 

83 "resOriginalAltHeight", 

84 "resOriginalAltFileType", 

85 "resOriginalAltFingerprint", 

86 "resOriginalAltRes", 

87 "resOriginalVidComplWidth", 

88 "resOriginalVidComplHeight", 

89 "resOriginalVidComplFileType", 

90 "resOriginalVidComplFingerprint", 

91 "resOriginalVidComplRes", 

92 "isDeleted", 

93 "isExpunged", 

94 "dateExpunged", 

95 "remappedRef", 

96 "recordName", 

97 "recordType", 

98 "recordChangeTag", 

99 "masterRef", 

100 "adjustmentRenderType", 

101 "assetDate", 

102 "addedDate", 

103 "isFavorite", 

104 "isHidden", 

105 "orientation", 

106 "duration", 

107 "assetSubtype", 

108 "assetSubtypeV2", 

109 "assetHDRType", 

110 "burstFlags", 

111 "burstFlagsExt", 

112 "burstId", 

113 "captionEnc", 

114 "extendedDescEnc", 

115 "locationEnc", 

116 "locationV2Enc", 

117 "locationLatitude", 

118 "locationLongitude", 

119 "adjustmentType", 

120 "timeZoneOffset", 

121 "vidComplDurValue", 

122 "vidComplDurScale", 

123 "vidComplDispValue", 

124 "vidComplDispScale", 

125 "vidComplVisibilityState", 

126 "customRenderedValue", 

127 "containerId", 

128 "itemId", 

129 "position", 

130 "isKeyAsset", 

131 "importedByBundleIdentifierEnc", 

132 "importedByDisplayNameEnc", 

133 "importedBy", 

134] 

135 

136 

137def _note_refresh_success() -> None: 

138 """Reset the consecutive refresh-failure streak after a successful refresh.""" 

139 global _consecutive_refresh_failures # noqa: PLW0603 

140 with _refresh_failure_lock: 

141 _consecutive_refresh_failures = 0 

142 

143 

144def _note_refresh_failure(record_name: str, reason: str) -> None: 

145 """Record a failed URL refresh, escalating to WARNING once failures repeat. 

146 

147 URL refresh is the last line of defence before a download is abandoned, so a 

148 systematically broken refresh path (rather than an occasional miss) should be 

149 visible without turning on debug logging. Individual failures stay at DEBUG; 

150 every ``_REFRESH_FAILURE_WARN_INTERVAL`` consecutive failures emits a WARNING. 

151 

152 Args: 

153 record_name: CloudKit recordName of the photo being refreshed 

154 reason: Human-readable description of why the refresh failed 

155 """ 

156 global _consecutive_refresh_failures # noqa: PLW0603 

157 with _refresh_failure_lock: 

158 _consecutive_refresh_failures += 1 

159 failures = _consecutive_refresh_failures 

160 

161 if failures % _REFRESH_FAILURE_WARN_INTERVAL == 0: 

162 LOGGER.warning( 

163 f"Download URL refresh has failed {failures} times in a row - expired-URL (HTTP 410) " 

164 f"recovery is not working, so affected photos will be reported as failed downloads. " 

165 f"Most recent failure: {record_name} - {reason}", 

166 ) 

167 else: 

168 LOGGER.debug(f"Failed to refresh download URL for {record_name}: {reason}") 

169 

170 

171def _refresh_photo_download_url(photo) -> bool: 

172 """Re-fetch the photo's master record from iCloud to obtain fresh download URLs. 

173 

174 iCloud download URLs are signed CDN tokens that expire after ~30–40 minutes. 

175 When a URL expires (HTTP 410 Gone), clearing ``photo._versions`` alone is 

176 insufficient because icloudpy re-parses the same stale ``_master_record`` 

177 which still contains the expired URL. This function makes a new 

178 ``records/lookup`` API call to get an updated master record with fresh URLs, 

179 then updates ``photo._master_record`` in place and clears ``_versions`` so 

180 the next ``download()`` call uses the fresh URL. 

181 

182 ``records/lookup`` is used rather than ``records/query`` because ``CPLMaster`` 

183 is not a query-indexable CloudKit type: querying it fails every time with 

184 ``Type is not marked indexable: CPLMaster (BAD_REQUEST)``. Lookup fetches 

185 records by name and returns the same ``{"records": [...]}`` shape. 

186 

187 Args: 

188 photo: PhotoAsset object from icloudpy 

189 

190 Returns: 

191 True if the master record was successfully refreshed, False otherwise. 

192 """ 

193 try: 

194 record_name = photo._master_record["recordName"] # noqa: SLF001 

195 # The record's own zone. icloudpy gives every asset the photos 

196 # service, whose zone is always the primary library, so a Shared 

197 # Library asset looked up there is never found. 

198 record_zone = photo._master_record.get("zoneID") # noqa: SLF001 

199 except (AttributeError, KeyError, TypeError): 

200 _note_refresh_failure("<unknown>", "photo missing _master_record or recordName") 

201 return False 

202 

203 service = getattr(photo, "_service", None) 

204 if service is None: 

205 _note_refresh_failure(record_name, "photo missing _service") 

206 return False 

207 

208 endpoint = getattr(service, "_service_endpoint", None) 

209 session = getattr(service, "session", None) 

210 params = getattr(service, "params", None) 

211 zone_id = record_zone if isinstance(record_zone, dict) and record_zone else getattr(service, "zone_id", None) 

212 

213 if not all([endpoint, session, params, zone_id]): 

214 _note_refresh_failure(record_name, "photo._service missing required attributes") 

215 return False 

216 

217 try: 

218 url = f"{endpoint}/records/lookup?{urlencode(params)}" 

219 query = { 

220 "records": [{"recordName": record_name}], 

221 "desiredKeys": _DESIRED_KEYS, 

222 "zoneID": zone_id, 

223 } 

224 request = session.post( 

225 url, 

226 data=json.dumps(query), 

227 headers={"Content-type": "text/plain"}, 

228 ) 

229 response = request.json() 

230 records = response.get("records", []) 

231 

232 for rec in records: 

233 if rec.get("recordName") != record_name: 

234 continue 

235 # A failed lookup is not an HTTP error: CloudKit returns it as a 

236 # record inside ``records`` carrying the requested recordName and a 

237 # ``serverErrorCode``/``reason`` in place of ``fields``. Matching on 

238 # the name alone therefore accepts an error payload as a fresh 

239 # master record -- the refresh reports success, and the retrying 

240 # download raises KeyError('fields') deep inside icloudpy, which 

241 # surfaces as an unexplained "Failed to download <path>: 'fields'" 

242 # and is charged to the download rather than to the refresh. 

243 # 

244 # An empty ``fields`` is no better than a missing one: icloudpy 

245 # builds ``versions`` by testing ``f"{prefix}Res" in fields``, so 

246 # an empty dict yields no versions at all, ``download()`` returns 

247 # None, and the caller dereferences ``.raw`` on it. Accepting 

248 # either shape also discards the still-valid master record we 

249 # already hold, poisoning the asset for the rest of the sync. 

250 # Two distinct failures, reported separately so a refresh that 

251 # keeps failing in the field says which one it is. 

252 if rec.get("serverErrorCode"): 

253 _note_refresh_failure( 

254 record_name, 

255 f"CloudKit error {rec['serverErrorCode']}: {rec.get('reason') or 'no reason given'}", 

256 ) 

257 return False 

258 if not rec.get("fields"): 

259 _note_refresh_failure(record_name, "record came back with no usable fields") 

260 return False 

261 photo._master_record = rec # noqa: SLF001 

262 with _versions_refresh_lock: 

263 photo._versions = None # noqa: SLF001 

264 LOGGER.debug(f"Refreshed download URL for {record_name}") 

265 _note_refresh_success() 

266 return True 

267 

268 _note_refresh_failure(record_name, "record not found in iCloud response") 

269 return False 

270 

271 except Exception as e: # noqa: BLE001 

272 _note_refresh_failure(record_name, str(e)) 

273 return False 

274 

275 

276def check_photo_exists(photo, file_size: str, local_path: str) -> bool: 

277 """Check if photo exists locally with correct size. 

278 

279 Args: 

280 photo: Photo object from iCloudPy 

281 file_size: File size variant (original, medium, thumb, etc.) 

282 local_path: Local file path to check 

283 

284 Returns: 

285 True if photo exists locally with correct size, False otherwise 

286 """ 

287 if not (photo and local_path and os.path.isfile(local_path)): 

288 return False 

289 

290 local_size = os.path.getsize(local_path) 

291 remote_size = int(photo.versions[file_size]["size"]) 

292 

293 if local_size == remote_size: 

294 LOGGER.debug(f"No changes detected. Skipping the file {local_path} ...") 

295 return True 

296 else: 

297 LOGGER.debug(f"Change detected: local_file_size is {local_size} and remote_file_size is {remote_size}.") 

298 return False 

299 

300 

301def create_hardlink(source_path: str, destination_path: str) -> bool: 

302 """Create a hard link from source to destination. 

303 

304 Args: 

305 source_path: Path to existing file to link from 

306 destination_path: Path where hardlink should be created 

307 

308 Returns: 

309 True if hardlink was created successfully, False otherwise 

310 """ 

311 try: 

312 # Ensure destination directory exists 

313 os.makedirs(os.path.dirname(destination_path), exist_ok=True) 

314 # Create hard link 

315 os.link(source_path, destination_path) 

316 LOGGER.info(f"Created hard link: {destination_path} (linked to existing file: {source_path})") 

317 return True 

318 except (OSError, FileNotFoundError) as e: 

319 LOGGER.warning(f"Failed to create hard link {destination_path}: {e!s}") 

320 return False 

321 

322 

323def download_photo_from_server( 

324 photo, 

325 file_size: str, 

326 destination_path: str, 

327 max_retries: int = 1, 

328 timeout: int = DEFAULT_REQUEST_TIMEOUT_SEC, 

329) -> bool: 

330 """Download photo from iCloud server to local path. 

331 

332 This function implements automatic retry logic for HTTP 410 (Gone) errors, 

333 which occur when iCloud download URLs expire. When a 410 error is detected, 

334 the function re-fetches the photo's master record from iCloud to obtain 

335 fresh download URLs, then retries the download. 

336 

337 Args: 

338 photo: Photo object from iCloudPy 

339 file_size: File size variant (original, medium, thumb, etc.) 

340 destination_path: Local path where photo should be saved 

341 max_retries: Maximum number of retries on 410 errors (default: 1) 

342 timeout: HTTP read timeout in seconds. Without one a stalled CDN 

343 connection blocks the worker thread forever. 

344 

345 Returns: 

346 True if download was successful, False otherwise 

347 """ 

348 if not (photo and file_size and destination_path): 

349 return False 

350 

351 LOGGER.info(f"Downloading {destination_path} ...") 

352 

353 max_retries = max(0, max_retries) # Clamp to minimum 0 for predictable behavior 

354 attempt = 0 

355 max_attempts = max_retries + 1 # Initial attempt + retries 

356 

357 # Download beside the target and move it into place only once it is 

358 # complete. Writing straight to destination_path left a failed transfer's 

359 # partial file where the real photo belongs, where anything reading the 

360 # library (an Immich external library, a backup) takes it for the photo, 

361 # and it also destroyed the previous good copy before the new one existed. 

362 # A partial stranded by a killed process is untracked, so obsolete-file 

363 # cleanup removes it. 

364 partial = f"{destination_path}.part" 

365 

366 while attempt < max_attempts: # noqa: PERF203 

367 try: 

368 download = photo.download(file_size, timeout=timeout) 

369 with open(partial, "wb") as file_out: 

370 shutil.copyfileobj(download.raw, file_out) 

371 

372 # iCloud declares every version's size, and photo_exists already 

373 # treats a mismatch as "not this file". Check it here too, so a 

374 # body that is short or is not the photo at all (an error page) 

375 # fails now instead of being recorded as downloaded. 

376 expected = _declared_size(photo, file_size) 

377 written = os.path.getsize(partial) 

378 if expected is not None and written != expected: 

379 _discard(partial) 

380 LOGGER.error( 

381 f"Incomplete download of {destination_path}: got {written} of {expected} bytes. " 

382 "Will retry on the next sync.", 

383 ) 

384 return False 

385 os.replace(partial, destination_path) 

386 

387 # Set file modification time to photo's added date. 

388 # iCloudPy returns added_date as an aware UTC datetime; replace() is a 

389 # safe no-op here because tzinfo is already UTC. If it ever returns a 

390 # naive datetime, replace(tzinfo=utc) correctly treats it as UTC. 

391 local_modified_time = photo.added_date.replace(tzinfo=timezone.utc).timestamp() 

392 os.utime(destination_path, (local_modified_time, local_modified_time)) 

393 

394 return True 

395 

396 except Exception as e: # noqa: PERF203 

397 _discard(partial) 

398 # Enhanced error logging with file path context 

399 # This catches all exceptions including iCloudPy errors like ObjectNotFoundException 

400 error_msg = str(e) 

401 

402 # Check for HTTP 410 Gone error - download URL has expired 

403 # The iCloudPy library raises exceptions with "Gone (410)" in the message 

404 # when the download URL has expired (typically after 30-40 minutes) 

405 if "Gone (410)" in error_msg: 

406 attempt += 1 

407 if attempt < max_attempts: 

408 LOGGER.warning( 

409 f"Download URL expired (410) for {destination_path}. " 

410 f"Refreshing URL and retrying (attempt {attempt}/{max_attempts})...", 

411 ) 

412 # Re-fetch the master record from iCloud to obtain fresh download URLs. 

413 # Simply clearing _versions is insufficient because icloudpy re-parses 

414 # the same stale _master_record which still contains the expired URL. 

415 _refresh_photo_download_url(photo) 

416 continue 

417 else: 

418 LOGGER.error( 

419 f"Failed to download {destination_path} after {max_retries} retries: {error_msg}", 

420 ) 

421 return False 

422 

423 # Handle other errors 

424 if "ObjectNotFoundException" in error_msg or "NOT_FOUND" in error_msg: 

425 LOGGER.error(f"Photo not found in iCloud Photos - {destination_path}: {error_msg}") 

426 else: 

427 LOGGER.error(f"Failed to download {destination_path}: {error_msg}") 

428 return False 

429 

430 # This line should never be reached due to the logic above, but is kept as defensive programming 

431 return False # pragma: no cover 

432 

433 

434def _declared_size(photo, file_size: str) -> int | None: 

435 """The size iCloud declares for this version, or None if it does not say.""" 

436 try: 

437 return int(photo.versions[file_size]["size"]) 

438 except (AttributeError, KeyError, TypeError, ValueError): 

439 return None 

440 

441 

442def _discard(path: str) -> None: 

443 """Remove a partial download if one was written.""" 

444 with contextlib.suppress(FileNotFoundError): 

445 os.remove(path) 

446 

447 

448def rename_legacy_file_if_exists(old_path: str, new_path: str) -> None: 

449 """Rename legacy file format to new format if it exists. 

450 

451 Args: 

452 old_path: Path to legacy file format 

453 new_path: Path to new file format 

454 """ 

455 if os.path.isfile(old_path): 

456 os.rename(old_path, new_path)