Coverage for src/photo_path_utils.py: 100%

99 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-08 21:05 +0000

1"""Photo path utils 

2 Extract filename and extension from photo. 

3 

4 Args: 

5 photo: Photo object from iCloudPy 

6 file_size: File size variant (original, medium, thumb, etc.) 

7 

8 Returns: 

9 Tuple of (name, extension) where name is filename without extension 

10 and extension is the file extension. 

11 

12This module contains utilities for generating photo file paths and managing 

13file naming conventions for photo synchronization. 

14""" 

15 

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

17 

18import base64 

19import os 

20import re 

21import unicodedata 

22from urllib.parse import unquote 

23 

24from src import get_logger 

25 

26LOGGER = get_logger() 

27 

28# The Live Photo paired-video file_size variants. These are QuickTime movies, 

29# not images, even though the parent asset's filename ends in .HEIC/.JPG. 

30_LIVE_VIDEO_SIZES = frozenset({"live_video_original", "live_video_medium", "live_video_thumb"}) 

31 

32 

33def get_photo_name_and_extension(photo, file_size: str) -> tuple[str, str]: 

34 """Extract filename and extension from photo. 

35 

36 Args: 

37 photo: Photo object from iCloudPy 

38 file_size: File size variant (original, medium, thumb, etc.) 

39 

40 Returns: 

41 Tuple of (name, extension) where name is filename without extension 

42 and extension is the file extension 

43 """ 

44 # Decode URL-encoded filename from iCloud API 

45 # This handles special characters like %CC%88 (combining diacritical marks) 

46 filename = unquote(photo.filename) 

47 name, extension = filename.rsplit(".", 1) if "." in filename else [filename, ""] 

48 

49 # Handle original_alt file type mapping 

50 if file_size == "original_alt" and file_size in photo.versions: 

51 filetype = photo.versions[file_size]["type"] 

52 if filetype in _get_original_alt_filetype_mapping(): 

53 extension = _get_original_alt_filetype_mapping()[filetype] 

54 else: 

55 LOGGER.warning( 

56 f"Unknown filetype {filetype} for original_alt version of {filename}", 

57 ) 

58 

59 # Handle Live Photo paired-video versions. photo.filename is the STILL 

60 # (e.g. IMG_1234.HEIC), but the live_video_* versions are the QuickTime 

61 # movie half of the Live Photo. Without this the .mov is written with the 

62 # still's extension (IMG_1234__live_video_original__<id>.HEIC), which every 

63 # downstream image tool then rejects as "unsupported image format" because 

64 # it is really a video. Map to the real container extension instead. 

65 elif file_size in _LIVE_VIDEO_SIZES and file_size in photo.versions: 

66 filetype = photo.versions[file_size].get("type") 

67 extension = _get_video_filetype_mapping().get(filetype, "MOV") 

68 

69 return name, extension 

70 

71 

72# Module-level toggle for hiding untouched originals of edited photos via 

73# the ``.original.bak`` suffix convention. ``sync_photos`` sets this once 

74# per sync run from ``photos.preserve_originals_as_bak``. 

75_PRESERVE_ORIGINALS_AS_BAK = False 

76 

77 

78def set_preserve_originals_as_bak(value: bool) -> None: 

79 """Set the module-level toggle for hiding untouched originals via .original.bak.""" 

80 global _PRESERVE_ORIGINALS_AS_BAK 

81 _PRESERVE_ORIGINALS_AS_BAK = bool(value) 

82 

83 

84def _photo_has_alt_version(photo) -> bool: 

85 """Check whether the asset has an edited (``original_alt``) version on iCloud. 

86 

87 Soft check — exceptions reading ``photo.versions`` are treated as "no alt" 

88 so a partial CloudKit record cannot break the filename pipeline. 

89 """ 

90 try: 

91 return "original_alt" in photo.versions 

92 except Exception: 

93 return False 

94 

95 

96_TEMPLATE_TOKEN_RE = re.compile(r"\$\{([^}]+)\}") 

