Coverage for src/album_sync_orchestrator.py: 100%

77 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-08 21:05 +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 

10import time 

11from typing import Any 

12 

13from src import BUFFERED_DOWNLOAD_MAX_AGE_SEC, DEFAULT_ENUMERATION_CHUNK_SIZE, config_parser, get_logger 

14from src.hardlink_registry import HardlinkRegistry 

15from src.photo_download_manager import ( 

16 DownloadTaskInfo, 

17 collect_download_task, 

18 execute_parallel_downloads, 

19) 

20from src.photo_filter_utils import is_photo_wanted 

21from src.photo_path_utils import normalize_file_path 

22 

23LOGGER = get_logger() 

24 

25 

26def _now() -> float: 

27 """Monotonic clock for buffer ageing; a seam for tests.""" 

28 return time.monotonic() 

29 

30 

31# DEFAULT_ENUMERATION_CHUNK_SIZE lives in src/__init__.py (with the other 

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

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

34# per chunk on typical iCloud libraries while still giving 

35# execute_parallel_downloads enough work to amortize HTTP connection setup. 

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

37 

38 

39def sync_album_photos( 

40 album, 

41 destination_path: str, 

42 file_sizes: list[str], 

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

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

45 folder_format: str | None = None, 

46 hardlink_registry: HardlinkRegistry | None = None, 

47 config=None, 

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

49 """Sync photos from given album. 

50 

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

52 1. Creating the destination directory 

53 2. Collecting download tasks for wanted photos 

54 3. Executing downloads in parallel 

55 4. Recursively syncing subalbums 

56 

57 Args: 

58 album: Album object from iCloudPy 

59 destination_path: Path where photos should be saved 

60 file_sizes: List of file size variants to download 

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

62 files: Set to track downloaded files 

63 folder_format: strftime format string for folder organization 

64 hardlink_registry: Registry for tracking downloaded files for hardlinks 

65 config: Configuration dictionary 

66 

67 Returns: 

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

69 """ 

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

71 return None 

72 

73 # Create destination directory with normalized path 

74 normalized_destination = normalize_file_path(destination_path) 

75 os.makedirs(normalized_destination, exist_ok=True) 

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

77 

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

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

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

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

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

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

84 chunk_size = config_parser.get_photos_enumeration_chunk_size(config=config) 

85 total_successful, total_failed = _collect_and_execute_album_in_chunks( 

86 album, 

87 normalized_destination, 

88 file_sizes, 

89 extensions, 

90 files, 

91 folder_format, 

92 hardlink_registry, 

93 config, 

94 chunk_size=chunk_size, 

95 ) 

96 

97 # Recursively sync subalbums and aggregate counts 

98 sub_successful, sub_failed = _sync_subalbums( 

99 album, 

100 normalized_destination, 

101 file_sizes, 

102 extensions, 

103 files, 

104 folder_format, 

105 hardlink_registry, 

106 config, 

107 ) 

108 total_successful += sub_successful 

109 total_failed += sub_failed 

110 

111 return total_successful, total_failed 

112 

113 

114def _collect_photo_download_tasks( 

115 photo: Any, 

116 destination_path: str, 

117 file_sizes: list[str], 

118 extensions: list[str] | None, 

119 files: set[str] | None, 

120 folder_format: str | None, 

121 hardlink_registry: HardlinkRegistry | None, 

122) -> list[DownloadTaskInfo]: 

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

124 

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

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

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

128 

129 Args: 

130 photo: Photo object from iCloudPy 

131 destination_path: Path where photos should be saved 

132 file_sizes: List of file size variants to download 

133 extensions: List of allowed file extensions 

134 files: Set to track downloaded files 

135 folder_format: strftime format string for folder organization 

136 hardlink_registry: Registry for tracking downloaded files 

137 

138 Returns: 

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

140 """ 

141 try: 

142 if not is_photo_wanted(photo, extensions): 

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

144 return [] 

145 tasks: list[DownloadTaskInfo] = [] 

146 for file_size in file_sizes: 

147 download_info = collect_download_task( 

148 photo, 

149 file_size, 

150 destination_path, 

151 files, 

152 folder_format, 

153 hardlink_registry, 

154 ) 

155 if download_info: 

156 tasks.append(download_info) 

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

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

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

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

161 return tasks 

162 except Exception as e: 

163 try: 

164 photo_id = photo.id 

165 except AttributeError: 

166 photo_id = "<unknown>" 

167 LOGGER.warning( 

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

169 ) 

170 return [] 

171 

172 

173def _collect_and_execute_album_in_chunks( 

174 album, 

175 destination_path: str, 

176 file_sizes: list[str], 

177 extensions: list[str] | None, 

178 files: set[str] | None, 

179 folder_format: str | None, 

180 hardlink_registry: HardlinkRegistry | None, 

181 config, 

182 chunk_size: int = DEFAULT_ENUMERATION_CHUNK_SIZE, 

183) -> tuple[int, int]: 

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

185 

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

187 ``execute_parallel_downloads`` and clears the buffer before 

188 collecting the next chunk. The buffer also drains once its oldest 

189 task is ``BUFFERED_DOWNLOAD_MAX_AGE_SEC`` old, so download URLs (which 

190 expire ~30-40 minutes after listing) do not wait out a long walk of 

191 photos already on disk. That is checked between photos, so it is a 

192 bound on the usual wait, not a guarantee; a URL that still expires is 

193 recovered by the expired-URL refresh. Memory is bounded by chunk_size, not by 

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

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

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

197 monotonically through enumeration. 

198 

199 Args: 

200 album: Album object from iCloudPy 

201 destination_path: Path where photos should be saved 

202 file_sizes: List of file size variants to download 

203 extensions: List of allowed file extensions 

204 files: Set to track downloaded files 

205 folder_format: strftime format string for folder organization 

206 hardlink_registry: Registry for tracking downloaded files 

207 config: Configuration dictionary (passed through to 

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

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

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

211 

212 Returns: 

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

214 """ 

215 if chunk_size <= 0: 

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

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

218 LOGGER.info( 

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

220 f"using default {DEFAULT_ENUMERATION_CHUNK_SIZE}.", 

221 ) 

222 chunk_size = DEFAULT_ENUMERATION_CHUNK_SIZE 

223 

224 buffer: list[DownloadTaskInfo] = [] 

225 # When the buffer must drain regardless of size; None while it is empty. 

226 drain_by: float | None = None 

227 total_successful = 0 

228 total_failed = 0 

229 

230 def _drain(): 

231 nonlocal total_successful, total_failed, buffer, drain_by 

232 if not buffer: 

233 return 

234 succ, fail = execute_parallel_downloads(buffer, config) 

235 total_successful += succ 

236 total_failed += fail 

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

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

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

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

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

242 buffer = [] 

243 drain_by = None 

244 

245 for photo in album: 

246 tasks = _collect_photo_download_tasks( 

247 photo, 

248 destination_path, 

249 file_sizes, 

250 extensions, 

251 files, 

252 folder_format, 

253 hardlink_registry, 

254 ) 

255 if tasks and drain_by is None: 

256 drain_by = _now() + BUFFERED_DOWNLOAD_MAX_AGE_SEC 

257 buffer.extend(tasks) 

258 aged_out = drain_by is not None and _now() >= drain_by 

259 if len(buffer) >= chunk_size or aged_out: 

260 _drain() 

261 

262 _drain() # final partial chunk 

263 

264 return total_successful, total_failed 

265 

266 

267def _sync_subalbums( 

268 album, 

269 destination_path: str, 

270 file_sizes: list[str], 

271 extensions: list[str] | None, 

272 files: set[str] | None, 

273 folder_format: str | None, 

274 hardlink_registry: HardlinkRegistry | None, 

275 config, 

276) -> tuple[int, int]: 

277 """Recursively sync all subalbums. 

278 

279 Args: 

280 album: Album object from iCloudPy 

281 destination_path: Base path where subalbums should be created 

282 file_sizes: List of file size variants to download 

283 extensions: List of allowed file extensions 

284 files: Set to track downloaded files 

285 folder_format: strftime format string for folder organization 

286 hardlink_registry: Registry for tracking downloaded files 

287 config: Configuration dictionary 

288 

289 Returns: 

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

291 """ 

292 total_successful, total_failed = 0, 0 

293 for subalbum in album.subalbums: 

294 result = sync_album_photos( 

295 album.subalbums[subalbum], 

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

297 file_sizes, 

298 extensions, 

299 files, 

300 folder_format, 

301 hardlink_registry, 

302 config, 

303 ) 

304 if result is not None: 

305 sub_successful, sub_failed = result 

306 total_successful += sub_successful 

307 total_failed += sub_failed 

308 return total_successful, total_failed