Skip to content

Commit 5bd53a0

Browse files
Fix issue #926 keyerror adjusted alternative (#1235)
1 parent 80f4d8d commit 5bd53a0

File tree

13 files changed

+487
-209
lines changed

13 files changed

+487
-209
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- fix: KeyError when downloading photos with --size adjusted --size alternative options [#926](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/926)
6+
57
## 1.32.0 (2025-08-29)
68

79
- feat: support multiple user configurations in single command [#1067](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/1067) [#923](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/923)

src/foundation/py.typed

Whitespace-only changes.

src/icloudpd/autodelete.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,17 +70,19 @@ def autodelete_photos(
7070

7171
paths: Set[str] = set({})
7272
_size: VersionSize
73-
for _size, _version in disambiguate_filenames(media.versions, _sizes).items():
73+
for _size, _version in disambiguate_filenames(media.versions, _sizes, media).items():
7474
if _size in [AssetVersionSize.ALTERNATIVE, AssetVersionSize.ADJUSTED]:
75-
paths.add(os.path.normpath(local_download_path(_version.filename, download_dir)))
75+
version_filename = media.calculate_version_filename(_version, _size)
76+
paths.add(os.path.normpath(local_download_path(version_filename, download_dir)))
7677
paths.add(
77-
os.path.normpath(local_download_path(_version.filename, download_dir)) + ".xmp"
78+
os.path.normpath(local_download_path(version_filename, download_dir)) + ".xmp"
7879
)
7980
for _size, _version in media.versions.items():
8081
if _size not in [AssetVersionSize.ALTERNATIVE, AssetVersionSize.ADJUSTED]:
81-
paths.add(os.path.normpath(local_download_path(_version.filename, download_dir)))
82+
version_filename = media.calculate_version_filename(_version, _size)
83+
paths.add(os.path.normpath(local_download_path(version_filename, download_dir)))
8284
paths.add(
83-
os.path.normpath(local_download_path(_version.filename, download_dir)) + ".xmp"
85+
os.path.normpath(local_download_path(version_filename, download_dir)) + ".xmp"
8486
)
8587
for path in paths:
8688
if os.path.exists(path):

src/icloudpd/base.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
from icloudpd.status import Status, StatusExchange
5656
from icloudpd.string_helpers import parse_timestamp_or_timedelta, truncate_middle
5757
from icloudpd.xmp_sidecar import generate_xmp_file
58+
from pyicloud_ipd.asset_version import add_suffix_to_filename
5859
from pyicloud_ipd.base import PyiCloudService
5960
from pyicloud_ipd.exceptions import (
6061
PyiCloudAPIResponseException,
@@ -68,7 +69,6 @@
6869
from pyicloud_ipd.live_photo_mov_filename_policy import LivePhotoMovFilenamePolicy
6970
from pyicloud_ipd.services.photos import PhotoAlbum, PhotoAsset, PhotoLibrary
7071
from pyicloud_ipd.utils import (
71-
add_suffix_to_filename,
7272
disambiguate_filenames,
7373
get_password_from_keyring,
7474
size_to_suffix,
@@ -554,7 +554,7 @@ def download_builder(
554554
date_path = folder_structure.format(created_date)
555555

556556
try:
557-
versions = disambiguate_filenames(photo.versions, primary_sizes)
557+
versions = disambiguate_filenames(photo.versions, primary_sizes, photo)
558558
except KeyError as ex:
559559
print(f"KeyError: {ex} attribute was not found in the photo fields.")
560560
with open(file="icloudpd-photo-error.json", mode="w", encoding="utf8") as outfile:
@@ -591,7 +591,7 @@ def download_builder(
591591
download_size = AssetVersionSize.ORIGINAL
592592

593593
version = versions[download_size]
594-
filename = version.filename
594+
filename = photo.calculate_version_filename(version, download_size)
595595

596596
download_path = local_download_path(filename, download_dir)
597597

@@ -657,7 +657,7 @@ def download_builder(
657657
lp_size = live_photo_size
658658
if lp_size in photo.versions:
659659
version = photo.versions[lp_size]
660-
lp_filename = version.filename
660+
lp_filename = photo.calculate_version_filename(version, lp_size)
661661
if live_photo_size != LivePhotoVersionSize.ORIGINAL:
662662
# Add size to filename if not original
663663
lp_filename = add_suffix_to_filename(

src/icloudpd/download.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,9 @@ def download_media(
141141
)
142142
else:
143143
logger.error(
144-
"Could not find URL to download %s for size %s", version.filename, size.value
144+
"Could not find URL to download %s for size %s",
145+
photo.calculate_version_filename(version, size),
146+
size.value,
145147
)
146148
break
147149

src/icloudpd/py.typed

Whitespace-only changes.

src/pyicloud_ipd/asset_version.py

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,77 @@
1-
from typing import Union
1+
import os
2+
from typing import Callable, Dict
3+
4+
from pyicloud_ipd.version_size import VersionSize
5+
6+
7+
def add_suffix_to_filename(suffix: str, filename: str) -> str:
8+
"""Add suffix to filename before extension."""
9+
_n, _e = os.path.splitext(filename)
10+
return _n + suffix + _e
211

312

413
class AssetVersion:
5-
def __init__(self, filename: str, size: int, url: str, type: str, checksum: str) -> None:
6-
self.filename = filename
14+
def __init__(self, size: int, url: str, type: str, checksum: str) -> None:
715
self.size = size
816
self.url = url
917
self.type = type
1018
self.checksum = checksum
19+
self._filename_override: str | None = None
20+
21+
@property
22+
def filename_override(self) -> str | None:
23+
"""Override filename used for disambiguation purposes."""
24+
return self._filename_override
25+
26+
@filename_override.setter
27+
def filename_override(self, value: str | None) -> None:
28+
"""Set override filename for disambiguation purposes."""
29+
self._filename_override = value
1130

1231
def __eq__(self, other: object) -> bool:
1332
if not isinstance(other, AssetVersion):
1433
# don't attempt to compare against unrelated types
1534
return NotImplemented
16-
return self.filename == other.filename and self.size == other.size and self.url == other.url and self.type == other.type
35+
return self.size == other.size and self.url == other.url and self.type == other.type
36+
37+
38+
def calculate_asset_version_filename(
39+
base_filename: str,
40+
asset_type: str,
41+
version_size: VersionSize,
42+
lp_filename_generator: Callable[[str], str],
43+
item_type_extensions: Dict[str, str],
44+
version_filename_suffix_lookup: Dict[VersionSize, str],
45+
is_image_item_type: bool = True
46+
) -> str:
47+
"""
48+
Calculate filename for an AssetVersion based on the base filename and version properties.
49+
50+
Args:
51+
base_filename: The base filename from PhotoAsset
52+
asset_type: The type field from the version
53+
version_size: The size key for this version
54+
lp_filename_generator: Function to generate live photo filenames
55+
item_type_extensions: Mapping of types to extensions
56+
version_filename_suffix_lookup: Mapping of sizes to filename suffixes
57+
is_image_item_type: Whether the parent asset is an image (for live photo detection)
58+
59+
Returns:
60+
The calculated filename for this asset version
61+
"""
62+
filename = base_filename
63+
64+
# Change live photo movie file extension to .MOV
65+
if is_image_item_type and asset_type == "com.apple.quicktime-movie":
66+
filename = lp_filename_generator(base_filename) # without size
67+
else:
68+
# for non live photo movie, try to change file type to match asset type
69+
_f, _e = os.path.splitext(filename)
70+
filename = _f + "." + item_type_extensions.get(asset_type, _e[1:])
71+
72+
# add size suffix
73+
if version_size in version_filename_suffix_lookup:
74+
_size_suffix = version_filename_suffix_lookup[version_size]
75+
filename = add_suffix_to_filename(f"-{_size_suffix}", filename)
76+
77+
return filename

src/pyicloud_ipd/py.typed

Whitespace-only changes.

src/pyicloud_ipd/services/photos.py

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from requests import Response
1414
from foundation import wrap_param_in_exception, bytes_decode
1515
from foundation.core import compose, identity
16-
from pyicloud_ipd.asset_version import AssetVersion
16+
from pyicloud_ipd.asset_version import AssetVersion, calculate_asset_version_filename, add_suffix_to_filename
1717
from pyicloud_ipd.exceptions import PyiCloudServiceNotActivatedException
1818
from pyicloud_ipd.exceptions import PyiCloudAPIResponseException
1919

@@ -25,7 +25,6 @@
2525
from pyicloud_ipd.item_type import AssetItemType
2626
from pyicloud_ipd.raw_policy import RawTreatmentPolicy
2727
from pyicloud_ipd.session import PyiCloudSession
28-
from pyicloud_ipd.utils import add_suffix_to_filename
2928
from pyicloud_ipd.version_size import AssetVersionSize, LivePhotoVersionSize, VersionSize
3029

3130
from tzlocal import get_localzone
@@ -720,6 +719,21 @@ def item_type_extension(self) -> str:
720719
return self.ITEM_TYPE_EXTENSIONS[item_type]
721720
return 'unknown'
722721

722+
def calculate_version_filename(self, version: AssetVersion, version_size: VersionSize) -> str:
723+
"""Calculate filename for a specific asset version."""
724+
if version.filename_override is not None:
725+
return version.filename_override
726+
727+
return calculate_asset_version_filename(
728+
self.filename,
729+
version.type,
730+
version_size,
731+
self._service.lp_filename_generator,
732+
self.ITEM_TYPE_EXTENSIONS,
733+
self.VERSION_FILENAME_SUFFIX_LOOKUP,
734+
(self.item_type or AssetItemType.IMAGE) == AssetItemType.IMAGE
735+
)
736+
723737
@property
724738
def versions(self) -> Dict[VersionSize, AssetVersion]:
725739
if not self._versions:
@@ -738,52 +752,21 @@ def versions(self) -> Dict[VersionSize, AssetVersion]:
738752
if not f and '%sRes' % prefix in self._master_record['fields']:
739753
f = self._master_record['fields']
740754
if f:
741-
version: Dict[str, Any] = {'filename': self.filename}
742-
743-
# width_entry = f.get('%sWidth' % prefix)
744-
# if width_entry:
745-
# version['width'] = width_entry['value']
746-
# else:
747-
# version['width'] = None
748-
749-
# height_entry = f.get('%sHeight' % prefix)
750-
# if height_entry:
751-
# version['height'] = height_entry['value']
752-
# else:
753-
# version['height'] = None
754-
755755
size_entry = f.get('%sRes' % prefix)
756756
if size_entry:
757-
version['size'] = size_entry['value']['size']
758-
version['url'] = size_entry['value']['downloadURL']
759-
version['checksum'] = size_entry['value']['fileChecksum']
757+
size = size_entry['value']['size']
758+
url = size_entry['value']['downloadURL']
759+
checksum = size_entry['value']['fileChecksum']
760760
else:
761761
raise ValueError(f"Expected {prefix}Res, but missing it")
762-
# version['size'] = None
763-
# version['url'] = None
764762

765763
type_entry = f.get('%sFileType' % prefix)
766764
if type_entry:
767-
version['type'] = type_entry['value']
765+
asset_type = type_entry['value']
768766
else:
769767
raise ValueError(f"Expected {prefix}FileType, but missing it")
770-
# version['type'] = None
771-
772-
# Change live photo movie file extension to .MOV
773-
if ((self.item_type or AssetItemType.IMAGE) == AssetItemType.IMAGE and
774-
version['type'] == "com.apple.quicktime-movie"):
775-
version['filename'] = self._service.lp_filename_generator(self.filename) # without size
776-
else:
777-
# for non live photo movie, try to change file type to match asset type
778-
_f, _e = os.path.splitext(version["filename"])
779-
version["filename"] = _f + "." + self.ITEM_TYPE_EXTENSIONS.get(version["type"], _e[1:])
780-
781-
# add size suffix
782-
if key in self.VERSION_FILENAME_SUFFIX_LOOKUP:
783-
_size_suffix = self.VERSION_FILENAME_SUFFIX_LOOKUP[key]
784-
version["filename"] = add_suffix_to_filename(f"-{_size_suffix}", version["filename"])
785768

786-
_versions[key] = AssetVersion(version["filename"], version['size'], version['url'], version['type'], version['checksum'])
769+
_versions[key] = AssetVersion(size, url, asset_type, checksum)
787770

788771
# swap original & alternative according to swap_raw_policy
789772
if AssetVersionSize.ALTERNATIVE in _versions and (("raw" in _versions[AssetVersionSize.ALTERNATIVE].type and self._service.raw_policy == RawTreatmentPolicy.AS_ORIGINAL) or ("raw" in _versions[AssetVersionSize.ORIGINAL].type and self._service.raw_policy == RawTreatmentPolicy.AS_ALTERNATIVE)):

src/pyicloud_ipd/utils.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import copy
22
import os
3-
from typing import Dict, Optional, Sequence
3+
from typing import TYPE_CHECKING, Dict, Optional, Sequence
44
import typing
55
import keyring
66
from requests import Response
77

8-
from pyicloud_ipd.asset_version import AssetVersion
8+
from pyicloud_ipd.asset_version import AssetVersion, add_suffix_to_filename
99
from pyicloud_ipd.version_size import AssetVersionSize, VersionSize
1010

1111
from .exceptions import PyiCloudNoStoredPasswordAvailableException, PyiCloudServiceUnavailableException
1212

13+
if TYPE_CHECKING:
14+
from pyicloud_ipd.services.photos import PhotoAsset
15+
1316
KEYRING_SYSTEM = 'pyicloud://icloud-password'
1417

1518

@@ -91,11 +94,8 @@ def underscore_to_camelcase(word:str , initial_capital: bool=False) -> str:
9194
def size_to_suffix(size: VersionSize) -> str:
9295
return f"-{size}".lower()
9396

94-
def add_suffix_to_filename(suffix: str, filename: str) -> str:
95-
_n, _e = os.path.splitext(filename)
96-
return _n + suffix + _e
97-
98-
def disambiguate_filenames(_versions: Dict[VersionSize, AssetVersion], _sizes:Sequence[AssetVersionSize]) -> Dict[AssetVersionSize, AssetVersion]:
97+
def disambiguate_filenames(_versions: Dict[VersionSize, AssetVersion], _sizes:Sequence[AssetVersionSize], photo_asset: 'PhotoAsset') -> Dict[AssetVersionSize, AssetVersion]:
98+
9999
_results: Dict[AssetVersionSize, AssetVersion] = {}
100100
# add those that were requested
101101
for _size in _sizes:
@@ -110,19 +110,33 @@ def disambiguate_filenames(_versions: Dict[VersionSize, AssetVersion], _sizes:Se
110110
# clone
111111
_results[AssetVersionSize.ADJUSTED] = copy.copy(_versions[AssetVersionSize.ORIGINAL])
112112
else:
113-
if AssetVersionSize.ADJUSTED in _results and _results[AssetVersionSize.ORIGINAL].filename == _results[AssetVersionSize.ADJUSTED].filename:
114-
_results[AssetVersionSize.ADJUSTED].filename = add_suffix_to_filename("-adjusted", _results[AssetVersionSize.ADJUSTED].filename)
113+
if AssetVersionSize.ADJUSTED in _results:
114+
original_filename = photo_asset.calculate_version_filename(_results[AssetVersionSize.ORIGINAL], AssetVersionSize.ORIGINAL)
115+
adjusted_filename = photo_asset.calculate_version_filename(_results[AssetVersionSize.ADJUSTED], AssetVersionSize.ADJUSTED)
116+
if original_filename == adjusted_filename:
117+
# Set filename override for adjusted version
118+
_results[AssetVersionSize.ADJUSTED].filename_override = add_suffix_to_filename("-adjusted", adjusted_filename)
115119

116120
# alternative
117121
if AssetVersionSize.ALTERNATIVE in _sizes:
118-
if AssetVersionSize.ORIGINAL not in _sizes and AssetVersionSize.ADJUSTED not in _results:
119-
if AssetVersionSize.ALTERNATIVE not in _results:
120-
# clone
122+
if AssetVersionSize.ALTERNATIVE not in _results:
123+
# Only clone from original when alternative is missing AND original is not requested
124+
if AssetVersionSize.ORIGINAL not in _sizes:
121125
_results[AssetVersionSize.ALTERNATIVE] = copy.copy(_versions[AssetVersionSize.ORIGINAL])
122126
else:
123-
if AssetVersionSize.ALTERNATIVE in _results:
124-
if AssetVersionSize.ADJUSTED in _results and _results[AssetVersionSize.ADJUSTED].filename == _results[AssetVersionSize.ALTERNATIVE].filename or AssetVersionSize.ORIGINAL in _results and _results[AssetVersionSize.ORIGINAL].filename == _results[AssetVersionSize.ALTERNATIVE].filename:
125-
_results[AssetVersionSize.ALTERNATIVE].filename = add_suffix_to_filename("-alternative", _results[AssetVersionSize.ALTERNATIVE].filename)
127+
# Check for filename conflicts and add disambiguating suffix if needed
128+
alternative_filename = photo_asset.calculate_version_filename(_results[AssetVersionSize.ALTERNATIVE], AssetVersionSize.ALTERNATIVE)
129+
alt_adjusted_filename: Optional[str] = None
130+
alt_original_filename: Optional[str] = None
131+
132+
if AssetVersionSize.ADJUSTED in _results:
133+
alt_adjusted_filename = photo_asset.calculate_version_filename(_results[AssetVersionSize.ADJUSTED], AssetVersionSize.ADJUSTED)
134+
if AssetVersionSize.ORIGINAL in _results:
135+
alt_original_filename = photo_asset.calculate_version_filename(_results[AssetVersionSize.ORIGINAL], AssetVersionSize.ORIGINAL)
136+
137+
if (alt_adjusted_filename and alternative_filename == alt_adjusted_filename) or (alt_original_filename and alternative_filename == alt_original_filename):
138+
# Set filename override for alternative version
139+
_results[AssetVersionSize.ALTERNATIVE].filename_override = add_suffix_to_filename("-alternative", alternative_filename)
126140

127141
for _size in _sizes:
128142
if _size not in [AssetVersionSize.ORIGINAL, AssetVersionSize.ADJUSTED, AssetVersionSize.ALTERNATIVE]:
@@ -134,7 +148,6 @@ def disambiguate_filenames(_versions: Dict[VersionSize, AssetVersion], _sizes:Se
134148
# _n, _e = os.path.splitext(_results[_size]["filename"])
135149
# _results[_size]["filename"] = f"{_n}-{_size}{_e}"
136150

137-
138151
return _results
139152

140153
def throw_on_503(response: Response) -> Response:

0 commit comments

Comments
 (0)