diff --git a/README.md b/README.md index 533a173ac..9d9f65d4f 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,10 @@ Options: there is no tty attached) --threads-num INTEGER RANGE Number of cpu threads -- deprecated. To be removed in future version + --delete-after-download Delete the photo/video after download it. + The deleted items will be appear in the + "Recently Deleted". Therefore, should not + combine with --auto-delete option. --version Show the version and exit. -h, --help Show this message and exit. ``` diff --git a/icloudpd/base.py b/icloudpd/base.py index 272ad2868..4dde21f98 100755 --- a/icloudpd/base.py +++ b/icloudpd/base.py @@ -9,6 +9,7 @@ import itertools import subprocess import json +import urllib import click from tqdm import tqdm @@ -193,6 +194,13 @@ type=click.IntRange(1), default=1, ) +@click.option( + "--delete-after-download", + help='Delete the photo/video after download it.' + + ' The deleted items will be appear in the "Recently Deleted".' + + ' Therefore, should not combine with --auto-delete option.', + is_flag=True, +) @click.version_option() # pylint: disable-msg=too-many-arguments,too-many-statements # pylint: disable-msg=too-many-branches,too-many-locals @@ -224,6 +232,7 @@ def main( no_progress_bar, notification_script, threads_num, # pylint: disable=W0613 + delete_after_download ): """Download all iCloud photos to a local directory""" @@ -246,6 +255,10 @@ def main( print('--directory or --list-albums are required') sys.exit(2) + if auto_delete and delete_after_download: + print('--auto-delete and --delete-after-download are mutually exclusive') + sys.exit(2) + raise_error_on_2sa = ( smtp_username is not None or notification_email is not None @@ -541,6 +554,32 @@ def download_photo(counter, photo): icloud, photo, lp_download_path, lp_size ) + def delete_photo(photo): + """Delete a photo from the iCloud account.""" + logger.info("Deleting %s", photo.filename) + # pylint: disable=W0212 + url = f"{icloud.photos._service_endpoint}/records/modify?"\ + f"{urllib.parse.urlencode(icloud.photos.params)}" + post_data = json.dumps( + { + "atomic": True, + "desiredKeys": ["isDeleted"], + "operations": [{ + "operationType": "update", + "record": { + "fields": {'isDeleted': {'value': 1}}, + "recordChangeTag": photo._asset_record["recordChangeTag"], + "recordName": photo._asset_record["recordName"], + "recordType": "CPLAsset", + } + }], + "zoneID": {"zoneName": "PrimarySync"} + } + ) + icloud.photos.session.post( + url, data=post_data, headers={ + "Content-type": "application/json"}) + consecutive_files_found = Counter(0) def should_break(counter): @@ -557,6 +596,8 @@ def should_break(counter): break item = next(photos_iterator) download_photo(consecutive_files_found, item) + if delete_after_download: + delete_photo(item) except StopIteration: break diff --git a/icloudpd/download.py b/icloudpd/download.py index 7b02f285a..be0c82185 100644 --- a/icloudpd/download.py +++ b/icloudpd/download.py @@ -27,13 +27,11 @@ def update_mtime(photo, download_path): return set_utime(download_path, created_date) - def set_utime(download_path, created_date): """Set date & time of the file""" ctime = time.mktime(created_date.timetuple()) os.utime(download_path, (ctime, ctime)) - def download_media(icloud, photo, download_path, size): """Download the photo to path, with retries and error handling""" logger = setup_logger() diff --git a/requirements.txt b/requirements.txt index 0df9144ec..6a04f9037 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ python_dateutil==2.8.2 requests==2.28.2 tqdm==4.64.1 piexif==1.1.3 +urllib3==1.26.6 \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index cf93876fe..faad786fc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -151,3 +151,20 @@ def test_missing_directory_param(self): ], ) assert result.exit_code == 2 + + def test_conflict_options_delete_after_download_and_auto_delete(self): + runner = CliRunner() + result = runner.invoke( + main, + [ + "--username", + "jdoe@gmail.com", + "--password", + "password1", + "-d", + "/tmp", + "--delete-after-download", + "--auto-delete" + ], + ) + assert result.exit_code == 2 diff --git a/tests/test_download_photos.py b/tests/test_download_photos.py index 0b4312319..87ea1f0ce 100644 --- a/tests/test_download_photos.py +++ b/tests/test_download_photos.py @@ -1295,3 +1295,58 @@ def test_download_chinese(self): photo_modified_time.strftime('%Y-%m-%d %H:%M:%S')) assert result.exit_code == 0 + + def test_download_after_delete(self): + base_dir = os.path.normpath(f"tests/fixtures/Photos/{inspect.stack()[0][3]}") + if os.path.exists(base_dir): + shutil.rmtree(base_dir) + os.makedirs(base_dir) + + with mock.patch.object(piexif, "insert") as piexif_patched: + piexif_patched.side_effect = InvalidImageDataError + with mock.patch( + "icloudpd.exif_datetime.get_photo_exif" + ) as get_exif_patched: + get_exif_patched.return_value = False + with vcr.use_cassette("tests/vcr_cassettes/listing_photos.yml"): + # Pass fixed client ID via environment variable + runner = CliRunner(env={ + "CLIENT_ID": "DE309E26-942E-11E8-92F5-14109FE0B321" + }) + result = runner.invoke( + main, + [ + "--username", + "jdoe@gmail.com", + "--password", + "password1", + "--recent", + "1", + "--skip-videos", + "--skip-live-photos", + "--no-progress-bar", + "--threads-num", + 1, + "--delete-after-download", + "-d", + base_dir, + ], + ) + print_result_exception(result) + + self.assertIn("DEBUG Looking up all photos from album All Photos...", self._caplog.text) + self.assertIn( + f"INFO Downloading the first original photo to {base_dir} ...", + self._caplog.text, + ) + self.assertIn( + f"INFO Downloading {os.path.join(base_dir, os.path.normpath('2018/07/31/IMG_7409.JPG'))}", + self._caplog.text, + ) + self.assertIn( + "INFO Deleting IMG_7409.JPG", self._caplog.text + ) + self.assertIn( + "INFO All photos have been downloaded!", self._caplog.text + ) + assert result.exit_code == 0 diff --git a/tests/vcr_cassettes/listing_photos.yml b/tests/vcr_cassettes/listing_photos.yml index fc92d7b90..31cd097d7 100644 --- a/tests/vcr_cassettes/listing_photos.yml +++ b/tests/vcr_cassettes/listing_photos.yml @@ -44551,4 +44551,34 @@ interactions: apple-seq: ['0'] apple-tk: ['false'] status: {code: 200, message: OK} +- request: + body: '{"atomic": true, "desiredKeys": ["isDeleted"], "operations": [{"operationType": + "update", "record": {"fields": {"isDeleted": {"value": 1}}, "recordChangeTag": + "49lh", "recordName": "F2A23C38-0020-42FE-A273-2923ADE3CAED", "recordType": + "CPLAsset"}}], "zoneID": {"zoneName": "PrimarySync"}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '288' + Content-type: + - application/json + Origin: + - https://www.icloud.com + Referer: + - https://www.icloud.com/ + User-Agent: + - Opera/9.52 (X11; Linux i686; U; en) + method: POST + uri: https://p10-ckdatabasews.icloud.com/database/1/com.apple.photos.cloud/production/private/records/modify?clientBuildNumber=17DHotfix5&clientMasteringNumber=17DHotfix5&ckjsBuildVersion=17DProjectDev77&ckjsVersion=2.0.5&clientId=DE309E26-942E-11E8-92F5-14109FE0B321&dsid=185776146&remapEnums=True&getCurrentSyncToken=True + response: + body: + string: "{}" + headers: + Content-type: application/json + status: {code: 200, message: OK} version: 1