Skip to content

Commit cc619ce

Browse files
teh-hippoCopilot
andcommitted
feat: add --accept-apple-changes flag, GIF/WEBP test fixtures
--accept-apple-changes: re-download files when iCloud reports a different version_size. Without this flag, version changes are logged as warnings but files are not re-downloaded. Metadata updates from iCloud are always applied regardless. Also adds test fixtures for GIF and WEBP formats with round-trip and skip behaviour tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 06f116e commit cc619ce

File tree

7 files changed

+86
-9
lines changed

7 files changed

+86
-9
lines changed

src/icloudpd/base.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ def password_provider(_username: str) -> str | None:
454454
dir_cache,
455455
manifest,
456456
collision_paths,
457+
user_config.accept_apple_changes,
457458
)
458459
if user_config.directory is not None
459460
else (lambda _s, _c, _p: False)
@@ -784,6 +785,7 @@ def download_builder(
784785
dir_cache: DirCache,
785786
manifest: "ManifestDB | None",
786787
collision_paths: set[str],
788+
accept_apple_changes: bool,
787789
icloud: PyiCloudService,
788790
counter: Counter,
789791
photo: PhotoAsset,
@@ -907,15 +909,24 @@ def download_builder(
907909
truncate_middle(download_path, 96),
908910
)
909911
elif manifest_row.version_size != version.size:
910-
# iCloud has a different version — re-download to the canonical path
911-
file_exists = False
912-
rel_path = os.path.relpath(download_path, directory)
913-
logger.debug(
914-
"%s version changed (manifest: %d, iCloud: %d), re-downloading",
915-
truncate_middle(download_path, 96),
916-
manifest_row.version_size,
917-
version.size,
918-
)
912+
# iCloud has a different version
913+
if accept_apple_changes:
914+
file_exists = False
915+
rel_path = os.path.relpath(download_path, directory)
916+
logger.info(
917+
"%s version changed (manifest: %d, iCloud: %d), re-downloading",
918+
truncate_middle(download_path, 96),
919+
manifest_row.version_size,
920+
version.size,
921+
)
922+
else:
923+
logger.warning(
924+
"%s version changed (manifest: %d, iCloud: %d), "
925+
"use --accept-apple-changes to re-download",
926+
truncate_middle(download_path, 96),
927+
manifest_row.version_size,
928+
version.size,
929+
)
919930
else:
920931
# Version matches — update metadata if needed
921932
meta = _extract_manifest_metadata(photo, version)

src/icloudpd/cli.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,15 @@ def add_options_for_user(parser: argparse.ArgumentParser) -> argparse.ArgumentPa
312312
help="Don't download any photos (default: download all photos and videos)",
313313
action="store_true",
314314
)
315+
cloned.add_argument(
316+
"--accept-apple-changes",
317+
help=(
318+
"Re-download files when iCloud reports a different version. "
319+
"Without this flag, version changes are logged as warnings. "
320+
"Metadata updates from iCloud are always applied regardless."
321+
),
322+
action="store_true",
323+
)
315324
return cloned
316325

317326

@@ -560,6 +569,7 @@ def map_to_config(user_ns: argparse.Namespace) -> UserConfig:
560569
skip_created_before=user_ns.skip_created_before,
561570
skip_created_after=user_ns.skip_created_after,
562571
skip_photos=user_ns.skip_photos,
572+
accept_apple_changes=user_ns.accept_apple_changes,
563573
)
564574

565575

src/icloudpd/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class _DefaultConfig:
5151
skip_created_before: datetime.datetime | datetime.timedelta | None
5252
skip_created_after: datetime.datetime | datetime.timedelta | None
5353
skip_photos: bool
54+
accept_apple_changes: bool
5455

5556

5657
@dataclass(kw_only=True)

tests/data/test_image.gif

35 Bytes
Loading

tests/data/test_image.webp

64 Bytes
Loading

