Coverage for src/photo_file_utils.py: 100%

98 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-05 00:26 +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# CloudKit fields to request when re-fetching a photo record for fresh download URLs. 

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

25_DESIRED_KEYS = [ 

26 "resJPEGFullWidth", 

27 "resJPEGFullHeight", 

28 "resJPEGFullFileType", 

29 "resJPEGFullFingerprint", 

30 "resJPEGFullRes", 

31 "resJPEGLargeWidth", 

32 "resJPEGLargeHeight", 

33 "resJPEGLargeFileType", 

34 "resJPEGLargeFingerprint", 

35 "resJPEGLargeRes", 

36 "resJPEGMedWidth", 

37 "resJPEGMedHeight", 

38 "resJPEGMedFileType", 

39 "resJPEGMedFingerprint", 

40 "resJPEGMedRes", 

41 "resJPEGThumbWidth", 

42 "resJPEGThumbHeight", 

43 "resJPEGThumbFileType", 

44 "resJPEGThumbFingerprint", 

45 "resJPEGThumbRes", 

46 "resVidFullWidth", 

47 "resVidFullHeight", 

48 "resVidFullFileType", 

49 "resVidFullFingerprint", 

50 "resVidFullRes", 

51 "resVidMedWidth", 

52 "resVidMedHeight", 

53 "resVidMedFileType", 

54 "resVidMedFingerprint", 

55 "resVidMedRes", 

56 "resVidSmallWidth", 

57 "resVidSmallHeight", 

58 "resVidSmallFileType", 

59 "resVidSmallFingerprint", 

60 "resVidSmallRes", 

61 "resSidecarWidth", 

62 "resSidecarHeight", 

63 "resSidecarFileType", 

64 "resSidecarFingerprint", 

65 "resSidecarRes", 

66 "itemType", 

67 "dataClassType", 

68 "filenameEnc", 

69 "originalOrientation", 

70 "resOriginalWidth", 

71 "resOriginalHeight", 

72 "resOriginalFileType", 

73 "resOriginalFingerprint", 

74 "resOriginalRes", 

75 "resOriginalAltWidth", 

76 "resOriginalAltHeight", 

77 "resOriginalAltFileType", 

78 "resOriginalAltFingerprint", 

79 "resOriginalAltRes", 

80 "resOriginalVidComplWidth", 

81 "resOriginalVidComplHeight", 

82 "resOriginalVidComplFileType", 

83 "resOriginalVidComplFingerprint", 

84 "resOriginalVidComplRes", 

85 "isDeleted", 

86 "isExpunged", 

87 "dateExpunged", 

88 "remappedRef", 

89 "recordName", 

90 "recordType", 

91 "recordChangeTag", 

92 "masterRef", 

93 "adjustmentRenderType", 

94 "assetDate", 

95 "addedDate", 

96 "isFavorite", 

97 "isHidden", 

98 "orientation", 

99 "duration", 

100 "assetSubtype", 

101 "assetSubtypeV2", 

102 "assetHDRType", 

103 "burstFlags", 

104 "burstFlagsExt", 

105 "burstId", 

106 "captionEnc", 

107 "extendedDescEnc", 

108 "locationEnc", 

109 "locationV2Enc", 

110 "locationLatitude", 

111 "locationLongitude", 

112 "adjustmentType", 

113 "timeZoneOffset", 

114 "vidComplDurValue", 

115 "vidComplDurScale", 

116 "vidComplDispValue", 

117 "vidComplDispScale", 

118 "vidComplVisibilityState", 

119 "customRenderedValue", 

120 "containerId", 

121 "itemId", 

122 "position", 

123 "isKeyAsset", 

124 "importedByBundleIdentifierEnc", 

125 "importedByDisplayNameEnc", 

126 "importedBy", 

127] 

128 

129 

130def _refresh_photo_download_url(photo) -> bool: 

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

132 

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

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

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

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

137 ``records/query`` API call to get an updated master record with fresh URLs, 

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

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

140 

141 Args: 

142 photo: PhotoAsset object from icloudpy 

143 

144 Returns: 

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

