Coverage for icloudpy / services / photos.py: 92%

259 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-06-18 19:11 +0000

1"""Photo service.""" 

2 

3import base64 

4import json 

5import logging 

6from datetime import datetime 

7 

8# fmt: off 

9from urllib.parse import urlencode # pylint: disable=bad-option-value,relative-import 

10 

11from pytz import UTC 

12 

13# fmt: on 

14from icloudpy.exceptions import ICloudPyServiceNotActivatedException 

15 

16LOGGER = logging.getLogger(__name__) 

17 

18 

19class PhotoLibrary: 

20 """Represents a library in the user's photos. 

21 

22 This provides access to all the albums as well as the photos. 

23 """ 

24 

25 SMART_FOLDERS = { 

26 "All Photos": { 

27 "obj_type": "CPLAssetByAddedDate", 

28 "list_type": "CPLAssetAndMasterByAddedDate", 

29 "direction": "ASCENDING", 

30 "query_filter": None, 

31 }, 

32 "Time-lapse": { 

33 "obj_type": "CPLAssetInSmartAlbumByAssetDate:Timelapse", 

34 "list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate", 

35 "direction": "ASCENDING", 

36 "query_filter": [ 

37 { 

38 "fieldName": "smartAlbum", 

39 "comparator": "EQUALS", 

40 "fieldValue": {"type": "STRING", "value": "TIMELAPSE"}, 

41 }, 

42 ], 

43 }, 

44 "Videos": { 

45 "obj_type": "CPLAssetInSmartAlbumByAssetDate:Video", 

46 "list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate", 

47 "direction": "ASCENDING", 

48 "query_filter": [ 

49 { 

50 "fieldName": "smartAlbum", 

51 "comparator": "EQUALS", 

52 "fieldValue": {"type": "STRING", "value": "VIDEO"}, 

53 }, 

54 ], 

55 }, 

56 "Slo-mo": { 

57 "obj_type": "CPLAssetInSmartAlbumByAssetDate:Slomo", 

58 "list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate", 

59 "direction": "ASCENDING", 

60 "query_filter": [ 

61 { 

62 "fieldName": "smartAlbum", 

63 "comparator": "EQUALS", 

64 "fieldValue": {"type": "STRING", "value": "SLOMO"}, 

65 }, 

66 ], 

67 }, 

68 "Bursts": { 

69 "obj_type": "CPLAssetBurstStackAssetByAssetDate", 

70 "list_type": "CPLBurstStackAssetAndMasterByAssetDate", 

71 "direction": "ASCENDING", 

72 "query_filter": None, 

73 }, 

74 "Favorites": { 

75 "obj_type": "CPLAssetInSmartAlbumByAssetDate:Favorite", 

76 "list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate", 

77 "direction": "ASCENDING", 

78 "query_filter": [ 

79 { 

80 "fieldName": "smartAlbum", 

81 "comparator": "EQUALS", 

82 "fieldValue": {"type": "STRING", "value": "FAVORITE"}, 

83 }, 

84 ], 

85 }, 

86 "Panoramas": { 

87 "obj_type": "CPLAssetInSmartAlbumByAssetDate:Panorama", 

88 "list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate", 

89 "direction": "ASCENDING", 

90 "query_filter": [ 

91 { 

92 "fieldName": "smartAlbum", 

93 "comparator": "EQUALS", 

94 "fieldValue": {"type": "STRING", "value": "PANORAMA"}, 

95 }, 

96 ], 

97 }, 

98 "Screenshots": { 

99 "obj_type": "CPLAssetInSmartAlbumByAssetDate:Screenshot", 

100 "list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate", 

101 "direction": "ASCENDING", 

102 "query_filter": [ 

103 { 

104 "fieldName": "smartAlbum", 

105 "comparator": "EQUALS", 

106 "fieldValue": {"type": "STRING", "value": "SCREENSHOT"}, 

107 }, 

108 ], 

109 }, 

110 "Live": { 

111 "obj_type": "CPLAssetInSmartAlbumByAssetDate:Live", 

112 "list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate", 

113 "direction": "ASCENDING", 

114 "query_filter": [ 

115 { 

116 "fieldName": "smartAlbum", 

117 "comparator": "EQUALS", 

118 "fieldValue": {"type": "STRING", "value": "LIVE"}, 

119 }, 

120 ], 

121 }, 

122 "Recently Deleted": { 

123 "obj_type": "CPLAssetDeletedByExpungedDate", 

124 "list_type": "CPLAssetAndMasterDeletedByExpungedDate", 

125 "direction": "ASCENDING", 

126 "query_filter": None, 

127 }, 

128 "Hidden": { 

129 "obj_type": "CPLAssetHiddenByAssetDate", 

130 "list_type": "CPLAssetAndMasterHiddenByAssetDate", 

131 "direction": "ASCENDING", 

132 "query_filter": None, 

133 }, 

134 } 