97_DEFAULT_FILENAME_FORMAT = "metadata" 

98 

99 

100def set_default_filename_format(filename_format: str) -> None: 

101 """Set the module-level default filename format. See get_photos_filename_format.""" 

102 global _DEFAULT_FILENAME_FORMAT 

103 if filename_format in ("metadata", "simple"): 

104 _DEFAULT_FILENAME_FORMAT = filename_format 

105 

106 

107# Module-level toggle for hiding untouched originals of edited photos via 

108# the ``.original.bak`` suffix convention. ``sync_photos`` sets this once 

109# per sync run from ``photos.preserve_originals_as_bak``. 

110def get_default_filename_format() -> str: 

111 """Read the current module-level default filename format. 

112 

113 Public accessor so callers in other modules can observe live updates 

114 from ``set_default_filename_format`` without reaching into the 

115 underscore-prefixed module global (which violates SLF001). 

116 """ 

117 return _DEFAULT_FILENAME_FORMAT 

118 

119 

120# photos.file_format: a single template applied to EVERY downloaded version 

121# (mirrors folder_format). None means "no template; use filename_format". 

122_FILE_FORMAT: str | None = None 

123_VARIANT_SEPARATOR = "_" 

124# Versions that are the "primary" copy and get no variant tag in ${photo.variant*}. 

125_PRIMARY_VERSIONS = frozenset({"original", "full"}) 

126 

127 

128def set_file_format(template: str | None, variant_separator: str = "_") -> None: 

129 """Set the single filename template + variant separator (photos.file_format).""" 

130 global _FILE_FORMAT, _VARIANT_SEPARATOR 

131 _FILE_FORMAT = template or None 

132 _VARIANT_SEPARATOR = variant_separator if variant_separator is not None else "_" 

133 

134 

135def get_file_format() -> str | None: 

136 """Read the single filename template (public accessor; avoids SLF001).""" 

137 return _FILE_FORMAT 

138 

139 

140def render_filename_template(template: str, photo, file_size: str) -> str: 

141 """Render photos.file_format for one photo version. 

142 

143 Tokens (unknown ones left literal): ``${photo.filename}`` ``${photo.ext}`` 

144 ``${photo.id}`` (base64url asset id) ``${photo.file_size}`` and the 

145 created-date parts ``${photo.year}`` ``${photo.month}`` ``${photo.day}``. 

146 

147 Variant tokens are empty for the primary versions (original/full) and the 

148 version name otherwise: ``${photo.variant}`` (bare, e.g. ``medium``) and 

149 ``${photo.variant_suffix}`` (separator + variant, e.g. ``_medium``) so the 

150 separator only appears when there actually is a variant. 

151 """ 

152 name, extension = get_photo_name_and_extension(photo, file_size) 

153 created = getattr(photo, "created", None) 

154 is_primary = file_size in _PRIMARY_VERSIONS 

155 variant = "" if is_primary else file_size 

156 variant_suffix = "" if is_primary else f"{_VARIANT_SEPARATOR}{file_size}" 

157 values = { 

158 "photo.filename": name, 

159 "photo.ext": extension, 

160 "photo.id": base64.urlsafe_b64encode(photo.id.encode()).decode(), 

161 "photo.file_size": file_size, 

162 "photo.variant": variant, 

163 "photo.variant_suffix": variant_suffix, 

164 "photo.year": f"{created.year:04d}" if created else "", 

165 "photo.month": f"{created.month:02d}" if created else "", 

166 "photo.day": f"{created.day:02d}" if created else "", 

167 } 

168 

169 def _sub(match: re.Match) -> str: 

170 token = match.group(1).strip() 

171 replacement = values.get(token) 

172 return replacement if replacement is not None else match.group(0) 

173 

174 return _TEMPLATE_TOKEN_RE.sub(_sub, template) 

175 

176 

177def generate_photo_filename_with_metadata( 

178 photo, 

179 file_size: str, 

180 filename_format: str | None = None, 

181) -> str: 

