Coverage for src/album_sync_orchestrator.py: 100%

68 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-05 00:26 +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 DEFAULT_ENUMERATION_CHUNK_SIZE, config_parser, 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# DEFAULT_ENUMERATION_CHUNK_SIZE lives in src/__init__.py (with the other 

25# DEFAULT_* config constants) and is re-exported here for backward-compat. 

26# Picked to keep peak RSS bounded at ~10–20 MB of DownloadTaskInfo objects 

27# per chunk on typical iCloud libraries while still giving 

28# execute_parallel_downloads enough work to amortize HTTP connection setup. 

29# Users can override via ``photos.enumeration_chunk_size`` in config.yaml. 

30 

31 

32def sync_album_photos( 

33 album, 

34 destination_path: str, 

35 file_sizes: list[str], 

36 extensions: list[str] | None = None, 

37 files: set[str] | None = None, 

38 folder_format: str | None = None, 

39 hardlink_registry: HardlinkRegistry | None = None, 

40 config=None, 

41) -> tuple[int, int] | None: 

42 """Sync photos from given album. 

43 

44 This function orchestrates the synchronization of a single album by: 

45 1. Creating the destination directory 

46 2. Collecting download tasks for wanted photos 

47 3. Executing downloads in parallel 

48 4. Recursively syncing subalbums 

49 

50 Args: 

51 album: Album object from iCloudPy 

52 destination_path: Path where photos should be saved 

53 file_sizes: List of file size variants to download 

54 extensions: List of allowed file extensions (None = all allowed) 

55 files: Set to track downloaded files 

56 folder_format: strftime format string for folder organization 

57 hardlink_registry: Registry for tracking downloaded files for hardlinks 

58 config: Configuration dictionary 

59 

60 Returns: 

61 Tuple of (total_successful, total_failed) download counts, or None on invalid input 

62 """ 

63 if album is None or destination_path is None or file_sizes is None: 

64 return None 

65 

66 # Create destination directory with normalized path 

67 normalized_destination = normalize_file_path(destination_path) 

68 os.makedirs(normalized_destination, exist_ok=True) 

69 LOGGER.info(f"Syncing {album.title}") 

70 

71 # Stream the album in fixed-size chunks: collect → download → 

72 # release → next chunk. Memory is bounded by chunk_size × per-task 

73 # size (~10 MB at chunk=1000) instead of len(album) × per-task size 

74 # (which OOM-kills containers on ~100K+ libraries: empirically a 

75 # 111K-photo library peaks at ~4 GB RSS without chunking, kernel- 

76 # confirmed via cgroup OOM at the 4 GB cap). 

77 chunk_size = config_parser.get_photos_enumeration_chunk_size(config=config) 

78 total_successful, total_failed = _collect_and_execute_album_in_chunks( 

79 album, 

80 normalized_destination, 

81 file_sizes, 

82 extensions, 

83 files, 

84 folder_format, 

85 hardlink_registry, 

86 config, 

87 chunk_size=chunk_size, 

88 ) 

89 

90 # Recursively sync subalbums and aggregate counts 

91 sub_successful, sub_failed = _sync_subalbums( 

92 album, 

93 normalized_destination, 

94 file_sizes, 

95 extensions, 

96 files, 

97 folder_format, 

98 hardlink_registry, 

99 config, 

100 ) 

101 total_successful += sub_successful 

102 total_failed += sub_failed 

103 

104 return total_successful, total_failed 

105 

106 

107def _collect_photo_download_tasks( 

108 photo: Any, 

109 destination_path: str, 

110 file_sizes: list[str], 

111 extensions: list[str] | None, 

112 files: set[str] | None, 

113 folder_format: str | None, 

114 hardlink_registry: HardlinkRegistry | None, 

115) -> list[DownloadTaskInfo]: 

116 """Collect download tasks for a single photo, handling errors gracefully. 

117 

118 Wraps per-photo processing so that exceptions (e.g. binascii.Error from 

119 iCloudPy's base64-encoded filename decoding) are caught at the photo level 

120 rather than inside the album iteration loop (avoids PERF203). 

121 

122 Args: 

123 photo: Photo object from iCloudPy 

124 destination_path: Path where photos should be saved 

125 file_sizes: List of file size variants to download 

126 extensions: List of allowed file extensions 

127 files: Set to track downloaded files 

128 folder_format: strftime format string for folder organization 

129 hardlink_registry: Registry for tracking downloaded files 

130 

131 Returns: 

132 List of download tasks for this photo (empty on error or if unwanted) 

133 """ 

134 try: 

135 if not is_photo_wanted(photo, extensions): 

136 LOGGER.debug(f"Skipping the unwanted photo {photo.filename}.") 

137 return [] 

138 tasks: list[DownloadTaskInfo] = [] 

139 for file_size in file_sizes: 

140 download_info = collect_download_task( 

141 photo, 

142 file_size, 

143 destination_path, 

144 files, 

145 folder_format, 

146 hardlink_registry, 

147 ) 

148 if download_info: 

149 tasks.append(download_info) 

150 # Live Photos: add "live_video_original" (or _medium/_thumb) to 

151 # photos.filters.file_sizes to pull the paired .mov. It flows through 

