Coverage for src/photo_file_utils.py: 100%

111 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 17:25 +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 json 

10import os 

11import shutil 

12import threading 

13from datetime import timezone 

14from urllib.parse import urlencode 

15 

16from src import get_logger 

17 

18LOGGER = get_logger() 

19 

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

21_versions_refresh_lock = threading.Lock() 

22 

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

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

25_refresh_failure_lock = threading.Lock() 

26_consecutive_refresh_failures = 0 

27_REFRESH_FAILURE_WARN_INTERVAL = 3 

28 

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

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

31_DESIRED_KEYS = [ 

32 "resJPEGFullWidth", 

33 "resJPEGFullHeight", 

34 "resJPEGFullFileType", 

35 "resJPEGFullFingerprint", 

36 "resJPEGFullRes", 

37 "resJPEGLargeWidth", 

38 "resJPEGLargeHeight", 

39 "resJPEGLargeFileType", 

40 "resJPEGLargeFingerprint", 

41 "resJPEGLargeRes", 

42 "resJPEGMedWidth", 

43 "resJPEGMedHeight", 

44 "resJPEGMedFileType", 

45 "resJPEGMedFingerprint", 

46 "resJPEGMedRes", 

47 "resJPEGThumbWidth", 

48 "resJPEGThumbHeight", 

49 "resJPEGThumbFileType", 

50 "resJPEGThumbFingerprint", 

51 "resJPEGThumbRes", 

52 "resVidFullWidth", 

53 "resVidFullHeight", 

54 "resVidFullFileType", 

55 "resVidFullFingerprint", 

56 "resVidFullRes", 

57 "resVidMedWidth", 

58 "resVidMedHeight", 

59 "resVidMedFileType", 

60 "resVidMedFingerprint", 

61 "resVidMedRes", 

62 "resVidSmallWidth", 

63 "resVidSmallHeight", 

64 "resVidSmallFileType", 

65 "resVidSmallFingerprint", 

66 "resVidSmallRes", 

67 "resSidecarWidth", 

68 "resSidecarHeight", 

69 "resSidecarFileType", 

70 "resSidecarFingerprint", 

71 "resSidecarRes", 

72 "itemType", 

73 "dataClassType", 

74 "filenameEnc", 

75 "originalOrientation", 

76 "resOriginalWidth", 

77 "resOriginalHeight", 

78 "resOriginalFileType", 

79 "resOriginalFingerprint", 

80 "resOriginalRes", 

81 "resOriginalAltWidth", 

82 "resOriginalAltHeight", 

83 "resOriginalAltFileType", 

84 "resOriginalAltFingerprint", 

85 "resOriginalAltRes", 

86 "resOriginalVidComplWidth", 

87 "resOriginalVidComplHeight", 

88 "resOriginalVidComplFileType", 

89 "resOriginalVidComplFingerprint", 

90 "resOriginalVidComplRes", 

91 "isDeleted", 

92 "isExpunged", 

93 "dateExpunged", 

94 "remappedRef", 

95 "recordName", 

96 "recordType", 

97 "recordChangeTag", 

98 "masterRef", 

99 "adjustmentRenderType", 

100 "assetDate", 

101 "addedDate", 

102 "isFavorite", 

103 "isHidden", 

104 "orientation", 

105 "duration", 

106 "assetSubtype", 

107 "assetSubtypeV2", 

108 "assetHDRType", 

109 "burstFlags", 

110 "burstFlagsExt", 

111 "burstId", 

112 "captionEnc", 

113 "extendedDescEnc", 

114 "locationEnc", 

115 "locationV2Enc", 

116 "locationLatitude", 

117 "locationLongitude", 

118 "adjustmentType", 

119 "timeZoneOffset", 

120 "vidComplDurValue", 

121 "vidComplDurScale", 

122 "vidComplDispValue", 

123 "vidComplDispScale", 

124 "vidComplVisibilityState", 

125 "customRenderedValue", 

126 "containerId", 

127 "itemId", 

128 "position", 

129 "isKeyAsset", 

130 "importedByBundleIdentifierEnc", 

131 "importedByDisplayNameEnc", 

132 "importedBy", 

133] 

134 

135 

136def _note_refresh_success() -> None: 

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

138 global _consecutive_refresh_failures # noqa: PLW0603 

139 with _refresh_failure_lock: 

140 _consecutive_refresh_failures = 0 

141 

142 

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

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

145 

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

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

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

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

150 

