From 2379ff20f69b8542f67d2578f61a2eb0e31ddb15 Mon Sep 17 00:00:00 2001 From: Jakub Tymejczyk Date: Thu, 5 Oct 2023 21:13:02 +0200 Subject: [PATCH] Add support for shared library Based on https://github.com/icloud-photos-downloader/icloud_photos_downloader/pull/678 --- icloudpy/services/photos.py | 215 ++++++++++++++++++++++-------------- 1 file changed, 133 insertions(+), 82 deletions(-) diff --git a/icloudpy/services/photos.py b/icloudpy/services/photos.py index 5e9c14b..30aa66f 100644 --- a/icloudpy/services/photos.py +++ b/icloudpy/services/photos.py @@ -14,9 +14,11 @@ # fmt: on -class PhotosService: - """The 'Photos' iCloud service.""" +class PhotoLibrary(object): + """Represents a library in the user's photos. + This provides access to all the albums as well as the photos. + """ SMART_FOLDERS = { "All Photos": { "obj_type": "CPLAssetByAddedDate", @@ -128,110 +130,152 @@ class PhotosService: }, } - def __init__(self, service_root, session, params): - self.session = session - self.params = dict(params) - self._service_root = service_root - self.service_endpoint = ( - f"{self._service_root}/database/1/com.apple.photos.cloud/production/private" - ) + def __init__(self, service, zone_id): + self.service = service + self.zone_id = zone_id self._albums = None - self.params.update({"remapEnums": True, "getCurrentSyncToken": True}) + url = ('%s/records/query?%s' % + (self.service._service_endpoint, urlencode(self.service.params))) + json_data = json.dumps({ + "query": {"recordType":"CheckIndexingState"}, + "zoneID": self.zone_id, + }) - url = f"{self.service_endpoint}/records/query?{urlencode(self.params)}" - json_data = ( - '{"query":{"recordType":"CheckIndexingState"},' - '"zoneID":{"zoneName":"PrimarySync"}}' - ) - request = self.session.post( - url, data=json_data, headers={"Content-type": "text/plain"} + request = self.service.session.post( + url, + data=json_data, + headers={'Content-type': 'text/plain'} ) response = request.json() - indexing_state = response["records"][0]["fields"]["state"]["value"] - if indexing_state != "FINISHED": - raise ICloudPyServiceNotActivatedException( - "iCloud Photo Library not finished indexing. " - "Please try again in a few minutes." - ) - - # TODO: Does syncToken ever change? # pylint: disable=fixme - # self.params.update({ - # 'syncToken': response['syncToken'], - # 'clientInstanceId': self.params.pop('clientId') - # }) - - self._photo_assets = {} + indexing_state = response['records'][0]['fields']['state']['value'] + if indexing_state != 'FINISHED': + raise PyiCloudServiceNotActivatedErrror( + ('iCloud Photo Library not finished indexing. Please try ' + 'again in a few minutes'), None) @property def albums(self): - """Returns photo albums.""" if not self._albums: - self._albums = {} + self._albums = { + name: PhotoAlbum(self.service, name, zone_id=self.zone_id, **props) + for (name, props) in self.SMART_FOLDERS.items() + } for folder in self._fetch_folders(): - - # Skipping albums having null name, that can happen sometime - if "albumNameEnc" not in folder["fields"]: + # FIXME: Handle subfolders + if folder['recordName'] in ('----Root-Folder----', + '----Project-Root-Folder----') or \ + (folder['fields'].get('isDeleted') and + folder['fields']['isDeleted']['value']): continue - if folder["recordName"] == "----Root-Folder----" or ( - folder["fields"].get("isDeleted") - and folder["fields"]["isDeleted"]["value"] - ): - continue - - folder_id = folder["recordName"] - folder_obj_type = ( - f"CPLContainerRelationNotDeletedByAssetDate:{folder_id}" - ) + folder_id = folder['recordName'] + folder_obj_type = \ + "CPLContainerRelationNotDeletedByAssetDate:%s" % folder_id folder_name = base64.b64decode( - folder["fields"]["albumNameEnc"]["value"] - ).decode("utf-8") - - query_filter = [ - { - "fieldName": "parentId", - "comparator": "EQUALS", - "fieldValue": {"type": "STRING", "value": folder_id}, + folder['fields']['albumNameEnc']['value']).decode('utf-8') + query_filter = [{ + "fieldName": "parentId", + "comparator": "EQUALS", + "fieldValue": { + "type": "STRING", + "value": folder_id } - ] + }] - album = PhotoAlbum( - self, - name=folder_name, - list_type="CPLContainerRelationLiveByAssetDate", - obj_type=folder_obj_type, - direction="ASCENDING", - query_filter=query_filter, - folder_id=folder_id, - ) + album = PhotoAlbum(self.service, folder_name, + 'CPLContainerRelationLiveByAssetDate', + folder_obj_type, 'ASCENDING', query_filter, + zone_id=self.zone_id) self._albums[folder_name] = album - for (name, props) in self.SMART_FOLDERS.items(): - self._albums[name] = PhotoAlbum(self, name, **props) - return self._albums def _fetch_folders(self): - url = f"{self.service_endpoint}/records/query?{urlencode(self.params)}" - json_data = ( - '{"query":{"recordType":"CPLAlbumByPositionLive"},' - '"zoneID":{"zoneName":"PrimarySync"}}' - ) + url = ('%s/records/query?%s' % + (self.service._service_endpoint, urlencode(self.service.params))) + json_data = json.dumps({ + "query": {"recordType":"CPLAlbumByPositionLive"}, + "zoneID": self.zone_id, + }) - request = self.session.post( - url, data=json_data, headers={"Content-type": "text/plain"} + request = self.service.session.post( + url, + data=json_data, + headers={'Content-type': 'text/plain'} ) response = request.json() - return response["records"] + return response['records'] @property def all(self): - """Returns all photos.""" - return self.albums["All Photos"] + return self.albums['All Photos'] + + +class PhotosService(PhotoLibrary): + """The 'Photos' iCloud service. + + This also acts as a way to access the user's primary library. + """ + def __init__(self, service_root, session, params): + self.session = session + self.params = dict(params) + self._service_root = service_root + self._service_endpoint = \ + ('%s/database/1/com.apple.photos.cloud/production/private' + % self._service_root) + + self._libraries = None + + self.params.update({ + 'remapEnums': True, + 'getCurrentSyncToken': True + }) + + # TODO: Does syncToken ever change? + # self.params.update({ + # 'syncToken': response['syncToken'], + # 'clientInstanceId': self.params.pop('clientId') + # }) + + self._photo_assets = {} + + super(PhotosService, self).__init__( + service=self, zone_id={u'zoneName': u'PrimarySync'}) + + @property + def libraries(self): + if not self._libraries: + try: + url = ('%s/changes/database' % + (self._service_endpoint, )) + request = self.session.post( + url, + data='{}', + headers={'Content-type': 'text/plain'} + ) + response = request.json() + zones = response['zones'] + except Exception as e: + logger.error("library exception: %s" % str(e)) + + libraries = {} + for zone in zones: + if not zone.get('deleted'): + zone_name = zone['zoneID']['zoneName'] + libraries[zone_name] = PhotoLibrary( + self, zone_id=zone['zoneID']) + # obj_type='CPLAssetByAssetDateWithoutHiddenOrDeleted', + # list_type="CPLAssetAndMasterByAssetDateWithoutHiddenOrDeleted", + # direction="ASCENDING", query_filter=None, + # zone_id=zone['zoneID']) + + self._libraries = libraries + + return self._libraries class PhotoAlbum: @@ -247,6 +291,7 @@ def __init__( query_filter=None, page_size=100, folder_id=None, + zone_id=None, ): self.name = name self.service = service @@ -257,6 +302,11 @@ def __init__( self.page_size = page_size self.folder_id = folder_id + if zone_id: + self._zone_id = zone_id + else: + self._zone_id = 'PrimarySync' + self._len = None self._subalbums = {} @@ -291,7 +341,7 @@ def __len__(self): "recordType": "HyperionIndexCountLookup", }, "zoneWide": True, - "zoneID": {"zoneName": "PrimarySync"}, + "zoneID": {"zoneName": self._zone_id}, } ] } @@ -326,10 +376,11 @@ def _fetch_subalbums(self): ] }}, "zoneID": {{ - "zoneName":"PrimarySync" + "zoneName":"{}" }} }}""".format( - self.folder_id + self.folder_id, + self._zone_id ) json_data = query request = self.service.session.post( @@ -388,7 +439,7 @@ def photos(self): offset = 0 while True: - url = (f"{self.service.service_endpoint}/records/query?") + urlencode( + url = (f"{self.service._service_endpoint}/records/query?") + urlencode( self.service.params ) request = self.service.session.post( @@ -543,7 +594,7 @@ def _list_query_gen(self, offset, list_type, direction, query_filter=None): "position", "isKeyAsset", ], - "zoneID": {"zoneName": "PrimarySync"}, + "zoneID": self._zone_id, } if query_filter: