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

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 unicodedata 

21from urllib.parse import unquote 

22 

23from src import get_logger 

24 

25LOGGER = get_logger() 

26 

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"}) 

30 

31 

32def get_photo_name_and_extension(photo, file_size: str) -> tuple[str, str]: 

33 """Extract filename and extension from photo. 

34 

35 Args: 

36 photo: Photo object from iCloudPy 

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

38 

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, ""] 

47 

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 ) 

57 

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") 

67 

68 return name, extension 

69 

70 

71def generate_photo_filename_with_metadata(photo, file_size: str) -> str: 

72 """Generate filename with file size and photo ID metadata. 

73 

74 Args: 

75 photo: Photo object from iCloudPy 

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

77 

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() 

83 

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}" 

88 

89 

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. 

92 

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. 

96 

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 

101 

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) 

109 

110 

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. 

115 

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 

120 

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 

128 

129 

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

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

132 

133 Args: 

134 file_path: File path to normalize 

135 

136 Returns: 

137 Normalized file path 

138 """ 

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

140 

141 

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

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

144 

145 Args: 

146 old_path: Path to legacy file format 

147 new_path: Path to new file format 

148 """ 

149 import os 

150 

151 if os.path.isfile(old_path): 

152 os.rename(old_path, new_path) 

153 

154 

155def _get_video_filetype_mapping() -> dict: 

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

157 

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). 

161 

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 } 

169 

170 

171def _get_original_alt_filetype_mapping() -> dict: 

172 """Get mapping of original_alt file types to extensions. 

173 

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 }