Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add support for shared library #156

Merged
merged 10 commits into from
Nov 1, 2023
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ photos:
all_albums: false # Optional, default false. If true preserve album structure. If same photo is in multpile albums creates duplicates on filesystem
folder_format: "%Y/%m" # optional, if set put photos in subfolders according to format. Format cheatsheet - https://strftime.org
filters:
# List of libraries to download. If omitted (default), photos from all libraries (own and shared) are downloaded. If included, photos only
# from the listed libraries are downloaded.
# libraries:
# - PrimarySync # Name of the own library

# if all_albums is false - albums list is used as filter-in, if all_albums is true - albums list is used as filter-out
# if albums list is empty and all_albums is false download all photos to "all" folder. if empty and all_albums is true download all folders
albums:
Expand Down
5 changes: 5 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ photos:
all_albums: false # Optional, default false. If true preserve album structure. If same photo is in multpile albums creates duplicates on filesystem
# folder_format: "%Y/%m" # optional, if set put photos in subfolders according to format. Format cheatsheet - https://strftime.org
filters:
# List of libraries to download. If omitted (default), photos from all libraries (own and shared) are downloaded. If included, photos only
# from the listed libraries are downloaded.
# libraries:
# - PrimarySync # Name of the own library

# if all_albums is false - albums list is used as filter-in, if all_albums is true - albums list is used as filter-out
# if albums list is empty and all_albums is false download all photos to "all" folder. if empty and all_albums is true download all folders
albums:
Expand Down
119 changes: 73 additions & 46 deletions src/config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,59 +280,86 @@ def get_photos_remove_obsolete(config):

def get_photos_filters(config):
"""Return photos filters from config."""
photos_filters = {"albums": None, "file_sizes": ["original"], "extensions": None}
photos_filters = {
"libraries": None,
"albums": None,
"file_sizes": ["original"],
"extensions": None,
}
valid_file_sizes = ["original", "medium", "thumb"]
config_path = ["photos", "filters"]

# Check for filters
if not traverse_config_path(config=config, config_path=config_path):
LOGGER.warning(
f"{config_path_to_string(config_path=config_path)} not found. Downloading all albums with original size ..."
f"{config_path_to_string(config_path=config_path)} not found. \
Downloading all libraries and albums with original size ..."
)
return photos_filters

# Parse libraries
config_path.append("libraries")
if (
not traverse_config_path(config=config, config_path=config_path)
or not get_config_value(config=config, config_path=config_path)
or len(get_config_value(config=config, config_path=config_path)) == 0
):
LOGGER.warning(
f"{config_path_to_string(config_path=config_path)} not found. Downloading all libraries ..."
)
else:
config_path.append("albums")
if (
not traverse_config_path(config=config, config_path=config_path)
or not get_config_value(config=config, config_path=config_path)
or len(get_config_value(config=config, config_path=config_path)) == 0
):
LOGGER.warning(
f"{config_path_to_string(config_path=config_path)} not found. Downloading all albums ..."
)
else:
photos_filters["albums"] = get_config_value(
config=config, config_path=config_path
)
photos_filters["libraries"] = get_config_value(
config=config, config_path=config_path
)

config_path[2] = "file_sizes"
if not traverse_config_path(config=config, config_path=config_path):
LOGGER.warning(
f"{config_path_to_string(config_path=config_path)} not found. Downloading original size photos ..."
)
else:
file_sizes = get_config_value(config=config, config_path=config_path)
for file_size in file_sizes:
if file_size not in valid_file_sizes:
LOGGER.warning(
f"Skipping the invalid file size {file_size}, "
+ f"valid file sizes are {','.join(valid_file_sizes)}."
)
file_sizes.remove(file_size)
if len(file_sizes) == 0:
file_sizes = ["original"]
photos_filters["file_sizes"] = file_sizes

config_path[2] = "extensions"
if (
not traverse_config_path(config=config, config_path=config_path)
or not get_config_value(config=config, config_path=config_path)
or len(get_config_value(config=config, config_path=config_path)) == 0
):
LOGGER.warning(
f"{config_path_to_string(config_path=config_path)} not found. Downloading all extensions ..."
)
else:
photos_filters["extensions"] = get_config_value(
config=config, config_path=config_path
)
# Parse albums
config_path[2] = "albums"
if (
not traverse_config_path(config=config, config_path=config_path)
or not get_config_value(config=config, config_path=config_path)
or len(get_config_value(config=config, config_path=config_path)) == 0
):
LOGGER.warning(
f"{config_path_to_string(config_path=config_path)} not found. Downloading all albums ..."
)
else:
photos_filters["albums"] = get_config_value(
config=config, config_path=config_path
)

