Coverage for src/photo_path_utils.py: 100%
37 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-12 17:18 +0000
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-12 17:18 +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()
28def get_photo_name_and_extension(photo, file_size: str) -> tuple[str, str]:
29 """Extract filename and extension from photo.
31 Args:
32 photo: Photo object from iCloudPy
33 file_size: File size variant (original, medium, thumb, etc.)
35 Returns:
36 Tuple of (name, extension) where name is filename without extension
37 and extension is the file extension
38 """
39 # Decode URL-encoded filename from iCloud API
40 # This handles special characters like %CC%88 (combining diacritical marks)
41 filename = unquote(photo.filename)
42 name, extension = filename.rsplit(".", 1) if "." in filename else [filename, ""]
44 # Handle original_alt file type mapping
45 if file_size == "original_alt" and file_size in photo.versions:
46 filetype = photo.versions[file_size]["type"]
47 if filetype in _get_original_alt_filetype_mapping():
48 extension = _get_original_alt_filetype_mapping()[filetype]
49 else:
50 LOGGER.warning(f"Unknown filetype {filetype} for original_alt version of {filename}")
52 return name, extension
55def generate_photo_filename_with_metadata(photo, file_size: str) -> str:
56 """Generate filename with file size and photo ID metadata.
58 Args:
59 photo: Photo object from iCloudPy
60 file_size: File size variant (original, medium, thumb, etc.)
62 Returns:
63 Filename string with format: name__filesize__base64id.extension
64 """
65 name, extension = get_photo_name_and_extension(photo, file_size)
66 photo_id_encoded = base64.urlsafe_b64encode(photo.id.encode()).decode()
68 if extension == "":
69 return f"{'__'.join([name, file_size, photo_id_encoded])}"
70 else:
71 return f"{'__'.join([name, file_size, photo_id_encoded])}.{extension}"
74def create_folder_path_if_needed(destination_path: str, folder_format: str | None, photo) -> str:
75 """Create folder path based on folder format and photo creation date.
77 Args:
78 destination_path: Base destination path
79 folder_format: strftime format string for folder creation (e.g., "%Y/%m")
80 photo: Photo object with created date
82 Returns:
83 Full destination path including created folder if folder_format is specified
84 """
85 if folder_format is None:
86 return destination_path
88 folder = photo.created.strftime(folder_format)
89 full_destination = os.path.join(destination_path, folder)
90 os.makedirs(full_destination, exist_ok=True)
91 return full_destination
94def normalize_file_path(file_path: str) -> str:
95 """Normalize file path using Unicode NFC normalization.
97 Args:
98 file_path: File path to normalize
100 Returns:
101 Normalized file path
102 """
103 return unicodedata.normalize("NFC", file_path)
106def rename_legacy_file_if_exists(old_path: str, new_path: str) -> None:
107 """Rename legacy file format to new format if it exists.
109 Args:
110 old_path: Path to legacy file format
111 new_path: Path to new file format
112 """
113 import os
115 if os.path.isfile(old_path):
116 os.rename(old_path, new_path)
119def _get_original_alt_filetype_mapping() -> dict:
120 """Get mapping of original_alt file types to extensions.
122 Returns:
123 Dictionary mapping file types to extensions
124 """
125 return {
126 "public.png": "png",
127 "public.jpeg": "jpeg",
128 "public.heic": "heic",
129 "public.image": "HEIC",
130 "com.sony.arw-raw-image": "arw",
131 "org.webmproject.webp": "webp",
132 "com.compuserve.gif": "gif",
133 "com.adobe.raw-image": "dng",
134 "public.tiff": "tiff",
135 "public.jpeg-2000": "jp2",
136 "com.truevision.tga-image": "tga",
137 "com.sgi.sgi-image": "sgi",
138 "com.adobe.photoshop-image": "psd",
139 "public.pbm": "pbm",
140 "public.heif": "heif",
141 "com.microsoft.bmp": "bmp",
142 "com.fuji.raw-image": "raf",
143 "com.canon.cr2-raw-image": "cr2",
144 "com.panasonic.rw2-raw-image": "rw2",
145 "com.nikon.nrw-raw-image": "nrw",
146 "com.pentax.raw-image": "pef",
147 "com.nikon.raw-image": "nef",
148 "com.olympus.raw-image": "orf",
149 "com.adobe.pdf": "pdf",
150 "com.canon.cr3-raw-image": "cr3",
151 "com.olympus.or-raw-image": "orf",
152 "public.mpo-image": "mpo",
153 "com.dji.mimo.pano.jpeg": "jpg",
154 "public.avif": "avif",
155 "com.canon.crw-raw-image": "crw",
156 }