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

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 

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

29 """Extract filename and extension from photo. 

30 

31 Args: 

32 photo: Photo object from iCloudPy 

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

34 

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

43 

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

51 

52 return name, extension 

53 

54 

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

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

57 

58 Args: 

59 photo: Photo object from iCloudPy 

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

61 

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

67 

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

72 

73 

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. 

76 

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 

81 

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 

87 

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 

92 

93 

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

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

96 

97 Args: 

98 file_path: File path to normalize 

99 

100 Returns: 

101 Normalized file path 

102 """ 

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

104 

105 

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

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

108 

109 Args: 

110 old_path: Path to legacy file format 

111 new_path: Path to new file format 

112 """ 

113 import os 

114 

115 if os.path.isfile(old_path): 

116 os.rename(old_path, new_path) 

117 

118 

119def _get_original_alt_filetype_mapping() -> dict: 

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

121 

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 }