135 

136 def __init__(self, service, zone_id): 

137 self.service = service 

138 self.zone_id = zone_id 

139 

140 self._albums = None 

141 

142 url = f"{self.service._service_endpoint}/records/query?{urlencode(self.service.params)}" 

143 json_data = json.dumps( 

144 { 

145 "query": {"recordType": "CheckIndexingState"}, 

146 "zoneID": self.zone_id, 

147 }, 

148 ) 

149 

150 request = self.service.session.post( 

151 url, 

152 data=json_data, 

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

154 ) 

155 response = request.json() 

156 indexing_state = response["records"][0]["fields"]["state"]["value"] 

157 if indexing_state != "FINISHED": 

158 raise ICloudPyServiceNotActivatedException( 

159 ("iCloud Photo Library not finished indexing. Please try " "again in a few minutes"), 

160 None, 

161 ) 

162 

163 @property 

164 def albums(self): 

165 if not self._albums: 

166 self._albums = { 

167 name: PhotoAlbum(self.service, name, zone_id=self.zone_id, **props) 

168 for (name, props) in self.SMART_FOLDERS.items() 

169 } 

170 

171 for folder in self._fetch_folders(): 

172 if folder["recordName"] in ( 

173 "----Root-Folder----", 

174 "----Project-Root-Folder----", 

175 ) or (folder["fields"].get("isDeleted") and folder["fields"]["isDeleted"]["value"]): 

176 continue 

177 

178 folder_id = folder["recordName"] 

179 folder_obj_type = f"CPLContainerRelationNotDeletedByAssetDate:{folder_id}" 

180 folder_name = base64.b64decode( 

181 folder["fields"]["albumNameEnc"]["value"], 

182 ).decode("utf-8") 

183 query_filter = [ 

184 { 

185 "fieldName": "parentId", 

186 "comparator": "EQUALS", 

187 "fieldValue": {"type": "STRING", "value": folder_id}, 

188 }, 

189 ] 

190 

191 album = PhotoAlbum( 

192 self.service, 

193 folder_name, 

194 "CPLContainerRelationLiveByAssetDate", 

195 folder_obj_type, 

196 "ASCENDING", 

197 query_filter, 

198 folder_id=folder_id, 

199 zone_id=self.zone_id, 

200 ) 

201 self._albums[folder_name] = album 

202 

203 return self._albums 

204 

205 def _fetch_folders(self): 

206 url = f"{self.service._service_endpoint}/records/query?{urlencode(self.service.params)}" 

207 json_data = json.dumps( 

208 { 

209 "query": {"recordType": "CPLAlbumByPositionLive"}, 

210 "zoneID": self.zone_id, 

211 }, 

212 ) 

213 

214 request = self.service.session.post( 

215 url, 

216 data=json_data, 

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

218 ) 

219 response = request.json() 

220 

221 return response["records"] 

222 

223 @property 

224 def all(self): 

225 return self.albums["All Photos"] 

226 

227 

228class PhotosService(PhotoLibrary): 

229 """The 'Photos' iCloud service. 

230 

231 This also acts as a way to access the user's primary library. 

232 """ 

233 

234 def __init__(self, service_root, session, params): 

235 self.session = session 

236 self.params = dict(params) 

237 self._service_root = service_root 

238 self._service_endpoint = f"{self._service_root}/database/1/com.apple.photos.cloud/production/private" 

239 

240 self._libraries = None 

241 

242 self.params.update({"remapEnums": True, "getCurrentSyncToken": True}) 

243 

244 self._photo_assets = {} 

245 

246 super().__init__(service=self, zone_id={"zoneName": "PrimarySync"}) 

247 

248 @property 

249 def libraries(self): 

250 if not self._libraries: 

251 try: 

252 url = f"{self._service_endpoint}/zones/list" 

