Coverage for src/photo_path_utils.py: 100%

123 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-26 21:28 +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 # Renditions whose container differs from the asset filename: the 

50 # RAW/JPEG pair alternate (``original_alt``) and Apple's rendered edit 

51 # (``full``), which can be HEIC or JPEG regardless of what the original 

52 # was -- and, for an edited video, QuickTime or MPEG-4. Derive the 

53 # extension from the version's own MIME type, not the name. 

54 if file_size in ("original_alt", "full") and file_size in photo.versions: 

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

56 mapped = _get_image_filetype_mapping().get(filetype) 

57 # A ``full`` rendition can be a movie (a trimmed or otherwise edited 

58 # video), whose container belongs to the video mapping. 

59 if mapped is None and file_size == "full": 

60 mapped = _get_video_filetype_mapping().get(filetype) 

61 if mapped is None: 

62 LOGGER.warning( 

63 f"Unknown filetype {filetype} for {file_size} version of {filename}", 

64 ) 

65 else: 

66 extension = mapped 

67 

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

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

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

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

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

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

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

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

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

77 

78 return name, extension 

79 

80 

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

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

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

84_PRESERVE_ORIGINALS_AS_BAK = False 

85 

86 

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

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

89 global _PRESERVE_ORIGINALS_AS_BAK 

90 _PRESERVE_ORIGINALS_AS_BAK = bool(value) 

91 

92 

93# The rendition fields Apple writes to the ``CPLAsset`` record for an edit: 

94# the still (``resJPEGFullRes``) and the movie (``resVidFullRes``). A ``full`` 

95# that icloudpy read from the ``CPLMaster`` record is a different thing -- the 

96# display render iCloud generates for formats it does not show natively (PNG, 

97# RAW, GIF, some MP4) -- and those assets were never edited. 

98_EDIT_RENDITION_PREFIXES = ("resJPEGFull", "resVidFull") 

99 

100# Extensions that name the same JPEG container; ``IMG_1.JPG`` and a JPEG edit 

101# of it must be recognised as colliding even though the strings differ. 

102_JPEG_EXTENSIONS = frozenset({"jpg", "jpeg"}) 

103 

104 

105def photo_has_edit(photo) -> bool: 

106 """Check whether the asset has an edited (``full``) version on iCloud. 

107 

108 The signal is ``resJPEGFullRes`` / ``resVidFullRes`` on the ``CPLAsset`` 

109 record. icloudpy reads versions asset-first and falls back to the 

110 ``CPLMaster`` record per version, so ``"full" in photo.versions`` is not 

111 enough: a master-carried ``full`` is Apple's display rendition for a 

112 format it does not render natively (PNG screenshots, RAW, GIF, some MP4), 

113 not an edit. ``original_alt`` is the RAW/JPEG pair alternate, not an edit, 

114 so it must not be treated as one here. 

115 

116 Soft check -- exceptions are treated as "no edit" so a partial CloudKit 

117 record cannot break the filename pipeline. An object that does not expose 

118 ``_asset_record`` as a dict (test doubles, older icloudpy objects) keeps 

119 the previous ``"full" in versions`` answer. 

120 """ 

121 try: 

122 asset_record = getattr(photo, "_asset_record", None) 

123 if isinstance(asset_record, dict): 

124 fields = asset_record.get("fields") or {} 

125 return any(f"{prefix}Res" in fields for prefix in _EDIT_RENDITION_PREFIXES) 

126 return "full" in photo.versions 

127 except Exception: 

128 return False 

129 

130 

131# When both the untouched original and the edit are requested, a plain 

132# filename format (``simple``, or a user template that omits 

133# ``${photo.file_size}``) can resolve both versions to the same name. 

134# ``sync_photos`` sets this once per run so the edit gets a marker instead of 

135# silently overwriting the original. 

136_ADJUSTED_SUFFIX = False 

137 

138 

