Skip to content

Commit a2ec95a

Browse files
authored
feat: list and download from shared libraries for invitee #947 (#1082)
* feat: list and download from shared libraries for invitee (#947) * test: add tests for shared libraries * doc: add changelog for shared libraries
1 parent 6101fa3 commit a2ec95a

File tree

8 files changed

+805
-40
lines changed

8 files changed

+805
-40
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- feature: list and download from shared libraries for invitee [#947](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/947)
6+
57
## 1.26.1 (2025-01-23)
68

79
- fix: XMP metadata with plist in XML format causes crash [#1059](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/1059)

src/icloudpd/base.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,7 +1051,7 @@ def delete_photo(
10511051
clean_filename_local = photo.filename
10521052
logger.debug("Deleting %s in iCloud...", clean_filename_local)
10531053
url = (
1054-
f"{photo_service._service_endpoint}/records/modify?"
1054+
f"{library_object.service_endpoint}/records/modify?"
10551055
f"{urllib.parse.urlencode(photo_service.params)}"
10561056
)
10571057
post_data = json.dumps(
@@ -1251,8 +1251,9 @@ def core(
12511251
library_object: PhotoLibrary = icloud.photos
12521252

12531253
if list_libraries:
1254-
libraries_dict = icloud.photos.libraries
1255-
library_names = libraries_dict.keys()
1254+
library_names = (
1255+
icloud.photos.private_libraries.keys() | icloud.photos.shared_libraries.keys()
1256+
)
12561257
print(*library_names, sep="\n")
12571258

12581259
else:
@@ -1263,9 +1264,11 @@ def core(
12631264
# case exit.
12641265
try:
12651266
if library:
1266-
try:
1267-
library_object = icloud.photos.libraries[library]
1268-
except KeyError:
1267+
if library in icloud.photos.private_libraries:
1268+
library_object = icloud.photos.private_libraries[library]
1269+
elif library in icloud.photos.shared_libraries:
1270+
library_object = icloud.photos.shared_libraries[library]
1271+
else:
12691272
logger.error("Unknown library: %s", library)
12701273
return 1
12711274
photos = library_object.albums[album]

src/icloudpd/xmp_sidecar.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,10 @@ def build_metadata(asset_record: dict[str, Any]) -> XMPMetadata:
162162
):
163163
rating = -1 # -1 means rejected: https://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#image-rating
164164
# only mark photo as favorite if not hidden or deleted
165-
elif "isFavorite" in asset_record["fields"] and asset_record["fields"]["isFavorite"]["value"] == 1:
165+
elif (
166+
"isFavorite" in asset_record["fields"]
167+
and asset_record["fields"]["isFavorite"]["value"] == 1
168+
):
166169
rating = 5
167170

168171
return XMPMetadata(

src/pyicloud_ipd/services/photos.py

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -154,14 +154,16 @@ class PhotoLibrary(object):
154154
},
155155
}
156156

157-
def __init__(self, service: "PhotosService", zone_id: Dict[str, Any]):
157+
def __init__(self, service: "PhotosService", zone_id: Dict[str, Any], library_type: str):
158158
self.service = service
159159
self.zone_id = zone_id
160+
self.library_type = library_type
161+
self.service_endpoint = self.service.get_service_endpoint(library_type)
160162

161163
self._albums: Optional[Dict[str, PhotoAlbum]] = None
162164

163165
url = ('%s/records/query?%s' %
164-
(self.service._service_endpoint, urlencode(self.service.params)))
166+
(self.service_endpoint, urlencode(self.service.params)))
165167
json_data = json.dumps({
166168
"query": {"recordType":"CheckIndexingState"},
167169
"zoneID": self.zone_id,
@@ -183,7 +185,7 @@ def __init__(self, service: "PhotosService", zone_id: Dict[str, Any]):
183185
def albums(self) -> Dict[str, "PhotoAlbum"]:
184186
if not self._albums:
185187
self._albums = {
186-
name: PhotoAlbum(self.service, name, zone_id=self.zone_id, **props) # type: ignore[arg-type] # dynamically builing params
188+
name: PhotoAlbum(self.service, self.service_endpoint, name, zone_id=self.zone_id, **props) # type: ignore[arg-type] # dynamically builing params
187189
for (name, props) in self.SMART_FOLDERS.items()
188190
}
189191

@@ -209,7 +211,7 @@ def albums(self) -> Dict[str, "PhotoAlbum"]:
209211
}
210212
}]
211213

212-
album = PhotoAlbum(self.service, folder_name,
214+
album = PhotoAlbum(self.service, self.service_endpoint, folder_name,
213215
'CPLContainerRelationLiveByAssetDate',
214216
folder_obj_type, 'ASCENDING', query_filter,
215217
zone_id=self.zone_id)
@@ -218,8 +220,10 @@ def albums(self) -> Dict[str, "PhotoAlbum"]:
218220
return self._albums
219221

220222
def _fetch_folders(self) -> Sequence[Dict[str, Any]]:
223+
if self.library_type == "shared":
224+
return []
221225
url = ('%s/records/query?%s' %
222-
(self.service._service_endpoint, urlencode(self.service.params)))
226+
(self.service_endpoint, urlencode(self.service.params)))
223227
json_data = json.dumps({
224228
"query": {"recordType":"CPLAlbumByPositionLive"},
225229
"zoneID": self.zone_id,
@@ -256,11 +260,9 @@ def __init__(
256260
self.session = session
257261
self.params = dict(params)
258262
self._service_root = service_root
259-
self._service_endpoint = \
260-
('%s/database/1/com.apple.photos.cloud/production/private'
261-
% self._service_root)
262263

263-
self._libraries: Optional[Dict[str, PhotoLibrary]] = None
264+
self._private_libraries: Optional[Dict[str, PhotoLibrary]] = None
265+
self._shared_libraries: Optional[Dict[str, PhotoLibrary]] = None
264266

265267
self.filename_cleaner = filename_cleaner
266268
self.lp_filename_generator = lp_filename_generator
@@ -281,46 +283,59 @@ def __init__(
281283
# self._photo_assets = {}
282284

283285
super(PhotosService, self).__init__(
284-
service=self, zone_id={u'zoneName': u'PrimarySync'})
286+
service=self, zone_id={u'zoneName': u'PrimarySync'}, library_type="private")
285287

286288
@property
287-
def libraries(self) -> Dict[str, PhotoLibrary]:
288-
if not self._libraries:
289-
try:
290-
url = ('%s/zones/list' %
291-
(self._service_endpoint, ))
292-
request = self.session.post(
293-
url,
294-
data='{}',
295-
headers={'Content-type': 'text/plain'}
296-
)
297-
response = request.json()
298-
zones = response['zones']
299-
except Exception as e:
300-
logger.error("library exception: %s" % str(e))
289+
def private_libraries(self) -> Dict[str, PhotoLibrary]:
290+
if not self._private_libraries:
291+
self._private_libraries = self._fetch_libraries("private")
292+
293+
return self._private_libraries
294+
295+
@property
296+
def shared_libraries(self) -> Dict[str, PhotoLibrary]:
297+
if not self._shared_libraries:
298+
self._shared_libraries = self._fetch_libraries("shared")
299+
300+
return self._shared_libraries
301301

302+
def _fetch_libraries(self, library_type: str) -> Dict[str, PhotoLibrary]:
303+
try:
302304
libraries = {}
303-
for zone in zones:
305+
service_endpoint = self.get_service_endpoint(library_type)
306+
url = ('%s/zones/list' %
307+
(service_endpoint, ))
308+
request = self.session.post(
309+
url,
310+
data='{}',
311+
headers={'Content-type': 'text/plain'}
312+
)
313+
response = request.json()
314+
for zone in response['zones']:
304315
if not zone.get('deleted'):
305316
zone_name = zone['zoneID']['zoneName']
306317
libraries[zone_name] = PhotoLibrary(
307-
self, zone_id=zone['zoneID'])
318+
self, zone_id=zone['zoneID'], library_type=library_type)
308319
# obj_type='CPLAssetByAssetDateWithoutHiddenOrDeleted',
309320
# list_type="CPLAssetAndMasterByAssetDateWithoutHiddenOrDeleted",
310321
# direction="ASCENDING", query_filter=None,
311322
# zone_id=zone['zoneID'])
323+
except Exception as e:
324+
logger.error("library exception: %s" % str(e))
325+
return libraries
312326

313-
self._libraries = libraries
314-
315-
return self._libraries
327+
def get_service_endpoint(self, library_type: str) -> str:
328+
return ('%s/database/1/com.apple.photos.cloud/production/%s'
329+
% (self._service_root, library_type))
316330

317331

318332
class PhotoAlbum(object):
319333

320-
def __init__(self, service:PhotosService, name: str, list_type: str, obj_type: str, direction: str,
334+
def __init__(self, service:PhotosService, service_endpoint: str, name: str, list_type: str, obj_type: str, direction: str,
321335
query_filter:Optional[Sequence[Dict[str, Any]]]=None, page_size:int=100, zone_id:Optional[Dict[str, Any]]=None):
322336
self.name = name
323337
self.service = service
338+
self.service_endpoint = service_endpoint
324339
self.list_type = list_type
325340
self.obj_type = obj_type
326341
self.direction = direction
@@ -345,7 +360,7 @@ def __iter__(self) -> Generator["PhotoAsset", Any, None]:
345360
def __len__(self) -> int:
346361
if self._len is None:
347362
url = ('%s/internal/records/query/batch?%s' %
348-
(self.service._service_endpoint,
363+
(self.service_endpoint,
349364
urlencode(self.service.params)))
350365
request = self.service.session.post(
351366
url,
@@ -362,7 +377,7 @@ def __len__(self) -> int:
362377
# Perform the request in a separate method so that we
363378
# can mock it to test session errors.
364379
def photos_request(self, offset: int) -> Response:
365-
url = ('%s/records/query?' % self.service._service_endpoint) + \
380+
url = ('%s/records/query?' % self.service_endpoint) + \
366381
urlencode(self.service.params)
367382
return self.service.session.post(
368383
url,
@@ -397,7 +412,7 @@ def photos(self) -> Generator["PhotoAsset", Any, None]:
397412

398413
exception_retries = 0
399414

400-
# url = ('%s/records/query?' % self.service._service_endpoint) + \
415+
# url = ('%s/records/query?' % self.service_endpoint) + \
401416
# urlencode(self.service.params)
402417
# request = self.service.session.post(
403418
# url,

tests/test_download_photos.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2323,3 +2323,36 @@ def test_download_filename_string_encoding(self) -> None:
23232323
print_result_exception(result)
23242324

23252325
self.assertEqual(result.exit_code, 0)
2326+
2327+
def test_download_from_shared_library(self) -> None:
2328+
base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3])
2329+
2330+
data_dir, result = run_icloudpd_test(
2331+
self.assertEqual,
2332+
self.root_path,
2333+
base_dir,
2334+
"listing_photos.yml",
2335+
[],
2336+
[],
2337+
[
2338+
"--username",
2339+
"jdoe@gmail.com",
2340+
"--password",
2341+
"password1",
2342+
"--library",
2343+
"SharedSync-00000000-1111-2222-3333-444444444444",
2344+
"--dry-run",
2345+
"--no-progress-bar",
2346+
],
2347+
)
2348+
2349+
self.assertEqual(result.exit_code, 0)
2350+
2351+
self.assertIn(
2352+
"DEBUG Looking up all photos and videos from album All Photos...", self._caplog.text
2353+
)
2354+
self.assertIn(
2355+
f"INFO Downloading the first original photo or video to {data_dir} ...",
2356+
self._caplog.text,
2357+
)
2358+
self.assertIn("INFO All photos have been downloaded", self._caplog.text)

tests/test_listing_libraries.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def test_listing_library(self) -> None:
5252
albums = result.output.splitlines()
5353

5454
self.assertIn("PrimarySync", albums)
55+
self.assertIn("SharedSync-00000000-1111-2222-3333-444444444444", albums)
5556
# self.assertIn("WhatsApp", albums)
5657

5758
assert result.exit_code == 0

tests/vcr_cassettes/listing_albums.yml

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,51 @@ interactions:
138138
content-length: ['804']
139139
via: ['xrail:nk11p00ic-ztdj17111401.me.com:8301:18H44:grp31', 'icloudedge:si03p01ic-ztde010302:7401:18RC341:Singapore']
140140
status: {code: 200, message: OK}
141+
- request:
142+
body: '{"query":{"recordType":"CheckIndexingState"},"zoneID":{"zoneName":"SharedSync-00000000-1111-2222-3333-444444444444"}}'
143+
headers:
144+
Accept: ['*/*']
145+
Accept-Encoding: ['gzip, deflate']
146+
Connection: [keep-alive]
147+
Content-Length: ['81']
148+
Content-type: [text/plain]
149+
Origin: ['https://www.icloud.com']
150+
Referer: ['https://www.icloud.com/']
151+
User-Agent: [Opera/9.52 (X11; Linux i686; U; en)]
152+
method: POST
153+
uri: https://p61-ckdatabasews.icloud.com/database/1/com.apple.photos.cloud/production/shared/records/query?clientBuildNumber=17DHotfix5&clientMasteringNumber=17DHotfix5&ckjsBuildVersion=17DProjectDev77&ckjsVersion=2.0.5&clientId=DE309E26-942E-11E8-92F5-14109FE0B321&dsid=12345678901&remapEnums=True&getCurrentSyncToken=True
154+
response:
155+
body: {string: "{\n \"records\" : [ {\n \"recordName\" : \"_ac333066-5d55-4c42-b033-f05bb9d2873c\",\n
156+
\ \"recordType\" : \"CheckIndexingState\",\n \"fields\" : {\n \"progress\"
157+
: {\n \"value\" : 100,\n \"type\" : \"INT64\"\n },\n \"state\"
158+
: {\n \"value\" : \"FINISHED\",\n \"type\" : \"STRING\"\n }\n
159+
\ },\n \"pluginFields\" : { },\n \"recordChangeTag\" : \"0\",\n \"created\"
160+
: {\n \"timestamp\" : 1533040623320,\n \"userRecordName\" : \"_10\",\n
161+
\ \"deviceID\" : \"1\"\n },\n \"modified\" : {\n \"timestamp\"
162+
: 1533040623320,\n \"userRecordName\" : \"_10\",\n \"deviceID\"
163+
: \"1\"\n },\n \"deleted\" : false,\n \"zoneID\" : {\n \"zoneName\"
164+
: \"PrimarySync\",\n \"ownerRecordName\" : \"_bfc6dbbcc77b03e6cebefd28a28f7e2f\"\n
165+
\ }\n } ],\n \"syncToken\" : \"AQAAAAAAAwmVf//////////Kd+LphRdKGbpJMSeRX5Td\"\n}"}
166+
headers:
167+
Access-Control-Allow-Credentials: ['true']
168+
Access-Control-Allow-Origin: ['https://www.icloud.com']
169+
Access-Control-Expose-Headers: ['X-Apple-Request-UUID, X-Responding-Instance',
170+
Via]
171+
Apple-Originating-System: [UnknownOriginatingSystem]
172+
Connection: [keep-alive]
173+
Content-Type: [application/json; charset=UTF-8]
174+
Date: ['Tue, 31 Jul 2018 12:37:03 GMT']
175+
Server: [AppleHttpServer/2f080fc0]
176+
Strict-Transport-Security: [max-age=31536000; includeSubDomains;]
177+
Transfer-Encoding: [chunked]
178+
X-Apple-CloudKit-Version: ['1.0']
179+
X-Apple-Request-UUID: [4bdf08db-dcb1-421b-96be-648eb85521b6]
180+
X-Responding-Instance: ['ckdatabasews:21000301:nk11p10me-ztbu22062301:8201:1813B216:nocommit']
181+
apple-seq: ['0']
182+
apple-tk: ['false']
183+
content-length: ['804']
184+
via: ['xrail:nk11p00ic-ztdj17111401.me.com:8301:18H44:grp31', 'icloudedge:si03p01ic-ztde010302:7401:18RC341:Singapore']
185+
status: {code: 200, message: OK}
141186

142187
- request:
143188
body: '{}'
@@ -203,6 +248,70 @@ interactions:
203248
code: 200
204249
message: OK
205250

251+
- request:
252+
body: '{}'
253+
headers:
254+
Accept:
255+
- '*/*'
256+
Accept-Encoding:
257+
- gzip, deflate
258+
Connection:
259+
- keep-alive
260+
Content-Length:
261+
- '2'
262+
Content-type:
263+
- text/plain
264+
Origin:
265+
- https://www.icloud.com
266+
Referer:
267+
- https://www.icloud.com/
268+
User-Agent:
269+
- Opera/9.52 (X11; Linux i686; U; en)
270+
method: POST
271+
uri: https://p61-ckdatabasews.icloud.com/database/1/com.apple.photos.cloud/production/shared/zones/list
272+
response:
273+
body:
274+
string: "{\n \"moreComing\" : false,\n \"syncToken\" : \"AQAAAAAAAwmVf//////////Kd+LphRdKGbpJMSeRX5Td\",\n
275+
\ \"zones\" : [ {\n \"zoneID\" : {\n \"zoneName\" : \"SharedSync-00000000-1111-2222-3333-444444444444\",\n
276+
\ \"ownerRecordName\" : \"_bfc6dbbcc77b03e6cebefd28a28f7e2f\",\n \"zoneType\"
277+
: \"REGULAR_CUSTOM_ZONE\"\n }\n } ]\n}"
278+
headers:
279+
Access-Control-Allow-Credentials:
280+
- 'true'
281+
Access-Control-Allow-Origin:
282+
- https://www.icloud.com
283+
Connection:
284+
- keep-alive
285+
Content-Type:
286+
- application/json; charset=UTF-8
287+
Date:
288+
- Tue, 29 Aug 2023 19:47:27 GMT
289+
Server:
290+
- AppleHttpServer/3faf4ee9434b
291+
Strict-Transport-Security:
292+
- max-age=31536000; includeSubDomains;
293+
Transfer-Encoding:
294+
- chunked
295+
X-Apple-CloudKit-Version:
296+
- '1.0'
297+
X-Apple-Edge-Response-Time:
298+
- '195'
299+
X-Apple-Request-UUID:
300+
- 34046027-8226-46f7-9960-4b0839c08b82
301+
X-Responding-Instance:
302+
- ckdatabasews:966835083:prod-p121-ckdatabasews-100percent-79bfc6b95d-klrcc:8080:2322B333:13811c3d707a78576fcc7f8962567af12bdfeeb4
303+
access-control-expose-headers:
304+
- X-Apple-Request-UUID,X-Responding-Instance,Via
305+
content-length:
306+
- '242'
307+
via:
308+
- xrail:icloud-xrail-group53-ext-84bc6d9cdc-z45hs:8301:23R321:grp53,631194250daa17e24277dea86cf30319:b99b663029a8c732254c763aa20830be:nlhfd1
309+
x-apple-user-partition:
310+
- '121'
311+
status:
312+
code: 200
313+
message: OK
314+
206315
- request:
207316
body: '{"query":{"recordType":"CheckIndexingState"},"zoneID":{"zoneName":"PrimarySync"}}'
208317
headers:

0 commit comments

Comments
 (0)