diff --git a/README.md b/README.md index 164783361..790e5b5f2 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ There are three ways to run `icloudpd`: - One time download and an option to monitor for iCloud changes continuously (`--watch-with-interval` option) - Optimizations for incremental runs (`--until-found` and `--recent` options) - Photo meta data (EXIF) updates (`--set-exif-datetime` option) +- Filter items to download by creation date (`--created-before` and/or `--created-after`) - ... and many more (use `--help` option to get full list) ## Experimental Mode diff --git a/src/icloudpd/base.py b/src/icloudpd/base.py index 2b1a5f3c7..8a4f6dcc2 100644 --- a/src/icloudpd/base.py +++ b/src/icloudpd/base.py @@ -233,6 +233,14 @@ type=click.Choice(["com", "cn"]), default="com", ) +@click.option("--created-before", + help="Only download pictures/videos created before specified date in YYYY-MM-DD format.", + default=None, + ) +@click.option("--created-after", + help="Only download pictures/videos created after specified date in YYYY-MM-DD format.", + default=None, + ) @click.option("--watch-with-interval", help="Run downloading in a infinite cycle, waiting specified seconds between runs", type=click.IntRange(1), @@ -281,6 +289,8 @@ def main( threads_num: int, # pylint: disable=W0613 delete_after_download: bool, domain: str, + created_before: str, + created_after: str, watch_with_interval: Optional[int], dry_run: bool ): @@ -324,6 +334,25 @@ def main( ) sys.exit(2) + if created_before: + try: + created_before = datetime.datetime.strptime( + created_before, "%Y-%m-%d") + except ValueError: + print("Given --created-before does not match required format YYYY-MM-DD.") + sys.exit(2) + + created_before = created_before.replace(tzinfo=get_localzone()) + + if created_after: + try: + created_after = datetime.datetime.strptime(created_after, "%Y-%m-%d") + except ValueError: + print("Given --created-after does not match required format YYYY-MM-DD.") + sys.exit(2) + + created_after = created_after.replace(tzinfo=get_localzone()) + sys.exit( core( download_builder( @@ -337,6 +366,8 @@ def main( set_exif_datetime, skip_live_photos, live_photo_size, + created_before, + created_after, dry_run) if directory is not None else ( lambda _s: lambda _c, _p: False), @@ -387,6 +418,8 @@ def download_builder( set_exif_datetime: bool, skip_live_photos: bool, live_photo_size: str, + created_before: datetime.datetime | None, + created_after: datetime.datetime | None, dry_run: bool) -> Callable[[PyiCloudService], Callable[[Counter, PhotoAsset], bool]]: """factory for downloader""" def state_( @@ -394,6 +427,7 @@ def state_( def download_photo_(counter: Counter, photo: PhotoAsset) -> bool: """internal function for actually downloading the photos""" filename = clean_filename(photo.filename) + if skip_videos and photo.item_type != "image": logger.debug( "Skipping %s, only downloading photos." + @@ -418,6 +452,18 @@ def download_photo_(counter: Counter, photo: PhotoAsset) -> bool: photo.created) created_date = photo.created + if created_before and created_date > created_before: + logger.debug( + "Skipping %s, date is after the given latest date.", + filename) + return False + + if created_after and created_date < created_after: + logger.debug( + "Skipping %s, date is before the given earliest date.", + filename) + return False + try: if folder_structure.lower() == "none": date_path = "" diff --git a/tests/test_cli.py b/tests/test_cli.py index 8f4865ec8..535c3ea48 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -209,3 +209,37 @@ def test_conflict_options_delete_after_download_and_auto_delete(self): ], ) assert result.exit_code == 2 + + def test_bad_created_before_format(self): + runner = CliRunner() + result = runner.invoke( + main, + [ + "--username", + "jdoe@gmail.com", + "--password", + "password1", + "-d", + "/tmp", + "--created-before", + "alpha" + ], + ) + assert result.exit_code == 2 + + def test_bad_created_after_format(self): + runner = CliRunner() + result = runner.invoke( + main, + [ + "--username", + "jdoe@gmail.com", + "--password", + "password1", + "-d", + "/tmp", + "--created-after", + "alpha" + ], + ) + assert result.exit_code == 2 \ No newline at end of file diff --git a/tests/test_download_photos.py b/tests/test_download_photos.py index 5495b0bac..d20f759dd 100644 --- a/tests/test_download_photos.py +++ b/tests/test_download_photos.py @@ -2293,3 +2293,99 @@ def raise_response_error(a0_, a1_, a2_): self.assertEqual(sum(1 for _ in files_in_result), 0, "Files in the result") + + def test_skip_by_created_before(self): + base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3]) + cookie_dir = os.path.join(base_dir, "cookie") + data_dir = os.path.join(base_dir, "data") + + for dir in [base_dir, cookie_dir, data_dir]: + recreate_path(dir) + + with vcr.use_cassette(os.path.join(self.vcr_path, "listing_photos.yml")): + runner = CliRunner(env={ + "CLIENT_ID": "DE309E26-942E-11E8-92F5-14109FE0B321" + }) + + result = runner.invoke( + main, + [ + "--username", + "jdoe@gmail.com", + "--password", + "password1", + "--recent", + "1", + "--skip-live-photos", + "--skip-videos", + "--dry-run", + "--no-progress-bar", + "--threads-num", + 1, + "-d", + data_dir, + "--cookie-directory", + cookie_dir, + "--created-before", + "2000-01-01" + ] + ) + print_result_exception(result) + + assert result.exit_code == 0 + self.assertIn( + "DEBUG Looking up all photos from album All Photos...", self._caplog.text) + self.assertIn( + f"DEBUG Skipping IMG_7409.JPG, date is after", self._caplog.text + ) + + files_in_result = glob.glob(os.path.join( + data_dir, "**/*.*"), recursive=True) + + assert sum(1 for _ in files_in_result) == 0, "No files should have been downloaded." + + def test_skip_by_created_after(self): + base_dir = os.path.join(self.fixtures_path, inspect.stack()[0][3]) + cookie_dir = os.path.join(base_dir, "cookie") + data_dir = os.path.join(base_dir, "data") + + for dir in [base_dir, cookie_dir, data_dir]: + recreate_path(dir) + + with vcr.use_cassette(os.path.join(self.vcr_path, "listing_photos.yml")): + runner = CliRunner(env={ + "CLIENT_ID": "DE309E26-942E-11E8-92F5-14109FE0B321" + }) + + result = runner.invoke( + main, + [ + "--username", + "jdoe@gmail.com", + "--password", + "password1", + "--recent", + "1", + "--skip-live-photos", + "--skip-videos", + "--dry-run", + "--no-progress-bar", + "--threads-num", + 1, + "-d", + data_dir, + "--cookie-directory", + cookie_dir, + "--created-after", + "2020-01-01" + ] + ) + print_result_exception(result) + assert result.exit_code == 0 + self.assertIn( + "DEBUG Looking up all photos from album All Photos...", self._caplog.text) + self.assertIn( + f"DEBUG Skipping IMG_7409.JPG, date is before", self._caplog.text + ) + files_in_result = glob.glob(os.path.join(data_dir, "**/*.*"), recursive=True) + assert sum(1 for _ in files_in_result) == 0, "No files should have been downloaded."