Coverage for src/photo_path_utils.py: 100%
46 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-05 00:26 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-05 00:26 +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 unicodedata
21from urllib.parse import unquote
23from src import get_logger
25LOGGER = get_logger()
27# The Live Photo paired-video file_size variants. These are QuickTime movies,
28# not images, even though the parent asset's filename ends in .HEIC/.JPG.
29_LIVE_VIDEO_SIZES = frozenset({"live_video_original", "live_video_medium", "live_video_thumb"})
32def get_photo_name_and_extension(photo, file_size: str) -> tuple[str, str]:
33 """Extract filename and extension from photo.
35 Args:
36 photo: Photo object from iCloudPy
37 file_size: File size variant (original, medium, thumb, etc.)
39 Returns:
40 Tuple of (name, extension) where name is filename without extension
41 and extension is the file extension
42 """
43 # Decode URL-encoded filename from iCloud API
44 # This handles special characters like %CC%88 (combining diacritical marks)
45 filename = unquote(photo.filename)
46 name, extension = filename.rsplit(".", 1) if "." in filename else [filename, ""]
48 # Handle original_alt file type mapping
49 if file_size == "original_alt" and file_size in photo.versions:
50 filetype = photo.versions[file_size]["type"]
51 if filetype in _get_original_alt_filetype_mapping():
52 extension = _get_original_alt_filetype_mapping()[filetype]
53 else:
54 LOGGER.warning(
55 f"Unknown filetype {filetype} for original_alt version of {filename}",
56 )
58 # Handle Live Photo paired-video versions. photo.filename is the STILL
59 # (e.g. IMG_1234.HEIC), but the live_video_* versions are the QuickTime
60 # movie half of the Live Photo. Without this the .mov is written with the
61 # still's extension (IMG_1234__live_video_original__<id>.HEIC), which every
62 # downstream image tool then rejects as "unsupported image format" because
63 # it is really a video. Map to the real container extension instead.
64 elif file_size in _LIVE_VIDEO_SIZES and file_size in photo.versions:
65 filetype = photo.versions[file_size].get("type")
66 extension = _get_video_filetype_mapping().get(filetype, "MOV")
68 return name, extension
71def generate_photo_filename_with_metadata(photo, file_size: str) -> str:
72 """Generate filename with file size and photo ID metadata.
74 Args:
75 photo: Photo object from iCloudPy
76 file_size: File size variant (original, medium, thumb, etc.)
78 Returns:
79 Filename string with format: name__filesize__base64id.extension
80 """
81 name, extension = get_photo_name_and_extension(photo, file_size)
82 photo_id_encoded = base64.urlsafe_b64encode(photo.id.encode()).decode()
84 if extension == "":
85 return f"{'__'.join([name, file_size, photo_id_encoded])}"
86 else:
87 return f"{'__'.join([name, file_size, photo_id_encoded])}.{extension}"
90def resolve_folder_path(destination_path: str, folder_format: str | None, photo) -> str:
91 """Compute the folder path for a photo WITHOUT touching the filesystem.
93 Same result as ``create_folder_path_if_needed`` but never creates the
94 directory. Read-only callers (e.g. the ``--dry-run`` migration checker)
95 use this so a preview never writes to disk.
97 Args:
98 destination_path: Base destination path
99 folder_format: strftime format string for folder creation (e.g., "%Y/%m")
100 photo: Photo object with created date
102 Returns:
103 Full destination path including the created-date folder if folder_format is set
104 """
105 if folder_format is None:
106 return destination_path
107 folder = photo.created.strftime(folder_format)
108 return os.path.join(destination_path, folder)
111def create_folder_path_if_needed(
112 destination_path: str, folder_format: str | None, photo,
113) -> str:
114 """Resolve the folder path and create it on disk if folder_format is set.
116 Args:
117 destination_path: Base destination path
118 folder_format: strftime format string for folder creation (e.g., "%Y/%m")
119 photo: Photo object with created date
121 Returns:
122 Full destination path including created folder if folder_format is specified
123 """
124 full_destination = resolve_folder_path(destination_path, folder_format, photo)
125 if folder_format is not None:
126 os.makedirs(full_destination, exist_ok=True)
127 return full_destination
130def normalize_file_path(file_path: str) -> str:
131 """Normalize file path using Unicode NFC normalization.
133 Args:
134 file_path: File path to normalize
136 Returns:
137 Normalized file path
138 """
139 return unicodedata.normalize("NFC", file_path)
142def rename_legacy_file_if_exists(old_path: str, new_path: str) -> None:
143 """Rename legacy file format to new format if it exists.
145 Args:
146 old_path: Path to legacy file format
147 new_path: Path to new file format
148 """
149 import os
151 if os.path.isfile(old_path):
152 os.rename(old_path, new_path)
155def _get_video_filetype_mapping() -> dict:
156 """Get mapping of Live Photo paired-video Apple UTI types to extensions.
158 Live Photo videos are QuickTime movies; iCloud reports the UTI in the
159 version's ``type`` field. Anything not listed falls back to ``MOV`` (the
160 only container Apple has ever used for the Live Photo motion component).
162 Returns:
163 Dictionary mapping Apple UTI type strings to file extensions
164 """
165 return {
166 "com.apple.quicktime-movie": "MOV",
167 "public.mpeg-4": "MP4",
168 }
171def _get_original_alt_filetype_mapping() -> dict:
172 """Get mapping of original_alt file types to extensions.
174 Returns:
175 Dictionary mapping file types to extensions
176 """
177 return {
178 "public.png": "png",
179 "public.jpeg": "jpeg",
180 "public.heic": "heic",
181 "public.image": "HEIC",
182 "com.sony.arw-raw-image": "arw",
183 "org.webmproject.webp": "webp",
184 "com.compuserve.gif": "gif",
185 "com.adobe.raw-image": "dng",
186 "public.tiff": "tiff",
187 "public.jpeg-2000": "jp2",
188 "com.truevision.tga-image": "tga",
189 "com.sgi.sgi-image": "sgi",
190 "com.adobe.photoshop-image": "psd",
191 "public.pbm": "pbm",
192 "public.heif": "heif",
193 "com.microsoft.bmp": "bmp",
194 "com.fuji.raw-image": "raf",
195 "com.canon.cr2-raw-image": "cr2",
196 "com.panasonic.rw2-raw-image": "rw2",
197 "com.nikon.nrw-raw-image": "nrw",
198 "com.pentax.raw-image": "pef",
199 "com.nikon.raw-image": "nef",
200 "com.olympus.raw-image": "orf",
201 "com.adobe.pdf": "pdf",
202 "com.canon.cr3-raw-image": "cr3",
203 "com.olympus.or-raw-image": "orf",
204 "public.mpo-image": "mpo",
205 "com.dji.mimo.pano.jpeg": "jpg",
206 "public.avif": "avif",
207 "com.canon.crw-raw-image": "crw",
208 }