253 request = self.session.post( 

254 url, 

255 data="{}", 

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

257 ) 

258 response = request.json() 

259 zones = response["zones"] 

260 except Exception as e: 

261 LOGGER.error(f"library exception: {str(e)}") 

262 

263 libraries = {} 

264 for zone in zones: 

265 if not zone.get("deleted"): 

266 zone_name = zone["zoneID"]["zoneName"] 

267 libraries[zone_name] = PhotoLibrary(self, zone_id=zone["zoneID"]) 

268 # obj_type='CPLAssetByAssetDateWithoutHiddenOrDeleted', 

269 # list_type="CPLAssetAndMasterByAssetDateWithoutHiddenOrDeleted", 

270 # direction="ASCENDING", query_filter=None, 

271 # zone_id=zone['zoneID']) 

272 

273 self._libraries = libraries 

274 

275 return self._libraries 

276 

277 

278class PhotoAlbum: 

279 """A photo album.""" 

280 

281 def __init__( 

282 self, 

283 service, 

284 name, 

285 list_type, 

286 obj_type, 

287 direction, 

288 query_filter=None, 

289 page_size=100, 

290 folder_id=None, 

291 zone_id=None, 

292 ): 

293 self.name = name 

294 self.service = service 

295 self.list_type = list_type 

296 self.obj_type = obj_type 

297 self.direction = direction 

298 self.query_filter = query_filter 

299 self.page_size = page_size 

300 self.folder_id = folder_id 

301 

302 if zone_id: 

303 self._zone_id = zone_id 

304 else: 

305 self._zone_id = "PrimarySync" 

306 

307 self._len = None 

308 

309 self._subalbums = {} 

310 

311 @property 

312 def title(self): 

313 """Gets the album name.""" 

314 return self.name 

315 

316 def __iter__(self): 

317 return self.photos 

318 

319 def iter_chunks(self, chunk_size=1000): 

320 """Yield lists of PhotoAsset objects in fixed-size batches. 

321 

322 Equivalent to building a list from ``__iter__`` and slicing, 

323 but the per-chunk list is yielded eagerly so callers can 

324 process + release each chunk before the next is materialised. 

325 Lets bulk-download callers bound peak memory by the chunk size 

326 rather than ``len(self)`` -- relevant for 100K+ libraries where 

327 ``list(album)`` would otherwise hold every PhotoAsset (and any 

328 per-asset state callers attach) in memory at once. 

329 

330 The underlying ``photos`` property already paginates HTTP 

331 responses (``page_size * 2`` records per request); this method 

332 is a pure-Python wrapper that batches the yielded 

333 ``PhotoAsset`` instances without changing the HTTP fetch 

334 pattern. 

335 

336 Args: 

337 chunk_size: Max number of PhotoAsset objects per yielded 

338 list. Values <= 0 are coerced to the default (1000). 

339 

340 Yields: 

341 List of up to ``chunk_size`` ``PhotoAsset`` instances. The 

342 final chunk may be smaller. No empty list is yielded for 

343 an empty album. 

344 """ 

345 if chunk_size <= 0: 

346 chunk_size = 1000 

347 buffer = [] 

348 for photo in self: 

349 buffer.append(photo) 

350 if len(buffer) >= chunk_size: 

351 yield buffer 

352 buffer = [] 

353 if buffer: 

354 yield buffer 

355 

356 def __len__(self): 

357 if self._len is None: 

358 url = f"{self.service._service_endpoint}/internal/records/query/batch?{urlencode(self.service.params)}" 

359 request = self.service.session.post( 

360 url, 

361 data=json.dumps( 

362 { 

363 "batch": [ 

364 { 

365 "resultsLimit": 1, 

366 "query": { 

367 "filterBy": { 

368 "fieldName": "indexCountID", 

369 "fieldValue": { 

370 "type": "STRING_LIST", 

371 "value": [self.obj_type], 

372 }, 

373 "comparator": "IN", 

374 }, 

375 "recordType": "HyperionIndexCountLookup", 

376 }, 

377 "zoneWide": True, 

378 "zoneID": {"zoneName": self._zone_id["zoneName"]}, 

379 }, 

380 ], 

381 }, 

382 ), 

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

384 ) 

385 response = request.json() 

386 

387 self._len = response["batch"][0]["records"][0]["fields"]["itemCount"]["value"] 

388 

389 return self._len 

390 

