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
« 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.
4 Args:
5 photo: Photo object from iCloudPy
6 file_size: File size variant (original, medium, thumb, etc.)
8 Returns:
9 Tuple of (name, extension) where name is filename without extension
10 and extension is the file extension.
12This module contains utilities for generating photo file paths and managing
13file naming conventions for photo synchronization.
14"""
16___author___ = "Mandar Patil <mandarons@pm.me>"
18import base64
19import os
20import re
21import unicodedata
22from urllib.parse import unquote
24from src import get_logger
26LOGGER = get_logger()
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"})
33def get_photo_name_and_extension(photo, file_size: str) -> tuple[str, str]:
34 """Extract filename and extension from photo.
36 Args:
37 photo: Photo object from iCloudPy
38 file_size: File size variant (original, medium, thumb, etc.)
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, ""]
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
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")
78 return name, extension
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
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)
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")
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"})
105def photo_has_edit(photo) -> bool:
106 """Check whether the asset has an edited (``full``) version on iCloud.
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.
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
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
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)
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
153def _adjusted_name_suffix(photo, file_size: str) -> str:
154 """Return the ``-adjusted`` marker for an edit that would collide.
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 ""
168_TEMPLATE_TOKEN_RE = re.compile(r"\$\{([^}]+)\}")
169_DEFAULT_FILENAME_FORMAT = "metadata"
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
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.
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
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"})
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 "_"
207def get_file_format() -> str | None:
208 """Read the single filename template (public accessor; avoids SLF001)."""
209 return _FILE_FORMAT
212def render_filename_template(template: str, photo, file_size: str) -> str:
213 """Render photos.file_format for one photo version.
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}``.
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 }
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)
253 return _TEMPLATE_TOKEN_RE.sub(_sub, template)
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.
263 Two conventions (controlled by ``filename_format`` or module default):
264 - ``"metadata"`` (default): ``name__filesize__base64id.extension``
265 - ``"simple"``: ``name.extension``
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.
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.
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).
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.
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)
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 )
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}"
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"
328 return result
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.
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.
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
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)
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.
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
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
371def normalize_file_path(file_path: str) -> str:
372 """Normalize file path using Unicode NFC normalization.
374 Args:
375 file_path: File path to normalize
377 Returns:
378 Normalized file path
379 """
380 return unicodedata.normalize("NFC", file_path)
383def rename_legacy_file_if_exists(old_path: str, new_path: str) -> None:
384 """Rename legacy file format to new format if it exists.
386 Args:
387 old_path: Path to legacy file format
388 new_path: Path to new file format
389 """
390 import os
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)
404def _get_video_filetype_mapping() -> dict:
405 """Get mapping of Live Photo paired-video Apple UTI types to extensions.
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).
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 }
420def _get_image_filetype_mapping() -> dict:
421 """Get mapping of Apple image UTI types to file extensions.
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).
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 }