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

1"""Album synchronization orchestration module. 

2 

3This module contains the main album sync orchestration logic 

4that coordinates photo filtering, download collection, and parallel execution. 

5""" 

6 

7___author___ = "Mandar Patil <mandarons@pm.me>" 

8 

9import os 

10from typing import Any 

11 

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 

21 

22LOGGER = get_logger() 

23 

24 

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. 

36 

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 

42 

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 

52 

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 

58 

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}") 

63 

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 ) 

74 

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) 

79 

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 

93 

94 return total_successful, total_failed 

95 

96 

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. 

107 

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). 

111 

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 

120 

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 [] 

148 

149 

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. 

160 

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 

169 

170 Returns: 

171 List of download tasks to execute 

172 """ 

173 download_tasks = [] 

174 

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 ) 

187 

188 return download_tasks 

189 

190 

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. 

202 

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 

212 

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