Skip to content

Commit

Permalink
Add iCloud Photo Library service
Browse files Browse the repository at this point in the history
Fixes issue picklepete#46.
  • Loading branch information
torarnv committed Feb 25, 2016
1 parent ab889e7 commit 58cbc51
Show file tree
Hide file tree
Showing 6 changed files with 361 additions and 1 deletion.
42 changes: 42 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
<PhotoAlbum: 'All Photos'>

Individual albums are available through the ``albums`` property:

>>> api.photos.albums['Selfies']
<PhotoAlbum: 'Selfies'>

Which you can index or iterate to access the photo assets:

>>> for photo in api.photos.albums['Selfies']:
print photo, photo.filename
<PhotoAsset: client_id=4429> 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 <http://www.python-requests.org/en/latest/api/#classes>`_, 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())
14 changes: 13 additions & 1 deletion pyicloud/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
CalendarService,
UbiquityService,
ContactsService,
RemindersService
RemindersService,
PhotosService
)
from pyicloud.utils import get_password_from_keyring

Expand Down Expand Up @@ -267,6 +268,17 @@ def files(self):
)
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']
Expand Down
8 changes: 8 additions & 0 deletions pyicloud/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,11 @@ class PyiCloudNoDevicesException(Exception):

class NoStoredPasswordAvailable(PyiCloudException):
pass


class PyiCloudBinaryFeedParseError(Exception):
pass


class PyiCloudPhotoLibraryNotActivatedErrror(Exception):
pass
1 change: 1 addition & 0 deletions pyicloud/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
from pyicloud.services.ubiquity import UbiquityService
from pyicloud.services.contacts import ContactsService
from pyicloud.services.reminders import RemindersService
from pyicloud.services.photos import PhotosService
296 changes: 296 additions & 0 deletions pyicloud/services/photos.py
Original file line number Diff line number Diff line change
@@ -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
)
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ click>=6.0,<7.0
six
pytz
certifi
bitstring

0 comments on commit 58cbc51

Please sign in to comment.