tests/test_cli.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ def test_cli_parser(self) -> None:
235235
skip_created_before=None,
236236
skip_created_after=None,
237237
skip_photos=False,
238+
accept_apple_changes=False,
238239
),
239240
UserConfig(
240241
directory="def",
@@ -276,6 +277,7 @@ def test_cli_parser(self) -> None:
276277
skip_created_before=None,
277278
skip_created_after=None,
278279
skip_photos=False,
280+
accept_apple_changes=False,
279281
),
280282
],
281283
),
@@ -355,6 +357,7 @@ def test_cli_parser(self) -> None:
355357
).astimezone(get_localzone()),
356358
skip_created_after=datetime.timedelta(days=2),
357359
skip_photos=False,
360+
accept_apple_changes=False,
358361
),
359362
],
360363
),

tests/test_metadata_writer.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,3 +1412,55 @@ def test_build_args_orientation_zero_skipped(self) -> None:
14121412
update = MetadataUpdate(orientation=0)
14131413
args = build_exiftool_args(update, {"orientation"}, "/photos/test.JPG")
14141414
self.assertFalse(any("Orientation" in a for a in args))
1415+
1416+
1417+
class TestWebpRoundTrip(TestCase):
1418+
"""Test WEBP metadata round-trip."""
1419+
1420+
def setUp(self) -> None:
1421+
self.tmp_dir = tempfile.mkdtemp()
1422+
1423+
def tearDown(self) -> None:
1424+
shutil.rmtree(self.tmp_dir)
1425+
1426+
def test_webp_rating_round_trip(self) -> None:
1427+
"""WEBP should support Rating via EXIF."""
1428+
src = os.path.join(_DATA_DIR, "test_image.webp")
1429+
path = os.path.join(self.tmp_dir, "test.webp")
1430+
shutil.copy2(src, path)
1431+
update = MetadataUpdate(rating=5)
1432+
result = write_metadata(path, update, {"rating"})
1433+
self.assertTrue(result)
1434+
result2 = write_metadata(path, update, {"rating"})
1435+
self.assertFalse(result2, "Second write should be idempotent")
1436+
1437+
def test_webp_gps_round_trip(self) -> None:
1438+
"""WEBP should support GPS via EXIF."""
1439+
src = os.path.join(_DATA_DIR, "test_image.webp")
1440+
path = os.path.join(self.tmp_dir, "test.webp")
1441+
shutil.copy2(src, path)
1442+
update = MetadataUpdate(gps_latitude=-33.86, gps_longitude=151.21, gps_altitude=50.0)
1443+
result = write_metadata(path, update, {"location"})
1444+
self.assertTrue(result)
1445+
result2 = write_metadata(path, update, {"location"})
1446+
self.assertFalse(result2, "Second write should be idempotent")
1447+
1448+
1449+
class TestGifSkip(TestCase):
1450+
"""Test that GIF files are skipped for EXIF writes."""
1451+
1452+
def test_gif_write_returns_false(self) -> None:
1453+
"""GIF should not attempt any exiftool writes."""
1454+
update = MetadataUpdate(rating=5, gps_latitude=-33.86, gps_longitude=151.21, orientation=6)
1455+
args = build_exiftool_args(update, {"all"}, "/photos/test.gif")
1456+
self.assertEqual(args, [], "GIF should produce no exiftool args")
1457+
1458+
def test_gif_write_metadata_returns_false(self) -> None:
1459+
"""write_metadata on GIF should return False (no args to write)."""
1460+
src = os.path.join(_DATA_DIR, "test_image.gif")
1461+
path = os.path.join(tempfile.mkdtemp(), "test.gif")
1462+
shutil.copy2(src, path)
1463+
update = MetadataUpdate(rating=5)
1464+
result = write_metadata(path, update, {"rating"})
1465+
self.assertFalse(result, "GIF writes should be skipped")
1466+
shutil.rmtree(os.path.dirname(path))

0 commit comments

Comments
 (0)