# Parse file sizes
config_path[2] = "file_sizes"
if not traverse_config_path(config=config, config_path=config_path):
LOGGER.warning(
f"{config_path_to_string(config_path=config_path)} not found. Downloading original size photos ..."
)
else:
file_sizes = get_config_value(config=config, config_path=config_path)
for file_size in file_sizes:
if file_size not in valid_file_sizes:
LOGGER.warning(
f"Skipping the invalid file size {file_size}, "
+ f"valid file sizes are {','.join(valid_file_sizes)}."
)
file_sizes.remove(file_size)
if len(file_sizes) == 0:
file_sizes = ["original"]
photos_filters["file_sizes"] = file_sizes

# Parse extensions
config_path[2] = "extensions"
if (
not traverse_config_path(config=config, config_path=config_path)
or not get_config_value(config=config, config_path=config_path)
or len(get_config_value(config=config, config_path=config_path)) == 0
):
LOGGER.warning(
f"{config_path_to_string(config_path=config_path)} not found. Downloading all extensions ..."
)
else:
photos_filters["extensions"] = get_config_value(
config=config, config_path=config_path
)

return photos_filters

Expand Down
54 changes: 29 additions & 25 deletions src/sync_photos.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,38 +162,42 @@ def sync_photos(config, photos):
filters = config_parser.get_photos_filters(config=config)
files = set()
download_all = config_parser.get_photos_all_albums(config=config)
libraries = (
filters["libraries"] if filters["libraries"] is not None else photos.libraries
)
folder_format = config_parser.get_photos_folder_format(config=config)
if download_all:
for album in photos.albums.keys():
if filters["albums"] and album in iter(filters["albums"]):
continue
sync_album(
album=photos.albums[album],
destination_path=os.path.join(destination_path, album),
file_sizes=filters["file_sizes"],
extensions=filters["extensions"],
files=files,
folder_format=folder_format,
)
elif filters["albums"]:
for album in iter(filters["albums"]):
for library in libraries:
if download_all and library == "PrimarySync":
for album in photos.libraries[library].albums.keys():
if filters["albums"] and album in iter(filters["albums"]):
continue
sync_album(
album=photos.libraries[library].albums[album],
destination_path=os.path.join(destination_path, album),
file_sizes=filters["file_sizes"],
extensions=filters["extensions"],
files=files,
folder_format=folder_format,
)
elif filters["albums"] and library == "PrimarySync":
for album in iter(filters["albums"]):
sync_album(
album=photos.libraries[library].albums[album],
destination_path=os.path.join(destination_path, album),
file_sizes=filters["file_sizes"],
extensions=filters["extensions"],
files=files,
folder_format=folder_format,
)
else:
sync_album(
album=photos.albums[album],
destination_path=os.path.join(destination_path, album),
album=photos.libraries[library].all,
destination_path=os.path.join(destination_path, "all"),
file_sizes=filters["file_sizes"],
extensions=filters["extensions"],
files=files,
folder_format=folder_format,
)
else:
sync_album(
album=photos.all,
destination_path=os.path.join(destination_path, "all"),
file_sizes=filters["file_sizes"],
extensions=filters["extensions"],
files=files,
folder_format=folder_format,
)

if config_parser.get_photos_remove_obsolete(config=config):
remove_obsolete(destination_path, files)
Expand Down
67 changes: 66 additions & 1 deletion tests/data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,35 @@
# Data
AUTH_OK = {"authType": "hsa2"}

ZONES_LIST_WORKING = {
"zones": [
{
"zoneID": {
"zoneName": "PrimarySync",
"ownerRecordName": "_fvhhqlzef1uvsgxnrw119mylkpjut1a0",
"zoneType": "REGULAR_CUSTOM_ZONE",
},
"syncToken": "HwoECJGaGRgAIhYI/ZL516KyxaXfARDm2sbu7KeQiZABKAA=",
"atomic": True,
"isEligibleForZoneShare": True,
"isEligibleForHierarchicalShare": True,
"ttl": 0,
"disableZoneDuringTtl": True,
},
{
"zoneID": {
"zoneName": "SharedSync-9DD9B767-9F30-4D6F-B658-F17DBA16D107",
"ownerRecordName": "_fvhhqlzef1uvsgxnrw119mylkpjut1a0",
"zoneType": "REGULAR_CUSTOM_ZONE",
},
"syncToken": "HwoECLiXBRgAIhUIs5Xxntqrrr9UEOLj2/q+geCjpAEoAA==",
"atomic": True,
"isEligibleForZoneShare": True,
"isEligibleForHierarchicalShare": False,
},
]
}

