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 photos > folder_format config option #158

Merged
merged 5 commits into from
Oct 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ photos:
remove_obsolete: false
sync_interval: 500
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:
# if all_albums is false list of albums to download, if all_albums is true list of ignored albums
# if empty and all_albums is false download all photos to "all" folder. if empty and all_albums is true download all folders
Expand Down
1 change: 1 addition & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ photos:
remove_obsolete: false
sync_interval: 500
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:
# if all_albums is false list of albums to download, if all_albums is true list of ignored albums
# if empty and all_albums is false download all photos to "all" folder. if empty and all_albums is true download all folders
Expand Down
10 changes: 10 additions & 0 deletions src/config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,3 +355,13 @@ def get_region(config):
region = "global"

return region


def get_photos_folder_format(config):
"""Return filename format or None."""
fmt = None
config_path = ["photos", "folder_format"]
if traverse_config_path(config=config, config_path=config_path):
fmt = get_config_value(config=config, config_path=config_path)
LOGGER.info(f"Using format {fmt}.")
return fmt
31 changes: 26 additions & 5 deletions src/sync_photos.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def photo_wanted(photo, extensions):
return False


def generate_file_name(photo, file_size, destination_path):
def generate_file_name(photo, file_size, destination_path, folder_format):
"""Generate full path to file."""
filename = photo.filename
name, extension = filename.rsplit(".", 1) if "." in filename else [filename, ""]
Expand All @@ -40,6 +40,17 @@ def generate_file_name(photo, file_size, destination_path):
else f'{"__".join([name, file_size, base64.urlsafe_b64encode(photo.id.encode()).decode()])}.{extension}',
)

if folder_format is not None:
folder = photo.created.strftime(folder_format)
file_size_id_path = os.path.join(
destination_path,
folder,
f'{"__".join([name, file_size, base64.urlsafe_b64encode(photo.id.encode()).decode()])}'
if extension == ""
else f'{"__".join([name, file_size, base64.urlsafe_b64encode(photo.id.encode()).decode()])}.{extension}',
)
os.makedirs(os.path.join(destination_path, folder), exist_ok=True)

file_size_id_path_norm = unicodedata.normalize("NFC", file_size_id_path)

if os.path.isfile(file_path):
Expand Down Expand Up @@ -83,10 +94,13 @@ def download_photo(photo, file_size, destination_path):
return True


def process_photo(photo, file_size, destination_path, files):
def process_photo(photo, file_size, destination_path, files, folder_format):
"""Process photo details."""
photo_path = generate_file_name(
photo=photo, file_size=file_size, destination_path=destination_path
photo=photo,
file_size=file_size,
destination_path=destination_path,
folder_format=folder_format,
)
if file_size not in photo.versions:
LOGGER.warning(
Expand All @@ -101,7 +115,9 @@ def process_photo(photo, file_size, destination_path, files):
return True


def sync_album(album, destination_path, file_sizes, extensions=None, files=None):
def sync_album(
album, destination_path, file_sizes, extensions=None, files=None, folder_format=None
):
"""Sync given album."""
if album is None or destination_path is None or file_sizes is None:
return None
Expand All @@ -110,7 +126,7 @@ def sync_album(album, destination_path, file_sizes, extensions=None, files=None)
for photo in album:
if photo_wanted(photo, extensions):
for file_size in file_sizes:
process_photo(photo, file_size, destination_path, files)
process_photo(photo, file_size, destination_path, files, folder_format)
else:
LOGGER.debug(f"Skipping the unwanted photo {photo.filename}.")
for subalbum in album.subalbums:
Expand All @@ -120,6 +136,7 @@ def sync_album(album, destination_path, file_sizes, extensions=None, files=None)
file_sizes,
extensions,
files,
folder_format,
)
return True

Expand All @@ -145,6 +162,7 @@ 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)
folder_format = config_parser.get_photos_folder_format(config=config)
if download_all:
for album in photos.albums.keys():
sync_album(
Expand All @@ -153,6 +171,7 @@ def sync_photos(config, photos):
file_sizes=filters["file_sizes"],
extensions=filters["extensions"],
files=files,
folder_format=folder_format,
)
elif filters["albums"]:
for album in iter(filters["albums"]):
Expand All @@ -162,6 +181,7 @@ def sync_photos(config, photos):
file_sizes=filters["file_sizes"],
extensions=filters["extensions"],
files=files,
folder_format=folder_format,
)
else:
sync_album(
Expand All @@ -170,6 +190,7 @@ def sync_photos(config, photos):
file_sizes=filters["file_sizes"],
extensions=filters["extensions"],
files=files,
folder_format=folder_format,
)

if config_parser.get_photos_remove_obsolete(config=config):
Expand Down
1 change: 1 addition & 0 deletions tests/data/test_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ photos:
remove_obsolete: false
sync_interval: -1
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:
# if all_albums is false list of albums to download, if all_albums is true list of ignored albums
# if empty and all_albums is false download all photos to "all" folder. if empty and all_albums is true download all folders
Expand Down
10 changes: 10 additions & 0 deletions tests/test_config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,3 +417,13 @@ def test_get_all_albums_false(self):
config["photos"]["all_albums"] = False
self.assertFalse(config_parser.get_photos_all_albums(config=config))

def test_get_photos_folder_format_empty(self):
"""Empty folder_format."""
config = read_config(config_path=tests.CONFIG_PATH)
self.assertIsNone(config_parser.get_photos_folder_format(config=config))

def test_get_photos_folder_format_valid(self):
"""folder_format is set."""
config = read_config(config_path=tests.CONFIG_PATH)
config["photos"]["folder_format"] = "%Y/%m"
self.assertEqual(config_parser.get_photos_folder_format(config=config), "%Y/%m")
30 changes: 30 additions & 0 deletions tests/test_sync_photos.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,34 @@ def test_sync_photos_all_albums(
self.assertTrue(len(os.listdir(album_0_path)) > 1)
self.assertTrue(len(os.listdir(album_1_path)) > 0)

@patch(target="keyring.get_password", return_value=data.VALID_PASSWORD)
@patch(
target="src.config_parser.get_username", return_value=data.AUTHENTICATED_USER
)
@patch("icloudpy.ICloudPyService")
@patch("src.read_config")
def test_sync_photos_folder_format(
self, mock_read_config, mock_service, mock_get_username, mock_get_password
):
"""Test for successful original photo size download with folder format."""
mock_service = self.service
config = self.config.copy()
config["photos"]["destination"] = self.destination_path
config["photos"]["folder_format"] = "%Y/%m"
mock_read_config.return_value = config
# Sync original photos
self.assertIsNone(
sync_photos.sync_photos(config=config, photos=mock_service.photos)
)
album_0_path = os.path.join(
self.destination_path, config["photos"]["filters"]["albums"][0]
)
album_1_path = os.path.join(
self.destination_path, config["photos"]["filters"]["albums"][1]
)
self.assertTrue(os.path.isdir(os.path.join(album_0_path, "2020", "08")))
self.assertTrue(os.path.isdir(os.path.join(album_1_path, "2020", "07")))

@patch(target="keyring.get_password", return_value=data.VALID_PASSWORD)
@patch(
target="src.config_parser.get_username", return_value=data.AUTHENTICATED_USER
Expand Down Expand Up @@ -491,6 +519,7 @@ def versions(self):
file_size="medium",
destination_path=self.destination_path,
files=None,
folder_format=None,
)
)

Expand All @@ -516,6 +545,7 @@ def versions(self):
file_size="thumb",
destination_path=self.destination_path,
files=None,
folder_format=None,
)
)

Expand Down