152 # the loop above like any other version; non-Live-Photos don't have 

153 # those versions and are skipped (quietly -- see collect_download_task). 

154 return tasks 

155 except Exception as e: 

156 try: 

157 photo_id = photo.id 

158 except AttributeError: 

159 photo_id = "<unknown>" 

160 LOGGER.warning( 

161 f"Error processing photo (id: {photo_id}), skipping: {type(e).__name__}: {e!s}", 

162 ) 

163 return [] 

164 

165 

166def _collect_and_execute_album_in_chunks( 

167 album, 

168 destination_path: str, 

169 file_sizes: list[str], 

170 extensions: list[str] | None, 

171 files: set[str] | None, 

172 folder_format: str | None, 

173 hardlink_registry: HardlinkRegistry | None, 

174 config, 

175 chunk_size: int = DEFAULT_ENUMERATION_CHUNK_SIZE, 

176) -> tuple[int, int]: 

177 """Stream album → fixed-size chunks → download → release. 

178 

179 Buffers up to ``chunk_size`` download tasks, then drains them via 

180 ``execute_parallel_downloads`` and clears the buffer before 

181 collecting the next chunk. Memory is bounded by chunk_size, not by 

182 len(album). Semantically equivalent to building the full task list 

183 and downloading once — same total counts, same per-photo 

184 side-effects — but resident-set stays flat instead of growing 

185 monotonically through enumeration. 

186 

187 Args: 

188 album: Album object from iCloudPy 

189 destination_path: Path where photos should be saved 

190 file_sizes: List of file size variants to download 

191 extensions: List of allowed file extensions 

192 files: Set to track downloaded files 

193 folder_format: strftime format string for folder organization 

194 hardlink_registry: Registry for tracking downloaded files 

195 config: Configuration dictionary (passed through to 

196 ``execute_parallel_downloads`` for per-album thread count) 

197 chunk_size: Tasks to buffer before draining. Smaller = lower 

198 peak memory but more per-chunk HTTP setup overhead. 

199 

200 Returns: 

201 Tuple of (total_successful, total_failed) summed across chunks. 

202 """ 

203 if chunk_size <= 0: 

204 # Degenerate config; fall back to default rather than refusing 

205 # to sync. Logging is at INFO so operators see the fallback. 

206 LOGGER.info( 

207 f"Invalid photos.enumeration_chunk_size={chunk_size!r}; " 

208 f"using default {DEFAULT_ENUMERATION_CHUNK_SIZE}.", 

209 ) 

210 chunk_size = DEFAULT_ENUMERATION_CHUNK_SIZE 

211 

212 buffer: list[DownloadTaskInfo] = [] 

213 total_successful = 0 

214 total_failed = 0 

215 

216 def _drain(): 

217 nonlocal total_successful, total_failed, buffer 

218 if not buffer: 

219 return 

220 succ, fail = execute_parallel_downloads(buffer, config) 

221 total_successful += succ 

222 total_failed += fail 

223 # Rebind to a fresh list rather than clearing in place: the old 

224 # list was just handed to execute_parallel_downloads, so a fresh 

225 # object gives each chunk an independent buffer (no aliasing of 

226 # an already-passed list) and lets the old chunk's task objects 

227 # (and the photo refs they hold) be collected immediately. 

228 buffer = [] 

229 

230 for photo in album: 

231 buffer.extend( 

232 _collect_photo_download_tasks( 

233 photo, 

234 destination_path, 

235 file_sizes, 

236 extensions, 

237 files, 

238 folder_format, 

239 hardlink_registry, 

240 ), 

241 ) 

242 if len(buffer) >= chunk_size: 

243 _drain() 

244 

245 _drain() # final partial chunk 

246 

247 return total_successful, total_failed 

248 

249 

250def _sync_subalbums( 

251 album, 

252 destination_path: str, 

253 file_sizes: list[str], 

254 extensions: list[str] | None, 

255 files: set[str] | None, 

256 folder_format: str | None, 

257 hardlink_registry: HardlinkRegistry | None, 

258 config, 

259) -> tuple[int, int]: 

260 """Recursively sync all subalbums. 

261 

262 Args: 

263 album: Album object from iCloudPy 

264 destination_path: Base path where subalbums should be created 

265 file_sizes: List of file size variants to download 

266 extensions: List of allowed file extensions 

267 files: Set to track downloaded files 

268 folder_format: strftime format string for folder organization 

269 hardlink_registry: Registry for tracking downloaded files 

270 config: Configuration dictionary 

271 

272 Returns: 

273 Tuple of (total_successful, total_failed) aggregated across all subalbums 

274 """ 

275 total_successful, total_failed = 0, 0 

276 for subalbum in album.subalbums: 

277 result = sync_album_photos( 

278 album.subalbums[subalbum], 

279 os.path.join(destination_path, subalbum), 

280 file_sizes, 

281 extensions, 

282 files, 

283 folder_format, 

284 hardlink_registry, 

285 config, 

286 ) 

287 if result is not None: 

288 sub_successful, sub_failed = result 

289 total_successful += sub_successful 

290 total_failed += sub_failed 

291 return total_successful, total_failed