563 lines
20 KiB
Python
563 lines
20 KiB
Python
import sys
|
|
import json
|
|
import logging
|
|
import base64
|
|
|
|
from datetime import datetime
|
|
from pyicloud.exceptions import PyiCloudServiceNotActivatedException
|
|
import pytz
|
|
|
|
from future.moves.urllib.parse import urlencode
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PhotosService(object):
|
|
"""The 'Photos' iCloud service."""
|
|
SMART_FOLDERS = {
|
|
"All Photos": {
|
|
"obj_type": "CPLAssetByAddedDate",
|
|
"list_type": "CPLAssetAndMasterByAddedDate",
|
|
"direction": "ASCENDING",
|
|
"query_filter": None
|
|
},
|
|
"Time-lapse": {
|
|
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Timelapse",
|
|
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
|
"direction": "ASCENDING",
|
|
"query_filter": [{
|
|
"fieldName": "smartAlbum",
|
|
"comparator": "EQUALS",
|
|
"fieldValue": {
|
|
"type": "STRING",
|
|
"value": "TIMELAPSE"
|
|
}
|
|
}]
|
|
},
|
|
"Videos": {
|
|
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Video",
|
|
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
|
"direction": "ASCENDING",
|
|
"query_filter": [{
|
|
"fieldName": "smartAlbum",
|
|
"comparator": "EQUALS",
|
|
"fieldValue": {
|
|
"type": "STRING",
|
|
"value": "VIDEO"
|
|
}
|
|
}]
|
|
},
|
|
"Slo-mo": {
|
|
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Slomo",
|
|
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
|
"direction": "ASCENDING",
|
|
"query_filter": [{
|
|
"fieldName": "smartAlbum",
|
|
"comparator": "EQUALS",
|
|
"fieldValue": {
|
|
"type": "STRING",
|
|
"value": "SLOMO"
|
|
}
|
|
}]
|
|
},
|
|
"Bursts": {
|
|
"obj_type": "CPLAssetBurstStackAssetByAssetDate",
|
|
"list_type": "CPLBurstStackAssetAndMasterByAssetDate",
|
|
"direction": "ASCENDING",
|
|
"query_filter": None
|
|
},
|
|
"Favorites": {
|
|
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Favorite",
|
|
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
|
"direction": "ASCENDING",
|
|
"query_filter": [{
|
|
"fieldName": "smartAlbum",
|
|
"comparator": "EQUALS",
|
|
"fieldValue": {
|
|
"type": "STRING",
|
|
"value": "FAVORITE"
|
|
}
|
|
}]
|
|
},
|
|
"Panoramas": {
|
|
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Panorama",
|
|
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
|
"direction": "ASCENDING",
|
|
"query_filter": [{
|
|
"fieldName": "smartAlbum",
|
|
"comparator": "EQUALS",
|
|
"fieldValue": {
|
|
"type": "STRING",
|
|
"value": "PANORAMA"
|
|
}
|
|
}]
|
|
},
|
|
"Screenshots": {
|
|
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Screenshot",
|
|
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
|
"direction": "ASCENDING",
|
|
"query_filter": [{
|
|
"fieldName": "smartAlbum",
|
|
"comparator": "EQUALS",
|
|
"fieldValue": {
|
|
"type": "STRING",
|
|
"value": "SCREENSHOT"
|
|
}
|
|
}]
|
|
},
|
|
"Live": {
|
|
"obj_type": "CPLAssetInSmartAlbumByAssetDate:Live",
|
|
"list_type": "CPLAssetAndMasterInSmartAlbumByAssetDate",
|
|
"direction": "ASCENDING",
|
|
"query_filter": [{
|
|
"fieldName": "smartAlbum",
|
|
"comparator": "EQUALS",
|
|
"fieldValue": {
|
|
"type": "STRING",
|
|
"value": "LIVE"
|
|
}
|
|
}]
|
|
},
|
|
"Recently Deleted": {
|
|
"obj_type": "CPLAssetDeletedByExpungedDate",
|
|
"list_type": "CPLAssetAndMasterDeletedByExpungedDate",
|
|
"direction": "ASCENDING",
|
|
"query_filter": None
|
|
},
|
|
"Hidden": {
|
|
"obj_type": "CPLAssetHiddenByAssetDate",
|
|
"list_type": "CPLAssetAndMasterHiddenByAssetDate",
|
|
"direction": "ASCENDING",
|
|
"query_filter": None
|
|
},
|
|
}
|
|
|
|
def __init__(self, service_root, session, params):
|
|
self.session = session
|
|
self.params = dict(params)
|
|
self._service_root = service_root
|
|
self._service_endpoint = \
|
|
('%s/database/1/com.apple.photos.cloud/production/private'
|
|
% self._service_root)
|
|
|
|
self._albums = None
|
|
|
|
self.params.update({
|
|
'remapEnums': True,
|
|
'getCurrentSyncToken': True
|
|
})
|
|
|
|
url = ('%s/records/query?%s' %
|
|
(self._service_endpoint, urlencode(self.params)))
|
|
json_data = ('{"query":{"recordType":"CheckIndexingState"},'
|
|
'"zoneID":{"zoneName":"PrimarySync"}}')
|
|
request = self.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 PyiCloudServiceNotActivatedException(
|
|
'iCloud Photo Library not finished indexing. '
|
|
'Please try again in a few minutes.'
|
|
)
|
|
|
|
# TODO: Does syncToken ever change?
|
|
# self.params.update({
|
|
# 'syncToken': response['syncToken'],
|
|
# 'clientInstanceId': self.params.pop('clientId')
|
|
# })
|
|
|
|
self._photo_assets = {}
|
|
|
|
@property
|
|
def albums(self):
|
|
if not self._albums:
|
|
self._albums = {name: PhotoAlbum(self, name, **props)
|
|
for (name, props) in self.SMART_FOLDERS.items()}
|
|
|
|
for folder in self._fetch_folders():
|
|
# FIXME: Handle subfolders
|
|
if folder['recordName'] == '----Root-Folder----' or \
|
|
(folder['fields'].get('isDeleted') and
|
|
folder['fields']['isDeleted']['value']):
|
|
continue
|
|
|
|
folder_id = folder['recordName']
|
|
folder_obj_type = \
|
|
"CPLContainerRelationNotDeletedByAssetDate:%s" % folder_id
|
|
folder_name = base64.b64decode(
|
|
folder['fields']['albumNameEnc']['value']).decode('utf-8')
|
|
query_filter = [{
|
|
"fieldName": "parentId",
|
|
"comparator": "EQUALS",
|
|
"fieldValue": {
|
|
"type": "STRING",
|
|
"value": folder_id
|
|
}
|
|
}]
|
|
|
|
album = PhotoAlbum(self, folder_name,
|
|
'CPLContainerRelationLiveByAssetDate',
|
|
folder_obj_type, 'ASCENDING', query_filter)
|
|
self._albums[folder_name] = album
|
|
|
|
return self._albums
|
|
|
|
def _fetch_folders(self):
|
|
url = ('%s/records/query?%s' %
|
|
(self._service_endpoint, urlencode(self.params)))
|
|
json_data = ('{"query":{"recordType":"CPLAlbumByPositionLive"},'
|
|
'"zoneID":{"zoneName":"PrimarySync"}}')
|
|
|
|
request = self.session.post(
|
|
url,
|
|
data=json_data,
|
|
headers={'Content-type': 'text/plain'}
|
|
)
|
|
response = request.json()
|
|
|
|
return response['records']
|
|
|
|
@property
|
|
def all(self):
|
|
return self.albums['All Photos']
|
|
|
|
|
|
class PhotoAlbum(object):
|
|
|
|
def __init__(self, service, name, list_type, obj_type, direction,
|
|
query_filter=None, page_size=100):
|
|
self.name = name
|
|
self.service = service
|
|
self.list_type = list_type
|
|
self.obj_type = obj_type
|
|
self.direction = direction
|
|
self.query_filter = query_filter
|
|
self.page_size = page_size
|
|
|
|
self._len = None
|
|
|
|
@property
|
|
def title(self):
|
|
return self.name
|
|
|
|
def __iter__(self):
|
|
return self.photos
|
|
|
|
def __len__(self):
|
|
if self._len is None:
|
|
url = ('%s/internal/records/query/batch?%s' %
|
|
(self.service._service_endpoint,
|
|
urlencode(self.service.params)))
|
|
request = self.service.session.post(
|
|
url,
|
|
data=json.dumps(self._count_query_gen(self.obj_type)),
|
|
headers={'Content-type': 'text/plain'}
|
|
)
|
|
response = request.json()
|
|
|
|
self._len = (response["batch"][0]["records"][0]["fields"]
|
|
["itemCount"]["value"])
|
|
|
|
return self._len
|
|
|
|
@property
|
|
def photos(self):
|
|
if self.direction == "DESCENDING":
|
|
offset = len(self) - 1
|
|
else:
|
|
offset = 0
|
|
|
|
while(True):
|
|
url = ('%s/records/query?' % self.service._service_endpoint) + \
|
|
urlencode(self.service.params)
|
|
request = self.service.session.post(
|
|
url,
|
|
data=json.dumps(self._list_query_gen(
|
|
offset, self.list_type, self.direction,
|
|
self.query_filter)),
|
|
headers={'Content-type': 'text/plain'}
|
|
)
|
|
response = request.json()
|
|
|
|
asset_records = {}
|
|
master_records = []
|
|
for rec in response['records']:
|
|
if rec['recordType'] == "CPLAsset":
|
|
master_id = \
|
|
rec['fields']['masterRef']['value']['recordName']
|
|
asset_records[master_id] = rec
|
|
elif rec['recordType'] == "CPLMaster":
|
|
master_records.append(rec)
|
|
|
|
master_records_len = len(master_records)
|
|
if master_records_len:
|
|
if self.direction == "DESCENDING":
|
|
offset = offset - master_records_len
|
|
else:
|
|
offset = offset + master_records_len
|
|
|
|
for master_record in master_records:
|
|
record_name = master_record['recordName']
|
|
yield PhotoAsset(self.service, master_record,
|
|
asset_records[record_name])
|
|
else:
|
|
break
|
|
|
|
def _count_query_gen(self, obj_type):
|
|
query = {
|
|
u'batch': [{
|
|
u'resultsLimit': 1,
|
|
u'query': {
|
|
u'filterBy': {
|
|
u'fieldName': u'indexCountID',
|
|
u'fieldValue': {
|
|
u'type': u'STRING_LIST',
|
|
u'value': [
|
|
obj_type
|
|
]
|
|
},
|
|
u'comparator': u'IN'
|
|
},
|
|
u'recordType': u'HyperionIndexCountLookup'
|
|
},
|
|
u'zoneWide': True,
|
|
u'zoneID': {
|
|
u'zoneName': u'PrimarySync'
|
|
}
|
|
}]
|
|
}
|
|
|
|
return query
|
|
|
|
def _list_query_gen(self, offset, list_type, direction, query_filter=None):
|
|
query = {
|
|
u'query': {
|
|
u'filterBy': [
|
|
{u'fieldName': u'startRank', u'fieldValue':
|
|
{u'type': u'INT64', u'value': offset},
|
|
u'comparator': u'EQUALS'},
|
|
{u'fieldName': u'direction', u'fieldValue':
|
|
{u'type': u'STRING', u'value': direction},
|
|
u'comparator': u'EQUALS'}
|
|
],
|
|
u'recordType': list_type
|
|
},
|
|
u'resultsLimit': self.page_size * 2,
|
|
u'desiredKeys': [
|
|
u'resJPEGFullWidth', u'resJPEGFullHeight',
|
|
u'resJPEGFullFileType', u'resJPEGFullFingerprint',
|
|
u'resJPEGFullRes', u'resJPEGLargeWidth',
|
|
u'resJPEGLargeHeight', u'resJPEGLargeFileType',
|
|
u'resJPEGLargeFingerprint', u'resJPEGLargeRes',
|
|
u'resJPEGMedWidth', u'resJPEGMedHeight',
|
|
u'resJPEGMedFileType', u'resJPEGMedFingerprint',
|
|
u'resJPEGMedRes', u'resJPEGThumbWidth',
|
|
u'resJPEGThumbHeight', u'resJPEGThumbFileType',
|
|
u'resJPEGThumbFingerprint', u'resJPEGThumbRes',
|
|
u'resVidFullWidth', u'resVidFullHeight',
|
|
u'resVidFullFileType', u'resVidFullFingerprint',
|
|
u'resVidFullRes', u'resVidMedWidth', u'resVidMedHeight',
|
|
u'resVidMedFileType', u'resVidMedFingerprint',
|
|
u'resVidMedRes', u'resVidSmallWidth', u'resVidSmallHeight',
|
|
u'resVidSmallFileType', u'resVidSmallFingerprint',
|
|
u'resVidSmallRes', u'resSidecarWidth', u'resSidecarHeight',
|
|
u'resSidecarFileType', u'resSidecarFingerprint',
|
|
u'resSidecarRes', u'itemType', u'dataClassType',
|
|
u'filenameEnc', u'originalOrientation', u'resOriginalWidth',
|
|
u'resOriginalHeight', u'resOriginalFileType',
|
|
u'resOriginalFingerprint', u'resOriginalRes',
|
|
u'resOriginalAltWidth', u'resOriginalAltHeight',
|
|
u'resOriginalAltFileType', u'resOriginalAltFingerprint',
|
|
u'resOriginalAltRes', u'resOriginalVidComplWidth',
|
|
u'resOriginalVidComplHeight', u'resOriginalVidComplFileType',
|
|
u'resOriginalVidComplFingerprint', u'resOriginalVidComplRes',
|
|
u'isDeleted', u'isExpunged', u'dateExpunged', u'remappedRef',
|
|
u'recordName', u'recordType', u'recordChangeTag',
|
|
u'masterRef', u'adjustmentRenderType', u'assetDate',
|
|
u'addedDate', u'isFavorite', u'isHidden', u'orientation',
|
|
u'duration', u'assetSubtype', u'assetSubtypeV2',
|
|
u'assetHDRType', u'burstFlags', u'burstFlagsExt', u'burstId',
|
|
u'captionEnc', u'locationEnc', u'locationV2Enc',
|
|
u'locationLatitude', u'locationLongitude', u'adjustmentType',
|
|
u'timeZoneOffset', u'vidComplDurValue', u'vidComplDurScale',
|
|
u'vidComplDispValue', u'vidComplDispScale',
|
|
u'vidComplVisibilityState', u'customRenderedValue',
|
|
u'containerId', u'itemId', u'position', u'isKeyAsset'
|
|
],
|
|
u'zoneID': {u'zoneName': u'PrimarySync'}
|
|
}
|
|
|
|
if query_filter:
|
|
query['query']['filterBy'].extend(query_filter)
|
|
|
|
return query
|
|
|
|
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('utf-8', 'ignore')
|
|
|
|
def __repr__(self):
|
|
return "<%s: '%s'>" % (
|
|
type(self).__name__,
|
|
self
|
|
)
|
|
|
|
|
|
class PhotoAsset(object):
|
|
def __init__(self, service, master_record, asset_record):
|
|
self._service = service
|
|
self._master_record = master_record
|
|
self._asset_record = asset_record
|
|
|
|
self._versions = None
|
|
|
|
PHOTO_VERSION_LOOKUP = {
|
|
u"original": u"resOriginal",
|
|
u"medium": u"resJPEGMed",
|
|
u"thumb": u"resJPEGThumb"
|
|
}
|
|
|
|
VIDEO_VERSION_LOOKUP = {
|
|
u"original": u"resOriginal",
|
|
u"medium": u"resVidMed",
|
|
u"thumb": u"resVidSmall"
|
|
}
|
|
|
|
@property
|
|
def id(self):
|
|
return self._master_record['recordName']
|
|
|
|
@property
|
|
def filename(self):
|
|
return base64.b64decode(
|
|
self._master_record['fields']['filenameEnc']['value']
|
|
).decode('utf-8')
|
|
|
|
@property
|
|
def size(self):
|
|
return self._master_record['fields']['resOriginalRes']['value']['size']
|
|
|
|
@property
|
|
def created(self):
|
|
return self.asset_date
|
|
|
|
@property
|
|
def asset_date(self):
|
|
try:
|
|
dt = datetime.fromtimestamp(
|
|
self._asset_record['fields']['assetDate']['value'] / 1000.0,
|
|
tz=pytz.utc)
|
|
except:
|
|
dt = datetime.fromtimestamp(0)
|
|
return dt
|
|
|
|
@property
|
|
def added_date(self):
|
|
dt = datetime.fromtimestamp(
|
|
self._asset_record['fields']['addedDate']['value'] / 1000.0,
|
|
tz=pytz.utc)
|
|
return dt
|
|
|
|
@property
|
|
def dimensions(self):
|
|
return (self._master_record['fields']['resOriginalWidth']['value'],
|
|
self._master_record['fields']['resOriginalHeight']['value'])
|
|
|
|
@property
|
|
def versions(self):
|
|
if not self._versions:
|
|
self._versions = {}
|
|
if 'resVidSmallRes' in self._master_record['fields']:
|
|
typed_version_lookup = self.VIDEO_VERSION_LOOKUP
|
|
else:
|
|
typed_version_lookup = self.PHOTO_VERSION_LOOKUP
|
|
|
|
for key, prefix in typed_version_lookup.items():
|
|
if '%sRes' % prefix in self._master_record['fields']:
|
|
f = self._master_record['fields']
|
|
version = {'filename': self.filename}
|
|
|
|
width_entry = f.get('%sWidth' % prefix)
|
|
if width_entry:
|
|
version['width'] = width_entry['value']
|
|
else:
|
|
version['width'] = None
|
|
|
|
height_entry = f.get('%sHeight' % prefix)
|
|
if height_entry:
|
|
version['height'] = height_entry['value']
|
|
else:
|
|
version['height'] = None
|
|
|
|
size_entry = f.get('%sRes' % prefix)
|
|
if size_entry:
|
|
version['size'] = size_entry['value']['size']
|
|
version['url'] = size_entry['value']['downloadURL']
|
|
else:
|
|
version['size'] = None
|
|
version['url'] = None
|
|
|
|
type_entry = f.get('%sFileType' % prefix)
|
|
if type_entry:
|
|
version['type'] = type_entry['value']
|
|
else:
|
|
version['type'] = None
|
|
|
|
self._versions[key] = version
|
|
|
|
return self._versions
|
|
|
|
def download(self, version='original', **kwargs):
|
|
if version not in self.versions:
|
|
return None
|
|
|
|
return self._service.session.get(
|
|
self.versions[version]['url'],
|
|
stream=True,
|
|
**kwargs
|
|
)
|
|
|
|
def delete(self):
|
|
recordName = self._asset_record['recordName']
|
|
recordType = self._asset_record['recordType']
|
|
recordChangeTag = self._master_record['recordChangeTag']
|
|
json_data = ('{"query":{"recordType":"CheckIndexingState"},'
|
|
'"zoneID":{"zoneName":"PrimarySync"}}')
|
|
|
|
json_data = ('{"operations":[{'
|
|
'"operationType":"update",'
|
|
'"record":{'
|
|
'"recordName":"%s","recordType":"%s",'
|
|
'"recordChangeTag":"%s",'
|
|
'"fields":{"isDeleted":{"value":1}'
|
|
'}}}],'
|
|
'"zoneID":{'
|
|
'"zoneName":"PrimarySync"'
|
|
'},"atomic":true}'
|
|
% (recordName, recordType, recordChangeTag))
|
|
|
|
endpoint = self._service._service_endpoint
|
|
params = urlencode(self._service.params)
|
|
url = ('%s/records/modify?%s' % (endpoint, params))
|
|
|
|
return self._service.session.post(
|
|
url,
|
|
data=json_data,
|
|
headers={'Content-type': 'text/plain'}
|
|
)
|
|
|
|
def __repr__(self):
|
|
return "<%s: id=%s>" % (
|
|
type(self).__name__,
|
|
self.id
|
|
)
|