146 """ 

147 try: 

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

149 record_type = photo._master_record.get("recordType", "CPLMaster") # noqa: SLF001 

150 except (AttributeError, KeyError, TypeError): 

151 LOGGER.debug("Cannot refresh download URL: photo missing _master_record or recordName") 

152 return False 

153 

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

155 if service is None: 

156 LOGGER.debug("Cannot refresh download URL: photo missing _service") 

157 return False 

158 

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

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

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

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

163 

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

165 LOGGER.debug("Cannot refresh download URL: photo._service missing required attributes") 

166 return False 

167 

168 try: 

169 url = f"{endpoint}/records/query?{urlencode(params)}" 

170 query = { 

171 "query": { 

172 "recordType": record_type, 

173 "filterBy": [ 

174 { 

175 "fieldName": "recordName", 

176 "comparator": "IN", 

177 "fieldValue": { 

178 "type": "STRING_LIST", 

179 "value": [record_name], 

180 }, 

181 }, 

182 ], 

183 }, 

184 "resultsLimit": 1, 

185 "desiredKeys": _DESIRED_KEYS, 

186 "zoneID": zone_id, 

187 } 

188 request = session.post( 

189 url, 

190 data=json.dumps(query), 

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

192 ) 

193 response = request.json() 

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

195 

196 for rec in records: 

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

198 photo._master_record = rec # noqa: SLF001 

199 with _versions_refresh_lock: 

200 photo._versions = None # noqa: SLF001 

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

202 return True 

203 

204 LOGGER.debug(f"Record {record_name} not found in iCloud response during URL refresh") 

205 return False 

206 

207 except Exception as e: # noqa: BLE001 

208 LOGGER.debug(f"Failed to refresh download URL for {record_name}: {e!s}") 

209 return False 

210 

211 

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

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

214 

215 Args: 

216 photo: Photo object from iCloudPy 

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

218 local_path: Local file path to check 

219 

220 Returns: 

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

222 """ 

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

224 return False 

225 

226 local_size = os.path.getsize(local_path) 

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

228 

229 if local_size == remote_size: 

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

231 return True 

232 else: 

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

234 return False 

235 

236 

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

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

239 

240 Args: 

241 source_path: Path to existing file to link from 

242 destination_path: Path where hardlink should be created 

243 

244 Returns: 

245 True if hardlink was created successfully, False otherwise 

246 """ 

247 try: 

248 # Ensure destination directory exists 

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

250 # Create hard link 

251 os.link(source_path, destination_path) 

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

253 return True 

254 except (OSError, FileNotFoundError) as e: 

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

256 return False 

257 

258 

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

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

261 

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

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

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

265 fresh download URLs, then retries the download. 

266 

267 Args: 

268 photo: Photo object from iCloudPy 

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

270 destination_path: Local path where photo should be saved 

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

272 

273 Returns: 

274 True if download was successful, False otherwise 

275 """ 

276 if not (photo and file_size and destination_path): 

277 return False 

278 

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

280 

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

282 attempt = 0 

283 max_attempts = max_retries + 1 # Initial attempt + retries 

284 

285 while attempt < max_attempts: # noqa: PERF203 

286 try: 

287 download = photo.download(file_size) 

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

289 shutil.copyfileobj(download.raw, file_out) 

290 

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

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

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

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

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

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

297 

298 return True 

299 

300 except Exception as e: # noqa: PERF203 

301 # Enhanced error logging with file path context 

302 # This catches all exceptions including iCloudPy errors like ObjectNotFoundException 

303 error_msg = str(e) 

304 

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

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

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

308 if "Gone (410)" in error_msg: 

309 attempt += 1 

310 if attempt < max_attempts: 

311 LOGGER.warning( 

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

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

314 ) 

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

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

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

318 _refresh_photo_download_url(photo) 

319 continue 

320 else: 

321 LOGGER.error( 

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

323 ) 

324 return False 

325 

326 # Handle other errors 

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

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

329 else: 

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

331 return False 

332 

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

334 return False # pragma: no cover 

335 

336 

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

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

339 

340 Args: 

341 old_path: Path to legacy file format 

342 new_path: Path to new file format 

343 """ 

344 if os.path.isfile(old_path): 

345 os.rename(old_path, new_path)