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
« 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.
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 # 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 )
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")
69 return name, extension
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
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)
84def _photo_has_alt_version(photo) -> bool:
85 """Check whether the asset has an edited (``original_alt``) version on iCloud.
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
96_TEMPLATE_TOKEN_RE = re.compile(r"\$\{([^}]+)\}")
97_DEFAULT_FILENAME_FORMAT = "metadata"
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
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.
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
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"})
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 "_"
135def get_file_format() -> str | None:
136 """Read the single filename template (public accessor; avoids SLF001)."""
137 return _FILE_FORMAT
140def render_filename_template(template: str, photo, file_size: str) -> str:
141 """Render photos.file_format for one photo version.
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}``.
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 }
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)
174 return _TEMPLATE_TOKEN_RE.sub(_sub, template)
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.
184 Two conventions (controlled by ``filename_format`` or module default):
185 - ``"metadata"`` (default): ``name__filesize__base64id.extension``
186 - ``"simple"``: ``name.extension``
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.
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).
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.
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)
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 )
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}"
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"
244 return result
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.
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.
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
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)
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.
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
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
287def normalize_file_path(file_path: str) -> str:
288 """Normalize file path using Unicode NFC normalization.
290 Args:
291 file_path: File path to normalize
293 Returns:
294 Normalized file path
295 """
296 return unicodedata.normalize("NFC", file_path)
299def rename_legacy_file_if_exists(old_path: str, new_path: str) -> None:
300 """Rename legacy file format to new format if it exists.
302 Args:
303 old_path: Path to legacy file format
304 new_path: Path to new file format
305 """
306 import os
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)
317def _get_video_filetype_mapping() -> dict:
318 """Get mapping of Live Photo paired-video Apple UTI types to extensions.
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).
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 }
333def _get_original_alt_filetype_mapping() -> dict:
334 """Get mapping of original_alt file types to extensions.
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 }