Add iCloud Photo Library service

Fixes issue #46.
This commit is contained in:
Tor Arne Vestbø 2016-01-13 14:12:13 +01:00
parent ab889e7232
commit 58cbc51701
6 changed files with 361 additions and 1 deletions

View file

@ -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) >>> download = api.files['com~apple~Notes']['Documents']['big_file.zip'].open(stream=True)
>>> with open('downloaded_file.zip', 'wb') as opened_file: >>> with open('downloaded_file.zip', 'wb') as opened_file:
opened_file.write(download.raw.read()) 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())

View file

@ -20,7 +20,8 @@ from pyicloud.services import (
CalendarService, CalendarService,
UbiquityService, UbiquityService,
ContactsService, ContactsService,
RemindersService RemindersService,
PhotosService
) )
from pyicloud.utils import get_password_from_keyring from pyicloud.utils import get_password_from_keyring
@ -267,6 +268,17 @@ class PyiCloudService(object):
) )
return self._files 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 @property
def calendar(self): def calendar(self):
service_root = self.webservices['calendar']['url'] service_root = self.webservices['calendar']['url']

View file

@ -34,3 +34,11 @@ class PyiCloudNoDevicesException(Exception):
class NoStoredPasswordAvailable(PyiCloudException): class NoStoredPasswordAvailable(PyiCloudException):
pass pass
class PyiCloudBinaryFeedParseError(Exception):
pass
class PyiCloudPhotoLibraryNotActivatedErrror(Exception):
pass

View file

@ -3,3 +3,4 @@ from pyicloud.services.findmyiphone import FindMyiPhoneServiceManager
from pyicloud.services.ubiquity import UbiquityService from pyicloud.services.ubiquity import UbiquityService
from pyicloud.services.contacts import ContactsService from pyicloud.services.contacts import ContactsService
from pyicloud.services.reminders import RemindersService from pyicloud.services.reminders import RemindersService
from pyicloud.services.photos import PhotosService

296
pyicloud/services/photos.py Normal file
View file

@ -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
)

View file

@ -5,3 +5,4 @@ click>=6.0,<7.0
six six
pytz pytz
certifi certifi
bitstring