Coverage for src/album_sync_orchestrator.py: 100%
55 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-06 02:49 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-06 02:49 +0000
1"""Album synchronization orchestration module.
3This module contains the main album sync orchestration logic
4that coordinates photo filtering, download collection, and parallel execution.
5"""
7___author___ = "Mandar Patil <mandarons@pm.me>"
9import os
10from typing import Any
12from src import get_logger
13from src.hardlink_registry import HardlinkRegistry
14from src.photo_download_manager import (
15 DownloadTaskInfo,
16 collect_download_task,
17 execute_parallel_downloads,
18)
19from src.photo_filter_utils import is_photo_wanted
20from src.photo_path_utils import normalize_file_path
22LOGGER = get_logger()
25def sync_album_photos(
26 album,
27 destination_path: str,
28 file_sizes: list[str],
29 extensions: list[str] | None = None,
30 files: set[str] | None = None,
31 folder_format: str | None = None,
32 hardlink_registry: HardlinkRegistry | None = None,
33 config=None,
34) -> tuple[int, int] | None:
35 """Sync photos from given album.
37 This function orchestrates the synchronization of a single album by:
38 1. Creating the destination directory
39 2. Collecting download tasks for wanted photos
40 3. Executing downloads in parallel
41 4. Recursively syncing subalbums
43 Args:
44 album: Album object from iCloudPy
45 destination_path: Path where photos should be saved
46 file_sizes: List of file size variants to download
47 extensions: List of allowed file extensions (None = all allowed)
48 files: Set to track downloaded files
49 folder_format: strftime format string for folder organization
50 hardlink_registry: Registry for tracking downloaded files for hardlinks
51 config: Configuration dictionary
53 Returns:
54 Tuple of (total_successful, total_failed) download counts, or None on invalid input
55 """
56 if album is None or destination_path is None or file_sizes is None:
57 return None
59 # Create destination directory with normalized path
60 normalized_destination = normalize_file_path(destination_path)
61 os.makedirs(normalized_destination, exist_ok=True)
62 LOGGER.info(f"Syncing {album.title}")
64 # Collect download tasks for photos
65 download_tasks = _collect_album_download_tasks(
66 album,
67 normalized_destination,
68 file_sizes,
69 extensions,
70 files,
71 folder_format,
72 hardlink_registry,
73 )
75 # Execute downloads in parallel if there are tasks
76 total_successful, total_failed = 0, 0
77 if download_tasks:
78 total_successful, total_failed = execute_parallel_downloads(download_tasks, config)
80 # Recursively sync subalbums and aggregate counts
81 sub_successful, sub_failed = _sync_subalbums(
82 album,
83 normalized_destination,
84 file_sizes,
85 extensions,
86 files,
87 folder_format,
88 hardlink_registry,
89 config,
90 )
91 total_successful += sub_successful
92 total_failed += sub_failed
94 return total_successful, total_failed
97def _collect_photo_download_tasks(
98 photo: Any,
99 destination_path: str,
100 file_sizes: list[str],
101 extensions: list[str] | None,
102 files: set[str] | None,
103 folder_format: str | None,
104 hardlink_registry: HardlinkRegistry | None,
105) -> list[DownloadTaskInfo]:
106 """Collect download tasks for a single photo, handling errors gracefully.
108 Wraps per-photo processing so that exceptions (e.g. binascii.Error from
109 iCloudPy's base64-encoded filename decoding) are caught at the photo level
110 rather than inside the album iteration loop (avoids PERF203).
112 Args:
113 photo: Photo object from iCloudPy
114 destination_path: Path where photos should be saved
115 file_sizes: List of file size variants to download
116 extensions: List of allowed file extensions
117 files: Set to track downloaded files
118 folder_format: strftime format string for folder organization
119 hardlink_registry: Registry for tracking downloaded files
121 Returns:
122 List of download tasks for this photo (empty on error or if unwanted)
123 """
124 try:
125 if not is_photo_wanted(photo, extensions):
126 LOGGER.debug(f"Skipping the unwanted photo {photo.filename}.")
127 return []
128 tasks: list[DownloadTaskInfo] = []
129 for file_size in file_sizes:
130 download_info = collect_download_task(
131 photo,
132 file_size,
133 destination_path,
134 files,
135 folder_format,
136 hardlink_registry,
137 )
138 if download_info:
139 tasks.append(download_info)
140 return tasks
141 except Exception as e:
142 try:
143 photo_id = photo.id
144 except Exception:
145 photo_id = "<unknown>"
146 LOGGER.warning(f"Error processing photo (id: {photo_id}), skipping: {type(e).__name__}: {e!s}")
147 return []
150def _collect_album_download_tasks(
151 album,
152 destination_path: str,
153 file_sizes: list[str],
154 extensions: list[str] | None,
155 files: set[str] | None,
156 folder_format: str | None,
157 hardlink_registry: HardlinkRegistry | None,
158) -> list:
159 """Collect download tasks for all photos in an album.
161 Args:
162 album: Album object from iCloudPy
163 destination_path: Path where photos should be saved
164 file_sizes: List of file size variants to download
165 extensions: List of allowed file extensions
166 files: Set to track downloaded files
167 folder_format: strftime format string for folder organization
168 hardlink_registry: Registry for tracking downloaded files
170 Returns:
171 List of download tasks to execute
172 """
173 download_tasks = []
175 for photo in album:
176 download_tasks.extend(
177 _collect_photo_download_tasks(
178 photo,
179 destination_path,
180 file_sizes,
181 extensions,
182 files,
183 folder_format,
184 hardlink_registry,
185 ),
186 )
188 return download_tasks
191def _sync_subalbums(
192 album,
193 destination_path: str,
194 file_sizes: list[str],
195 extensions: list[str] | None,
196 files: set[str] | None,
197 folder_format: str | None,
198 hardlink_registry: HardlinkRegistry | None,
199 config,
200) -> tuple[int, int]:
201 """Recursively sync all subalbums.
203 Args:
204 album: Album object from iCloudPy
205 destination_path: Base path where subalbums should be created
206 file_sizes: List of file size variants to download
207 extensions: List of allowed file extensions
208 files: Set to track downloaded files
209 folder_format: strftime format string for folder organization
210 hardlink_registry: Registry for tracking downloaded files
211 config: Configuration dictionary
213 Returns:
214 Tuple of (total_successful, total_failed) aggregated across all subalbums
215 """
216 total_successful, total_failed = 0, 0
217 for subalbum in album.subalbums:
218 result = sync_album_photos(
219 album.subalbums[subalbum],
220 os.path.join(destination_path, subalbum),
221 file_sizes,
222 extensions,
223 files,
224 folder_format,
225 hardlink_registry,
226 config,
227 )
228 if result is not None:
229 sub_successful, sub_failed = result
230 total_successful += sub_successful
231 total_failed += sub_failed
232 return total_successful, total_failed