Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for shared library #33

Merged
merged 5 commits into from
Oct 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 116 additions & 57 deletions icloudpy/services/photos.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Photo service."""
import base64
import json
import logging
from datetime import datetime

# fmt: off
Expand All @@ -9,13 +10,17 @@
from pytz import UTC
from six import PY2

# fmt: on
from icloudpy.exceptions import ICloudPyServiceNotActivatedException

# fmt: on
LOGGER = logging.getLogger(__name__)


class PhotoLibrary:
"""Represents a library in the user's photos.

class PhotosService:
"""The 'Photos' iCloud service."""
This provides access to all the albums as well as the photos.
"""

SMART_FOLDERS = {
"All Photos": {
Expand Down Expand Up @@ -128,55 +133,48 @@ 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 = f"{self.service_endpoint}/records/query?{urlencode(self.params)}"
json_data = (
'{"query":{"recordType":"CheckIndexingState"},'
'"zoneID":{"zoneName":"PrimarySync"}}'
url = f"{self.service._service_endpoint}/records/query?{urlencode(self.service.params)}"
json_data = json.dumps(
{
"query": {"recordType": "CheckIndexingState"},
"zoneID": self.zone_id,
}
)
request = self.session.post(

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."
(
"iCloud Photo Library not finished indexing. Please try "
"again in a few minutes"
),
None,
)

# TODO: Does syncToken ever change? # pylint: disable=fixme
# self.params.update({
# 'syncToken': response['syncToken'],
# 'clientInstanceId': self.params.pop('clientId')
# })

self._photo_assets = {}

@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"]:
continue

if folder["recordName"] == "----Root-Folder----" or (
# FIXME: Handle subfolders
if folder["recordName"] in (
"----Root-Folder----",
"----Project-Root-Folder----",
) or (
folder["fields"].get("isDeleted")
and folder["fields"]["isDeleted"]["value"]
):
Expand All @@ -189,7 +187,6 @@ def albums(self):
folder_name = base64.b64decode(
folder["fields"]["albumNameEnc"]["value"]
).decode("utf-8")

query_filter = [
{
"fieldName": "parentId",
Expand All @@ -199,29 +196,29 @@ def albums(self):
]

album = PhotoAlbum(
self,
name=folder_name,
list_type="CPLContainerRelationLiveByAssetDate",
obj_type=folder_obj_type,
direction="ASCENDING",
query_filter=query_filter,
self.service,
folder_name,
"CPLContainerRelationLiveByAssetDate",
folder_obj_type,
"ASCENDING",
query_filter,
folder_id=folder_id,
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 = f"{self.service._service_endpoint}/records/query?{urlencode(self.service.params)}"
json_data = json.dumps(
{
"query": {"recordType": "CPLAlbumByPositionLive"},
"zoneID": self.zone_id,
}
)

request = self.session.post(
request = self.service.session.post(
url, data=json_data, headers={"Content-type": "text/plain"}
)
response = request.json()
Expand All @@ -230,10 +227,65 @@ def _fetch_folders(self):

@property
def all(self):
"""Returns 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 = (
f"{self._service_root}/database/1/com.apple.photos.cloud/production/private"
)

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().__init__(service=self, zone_id={"zoneName": "PrimarySync"})

@property
def libraries(self):
if not self._libraries:
try:
url = f"{self._service_endpoint}/zones/list"
request = self.session.post(
url, data="{}", headers={"Content-type": "text/plain"}
)
response = request.json()
zones = response["zones"]
except Exception as e:
LOGGER.error(f"library exception: {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:
"""A photo album."""

Expand All @@ -247,6 +299,7 @@ def __init__(
query_filter=None,
page_size=100,
folder_id=None,
zone_id=None,
):
self.name = name
self.service = service
Expand All @@ -257,6 +310,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 = {}
Expand Down Expand Up @@ -291,7 +349,7 @@ def __len__(self):
"recordType": "HyperionIndexCountLookup",
},
"zoneWide": True,
"zoneID": {"zoneName": "PrimarySync"},
"zoneID": {"zoneName": self._zone_id},
}
]
}
Expand All @@ -307,7 +365,7 @@ def __len__(self):
return self._len

def _fetch_subalbums(self):
url = (f"{self.service.service_endpoint}/records/query?") + urlencode(
url = (f"{self.service._service_endpoint}/records/query?") + urlencode(
self.service.params
)
# pylint: disable=consider-using-f-string
Expand All @@ -326,10 +384,10 @@ def _fetch_subalbums(self):
]
}},
"zoneID": {{
"zoneName":"PrimarySync"
"zoneName":"{}"
}}
}}""".format(
self.folder_id
self.folder_id, self._zone_id["zoneName"]
)
json_data = query
request = self.service.session.post(
Expand Down Expand Up @@ -375,6 +433,7 @@ def subalbums(self):
direction="ASCENDING",
query_filter=query_filter,
folder_id=folder_id,
zone_id=self._zone_id,
)
self._subalbums[folder_name] = album
return self._subalbums
Expand All @@ -388,7 +447,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(
Expand Down Expand Up @@ -543,7 +602,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:
Expand Down
3 changes: 3 additions & 0 deletions pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ disable=abstract-method,
wrong-import-order,
xrange-builtin,
zip-builtin-not-iterating,
protected-access,
logging-not-lazy,
logging-fstring-interpolation


[REPORTS]
Expand Down