139def set_adjusted_suffix(value: bool) -> None: 

140 """Set whether the edit needs a ``-adjusted`` marker to stay distinct.""" 

141 global _ADJUSTED_SUFFIX 

142 _ADJUSTED_SUFFIX = bool(value) 

143 

144 

145def _same_image_container(left: str, right: str) -> bool: 

146 """Whether two extensions name the same container (``jpg`` == ``jpeg``).""" 

147 left, right = left.lower(), right.lower() 

148 if left in _JPEG_EXTENSIONS and right in _JPEG_EXTENSIONS: 

149 return True 

150 return left == right 

151 

152 

153def _adjusted_name_suffix(photo, file_size: str) -> str: 

154 """Return the ``-adjusted`` marker for an edit that would collide. 

155 

156 Applies only to ``full`` when both ``original`` and ``full`` were 

157 requested *and* the two resolve to the same container: a HEIC original 

158 plus a JPEG edit already have distinct filenames and need no marker, but 

159 ``.JPG`` and ``.jpeg`` are one container and do. 

160 """ 

161 if not (_ADJUSTED_SUFFIX and file_size == "full"): 

162 return "" 

163 original_extension = get_photo_name_and_extension(photo, "original")[1] 

164 edit_extension = get_photo_name_and_extension(photo, file_size)[1] 

165 return "-adjusted" if _same_image_container(original_extension, edit_extension) else "" 

166 

167 

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

169_DEFAULT_FILENAME_FORMAT = "metadata" 

170 

171 

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

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

174 global _DEFAULT_FILENAME_FORMAT 

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

176 _DEFAULT_FILENAME_FORMAT = filename_format 

177 

178 

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

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

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

182def get_default_filename_format() -> str: 

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

184 

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

186 from ``set_default_filename_format`` without reaching into the 

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

188 """ 

189 return _DEFAULT_FILENAME_FORMAT 

190 

191 

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

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

194_FILE_FORMAT: str | None = None 

195_VARIANT_SEPARATOR = "_" 

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

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

198 

199 

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

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

202 global _FILE_FORMAT, _VARIANT_SEPARATOR 

203 _FILE_FORMAT = template or None 

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

205 

206 

207def get_file_format() -> str | None: 

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

209 return _FILE_FORMAT 

210 

211 

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

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

214 

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

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

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

218 

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

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

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

222 separator only appears when there actually is a variant. ``full`` counts 

223 as a variant when ``original`` is requested in the same run, so a template 

224 cannot resolve the edit and the original to one name. 

225 """ 

226 name, extension = get_photo_name_and_extension(photo, file_size) 

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

228 # ``full`` is a primary only while it is the sole copy requested; with 

229 # ``original`` also in the list the template needs a distinct variant so 

230 # the two files cannot land on the same name. 

231 is_primary = file_size in _PRIMARY_VERSIONS and not ( 

232 _ADJUSTED_SUFFIX and file_size == "full" 

233 ) 

234 variant = "" if is_primary else file_size 

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

236 values = { 

237 "photo.filename": name, 

238 "photo.ext": extension, 

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

240 "photo.file_size": file_size, 

241 "photo.variant": variant, 

242 "photo.variant_suffix": variant_suffix, 

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

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

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

246 } 

247 

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

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

250 replacement = values.get(token) 

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

252 

253 return _TEMPLATE_TOKEN_RE.sub(_sub, template) 

254 

255 

256def generate_photo_filename_with_metadata( 

257 photo, 

258 file_size: str, 

259 filename_format: str | None = None, 

260) -> str: 

261 """Generate filename for a photo asset. 

262 

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

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

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

266 

267 In ``simple`` mode, when both ``original`` and ``full`` are requested and 

268 both resolve to the same extension, the edit gains a ``-adjusted`` marker 

269 (``IMG_1234-adjusted.jpeg``) so it cannot overwrite the original. 

270 

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

272 size AND the asset has an edited (``full``) version on iCloud, the 

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

274 file remains filesystem-recoverable. 

275 

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

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

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

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

280 

281 Args: 

282 photo: Photo object from iCloudPy 

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

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

285 use the module-level default. 

286 

287 Returns: 

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

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

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

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

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

293 """ 

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