151 Args: 

152 record_name: CloudKit recordName of the photo being refreshed 

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

154 """ 

155 global _consecutive_refresh_failures # noqa: PLW0603 

156 with _refresh_failure_lock: 

157 _consecutive_refresh_failures += 1 

158 failures = _consecutive_refresh_failures 

159 

160 if failures % _REFRESH_FAILURE_WARN_INTERVAL == 0: 

161 LOGGER.warning( 

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

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

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

165 ) 

166 else: 

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

168 

169 

170def _refresh_photo_download_url(photo) -> bool: 

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

172 

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

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

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

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

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

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

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

180 

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

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

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

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

185 

186 Args: 

187 photo: PhotoAsset object from icloudpy 

188 

189 Returns: 

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

191 """ 

192 try: 

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

194 except (AttributeError, KeyError, TypeError): 

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

196 return False 

197 

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

199 if service is None: 

200 _note_refresh_failure(record_name, "photo missing _service") 

201 return False 

202 

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

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

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

206 zone_id = getattr(service, "zone_id", None) 

207 

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

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

210 return False 

211 

212 try: 

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

214 query = { 

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

216 "desiredKeys": _DESIRED_KEYS, 

217 "zoneID": zone_id, 

218 } 

219 request = session.post( 

220 url, 

221 data=json.dumps(query), 

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

223 ) 

224 response = request.json() 

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

226 

227 for rec in records: 

228 if rec.get("recordName") == record_name: 

229 photo._master_record = rec # noqa: SLF001 

230 with _versions_refresh_lock: 

231 photo._versions = None # noqa: SLF001 

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

233 _note_refresh_success() 

234 return True 

235 

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

237 return False 

238 

239 except Exception as e: # noqa: BLE001 

240 _note_refresh_failure(record_name, str(e)) 

241 return False 

242 

243 

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

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

246 

247 Args: 

248 photo: Photo object from iCloudPy 

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

250 local_path: Local file path to check 

251 

252 Returns: 

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

254 """ 

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

256 return False 

257 

258 local_size = os.path.getsize(local_path) 

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

260 

261 if local_size == remote_size: 

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

263 return True 

264 else: 

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

266 return False 

267 

268 

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

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

271 

272 Args: 

273 source_path: Path to existing file to link from 

274 destination_path: Path where hardlink should be created 

275 

276 Returns: 

277 True if hardlink was created successfully, False otherwise 

278 """ 

279 try: 

280 # Ensure destination directory exists 

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

282 # Create hard link 

283 os.link(source_path, destination_path) 

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

285 return True 

286 except (OSError, FileNotFoundError) as e: 

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

288 return False 

289 

290 

291def download_photo_from_server(photo, file_size: str, destination_path: str, max_retries: int = 1) -> bool: 

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

293 

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

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

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

297 fresh download URLs, then retries the download. 

298 

299 Args: 

300 photo: Photo object from iCloudPy 

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

302 destination_path: Local path where photo should be saved 

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

304 

305 Returns: 

306 True if download was successful, False otherwise 

307 """ 

308 if not (photo and file_size and destination_path): 

309 return False 

310 

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

312 

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

314 attempt = 0 

315 max_attempts = max_retries + 1 # Initial attempt + retries 

316 

317 while attempt < max_attempts: # noqa: PERF203 

318 try: 

319 download = photo.download(file_size) 

320 with open(destination_path, "wb") as file_out: 

321 shutil.copyfileobj(download.raw, file_out) 

322 

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

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

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

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

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

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

329 

330 return True 

331 

332 except Exception as e: # noqa: PERF203 

333 # Enhanced error logging with file path context 

334 # This catches all exceptions including iCloudPy errors like ObjectNotFoundException 

335 error_msg = str(e) 

336 

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

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

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

340 if "Gone (410)" in error_msg: 

341 attempt += 1 

342 if attempt < max_attempts: 

343 LOGGER.warning( 

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

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

346 ) 

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

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

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

350 _refresh_photo_download_url(photo) 

351 continue 

352 else: 

353 LOGGER.error( 

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

355 ) 

356 return False 

357 

358 # Handle other errors 

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

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

361 else: 

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

363 return False 

364 

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

366 return False # pragma: no cover 

367 

368 

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

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

371 

372 Args: 

373 old_path: Path to legacy file format 

374 new_path: Path to new file format 

375 """ 

376 if os.path.isfile(old_path): 

377 os.rename(old_path, new_path)