diff --git a/README.rst b/README.rst index 13c82a1..d64d608 100644 --- a/README.rst +++ b/README.rst @@ -228,3 +228,45 @@ Or, if you're downloading a particularly large file, you may want to use the ``s >>> download = api.files['com~apple~Notes']['Documents']['big_file.zip'].open(stream=True) >>> with open('downloaded_file.zip', 'wb') as opened_file: opened_file.write(download.raw.read()) + +======================= +Photo Library +======================= + +You can access the iCloud Photo Library through the ``photos`` property. + +>>> api.photos.all + + +Individual albums are available through the ``albums`` property: + +>>> api.photos.albums['Selfies'] + + +Which you can index or iterate to access the photo assets: + +>>> for photo in api.photos.albums['Selfies']: + print photo, photo.filename + IMG_6045.JPG + +Metadata about photos is fetched on demand as you access properties of the ``PhotoAsset`` object, and are also prefetched to improve performance. + +To download a photo use the `download` method, which will return a `response object `_, initialized with ``stream`` set to ``True``, so you can read from the raw response object: + +>>> photo = api.photos.albums['Selfies'][0] +>>> download = photo.download() +>>> with open(photo.filename, 'wb') as opened_file: + opened_file.write(download.raw.read()) + +Note: Consider using ``shutil.copyfile`` or another buffered strategy for downloading the file so that the whole file isn't read into memory before writing. + +Information about each version can be accessed through the ``versions`` property: + +>>> photo.versions.keys() +[u'large', u'medium', u'original', u'thumb'] + +To download a specific version of the photo asset, pass the version to ``download()``: + +>>> download = photo.download('thumb') +>>> with open(photo.versions['thumb'].filename, 'wb') as thumb_file: + thumb_file.write(download.raw.read()) diff --git a/pyicloud/base.py b/pyicloud/base.py index 8bf1e5c..c0b493b 100644 --- a/pyicloud/base.py +++ b/pyicloud/base.py @@ -20,7 +20,8 @@ from pyicloud.services import ( CalendarService, UbiquityService, ContactsService, - RemindersService + RemindersService, + PhotosService ) from pyicloud.utils import get_password_from_keyring @@ -267,6 +268,17 @@ class PyiCloudService(object): ) return self._files + @property + def photos(self): + if not hasattr(self, '_photos'): + service_root = self.webservices['photos']['url'] + self._photos = PhotosService( + service_root, + self.session, + self.params + ) + return self._photos + @property def calendar(self): service_root = self.webservices['calendar']['url'] diff --git a/pyicloud/exceptions.py b/pyicloud/exceptions.py index 487def4..d33d5a3 100644 --- a/pyicloud/exceptions.py +++ b/pyicloud/exceptions.py @@ -34,3 +34,11 @@ class PyiCloudNoDevicesException(Exception): class NoStoredPasswordAvailable(PyiCloudException): pass + + +class PyiCloudBinaryFeedParseError(Exception): + pass + + +class PyiCloudPhotoLibraryNotActivatedErrror(Exception): + pass diff --git a/pyicloud/services/__init__.py b/pyicloud/services/__init__.py index fbcf208..9a72d59 100644 --- a/pyicloud/services/__init__.py +++ b/pyicloud/services/__init__.py @@ -3,3 +3,4 @@ from pyicloud.services.findmyiphone import FindMyiPhoneServiceManager from pyicloud.services.ubiquity import UbiquityService from pyicloud.services.contacts import ContactsService from pyicloud.services.reminders import RemindersService +from pyicloud.services.photos import PhotosService diff --git a/pyicloud/services/photos.py b/pyicloud/services/photos.py new file mode 100644 index 0000000..5127b74 --- /dev/null +++ b/pyicloud/services/photos.py @@ -0,0 +1,296 @@ +import sys +import json +import urllib + +from datetime import datetime +from base64 import b64decode +from bitstring import ConstBitStream +from pyicloud.exceptions import ( + PyiCloudAPIResponseError, + PyiCloudBinaryFeedParseError, + PyiCloudPhotoLibraryNotActivatedErrror +) + + +class PhotosService(object): + """ The 'Photos' iCloud service.""" + + def __init__(self, service_root, session, params): + self.session = session + self.params = dict(params) + + self.prepostfetch = 200 + + self._service_root = service_root + self._service_endpoint = '%s/ph' % self._service_root + + try: + request = self.session.get( + '%s/startup' % self._service_endpoint, + params=self.params + ) + response = request.json() + self.params.update({ + 'syncToken': response['syncToken'], + 'clientInstanceId': self.params.pop('clientId') + }) + except PyiCloudAPIResponseError, error: + if error.code == 402: + raise PyiCloudPhotoLibraryNotActivatedErrror( + "iCloud Photo Library has not been activated yet " + "for this user") + + self._photo_assets = {} + + @property + def albums(self): + request = self.session.get( + '%s/folders' % self._service_endpoint, + params=self.params + ) + response = request.json() + albums = {} + for folder in response['folders']: + if not folder['type'] == 'album': + continue + + album = PhotoAlbum(folder, self) + albums[album.title] = album + + return albums + + @property + def all(self): + return self.albums['All Photos'] + + def _fetch_asset_data_for(self, client_ids): + client_ids = [cid for cid in client_ids + if cid not in self._photo_assets] + + data = json.dumps({ + 'syncToken': self.params.get('syncToken'), + 'methodOverride': 'GET', + 'clientIds': client_ids, + }) + request = self.session.post( + '%s/assets' % self._service_endpoint, + params=self.params, + data=data + ) + + response = request.json() + + for asset in response['assets']: + self._photo_assets[asset['clientId']] = asset + + +class PhotoAlbum(object): + def __init__(self, data, service): + self.data = data + self.service = service + self._photo_assets = None + + @property + def title(self): + BUILTIN_ALBUMS = { + 'recently-added': "Recently Added", + 'time-lapse': "Time-lapse", + 'videos': "Videos", + 'slo-mo': 'Slo-mo', + 'all-photos': "All Photos", + 'selfies': "Selfies", + 'bursts': "Bursts", + 'favorites': "Favourites", + 'panoramas': "Panoramas", + 'deleted-photos': "Recently Deleted", + 'hidden': "Hidden", + 'screenshots': "Screenshots" + } + if self.data.get('isServerGenerated'): + return BUILTIN_ALBUMS[self.data.get('serverId')] + else: + return self.data.get('title') + + def __iter__(self): + return iter(self.photos) + + def __getitem__(self, index): + return self.photos[index] + + @property + def photos(self): + if not self._photo_assets: + child_assets = self.data.get('childAssetsBinaryFeed') + if not child_assets: + raise PyiCloudBinaryFeedParseError( + "Missing childAssetsBinaryFeed in photo album") + self._photo_assets = self._parse_binary_feed(child_assets) + + return self._photo_assets + + def _parse_binary_feed(self, feed): + binaryfeed = bytearray(b64decode(feed)) + bitstream = ConstBitStream(binaryfeed) + + payload_encoding = binaryfeed[0] + if payload_encoding != bitstream.read("uint:8"): + raise PyiCloudBinaryFeedParseError( + "Missmatch betweeen binaryfeed and bistream payload encoding") + + ASSET_PAYLOAD = 255 + ASSET_WITH_ORIENTATION_PAYLOAD = 254 + ASPECT_RATIOS = [ + 0.75, + 4.0 / 3.0 - 3.0 * (4.0 / 3.0 - 1.0) / 4.0, + 4.0 / 3.0 - 2.0 * (4.0 / 3.0 - 1.0) / 4.0, + 1.25, + 4.0 / 3.0, 1.5 - 2.0 * (1.5 - 4.0 / 3.0) / 3.0, + 1.5 - 1.0 * (1.5 - 4.0 / 3.0) / 3.0, + 1.5, + 1.5694444444444444, + 1.6388888888888888, + 1.7083333333333333, + 16.0 / 9.0, + 2.0 - 2.0 * (2.0 - 16.0 / 9.0) / 3.0, + 2.0 - 1.0 * (2.0 - 16.0 / 9.0) / 3.0, + 2, + 3 + ] + + valid_payloads = [ASSET_PAYLOAD, ASSET_WITH_ORIENTATION_PAYLOAD] + if payload_encoding not in valid_payloads: + raise PyiCloudBinaryFeedParseError( + "Unknown payload encoding '%s'" % payload_encoding) + + assets = {} + while len(bitstream) - bitstream.pos >= 48: + range_start = bitstream.read("uint:24") + range_length = bitstream.read("uint:24") + range_end = range_start + range_length + + previous_asset_id = 0 + for index in range(range_start, range_end): + aspect_ratio = ASPECT_RATIOS[bitstream.read("uint:4")] + + id_size = bitstream.read("uint:2") + if id_size: + # A size has been reserved for the asset id + asset_id = bitstream.read("uint:%s" % (2 + 8 * id_size)) + else: + # The id is just an increment to a previous id + asset_id = previous_asset_id + bitstream.read("uint:2") + 1 + + orientation = None + if payload_encoding == ASSET_WITH_ORIENTATION_PAYLOAD: + orientation = bitstream.read("uint:3") + + assets[index] = PhotoAsset(index, asset_id, aspect_ratio, + orientation, self) + previous_asset_id = asset_id + + asset_values = assets.values() + if len(asset_values) != len(assets): + raise PyiCloudBinaryFeedParseError( + "Sparse photo album index detected") + + return asset_values + + def _fetch_asset_data_for(self, asset): + if asset.client_id in self.service._photo_assets: + return self.service._photo_assets[asset.client_id] + + client_ids = [] + prefetch = postfetch = self.service.prepostfetch + for index in range( + max(asset.album_index - prefetch, 0), + min(asset.album_index + postfetch + 1, + len(self._photo_assets))): + client_ids.append(self._photo_assets[index].client_id) + + self.service._fetch_asset_data_for(client_ids) + return self.service._photo_assets[asset.client_id] + + def __unicode__(self): + return self.title + + def __str__(self): + as_unicode = self.__unicode__() + if sys.version_info[0] >= 3: + return as_unicode + else: + return as_unicode.encode('ascii', 'ignore') + + def __repr__(self): + return "<%s: '%s'>" % ( + type(self).__name__, + self + ) + + +class PhotoAsset(object): + def __init__(self, index, client_id, aspect_ratio, orientation, album): + self.album_index = index + self.client_id = client_id + self.aspect_ratio = aspect_ratio + self.orientation = orientation + self.album = album + self._data = None + + @property + def data(self): + if not self._data: + self._data = self.album._fetch_asset_data_for(self) + return self._data + + @property + def filename(self): + return self.data['details'].get('filename') + + @property + def size(self): + try: + return int(self.data['details'].get('filesize')) + except ValueError: + return None + + @property + def created(self): + dt = datetime.fromtimestamp(self.data.get('createdDate') / 1000.0) + return dt.strftime('%Y-%m-%dT%H:%M:%SZ') + + @property + def dimensions(self): + return self.data.get('dimensions') + + @property + def versions(self): + versions = {} + for version in self.data.get('derivativeInfo'): + (version, width, height, size, mimetype, + u1, u2, u3, url, filename) = version.split(':') + versions[version] = { + 'width': width, + 'height': height, + 'size': size, + 'mimetype': mimetype, + 'url': urllib.unquote(url), + 'filename': filename, + } + return versions + + def download(self, version='original', **kwargs): + print version, kwargs + if version not in self.versions: + return None + + return self.album.service.session.get( + self.versions[version]['url'], + stream=True, + **kwargs + ) + + def __repr__(self): + return "<%s: client_id=%s>" % ( + type(self).__name__, + self.client_id + ) diff --git a/requirements.txt b/requirements.txt index 6829ac6..3985151 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ click>=6.0,<7.0 six pytz certifi +bitstring