Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- feat: add `--skip-photos` filter [#401](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/401) [#1206](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/1206)

## 1.29.4 (2025-08-12)

- fix: failed auth terminate webui [#1195](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/1195)
Expand Down
9 changes: 9 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ This is a list of all options available for command line interface (CLI) of the

: If specified, video assets will not be processed

(skip-photos-parameter)=
`--skip-photos`

```{versionadded} 1.30.0
```


: If specified, photos assets will not be processed

(skip-live-photos-parameter)=
`--skip-live-photos`

Expand Down
61 changes: 46 additions & 15 deletions src/icloudpd/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,11 @@ def report_version(ctx: click.Context, _param: click.Parameter, value: bool) ->
help="Do not process assets created after specified timestamp in ISO format (2025-01-02) or interval from now (20d)",
callback=skip_created_after_generator,
)
@click.option(
"--skip-photos",
help="Don't download any photos (default: Download all photos and videos)",
is_flag=True,
)
@click.option(
"--version",
help="Show the version, commit hash and timestamp",
Expand Down Expand Up @@ -680,6 +685,7 @@ def main(
use_os_locale: bool,
skip_created_before: datetime.datetime | datetime.timedelta | None,
skip_created_after: datetime.datetime | datetime.timedelta | None,
skip_photos: bool,
) -> NoReturn:
"""Download all iCloud photos to a local directory"""

Expand All @@ -703,6 +709,10 @@ def main(
logger.setLevel(logging.ERROR)

with logging_redirect_tqdm():
if skip_videos and skip_photos:
print("Only one of --skip-videos and --skip-photos can be used at a time")
sys.exit(2)

# check required directory param only if not list albums
if not list_albums and not list_libraries and not directory and not auth_only:
print("--auth-only, --directory, --list-libraries or --list-albums are required")
Expand Down Expand Up @@ -826,6 +836,7 @@ def main(
skip_videos,
skip_created_before,
skip_created_after,
skip_photos,
)
downloader = (
partial(
Expand Down Expand Up @@ -892,6 +903,7 @@ def main(
password_providers,
mfa_provider,
status_exchange,
skip_photos,
)
sys.exit(result)

Expand Down Expand Up @@ -938,6 +950,7 @@ def where_builder(
skip_videos: bool,
skip_created_before: datetime.datetime | datetime.timedelta | None,
skip_created_after: datetime.datetime | datetime.timedelta | None,
skip_photos: bool,
photo: PhotoAsset,
) -> bool:
if skip_videos and photo.item_type == AssetItemType.MOVIE:
Expand All @@ -947,6 +960,13 @@ def where_builder(
photo.item_type,
)
return False
if skip_photos and photo.item_type == AssetItemType.IMAGE:
logger.debug(
"Skipping %s, only downloading videos." + "(Item type was: %s)",
photo.filename,
photo.item_type,
)
return False

try:
created_date = photo.created.astimezone(get_localzone())
Expand Down Expand Up @@ -1320,6 +1340,7 @@ def core(
password_providers: Dict[str, Tuple[Callable[[str], str | None], Callable[[str, str], None]]],
mfa_provider: MFAProvider,
status_exchange: StatusExchange,
skip_photos: bool,
) -> int:
"""Download all iCloud photos to a local directory"""

Expand Down Expand Up @@ -1394,9 +1415,12 @@ def append_response(captured: List[Mapping[str, Any]], response: Mapping[str, An
# would be better to have that in types though
directory = os.path.normpath(cast(str, directory))

videos_phrase = "" if skip_videos else " and videos"
if skip_photos or skip_videos:
photo_video_phrase = "photos" if skip_videos else "videos"
else:
photo_video_phrase = "photos and videos"
album_phrase = f" from album {album}" if album else ""
logger.debug(f"Looking up all photos{videos_phrase}{album_phrase}...")
logger.debug(f"Looking up all {photo_video_phrase}{album_phrase}...")

session_exception_handler = partial(session_error_handle_builder, logger, icloud)
internal_error_handler = partial(internal_error_handle_builder, logger)
Expand Down Expand Up @@ -1439,21 +1463,25 @@ def append_response(captured: List[Mapping[str, Any]], response: Mapping[str, An

if photos_count is not None:
plural_suffix = "" if photos_count == 1 else "s"
video_suffix = ""
photos_count_str = "the first" if photos_count == 1 else photos_count
photos_count_str = "the first" if photos_count == 1 else str(photos_count)

if not skip_videos:
video_suffix = " or video" if photos_count == 1 else " and videos"
if skip_photos or skip_videos:
photo_video_phrase = ("photo" if skip_videos else "video") + plural_suffix
else:
photo_video_phrase = (
"photo or video" if photos_count == 1 else "photos and videos"
)
else:
photos_count_str = "???"
plural_suffix = "s"
video_suffix = " and videos" if not skip_videos else ""
if skip_photos or skip_videos:
photo_video_phrase = "photos" if skip_videos else "videos"
else:
photo_video_phrase = "photos and videos"
logger.info(
("Downloading %s %s" + " photo%s%s to %s ..."),
("Downloading %s %s %s to %s ..."),
photos_count_str,
",".join([_s.value for _s in primary_sizes]),
plural_suffix,
video_suffix,
photo_video_phrase,
directory,
)

Expand Down Expand Up @@ -1537,10 +1565,13 @@ def should_break(counter: Counter) -> bool:
logger.info("Iteration was cancelled")
status_exchange.get_progress().photos_last_message = "Iteration was cancelled"
else:
logger.info("All photos have been downloaded")
status_exchange.get_progress().photos_last_message = (
"All photos have been downloaded"
)
if skip_photos or skip_videos:
photo_video_phrase = "photos" if skip_videos else "videos"
else:
photo_video_phrase = "photos and videos"
message = f"All {photo_video_phrase} have been downloaded"
logger.info(message)
status_exchange.get_progress().photos_last_message = message
status_exchange.get_progress().reset()

if auto_delete:
Expand Down
12 changes: 8 additions & 4 deletions tests/test_autodelete_photos.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ def astimezone(self, _tz: (Any | None) = None) -> NoReturn:
"INFO Deleted IMG_3589.JPG",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn(
"INFO All photos and videos have been downloaded", self._caplog.text
)

# check files
for file_name in files:
Expand Down Expand Up @@ -129,7 +131,9 @@ def astimezone(self, _tz: (Any | None) = None) -> NoReturn:
f"INFO Downloading 0 original photos and videos to {data_dir} ...",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn(
"INFO All photos and videos have been downloaded", self._caplog.text
)
self.assertIn(
"INFO Deleting any files found in 'Recently Deleted'...",
self._caplog.text,
Expand Down Expand Up @@ -198,7 +202,7 @@ def test_download_autodelete_photos(self) -> None:
"INFO Deleted IMG_3589.JPG",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn("INFO All photos and videos have been downloaded", self._caplog.text)

# check files
for file_name in files:
Expand Down Expand Up @@ -232,7 +236,7 @@ def test_download_autodelete_photos(self) -> None:
f"INFO Downloading 0 original photos and videos to {data_dir} ...",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn("INFO All photos and videos have been downloaded", self._caplog.text)
self.assertIn(
"INFO Deleting any files found in 'Recently Deleted'...",
self._caplog.text,
Expand Down
4 changes: 2 additions & 2 deletions tests/test_download_live_photos.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def test_skip_existing_downloads_for_live_photos(self) -> None:
],
)

self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn("INFO All photos and videos have been downloaded", self._caplog.text)
assert result.exit_code == 0

def test_skip_existing_live_photodownloads(self) -> None:
Expand Down Expand Up @@ -105,7 +105,7 @@ def test_skip_existing_live_photodownloads(self) -> None:
f"INFO Downloading 3 original photos and videos to {data_dir} ...",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn("INFO All photos and videos have been downloaded", self._caplog.text)
assert result.exit_code == 0

def test_skip_existing_live_photo_print_filenames(self) -> None:
Expand Down
4 changes: 2 additions & 2 deletions tests/test_download_live_photos_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def test_skip_existing_downloads_for_live_photos_name_id7(self) -> None:
],
)

self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn("INFO All photos and videos have been downloaded", self._caplog.text)
assert result.exit_code == 0

def test_skip_existing_live_photodownloads_name_id7(self) -> None:
Expand Down Expand Up @@ -93,7 +93,7 @@ def test_skip_existing_live_photodownloads_name_id7(self) -> None:
f"INFO Downloading 3 original photos and videos to {data_dir} ...",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn("INFO All photos and videos have been downloaded", self._caplog.text)
assert result.exit_code == 0

def test_skip_existing_live_photo_print_filenames_name_id7(self) -> None:
Expand Down
34 changes: 22 additions & 12 deletions tests/test_download_photos.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ def mocked_download(pa: PhotoAsset, _url: str) -> Response:
f"DEBUG Setting EXIF timestamp for {os.path.join(data_dir, os.path.normpath('2018/07/31/IMG_7409.JPG'))}: {expectedDatetime}",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn("INFO All photos and videos have been downloaded", self._caplog.text)

def test_download_photos_and_get_exif_exceptions(self) -> None:
base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3])
Expand Down Expand Up @@ -291,7 +291,7 @@ def test_skip_existing_downloads(self) -> None:
f"DEBUG {os.path.join(data_dir, os.path.normpath('2018/07/31/IMG_7409.MOV'))} already exists",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn("INFO All photos and videos have been downloaded", self._caplog.text)

def test_until_found(self) -> None:
base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3])
Expand Down Expand Up @@ -660,7 +660,7 @@ def test_missing_size(self) -> None:
f"Errors for {filename} size {size}",
)

self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn("INFO All photos and videos have been downloaded", self._caplog.text)
self.assertEqual(result.exit_code, 0, "Exit code")

def test_size_fallback_to_original(self) -> None:
Expand Down Expand Up @@ -713,7 +713,9 @@ def test_size_fallback_to_original(self) -> None:
f"DEBUG Downloading {os.path.join(data_dir, os.path.normpath('2018/07/31/IMG_7409.JPG'))}",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn(
"INFO All photos and videos have been downloaded", self._caplog.text
)
dp_patched.assert_called_once_with(
ANY,
False,
Expand Down Expand Up @@ -773,7 +775,9 @@ def test_force_size(self) -> None:
"ERROR thumb size does not exist for IMG_7409.JPG. Skipping...",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn(
"INFO All photos and videos have been downloaded", self._caplog.text
)
dp_patched.assert_not_called()

assert result.exit_code == 0
Expand Down Expand Up @@ -829,7 +833,9 @@ def test_download_two_sizes_with_force_size(self) -> None:
"ERROR medium size does not exist for IMG_7409.JPG. Skipping...",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn(
"INFO All photos and videos have been downloaded", self._caplog.text
)
dp_patched.assert_called_once_with(
ANY,
False,
Expand Down Expand Up @@ -893,7 +899,7 @@ def astimezone(self, _tz: (Any | None) = None) -> NoReturn:
f"DEBUG Downloading {os.path.join(data_dir, os.path.normpath('2018/01/01/IMG_7409.JPG'))}",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn("INFO All photos and videos have been downloaded", self._caplog.text)
assert result.exit_code == 0

@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows")
Expand Down Expand Up @@ -951,7 +957,7 @@ def test_creation_date_without_century(self) -> None:
f"DEBUG Downloading {os.path.join(data_dir, os.path.normpath('5/01/01/IMG_7409.JPG'))}",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn("INFO All photos and videos have been downloaded", self._caplog.text)
assert result.exit_code == 0

def test_creation_date_prior_1970(self) -> None:
Expand Down Expand Up @@ -999,7 +1005,7 @@ def test_creation_date_prior_1970(self) -> None:
f"DEBUG Downloading {os.path.join(data_dir, os.path.normpath('1965/01/01/IMG_7409.JPG'))}",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn("INFO All photos and videos have been downloaded", self._caplog.text)
assert result.exit_code == 0

def test_missing_item_type(self) -> None:
Expand Down Expand Up @@ -1310,7 +1316,9 @@ def mocked_download(pa: PhotoAsset, _url: str) -> Response:
f"INFO Downloading the first original photo or video to {data_dir} ...",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn(
"INFO All photos and videos have been downloaded", self._caplog.text
)
assert result.exit_code == 0

def test_download_one_recent_live_photo_chinese(self) -> None:
Expand Down Expand Up @@ -1367,7 +1375,9 @@ def mocked_download(pa: PhotoAsset, _url: str) -> Response:
f"INFO Downloading the first original photo or video to {data_dir} ...",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn(
"INFO All photos and videos have been downloaded", self._caplog.text
)
assert result.exit_code == 0

def test_download_and_delete_after(self) -> None:
Expand Down Expand Up @@ -2343,7 +2353,7 @@ def test_download_from_shared_library(self) -> None:
f"INFO Downloading the first original photo or video to {data_dir} ...",
self._caplog.text,
)
self.assertIn("INFO All photos have been downloaded", self._caplog.text)
self.assertIn("INFO All photos and videos have been downloaded", self._caplog.text)

def test_download_and_skip_old(self) -> None:
base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3])
Expand Down
Loading
Loading