295 if filename_format is None: 

296 filename_format = _DEFAULT_FILENAME_FORMAT 

297 name, extension = get_photo_name_and_extension(photo, file_size) 

298 

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

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

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

302 rendered = render_filename_template(_FILE_FORMAT, photo, file_size) 

303 if rendered.strip(): 

304 return rendered 

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

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

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

308 LOGGER.warning( 

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

310 ) 

311 

312 if filename_format == "simple": 

313 name = f"{name}{_adjusted_name_suffix(photo, file_size)}" 

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

315 else: 

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

317 if extension == "": 

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

319 else: 

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

321 

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

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

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

325 if _PRESERVE_ORIGINALS_AS_BAK and file_size == "original" and photo_has_edit(photo): 

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

327 

328 return result 

329 

330 

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

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

333 

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

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

336 use this so a preview never writes to disk. 

337 

338 Args: 

339 destination_path: Base destination path 

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

341 photo: Photo object with created date 

342 

343 Returns: 

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

345 """ 

346 if folder_format is None: 

347 return destination_path 

348 folder = photo.created.strftime(folder_format) 

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

350 

351 

352def create_folder_path_if_needed( 

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

354) -> str: 

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

356 

357 Args: 

358 destination_path: Base destination path 

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

360 photo: Photo object with created date 

361 

362 Returns: 

363 Full destination path including created folder if folder_format is specified 

364 """ 

365 full_destination = resolve_folder_path(destination_path, folder_format, photo) 

366 if folder_format is not None: 

367 os.makedirs(full_destination, exist_ok=True) 

368 return full_destination 

369 

370 

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

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

373 

374 Args: 

375 file_path: File path to normalize 

376 

377 Returns: 

378 Normalized file path 

379 """ 

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

381 

382 

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

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

385 

386 Args: 

387 old_path: Path to legacy file format 

388 new_path: Path to new file format 

389 """ 

390 import os 

391 

392 # Without folder_format, a "legacy" flat path can be the file's current 

393 # path. There is nothing to move, and warning about it would fire for 

394 # every existing file on every run. 

395 if old_path == new_path or not os.path.isfile(old_path): 

396 return 

397 if os.path.isfile(new_path): 

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

399 else: 

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

401 os.rename(old_path, new_path) 

402 

403 

404def _get_video_filetype_mapping() -> dict: 

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

406 

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

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

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

410 

411 Returns: 

412 Dictionary mapping Apple UTI type strings to file extensions 

413 """ 

414 return { 

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

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

417 } 

418 

419 

420def _get_image_filetype_mapping() -> dict: 

421 """Get mapping of Apple image UTI types to file extensions. 

422 

423 Used for renditions whose container may differ from the asset filename: 

424 the RAW/JPEG pair alternate and Apple's rendered edit (usually HEIC, 

425 JPEG for older edits, and a movie container for an edited video). 

426 

427 Returns: 

428 Dictionary mapping file types to extensions 

429 """ 

430 return { 

431 "public.png": "png", 

432 "public.jpeg": "jpeg", 

433 "public.heic": "heic", 

434 "public.image": "HEIC", 

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

436 "org.webmproject.webp": "webp", 

437 "com.compuserve.gif": "gif", 

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

439 "public.tiff": "tiff", 

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

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

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

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

444 "public.pbm": "pbm", 

445 "public.heif": "heif", 

446 "com.microsoft.bmp": "bmp", 

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

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

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

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

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

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

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

454 "com.adobe.pdf": "pdf", 

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

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

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

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

459 "public.avif": "avif", 

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

461 }