391 def _fetch_subalbums(self): 

392 url = (f"{self.service._service_endpoint}/records/query?") + urlencode( 

393 self.service.params, 

394 ) 

395 # pylint: disable=consider-using-f-string 

396 query = """{{ 

397 "query": {{ 

398 "recordType":"CPLAlbumByPositionLive", 

399 "filterBy": [ 

400 {{ 

401 "fieldName": "parentId", 

402 "comparator": "EQUALS", 

403 "fieldValue": {{ 

404 "value": "{}", 

405 "type": "STRING" 

406 }} 

407 }} 

408 ] 

409 }}, 

410 "zoneID": {{ 

411 "zoneName":"{}" 

412 }} 

413 }}""".format( 

414 self.folder_id, 

415 self._zone_id["zoneName"], 

416 ) 

417 json_data = query 

418 request = self.service.session.post( 

419 url, 

420 data=json_data, 

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

422 ) 

423 response = request.json() 

424 

425 return response["records"] 

426 

427 @property 

428 def subalbums(self): 

429 """Returns the subalbums""" 

430 if not self._subalbums and self.folder_id: 

431 for folder in self._fetch_subalbums(): 

432 if folder["fields"].get("isDeleted") and folder["fields"]["isDeleted"]["value"]: 

433 continue 

434 

435 folder_id = folder["recordName"] 

436 folder_obj_type = f"CPLContainerRelationNotDeletedByAssetDate:{folder_id}" 

437 folder_name = base64.b64decode( 

438 folder["fields"]["albumNameEnc"]["value"], 

439 ).decode("utf-8") 

440 query_filter = [ 

441 { 

442 "fieldName": "parentId", 

443 "comparator": "EQUALS", 

444 "fieldValue": {"type": "STRING", "value": folder_id}, 

445 }, 

446 ] 

447 

448 album = PhotoAlbum( 

449 self.service, 

450 name=folder_name, 

451 list_type="CPLContainerRelationLiveByAssetDate", 

452 obj_type=folder_obj_type, 

453 direction="ASCENDING", 

454 query_filter=query_filter, 

455 folder_id=folder_id, 

456 zone_id=self._zone_id, 

457 ) 

458 self._subalbums[folder_name] = album 

459 return self._subalbums 

460 

461 @property 

462 def photos(self): 

463 """Returns the album photos.""" 

464 if self.direction == "DESCENDING": 

465 offset = len(self) - 1 

466 else: 

467 offset = 0 

468 

469 while True: 

470 url = (f"{self.service._service_endpoint}/records/query?") + urlencode( 

471 self.service.params, 

472 ) 

473 request = self.service.session.post( 

474 url, 

475 data=json.dumps( 

476 self._list_query_gen( 

477 offset, 

478 self.list_type, 

479 self.direction, 

480 self.query_filter, 

481 ), 

482 ), 

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

484 ) 

485 response = request.json() 

486 

487 asset_records = {} 

488 master_records = [] 

489 for rec in response["records"]: 

490 if rec["recordType"] == "CPLAsset": 

491 master_id = rec["fields"]["masterRef"]["value"]["recordName"] 

492 asset_records[master_id] = rec 

493 elif rec["recordType"] == "CPLMaster": 

494 master_records.append(rec) 

495 

496 master_records_len = len(master_records) 

497 if master_records_len: 

498 if self.direction == "DESCENDING": 

499 offset = offset - master_records_len 

500 else: 

501 offset = offset + master_records_len 

502 

503 for master_record in master_records: 

504 record_name = master_record["recordName"] 

505 yield PhotoAsset( 

506 self.service, 

507 master_record, 

508 asset_records[record_name], 

509 ) 

510 else: 

511 break 

512 

513 def _list_query_gen(self, offset, list_type, direction, query_filter=None): 

