Merge pull request #59 from torarnv/add-photos-service
Add photos service
This commit is contained in:
commit
d4e2bac926
6 changed files with 361 additions and 1 deletions
42
README.rst
42
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)
|
>>> 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())
|
||||||
|
|
|
@ -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']
|
||||||
|
|
|
@ -34,3 +34,11 @@ class PyiCloudNoDevicesException(Exception):
|
||||||
|
|
||||||
class NoStoredPasswordAvailable(PyiCloudException):
|
class NoStoredPasswordAvailable(PyiCloudException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PyiCloudBinaryFeedParseError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PyiCloudPhotoLibraryNotActivatedErrror(Exception):
|
||||||
|
pass
|
||||||
|
|
|
@ -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
296
pyicloud/services/photos.py
Normal 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
|
||||||
|
)
|
|
@ -5,3 +5,4 @@ click>=6.0,<7.0
|
||||||
six
|
six
|
||||||
pytz
|
pytz
|
||||||
certifi
|
certifi
|
||||||
|
bitstring
|
||||||
|
|
Loading…
Reference in a new issue