Skip to content

Commit e0edc2c

Browse files
teh-hippoCopilot
andcommitted
feat: add --delete-orphaned flag for cleaning up files no longer in iCloud
Detects manifest entries whose asset_id was not returned by the API during a complete successful run. Without the flag, logs a warning with the orphan count. With the flag, deletes orphaned files, XMP sidecars, and manifest entries. Handles: deleted photos, photos moved between libraries (Personal to Shared or vice versa), and any asset the API stops returning. Safety: only runs after complete iteration (interrupted runs skip the check entirely). Respects --dry-run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent aaa4565 commit e0edc2c

File tree

6 files changed

+302
-0
lines changed

6 files changed

+302
-0
lines changed

src/icloudpd/base.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1403,6 +1403,57 @@ def asset_type_skip_message(
14031403
return f"Skipping {filename}, only downloading {photo_video_phrase}. (Item type was: {photo.item_type})"
14041404

14051405

1406+
def delete_orphaned(
1407+
logger: logging.Logger,
1408+
manifest: "ManifestDB",
1409+
directory: str,
1410+
seen_asset_ids: set[str],
1411+
delete: bool,
1412+
dry_run: bool,
1413+
) -> None:
1414+
"""Check for orphaned manifest entries and optionally delete them."""
1415+
orphans = manifest.find_orphaned(seen_asset_ids)
1416+
if not orphans:
1417+
return
1418+
1419+
orphan_count = len(orphans)
1420+
unique_assets = len({o.asset_id for o in orphans})
1421+
1422+
if not delete:
1423+
logger.warning(
1424+
"Found %d orphaned files (%d assets) not in iCloud library. "
1425+
"Use --delete-orphaned to clean up.",
1426+
orphan_count,
1427+
unique_assets,
1428+
)
1429+
return
1430+
1431+
logger.info(
1432+
"Deleting %d orphaned files (%d assets) not in iCloud library...",
1433+
orphan_count,
1434+
unique_assets,
1435+
)
1436+
deleted = 0
1437+
for orphan in orphans:
1438+
file_path = os.path.join(directory, orphan.local_path)
1439+
xmp_path = file_path + ".xmp"
1440+
if dry_run:
1441+
logger.info("[DRY RUN] Would delete orphaned %s", file_path)
1442+
else:
1443+
for path in (file_path, xmp_path):
1444+
if os.path.exists(path):
1445+
try:
1446+
os.remove(path)
1447+
logger.info("Deleted orphaned %s", path)
1448+
except OSError as e:
1449+
logger.warning("Failed to delete %s: %s", path, e)
1450+
manifest.remove(orphan.asset_id, orphan.zone_id, orphan.asset_resource)
1451+
deleted += 1
1452+
if not dry_run:
1453+
manifest.flush()
1454+
logger.info("Orphan cleanup complete: %d entries removed", deleted)
1455+
1456+
14061457
def core_single_run(
14071458
logger: logging.Logger,
14081459
status_exchange: StatusExchange,
@@ -1523,6 +1574,8 @@ def sum_(inp: Iterable[int]) -> int:
15231574
return sum(inp)
15241575

15251576
photos_count: int | None = compose(sum_, album_lengths)(albums)
1577+
seen_asset_ids: set[str] = set()
1578+
iteration_complete = False
15261579
for photo_album in albums:
15271580
photos_enumerator: Iterable[PhotoAsset] = photo_album
15281581

@@ -1607,6 +1660,7 @@ def should_break(counter: Counter) -> bool:
16071660

16081661
for item in photos_bar:
16091662
try:
1663+
seen_asset_ids.add(item.id)
16101664
if should_break(consecutive_files_found):
16111665
logger.info(
16121666
"Found %s consecutive previously downloaded photos. Exiting",
@@ -1712,6 +1766,8 @@ def should_break(counter: Counter) -> bool:
17121766
if manifest is not None:
17131767
manifest.flush()
17141768

1769+
iteration_complete = not status_exchange.get_progress().cancel
1770+
17151771
if user_config.auto_delete:
17161772
autodelete_photos(
17171773
logger,
@@ -1726,6 +1782,16 @@ def should_break(counter: Counter) -> bool:
17261782
)
17271783
else:
17281784
pass
1785+
1786+
if manifest is not None and iteration_complete:
1787+
delete_orphaned(
1788+
logger,
1789+
manifest,
1790+
directory,
1791+
seen_asset_ids,
1792+
delete=user_config.delete_orphaned,
1793+
dry_run=user_config.dry_run,
1794+
)
17291795
except PyiCloudFailedLoginException as error:
17301796
logger.info(error)
17311797
dump_responses(logger.debug, captured_responses)

src/icloudpd/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ def add_options_for_user(parser: argparse.ArgumentParser) -> argparse.ArgumentPa
147147
+ "(If you restore the photo in iCloud, it will be downloaded again.)",
148148
action="store_true",
149149
)
150+
cloned.add_argument(
151+
"--delete-orphaned",
152+
help="Delete local files that are no longer in the iCloud library "
153+
+ "(e.g. deleted from iCloud or moved to another library).",
154+
action="store_true",
155+
)
150156
cloned.add_argument(
151157
"--folder-structure",
152158
help="Folder structure. If set to `none`, all photos will be placed into the download directory. Default: %(default)s",
@@ -545,6 +551,7 @@ def map_to_config(user_ns: argparse.Namespace) -> UserConfig:
545551
skip_live_photos=user_ns.skip_live_photos,
546552
force_size=user_ns.force_size,
547553
auto_delete=user_ns.auto_delete,
554+
delete_orphaned=user_ns.delete_orphaned,
548555
folder_structure=user_ns.folder_structure,
549556
set_exif_datetime=user_ns.set_exif_datetime,
550557
write_metadata_xmp=write_metadata_xmp,

src/icloudpd/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class _DefaultConfig:
2929
skip_live_photos: bool
3030
force_size: bool
3131
auto_delete: bool
32+
delete_orphaned: bool
3233
folder_structure: str
3334
set_exif_datetime: bool
3435
write_metadata_xmp: frozenset[str]

src/icloudpd/manifest.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,3 +485,18 @@ def count(self) -> int:
485485
"""Return the total number of manifest entries."""
486486
row = self._db.execute("SELECT COUNT(*) FROM manifest").fetchone()
487487
return row[0] if row else 0
488+
489+
def find_orphaned(self, seen_asset_ids: set[str]) -> list[ManifestRow]:
490+
"""Return manifest rows whose asset_id is not in the seen set."""
491+
all_rows = self._db.execute(
492+
"SELECT * FROM manifest"
493+
).fetchall()
494+
col_names = [desc[0] for desc in self._db.execute(
495+
"SELECT * FROM manifest LIMIT 0"
496+
).description or []]
497+
orphans: list[ManifestRow] = []
498+
for row in all_rows:
499+
row_dict = dict(zip(col_names, row, strict=False))
500+
if row_dict["asset_id"] not in seen_asset_ids:
501+
orphans.append(ManifestRow(**row_dict))
502+
return orphans

tests/test_cli.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ def test_cli_parser(self) -> None:
213213
skip_live_photos=False,
214214
force_size=False,
215215
auto_delete=False,
216+
delete_orphaned=False,
216217
folder_structure="{:%Y/%m/%d}",
217218
set_exif_datetime=False,
218219
write_metadata_xmp=frozenset(),
@@ -255,6 +256,7 @@ def test_cli_parser(self) -> None:
255256
skip_live_photos=False,
256257
force_size=False,
257258
auto_delete=False,
259+
delete_orphaned=False,
258260
folder_structure="{:%Y/%m/%d}",
259261
set_exif_datetime=False,
260262
write_metadata_xmp=frozenset(),
@@ -333,6 +335,7 @@ def test_cli_parser(self) -> None:
333335
skip_live_photos=False,
334336
force_size=False,
335337
auto_delete=False,
338+
delete_orphaned=False,
336339
folder_structure="{:%Y/%m/%d}",
337340
set_exif_datetime=False,
338341
write_metadata_xmp=frozenset(),

tests/test_delete_orphaned.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""Tests for delete_orphaned functionality."""
2+
3+
import logging
4+
import os
5+
6+
from icloudpd.base import delete_orphaned
7+
from icloudpd.manifest import ManifestDB
8+
9+
10+
def _setup_manifest_with_files(
11+
tmpdir: str,
12+
entries: list[dict[str, str]],
13+
) -> ManifestDB:
14+
"""Create a manifest DB with entries and corresponding files on disk."""
15+
manifest = ManifestDB(tmpdir)
16+
manifest.open()
17+
for entry in entries:
18+
asset_id = entry["asset_id"]
19+
zone_id = entry.get("zone_id", "PrimarySync")
20+
local_path = entry["local_path"]
21+
asset_resource = entry.get("asset_resource", "resOriginal")
22+
# Create the file on disk
23+
full_path = os.path.join(tmpdir, local_path)
24+
os.makedirs(os.path.dirname(full_path), exist_ok=True)
25+
with open(full_path, "w") as f:
26+
f.write("test content")
27+
# Create XMP sidecar
28+
with open(full_path + ".xmp", "w") as f:
29+
f.write("<xmp>test</xmp>")
30+
# Add to manifest
31+
manifest.upsert(
32+
asset_id=asset_id,
33+
zone_id=zone_id,
34+
local_path=local_path,
35+
version_size=1,
36+
asset_resource=asset_resource,
37+
)
38+
manifest.flush()
39+
return manifest
40+
41+
42+
class TestFindOrphaned:
43+
"""Tests for ManifestDB.find_orphaned()."""
44+
45+
def test_no_orphans(self, tmp_path: object) -> None:
46+
tmpdir = str(tmp_path)
47+
manifest = _setup_manifest_with_files(tmpdir, [
48+
{"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"},
49+
{"asset_id": "A2", "local_path": "2024-01/photo2.HEIC"},
50+
])
51+
seen = {"A1", "A2"}
52+
orphans = manifest.find_orphaned(seen)
53+
assert len(orphans) == 0
54+
manifest.close()
55+
56+
def test_all_orphaned(self, tmp_path: object) -> None:
57+
tmpdir = str(tmp_path)
58+
manifest = _setup_manifest_with_files(tmpdir, [
59+
{"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"},
60+
{"asset_id": "A2", "local_path": "2024-01/photo2.HEIC"},
61+
])
62+
seen: set[str] = set()
63+
orphans = manifest.find_orphaned(seen)
64+
assert len(orphans) == 2
65+
assert {o.asset_id for o in orphans} == {"A1", "A2"}
66+
manifest.close()
67+
68+
def test_partial_orphan(self, tmp_path: object) -> None:
69+
tmpdir = str(tmp_path)
70+
manifest = _setup_manifest_with_files(tmpdir, [
71+
{"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"},
72+
{"asset_id": "A2", "local_path": "2024-01/photo2.HEIC"},
73+
{"asset_id": "A3", "local_path": "2024-01/photo3.HEIC"},
74+
])
75+
seen = {"A1", "A3"}
76+
orphans = manifest.find_orphaned(seen)
77+
assert len(orphans) == 1
78+
assert orphans[0].asset_id == "A2"
79+
manifest.close()
80+
81+
def test_multi_resource_orphan(self, tmp_path: object) -> None:
82+
"""An asset with multiple resources (HEIC + MOV) should return all rows."""
83+
tmpdir = str(tmp_path)
84+
manifest = _setup_manifest_with_files(tmpdir, [
85+
{"asset_id": "A1", "local_path": "2024-01/IMG_001.HEIC", "asset_resource": "resOriginal"},
86+
{"asset_id": "A1", "local_path": "2024-01/IMG_001_HEVC.MOV", "asset_resource": "resOriginalVidCompl"},
87+
{"asset_id": "A2", "local_path": "2024-01/IMG_002.HEIC", "asset_resource": "resOriginal"},
88+
])
89+
seen = {"A2"}
90+
orphans = manifest.find_orphaned(seen)
91+
assert len(orphans) == 2
92+
assert all(o.asset_id == "A1" for o in orphans)
93+
manifest.close()
94+
95+
def test_empty_manifest(self, tmp_path: object) -> None:
96+
tmpdir = str(tmp_path)
97+
manifest = ManifestDB(tmpdir)
98+
manifest.open()
99+
orphans = manifest.find_orphaned({"A1", "A2"})
100+
assert len(orphans) == 0
101+
manifest.close()
102+
103+
104+
class TestDeleteOrphaned:
105+
"""Tests for the delete_orphaned() function."""
106+
107+
def test_warning_mode_no_flag(self, tmp_path: object) -> None:
108+
"""Without --delete-orphaned, logs warning but doesn't delete."""
109+
tmpdir = str(tmp_path)
110+
manifest = _setup_manifest_with_files(tmpdir, [
111+
{"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"},
112+
])
113+
logger = logging.getLogger("test")
114+
# Call without delete flag
115+
delete_orphaned(logger, manifest, tmpdir, set(), delete=False, dry_run=False)
116+
# File should still exist
117+
assert os.path.exists(os.path.join(tmpdir, "2024-01/photo1.HEIC"))
118+
assert os.path.exists(os.path.join(tmpdir, "2024-01/photo1.HEIC.xmp"))
119+
# Manifest should still have the entry
120+
assert manifest.count() == 1
121+
manifest.close()
122+
123+
def test_delete_mode(self, tmp_path: object) -> None:
124+
"""With --delete-orphaned, files and manifest entries are removed."""
125+
tmpdir = str(tmp_path)
126+
manifest = _setup_manifest_with_files(tmpdir, [
127+
{"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"},
128+
{"asset_id": "A2", "local_path": "2024-01/photo2.HEIC"},
129+
])
130+
logger = logging.getLogger("test")
131+
delete_orphaned(logger, manifest, tmpdir, {"A2"}, delete=True, dry_run=False)
132+
# A1 should be deleted
133+
assert not os.path.exists(os.path.join(tmpdir, "2024-01/photo1.HEIC"))
134+
assert not os.path.exists(os.path.join(tmpdir, "2024-01/photo1.HEIC.xmp"))
135+
# A2 should remain
136+
assert os.path.exists(os.path.join(tmpdir, "2024-01/photo2.HEIC"))
137+
assert os.path.exists(os.path.join(tmpdir, "2024-01/photo2.HEIC.xmp"))
138+
# Manifest should only have A2
139+
assert manifest.count() == 1
140+
manifest.close()
141+
142+
def test_dry_run_skips_deletion(self, tmp_path: object) -> None:
143+
"""Dry run logs but doesn't delete files or manifest entries."""
144+
tmpdir = str(tmp_path)
145+
manifest = _setup_manifest_with_files(tmpdir, [
146+
{"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"},
147+
])
148+
logger = logging.getLogger("test")
149+
delete_orphaned(logger, manifest, tmpdir, set(), delete=True, dry_run=True)
150+
# File should still exist
151+
assert os.path.exists(os.path.join(tmpdir, "2024-01/photo1.HEIC"))
152+
assert os.path.exists(os.path.join(tmpdir, "2024-01/photo1.HEIC.xmp"))
153+
# Manifest should still have the entry
154+
assert manifest.count() == 1
155+
manifest.close()
156+
157+
def test_multi_resource_cleanup(self, tmp_path: object) -> None:
158+
"""Deleting orphaned asset removes all resources (HEIC + MOV + XMPs)."""
159+
tmpdir = str(tmp_path)
160+
manifest = _setup_manifest_with_files(tmpdir, [
161+
{"asset_id": "A1", "local_path": "2024-01/IMG.HEIC", "asset_resource": "resOriginal"},
162+
{"asset_id": "A1", "local_path": "2024-01/IMG_HEVC.MOV", "asset_resource": "resOriginalVidCompl"},
163+
])
164+
logger = logging.getLogger("test")
165+
delete_orphaned(logger, manifest, tmpdir, set(), delete=True, dry_run=False)
166+
assert not os.path.exists(os.path.join(tmpdir, "2024-01/IMG.HEIC"))
167+
assert not os.path.exists(os.path.join(tmpdir, "2024-01/IMG.HEIC.xmp"))
168+
assert not os.path.exists(os.path.join(tmpdir, "2024-01/IMG_HEVC.MOV"))
169+
assert not os.path.exists(os.path.join(tmpdir, "2024-01/IMG_HEVC.MOV.xmp"))
170+
assert manifest.count() == 0
171+
manifest.close()
172+
173+
def test_no_orphans_no_action(self, tmp_path: object) -> None:
174+
"""When all assets are seen, no warning or deletion."""
175+
tmpdir = str(tmp_path)
176+
manifest = _setup_manifest_with_files(tmpdir, [
177+
{"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"},
178+
])
179+
logger = logging.getLogger("test")
180+
delete_orphaned(logger, manifest, tmpdir, {"A1"}, delete=True, dry_run=False)
181+
assert os.path.exists(os.path.join(tmpdir, "2024-01/photo1.HEIC"))
182+
assert manifest.count() == 1
183+
manifest.close()
184+
185+
def test_file_already_missing(self, tmp_path: object) -> None:
186+
"""Orphan in manifest but file already deleted from disk."""
187+
tmpdir = str(tmp_path)
188+
manifest = _setup_manifest_with_files(tmpdir, [
189+
{"asset_id": "A1", "local_path": "2024-01/photo1.HEIC"},
190+
])
191+
# Remove the file manually
192+
os.remove(os.path.join(tmpdir, "2024-01/photo1.HEIC"))
193+
os.remove(os.path.join(tmpdir, "2024-01/photo1.HEIC.xmp"))
194+
logger = logging.getLogger("test")
195+
# Should not crash, just remove manifest entry
196+
delete_orphaned(logger, manifest, tmpdir, set(), delete=True, dry_run=False)
197+
assert manifest.count() == 0
198+
manifest.close()
199+
200+
def test_dedup_suffix_files(self, tmp_path: object) -> None:
201+
"""Orphan with dedup suffix in path is handled correctly."""
202+
tmpdir = str(tmp_path)
203+
manifest = _setup_manifest_with_files(tmpdir, [
204+
{"asset_id": "A1", "local_path": "2024-01/IMG_001_aB3x.HEIC"},
205+
])
206+
logger = logging.getLogger("test")
207+
delete_orphaned(logger, manifest, tmpdir, set(), delete=True, dry_run=False)
208+
assert not os.path.exists(os.path.join(tmpdir, "2024-01/IMG_001_aB3x.HEIC"))
209+
assert manifest.count() == 0
210+
manifest.close()

0 commit comments

Comments
 (0)