Repository: mandarons/icloud-drive-docker
Pull request #158 (2023-10-07 08:52:36)

Add support for `photos > folder_format` config option

mandarons: @tymmej Thank you for adding tests.

Patch:
diff --git a/README.md b/README.md
index 3fdebfd6..afbd9869 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/config.yaml b/config.yaml
index 3406030d..a360111e 100644
--- a/config.yaml
+++ b/config.yaml
@@ -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
diff --git a/src/config_parser.py b/src/config_parser.py
index 58a58f70..3de28131 100644
--- a/src/config_parser.py
+++ b/src/config_parser.py
@@ -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
diff --git a/src/sync_photos.py b/src/sync_photos.py
index fbb864e7..da9de171 100644
--- a/src/sync_photos.py
+++ b/src/sync_photos.py
@@ -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, ""]
@@ -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):
@@ -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(
@@ -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
@@ -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:
@@ -120,6 +136,7 @@ def sync_album(album, destination_path, file_sizes, extensions=None, files=None)
             file_sizes,
             extensions,
             files,
+            folder_format,
         )
     return True
 
@@ -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(
@@ -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"]):
@@ -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(
@@ -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):


Test patch:
diff --git a/tests/data/test_config.yaml b/tests/data/test_config.yaml
index bcc0e717..b3873f34 100644
--- a/tests/data/test_config.yaml
+++ b/tests/data/test_config.yaml
@@ -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
diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py
index 402bcc79..c2b1c996 100644
--- a/tests/test_config_parser.py
+++ b/tests/test_config_parser.py
@@ -417,3 +417,13 @@ class TestConfigParser(unittest.TestCase):
         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")
diff --git a/tests/test_sync_photos.py b/tests/test_sync_photos.py
index d1821d88..e2483b8d 100644
--- a/tests/test_sync_photos.py
+++ b/tests/test_sync_photos.py
@@ -89,6 +89,34 @@ class TestSyncPhotos(unittest.TestCase):
         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
@@ -491,6 +519,7 @@ class TestSyncPhotos(unittest.TestCase):
                 file_size="medium",
                 destination_path=self.destination_path,
                 files=None,
+                folder_format=None,
             )
         )
 
@@ -516,6 +545,7 @@ class TestSyncPhotos(unittest.TestCase):
                 file_size="thumb",
                 destination_path=self.destination_path,
                 files=None,
+                folder_format=None,
             )
         )