514 query = { 

515 "query": { 

516 "filterBy": [ 

517 { 

518 "fieldName": "startRank", 

519 "fieldValue": {"type": "INT64", "value": offset}, 

520 "comparator": "EQUALS", 

521 }, 

522 { 

523 "fieldName": "direction", 

524 "fieldValue": {"type": "STRING", "value": direction}, 

525 "comparator": "EQUALS", 

526 }, 

527 ], 

528 "recordType": list_type, 

529 }, 

530 "resultsLimit": self.page_size * 2, 

531 "desiredKeys": [ 

532 "resJPEGFullWidth", 

533 "resJPEGFullHeight", 

534 "resJPEGFullFileType", 

535 "resJPEGFullFingerprint", 

536 "resJPEGFullRes", 

537 "resJPEGLargeWidth", 

538 "resJPEGLargeHeight", 

539 "resJPEGLargeFileType", 

540 "resJPEGLargeFingerprint", 

541 "resJPEGLargeRes", 

542 "resJPEGMedWidth", 

543 "resJPEGMedHeight", 

544 "resJPEGMedFileType", 

545 "resJPEGMedFingerprint", 

546 "resJPEGMedRes", 

547 "resJPEGThumbWidth", 

548 "resJPEGThumbHeight", 

549 "resJPEGThumbFileType", 

550 "resJPEGThumbFingerprint", 

551 "resJPEGThumbRes", 

552 "resVidFullWidth", 

553 "resVidFullHeight", 

554 "resVidFullFileType", 

555 "resVidFullFingerprint", 

556 "resVidFullRes", 

557 "resVidMedWidth", 

558 "resVidMedHeight", 

559 "resVidMedFileType", 

560 "resVidMedFingerprint", 

561 "resVidMedRes", 

562 "resVidSmallWidth", 

563 "resVidSmallHeight", 

564 "resVidSmallFileType", 

565 "resVidSmallFingerprint", 

566 "resVidSmallRes", 

567 "resSidecarWidth", 

568 "resSidecarHeight", 

569 "resSidecarFileType", 

570 "resSidecarFingerprint", 

571 "resSidecarRes", 

572 "itemType", 

573 "dataClassType", 

574 "filenameEnc", 

575 "originalOrientation", 

576 "resOriginalWidth", 

577 "resOriginalHeight", 

578 "resOriginalFileType", 

579 "resOriginalFingerprint", 

580 "resOriginalRes", 

581 "resOriginalAltWidth", 

582 "resOriginalAltHeight", 

583 "resOriginalAltFileType", 

584 "resOriginalAltFingerprint", 

585 "resOriginalAltRes", 

586 "resOriginalVidComplWidth", 

587 "resOriginalVidComplHeight", 

588 "resOriginalVidComplFileType", 

589 "resOriginalVidComplFingerprint", 

590 "resOriginalVidComplRes", 

591 "isDeleted", 

592 "isExpunged", 

593 "dateExpunged", 

594 "remappedRef", 

595 "recordName", 

596 "recordType", 

597 "recordChangeTag", 

598 "masterRef", 

599 "adjustmentRenderType", 

600 "assetDate", 

601 "addedDate", 

602 "isFavorite", 

603 "isHidden", 

604 "orientation", 

605 "duration", 

606 "assetSubtype", 

607 "assetSubtypeV2", 

608 "assetHDRType", 

609 "burstFlags", 

610 "burstFlagsExt", 

611 "burstId", 

612 "captionEnc", 

613 "extendedDescEnc", 

614 "locationEnc", 

615 "locationV2Enc", 

616 "locationLatitude", 

617 "locationLongitude", 

618 "adjustmentType", 

619 "timeZoneOffset", 

620 "vidComplDurValue", 

621 "vidComplDurScale", 

622 "vidComplDispValue", 

623 "vidComplDispScale", 

624 "vidComplVisibilityState", 

625 "customRenderedValue", 

626 "containerId", 

627 "itemId", 

628 "position", 

629 "isKeyAsset", 

630 "importedByBundleIdentifierEnc", 

631 "importedByDisplayNameEnc", 

632 "importedBy", 

633 ], 

634 "zoneID": self._zone_id, 

635 } 

636 

637 if query_filter: 

638 query["query"]["filterBy"].extend(query_filter) 

639 

640 return query 

641 

642 def __unicode__(self): 

643 return self.title 

644 

645 def __str__(self): 

646 return self.__unicode__() 

647 

648 def __repr__(self): 

649 return f"<{type(self).__name__}: '{self}'>" 

650 

651 

652class PhotoAsset: 

653 """A photo.""" 

654 

655 def __init__(self, service, master_record, asset_record): 

656 self._service = service 

657 self._master_record = master_record 

658 self._asset_record = asset_record 

659 

660 self._versions = None 

661 

662 # Apple Uniform Type Identifiers (UTIs) for known iCloud photo/video assets. 

663 # Mirrors the table in icloud_photos_downloader's pyicloud_ipd.services.photos. 

