Skip to content

Commit 77e3e28

Browse files
resume interrupted downloads #968 #793 (#1217)
1 parent bbc4d88 commit 77e3e28

File tree

8 files changed

+12061
-245
lines changed

8 files changed

+12061
-245
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Unreleased
44

5+
- feat: resume interrupted downloads [#968](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/968) [#793](https://github.com/icloud-photos-downloader/icloud_photos_downloader/issues/793)
56
- 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)
67

78
## 1.29.4 (2025-08-12)

src/icloudpd/download.py

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Handles file downloads with retries and error handling"""
22

3+
import base64
34
import datetime
45
import logging
56
import os
67
import time
8+
from functools import partial
79

810
from requests import Response
911
from tzlocal import get_localzone
@@ -68,11 +70,14 @@ def mkdirs_for_path_dry_run(logger: logging.Logger, download_path: str) -> bool:
6870

6971

7072
def download_response_to_path(
71-
_logger: logging.Logger, response: Response, download_path: str, created_date: datetime.datetime
73+
response: Response,
74+
temp_download_path: str,
75+
append_mode: bool,
76+
download_path: str,
77+
created_date: datetime.datetime,
7278
) -> bool:
7379
"""Saves response content into file with desired created date"""
74-
temp_download_path = download_path + ".part"
75-
with open(temp_download_path, "wb") as file_obj:
80+
with open(temp_download_path, ("ab" if append_mode else "wb")) as file_obj:
7681
for chunk in response.iter_content(chunk_size=1024):
7782
if chunk:
7883
file_obj.write(chunk)
@@ -84,6 +89,8 @@ def download_response_to_path(
8489
def download_response_to_path_dry_run(
8590
logger: logging.Logger,
8691
_response: Response,
92+
_temp_download_path: str,
93+
_append_mode: bool,
8794
download_path: str,
8895
_created_date: datetime.datetime,
8996
) -> bool:
@@ -107,22 +114,36 @@ def download_media(
107114
"""Download the photo to path, with retries and error handling"""
108115

109116
mkdirs_local = mkdirs_for_path_dry_run if dry_run else mkdirs_for_path
110-
download_local = download_response_to_path_dry_run if dry_run else download_response_to_path
111-
112117
if not mkdirs_local(logger, download_path):
113118
return False
114119

120+
checksum = base64.b64decode(version.checksum)
121+
checksum32 = base64.b32encode(checksum).decode()
122+
download_dir = os.path.dirname(download_path)
123+
temp_download_path = os.path.join(download_dir, checksum32) + ".part"
124+
125+
download_local = (
126+
partial(download_response_to_path_dry_run, logger) if dry_run else download_response_to_path
127+
)
128+
115129
retries = 0
116130
while True:
117131
try:
118-
photo_response = photo.download(version.url)
119-
if photo_response:
120-
return download_local(logger, photo_response, download_path, photo.created)
121-
122-
logger.error(
123-
"Could not find URL to download %s for size %s", version.filename, size.value
124-
)
125-
break
132+
append_mode = os.path.exists(temp_download_path)
133+
current_size = os.path.getsize(temp_download_path) if append_mode else 0
134+
if append_mode:
135+
logger.debug(f"Resuming downloading of {download_path} from {current_size}")
136+
137+
photo_response = photo.download(version.url, current_size)
138+
if photo_response.ok:
139+
return download_local(
140+
photo_response, temp_download_path, append_mode, download_path, photo.created
141+
)
142+
else:
143+
logger.error(
144+
"Could not find URL to download %s for size %s", version.filename, size.value
145+
)
146+
break
126147

127148
except PyiCloudAPIResponseException as ex:
128149
if "Invalid global session" in str(ex):

src/pyicloud_ipd/asset_version.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33

44
class AssetVersion:
5-
def __init__(self, filename: str, size: int, url: str, type: str) -> None:
5+
def __init__(self, filename: str, size: int, url: str, type: str, checksum: str) -> None:
66
self.filename = filename
77
self.size = size
88
self.url = url
99
self.type = type
10+
self.checksum = checksum
1011

1112
def __eq__(self, other: object) -> bool:
1213
if not isinstance(other, AssetVersion):

src/pyicloud_ipd/services/photos.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,7 @@ def versions(self) -> Dict[VersionSize, AssetVersion]:
788788
if size_entry:
789789
version['size'] = size_entry['value']['size']
790790
version['url'] = size_entry['value']['downloadURL']
791+
version['checksum'] = size_entry['value']['fileChecksum']
791792
else:
792793
raise ValueError(f"Expected {prefix}Res, but missing it")
793794
# version['size'] = None
@@ -814,7 +815,7 @@ def versions(self) -> Dict[VersionSize, AssetVersion]:
814815
_size_suffix = self.VERSION_FILENAME_SUFFIX_LOOKUP[key]
815816
version["filename"] = add_suffix_to_filename(f"-{_size_suffix}", version["filename"])
816817

817-
_versions[key] = AssetVersion(version["filename"], version['size'], version['url'], version['type'])
818+
_versions[key] = AssetVersion(version["filename"], version['size'], version['url'], version['type'], version['checksum'])
818819

819820
# swap original & alternative according to swap_raw_policy
820821
if AssetVersionSize.ALTERNATIVE in _versions and (("raw" in _versions[AssetVersionSize.ALTERNATIVE].type and self._service.raw_policy == RawTreatmentPolicy.AS_ORIGINAL) or ("raw" in _versions[AssetVersionSize.ORIGINAL].type and self._service.raw_policy == RawTreatmentPolicy.AS_ALTERNATIVE)):
@@ -827,9 +828,13 @@ def versions(self) -> Dict[VersionSize, AssetVersion]:
827828

828829
return self._versions
829830

830-
def download(self, url: str) -> Response:
831+
def download(self, url: str, start:int = 0) -> Response:
832+
headers = {
833+
"Range": f"bytes={start}-"
834+
}
831835
return self._service.session.get(
832836
url,
837+
headers=headers,
833838
stream=True
834839
)
835840

0 commit comments

Comments
 (0)