182 """Generate filename for a photo asset. 

183 

184 Two conventions (controlled by ``filename_format`` or module default): 

185 - ``"metadata"`` (default): ``name__filesize__base64id.extension`` 

186 - ``"simple"``: ``name.extension`` 

187 

188 When ``_PRESERVE_ORIGINALS_AS_BAK`` is on AND this is the ``original`` 

189 size AND the asset has an ``original_alt`` version on iCloud (edited), 

190 the filename ends with ``.original.bak`` so photo browsers skip it but 

191 the file remains filesystem-recoverable. 

192 

193 Works uniformly across both ``filename_format`` modes — the 

194 ``.original.bak`` qualifier is appended to whatever the base filename 

195 would have been (``name__filesize__base64id.ext`` in metadata mode or 

196 ``name.ext`` in simple mode). 

197 

198 Args: 

199 photo: Photo object from iCloudPy 

200 file_size: File size variant (original, medium, thumb, etc.) 

201 filename_format: ``"metadata"`` or ``"simple"``, or ``None`` to 

202 use the module-level default. 

203 

204 Returns: 

205 Filename string in the chosen format, plus ``.original.bak`` suffix 

206 when the bak-preservation toggle applies to this file. The bak suffix 

207 is appended AFTER the base filename is composed, so it works the same 

208 whether the base came from ``simple`` (``IMG_1234.HEIC``) or 

209 ``metadata`` (``IMG_1234__original__<id>.HEIC``) mode. 

210 """ 

211 forced = filename_format # explicit value; None means a normal (non-fallback) call 

212 if filename_format is None: 

213 filename_format = _DEFAULT_FILENAME_FORMAT 

214 name, extension = get_photo_name_and_extension(photo, file_size) 

215 

216 # photos.file_format wins for normal calls; the collision fallback passes 

217 # filename_format="metadata" explicitly to force the always-unique name. 

218 if forced != "metadata" and _FILE_FORMAT is not None: 

219 rendered = render_filename_template(_FILE_FORMAT, photo, file_size) 

220 if rendered.strip(): 

221 return rendered 

222 # Defensive: a template that renders empty for this version (e.g. a bare 

223 # ${photo.variant} on an original) would otherwise yield a path pointing 

224 # at the directory. Fall back to filename_format instead. 

225 LOGGER.warning( 

226 f"photos.file_format rendered an empty name for {file_size}; falling back to {filename_format} naming.", 

227 ) 

228 

229 if filename_format == "simple": 

230 result = name if extension == "" else f"{name}.{extension}" 

231 else: 

232 photo_id_encoded = base64.urlsafe_b64encode(photo.id.encode()).decode() 

233 if extension == "": 

234 result = f"{'__'.join([name, file_size, photo_id_encoded])}" 

235 else: 

236 result = f"{'__'.join([name, file_size, photo_id_encoded])}.{extension}" 

237 

238 # Apply .original.bak hide-suffix when applicable. Uniformly handled for 

239 # both filename_format modes — simple+bak yields IMG_1234.HEIC.original.bak, 

240 # metadata+bak yields IMG_1234__original__<id>.HEIC.original.bak. 

241 if _PRESERVE_ORIGINALS_AS_BAK and file_size == "original" and _photo_has_alt_version(photo): 

242 result = f"{result}.original.bak" 

243 

244 return result 

245 

246 

247def resolve_folder_path(destination_path: str, folder_format: str | None, photo) -> str: 

248 """Compute the folder path for a photo WITHOUT touching the filesystem. 

249 

250 Same result as ``create_folder_path_if_needed`` but never creates the 

251 directory. Read-only callers (e.g. the ``--dry-run`` migration checker) 

252 use this so a preview never writes to disk. 

253 

254 Args: 

255 destination_path: Base destination path 

256 folder_format: strftime format string for folder creation (e.g., "%Y/%m") 

257 photo: Photo object with created date 

258 

259 Returns: 

260 Full destination path including the created-date folder if folder_format is set 

261 """ 

262 if folder_format is None: 

263 return destination_path 

264 folder = photo.created.strftime(folder_format) 