LOGIN_WORKING = {
"dsInfo": {
"lastName": LAST_NAME,
Expand Down Expand Up @@ -3661,7 +3690,7 @@
"deleted": False,
"zoneID": {
"zoneName": "PrimarySync",
"ownerRecordName": "_0b5c3c201b3a7f1daac8ff7e7fbc0c35",
"ownerRecordName": "_fvhhqlzef1uvsgxnrw119mylkpjut1a0",
"zoneType": "REGULAR_CUSTOM_ZONE",
},
}
Expand Down Expand Up @@ -3855,6 +3884,8 @@ def request(self, method, url, **kwargs):

# Photos
if "com.apple.photos.cloud" in url:
if url.endswith("zones/list"):
return ResponseMock(ZONES_LIST_WORKING)
if "query?remapEnums=True&getCurrentSyncToken=True" in url:
if data.get("query").get("recordType") == "CheckIndexingState":
return ResponseMock(
Expand Down Expand Up @@ -3962,12 +3993,37 @@ def request(self, method, url, **kwargs):
"query?remapEnums=True&getCurrentSyncToken=True"
][7]["response"]
)
if (
"zoneID" in data
and data.get("zoneID").get("zoneName")
== "SharedSync-9DD9B767-9F30-4D6F-B658-F17DBA16D107"
):
return ResponseMock(
photos_data.DATA[
"query?remapEnums=True&getCurrentSyncToken=True"
][8]["response"]
)

return ResponseMock(
photos_data.DATA[
"query?remapEnums=True&getCurrentSyncToken=True"
][1]["response"]
)
if (
data.get("query").get("recordType")
== "CPLAssetAndMasterByAddedDate"
):
if data.get("query").get("filterBy")[0]["fieldValue"]["value"] == 0:
return ResponseMock(
photos_data.DATA[
"query?remapEnums=True&getCurrentSyncToken=True"
][9]["response"]
)
return ResponseMock(
photos_data.DATA[
"query?remapEnums=True&getCurrentSyncToken=True"
][8]["response"]
)
if (
data.get("query").get("recordType")
== "CPLContainerRelationLiveByAssetDate"
Expand Down Expand Up @@ -4013,6 +4069,9 @@ def request(self, method, url, **kwargs):
# IMG_3148.JPG another device
or "https://cvws.icloud-content.com/B/ATTRy6p-Q3U1HqcF6BUKrrOMnjvoATqG89bMsXhtmMRMw009uhyJc_Kh"
in url
# IMG_5513.HEIC Shared Library
or "https://cvws.icloud-content.com/B/AQDN6auXvelQyb_btBqkNNjA97E2AZ_h3_ZBuSDV7J1SfMKpllmP-FGN"
in url
):
return ResponseMock(
{},
Expand Down Expand Up @@ -4040,6 +4099,9 @@ def request(self, method, url, **kwargs):
# IMG_3148.JPG another device
or "https://cvws.icloud-content.com/B/Ab_8kUAhnGzSxnl9yWvh8JKBpOvWAVLSGMHt-PAQ9_krqqfXATNX57d5"
in url
# IMG_5513.HEIC Shared Library
or "https://cvws.icloud-content.com/B/AY4eS1ezj9pmMHzfVzwC2CLmBwZOAXKLBx985QzfCKCGyN0wbGs6SuTf"
in url
):
return ResponseMock(
{},
Expand Down Expand Up @@ -4069,6 +4131,9 @@ def request(self, method, url, **kwargs):
# IMG_3148.JPG another device
or "https://cvws.icloud-content.com/B/AQNND5zpteAXnnBP2BmDd0ropjY0AV2Zh7WygJu74eNWVuuMT4lM8qme"
in url
# IMG_5513.HEIC Shared Library
or "https://cvws.icloud-content.com/B/Aa_QVPVEM9bvm5Owy3GRFNyqbKuXAbgec55EhUFp9db5znXM3Xz-nq1X"
in url
):
return ResponseMock(
{},
Expand Down
Loading