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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```
Expand Down
41 changes: 41 additions & 0 deletions icloudpd/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import itertools
import subprocess
import json
import urllib
import click

from tqdm import tqdm
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"""

Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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

Expand Down
2 changes: 0 additions & 2 deletions icloudpd/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 17 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
55 changes: 55 additions & 0 deletions tests/test_download_photos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 30 additions & 0 deletions tests/vcr_cassettes/listing_photos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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