265 return os.path.join(destination_path, folder) 

266 

267 

268def create_folder_path_if_needed( 

269 destination_path: str, folder_format: str | None, photo, 

270) -> str: 

271 """Resolve the folder path and create it on disk if folder_format is set. 

272 

273 Args: 

274 destination_path: Base destination path 

275 folder_format: strftime format string for folder creation (e.g., "%Y/%m") 

276 photo: Photo object with created date 

277 

278 Returns: 

279 Full destination path including created folder if folder_format is specified 

280 """ 

281 full_destination = resolve_folder_path(destination_path, folder_format, photo) 

282 if folder_format is not None: 

283 os.makedirs(full_destination, exist_ok=True) 

284 return full_destination 

285 

286 

287def normalize_file_path(file_path: str) -> str: 

288 """Normalize file path using Unicode NFC normalization. 

289 

290 Args: 

291 file_path: File path to normalize 

292 

293 Returns: 

294 Normalized file path 

295 """ 

296 return unicodedata.normalize("NFC", file_path) 

297 

298 

299def rename_legacy_file_if_exists(old_path: str, new_path: str) -> None: 

300 """Rename legacy file format to new format if it exists. 

301 

302 Args: 

303 old_path: Path to legacy file format 

304 new_path: Path to new file format 

305 """ 

306 import os 

307 

308 if not os.path.isfile(old_path): 

309 return 

310 if os.path.isfile(new_path): 

311 LOGGER.warning(f"Renaming {old_path} over existing {new_path}") 

312 else: 

313 LOGGER.info(f"Renaming {old_path} -> {new_path}") 

314 os.rename(old_path, new_path) 

315 

316 

317def _get_video_filetype_mapping() -> dict: 

318 """Get mapping of Live Photo paired-video Apple UTI types to extensions. 

319 

320 Live Photo videos are QuickTime movies; iCloud reports the UTI in the 

321 version's ``type`` field. Anything not listed falls back to ``MOV`` (the 

322 only container Apple has ever used for the Live Photo motion component). 

323 

324 Returns: 

325 Dictionary mapping Apple UTI type strings to file extensions 

326 """ 

327 return { 

328 "com.apple.quicktime-movie": "MOV", 

329 "public.mpeg-4": "MP4", 

330 } 

331 

332 

333def _get_original_alt_filetype_mapping() -> dict: 

334 """Get mapping of original_alt file types to extensions. 

335 

336 Returns: 

337 Dictionary mapping file types to extensions 

338 """ 

339 return { 

340 "public.png": "png", 

341 "public.jpeg": "jpeg", 

342 "public.heic": "heic", 

343 "public.image": "HEIC", 

344 "com.sony.arw-raw-image": "arw", 

345 "org.webmproject.webp": "webp", 

346 "com.compuserve.gif": "gif", 

347 "com.adobe.raw-image": "dng", 

348 "public.tiff": "tiff", 

349 "public.jpeg-2000": "jp2", 

350 "com.truevision.tga-image": "tga", 

351 "com.sgi.sgi-image": "sgi", 

352 "com.adobe.photoshop-image": "psd", 

353 "public.pbm": "pbm", 

354 "public.heif": "heif", 

355 "com.microsoft.bmp": "bmp", 

356 "com.fuji.raw-image": "raf", 

357 "com.canon.cr2-raw-image": "cr2", 

358 "com.panasonic.rw2-raw-image": "rw2", 

359 "com.nikon.nrw-raw-image": "nrw", 

360 "com.pentax.raw-image": "pef", 

361 "com.nikon.raw-image": "nef", 

362 "com.olympus.raw-image": "orf", 

363 "com.adobe.pdf": "pdf", 

364 "com.canon.cr3-raw-image": "cr3", 

365 "com.olympus.or-raw-image": "orf", 

366 "public.mpo-image": "mpo", 

367 "com.dji.mimo.pano.jpeg": "jpg", 

368 "public.avif": "avif", 

369 "com.canon.crw-raw-image": "crw", 

370 }