664 # Anything not listed here falls back to the filename-extension heuristic in 

665 # `item_type` below. 

666 ITEM_TYPES = { 

667 "public.heic": "image", 

668 "public.heif": "image", 

669 "public.jpeg": "image", 

670 "public.png": "image", 

671 "public.tiff": "image", 

672 "com.adobe.raw-image": "image", 

673 "com.canon.cr2-raw-image": "image", 

674 "com.canon.cr3-raw-image": "image", 

675 "com.canon.crw-raw-image": "image", 

676 "com.fuji.raw-image": "image", 

677 "com.nikon.nrw-raw-image": "image", 

678 "com.nikon.raw-image": "image", 

679 "com.olympus.or-raw-image": "image", 

680 "com.olympus.raw-image": "image", 

681 "com.panasonic.rw2-raw-image": "image", 

682 "com.pentax.raw-image": "image", 

683 "com.sony.arw-raw-image": "image", 

684 "com.apple.quicktime-movie": "movie", 

685 "public.mpeg-4": "movie", 

686 } 

687 

688 PHOTO_VERSION_LOOKUP = { 

689 "full": "resJPEGFull", 

690 "large": "resJPEGLarge", 

691 "medium": "resJPEGMed", 

692 "thumb": "resJPEGThumb", 

693 "sidecar": "resSidecar", 

694 "original": "resOriginal", 

695 "original_alt": "resOriginalAlt", 

696 # Live Photo video components — present alongside the still image for 

697 # Live Photos. The CloudKit master_record carries `resOriginalVidCompl*` 

698 # (the original .mov), and may also expose `resVidMed*` / `resVidSmall*` 

699 # for smaller variants. If the asset is a regular still photo, these 

700 # keys are silently skipped by `versions` since the underlying fields 

701 # are absent. 

702 "live_video_original": "resOriginalVidCompl", 

703 "live_video_medium": "resVidMed", 

704 "live_video_thumb": "resVidSmall", 

705 } 

706 

707 VIDEO_VERSION_LOOKUP = { 

708 "full": "resVidFull", 

709 "medium": "resVidMed", 

710 "thumb": "resVidSmall", 

711 "original": "resOriginal", 

712 "original_compl": "resOriginalVidCompl", 

713 } 

714 

715 @property 

716 def id(self): 

717 """Gets the photo id.""" 

718 return self._master_record["recordName"] 

719 

720 @property 

721 def filename(self): 

722 """Gets the photo file name.""" 

723 try: 

724 return base64.b64decode( 

725 self._master_record["fields"]["filenameEnc"]["value"], 

726 ).decode("utf-8") 

727 except KeyError: 

728 # Some photos/videos (e.g., GoPro videos) may not have a filename 

729 return None 

730 

731 @property 

732 def size(self): 

733 """Gets the photo size.""" 

734 return self._master_record["fields"]["resOriginalRes"]["value"]["size"] 

735 

736 @property 

737 def created(self): 

738 """Gets the photo created date.""" 

739 return self.asset_date 

740 

741 @property 

742 def asset_date(self): 

743 """Gets the photo asset date.""" 

744 try: 

745 return datetime.fromtimestamp( 

746 self._asset_record["fields"]["assetDate"]["value"] / 1000.0, 

747 tz=UTC, 

748 ) 

749 except KeyError: 

750 return datetime.fromtimestamp(0) 

751 

752 @property 

753 def added_date(self): 

754 """Gets the photo added date.""" 

755 return datetime.fromtimestamp( 

756 self._asset_record["fields"]["addedDate"]["value"] / 1000.0, 

757 tz=UTC, 

758 ) 

759 

760 @property 

761 def dimensions(self): 

762 """Gets the photo dimensions.""" 

763 return ( 

764 self._master_record["fields"]["resOriginalWidth"]["value"], 

765 self._master_record["fields"]["resOriginalHeight"]["value"], 

766 ) 

767 

768 @property 

769 def item_type(self): 

770 """Returns 'image' or 'movie' for this asset. 

771 

772 Reads the CloudKit ``itemType`` field (Apple UTI string) and maps it 

773 via ``ITEM_TYPES``. Falls back to a filename-extension heuristic when 

774 the UTI is missing or unrecognised — that path catches GoPro footage 

775 and a handful of camera-RAW formats that Apple has not assigned a 

776 canonical UTI to. 

777 

778 Returns ``None`` only when both the UTI is missing AND no filename is 

779 available (rare — usually a CloudKit record with no master payload). 

780 """ 

781 fields = self._master_record["fields"] 

782 uti = fields.get("itemType", {}).get("value") 

783 if uti in self.ITEM_TYPES: 

784 return self.ITEM_TYPES[uti] 

785 

786 # Extension-based fallback. Mirrors pyicloud_ipd's heuristic. 

787 filename = self.filename 

788 if filename: 

789 lower = filename.lower() 

790 if lower.endswith((".heic", ".heif", ".png", ".jpg", ".jpeg", ".tif", ".tiff")): 

791 return "image" 

792 if lower.endswith((".mov", ".mp4", ".m4v", ".avi", ".3gp")): 

793 return "movie" 

794 return None 

795 

796 @property 

797 def versions(self): 

798 """Gets the photo versions. 

799 

800 For ``item_type == "movie"`` returns the video versions 

801 (``VIDEO_VERSION_LOOKUP``). For everything else returns the image 

802 versions (``PHOTO_VERSION_LOOKUP``) — which includes Live Photo video 

803 keys (``live_video_original`` / ``live_video_medium`` / 

804 ``live_video_thumb``) when the underlying CloudKit fields are present. 

805 

806 Backward compatibility: callers that previously received only photo 

807 versions for still images and only video versions for movies still see 

808 those keys. Live Photo callers gain new ``live_video_*`` keys that 

809 were previously inaccessible — earlier icloudpy versions misclassified 

810 Live Photos as movies (via the ``resVidSmallRes`` presence heuristic) 

811 and dropped the still image entirely. 

812 """ 

813 if not self._versions: 

814 self._versions = {} 

815 if self.item_type == "movie": 

816 typed_version_lookup = self.VIDEO_VERSION_LOOKUP 

817 else: 

818 typed_version_lookup = self.PHOTO_VERSION_LOOKUP 

819 

820 for key, prefix in typed_version_lookup.items(): 

821 if f"{prefix}Res" in self._master_record["fields"]: 

822 fields = self._master_record["fields"] 

823 version = {"filename": self.filename} 

824 

825 width_entry = fields.get(f"{prefix}Width") 

826 if width_entry: 

827 version["width"] = width_entry["value"] 

828 else: 

829 version["width"] = None 

830 

831 height_entry = fields.get(f"{prefix}Height") 

832 if height_entry: 

833 version["height"] = height_entry["value"] 

834 else: 

835 version["height"] = None 

836 

837 size_entry = fields.get(f"{prefix}Res") 

838 if size_entry: 

839 version["size"] = size_entry["value"]["size"] 

840 version["url"] = size_entry["value"]["downloadURL"] 

841 else: 

842 version["size"] = None 

843 version["url"] = None 

844 

845 type_entry = fields.get(f"{prefix}FileType") 

846 if type_entry: 

847 version["type"] = type_entry["value"] 

848 else: 

849 version["type"] = None 

850 

851 self._versions[key] = version 

852 

853 return self._versions 

854 

855 def download(self, version="original", **kwargs): 

856 """Returns the photo file.""" 

857 if version not in self.versions: 

858 return None 

859 

860 return self._service.session.get( 

861 self.versions[version]["url"], 

862 stream=True, 

863 **kwargs, 

864 ) 

865 

866 def delete(self): 

867 """Deletes the photo.""" 

868 json_data = json.dumps( 

869 { 

870 "atomic": True, 

871 "desiredKeys": ["isDeleted"], 

872 "operations": [ 

873 { 

874 "operationType": "update", 

875 "record": { 

876 "fields": {"isDeleted": {"value": 1}}, 

877 "recordChangeTag": self._asset_record["recordChangeTag"], 

878 "recordName": self._asset_record["recordName"], 

879 "recordType": self._asset_record["recordType"], 

880 }, 

881 }, 

882 ], 

883 "zoneID": self._service.zone_id, 

884 }, 

885 ) 

886 

887 endpoint = self._service._service_endpoint 

888 params = urlencode(self._service.params) 

889 url = f"{endpoint}/records/modify?{params}" 

890 

891 return self._service.session.post( 

892 url, 

893 data=json_data, 

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

895 ) 

896 

897 def __repr__(self): 

898 return f"<{type(self).__name__}: id={self.id}>"