293 lines
12 KiB
Python
293 lines
12 KiB
Python
|
import math
|
||
|
import os
|
||
|
import time
|
||
|
from concurrent.futures import as_completed
|
||
|
from concurrent.futures.thread import ThreadPoolExecutor
|
||
|
from http.cookiejar import CookieJar
|
||
|
from pathlib import Path
|
||
|
from typing import Any, Generator, MutableMapping, Optional, Union
|
||
|
|
||
|
from requests import Session
|
||
|
from requests.adapters import HTTPAdapter
|
||
|
from rich import filesize
|
||
|
|
||
|
from devine.core.constants import DOWNLOAD_CANCELLED
|
||
|
from devine.core.utilities import get_extension
|
||
|
|
||
|
MAX_ATTEMPTS = 5
|
||
|
RETRY_WAIT = 2
|
||
|
CHUNK_SIZE = 1024
|
||
|
PROGRESS_WINDOW = 5
|
||
|
|
||
|
DOWNLOAD_SIZES = []
|
||
|
LAST_SPEED_REFRESH = time.time()
|
||
|
|
||
|
def download(
|
||
|
url: str,
|
||
|
save_path: Path,
|
||
|
session: Optional[Session] = None,
|
||
|
segmented: bool = False,
|
||
|
**kwargs: Any
|
||
|
) -> Generator[dict[str, Any], None, None]:
|
||
|
"""
|
||
|
Download a file using Python Requests.
|
||
|
https://requests.readthedocs.io
|
||
|
|
||
|
Yields the following download status updates while chunks are downloading:
|
||
|
|
||
|
- {total: 123} (there are 123 chunks to download)
|
||
|
- {total: None} (there are an unknown number of chunks to download)
|
||
|
- {advance: 1} (one chunk was downloaded)
|
||
|
- {downloaded: "10.1 MB/s"} (currently downloading at a rate of 10.1 MB/s)
|
||
|
- {file_downloaded: Path(...), written: 1024} (download finished, has the save path and size)
|
||
|
|
||
|
The data is in the same format accepted by rich's progress.update() function. The
|
||
|
`downloaded` key is custom and is not natively accepted by all rich progress bars.
|
||
|
|
||
|
Parameters:
|
||
|
url: Web URL of a file to download.
|
||
|
save_path: The path to save the file to. If the save path's directory does not
|
||
|
exist then it will be made automatically.
|
||
|
session: The Requests Session to make HTTP requests with. Useful to set Header,
|
||
|
Cookie, and Proxy data. Connections are saved and re-used with the session
|
||
|
so long as the server keeps the connection alive.
|
||
|
segmented: If downloads are segments or parts of one bigger file.
|
||
|
kwargs: Any extra keyword arguments to pass to the session.get() call. Use this
|
||
|
for one-time request changes like a header, cookie, or proxy. For example,
|
||
|
to request Byte-ranges use e.g., `headers={"Range": "bytes=0-128"}`.
|
||
|
"""
|
||
|
global LAST_SPEED_REFRESH
|
||
|
|
||
|
session = session or Session()
|
||
|
|
||
|
save_dir = save_path.parent
|
||
|
control_file = save_path.with_name(f"{save_path.name}.!dev")
|
||
|
|
||
|
save_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
||
|
if control_file.exists():
|
||
|
# consider the file corrupt if the control file exists
|
||
|
save_path.unlink(missing_ok=True)
|
||
|
control_file.unlink()
|
||
|
elif save_path.exists():
|
||
|
# if it exists, and no control file, then it should be safe
|
||
|
yield dict(
|
||
|
file_downloaded=save_path,
|
||
|
written=save_path.stat().st_size
|
||
|
)
|
||
|
# TODO: This should return, potential recovery bug
|
||
|
|
||
|
# TODO: Design a control file format so we know how much of the file is missing
|
||
|
control_file.write_bytes(b"")
|
||
|
|
||
|
attempts = 1
|
||
|
try:
|
||
|
while True:
|
||
|
written = 0
|
||
|
|
||
|
# these are for single-url speed calcs only
|
||
|
download_sizes = []
|
||
|
last_speed_refresh = time.time()
|
||
|
|
||
|
try:
|
||
|
stream = session.get(url, stream=True, **kwargs)
|
||
|
stream.raise_for_status()
|
||
|
|
||
|
if not segmented:
|
||
|
try:
|
||
|
content_length = int(stream.headers.get("Content-Length", "0"))
|
||
|
except ValueError:
|
||
|
content_length = 0
|
||
|
|
||
|
if content_length > 0:
|
||
|
yield dict(total=math.ceil(content_length / CHUNK_SIZE))
|
||
|
else:
|
||
|
# we have no data to calculate total chunks
|
||
|
yield dict(total=None) # indeterminate mode
|
||
|
|
||
|
with open(save_path, "wb") as f:
|
||
|
for chunk in stream.iter_content(chunk_size=CHUNK_SIZE):
|
||
|
download_size = len(chunk)
|
||
|
f.write(chunk)
|
||
|
written += download_size
|
||
|
|
||
|
if not segmented:
|
||
|
yield dict(advance=1)
|
||
|
now = time.time()
|
||
|
time_since = now - last_speed_refresh
|
||
|
download_sizes.append(download_size)
|
||
|
if time_since > PROGRESS_WINDOW or download_size < CHUNK_SIZE:
|
||
|
data_size = sum(download_sizes)
|
||
|
download_speed = math.ceil(data_size / (time_since or 1))
|
||
|
yield dict(downloaded=f"{filesize.decimal(download_speed)}/s")
|
||
|
last_speed_refresh = now
|
||
|
download_sizes.clear()
|
||
|
|
||
|
yield dict(file_downloaded=save_path, written=written)
|
||
|
|
||
|
if segmented:
|
||
|
yield dict(advance=1)
|
||
|
now = time.time()
|
||
|
time_since = now - LAST_SPEED_REFRESH
|
||
|
if written: # no size == skipped dl
|
||
|
DOWNLOAD_SIZES.append(written)
|
||
|
if DOWNLOAD_SIZES and time_since > PROGRESS_WINDOW:
|
||
|
data_size = sum(DOWNLOAD_SIZES)
|
||
|
download_speed = math.ceil(data_size / (time_since or 1))
|
||
|
yield dict(downloaded=f"{filesize.decimal(download_speed)}/s")
|
||
|
LAST_SPEED_REFRESH = now
|
||
|
DOWNLOAD_SIZES.clear()
|
||
|
break
|
||
|
except Exception as e:
|
||
|
save_path.unlink(missing_ok=True)
|
||
|
if DOWNLOAD_CANCELLED.is_set() or attempts == MAX_ATTEMPTS:
|
||
|
raise e
|
||
|
time.sleep(RETRY_WAIT)
|
||
|
attempts += 1
|
||
|
finally:
|
||
|
control_file.unlink()
|
||
|
|
||
|
|
||
|
def requests(
|
||
|
urls: Union[str, list[str], dict[str, Any], list[dict[str, Any]]],
|
||
|
output_dir: Path,
|
||
|
filename: str,
|
||
|
headers: Optional[MutableMapping[str, Union[str, bytes]]] = None,
|
||
|
cookies: Optional[Union[MutableMapping[str, str], CookieJar]] = None,
|
||
|
proxy: Optional[str] = None,
|
||
|
max_workers: Optional[int] = None
|
||
|
) -> Generator[dict[str, Any], None, None]:
|
||
|
"""
|
||
|
Download a file using Python Requests.
|
||
|
https://requests.readthedocs.io
|
||
|
|
||
|
Yields the following download status updates while chunks are downloading:
|
||
|
|
||
|
- {total: 123} (there are 123 chunks to download)
|
||
|
- {total: None} (there are an unknown number of chunks to download)
|
||
|
- {advance: 1} (one chunk was downloaded)
|
||
|
- {downloaded: "10.1 MB/s"} (currently downloading at a rate of 10.1 MB/s)
|
||
|
- {file_downloaded: Path(...), written: 1024} (download finished, has the save path and size)
|
||
|
|
||
|
The data is in the same format accepted by rich's progress.update() function.
|
||
|
However, The `downloaded`, `file_downloaded` and `written` keys are custom and not
|
||
|
natively accepted by rich progress bars.
|
||
|
|
||
|
Parameters:
|
||
|
urls: Web URL(s) to file(s) to download. You can use a dictionary with the key
|
||
|
"url" for the URI, and other keys for extra arguments to use per-URL.
|
||
|
output_dir: The folder to save the file into. If the save path's directory does
|
||
|
not exist then it will be made automatically.
|
||
|
filename: The filename or filename template to use for each file. The variables
|
||
|
you can use are `i` for the URL index and `ext` for the URL extension.
|
||
|
headers: A mapping of HTTP Header Key/Values to use for all downloads.
|
||
|
cookies: A mapping of Cookie Key/Values or a Cookie Jar to use for all downloads.
|
||
|
proxy: An optional proxy URI to route connections through for all downloads.
|
||
|
max_workers: The maximum amount of threads to use for downloads. Defaults to
|
||
|
min(32,(cpu_count+4)).
|
||
|
"""
|
||
|
if not urls:
|
||
|
raise ValueError("urls must be provided and not empty")
|
||
|
elif not isinstance(urls, (str, dict, list)):
|
||
|
raise TypeError(f"Expected urls to be {str} or {dict} or a list of one of them, not {type(urls)}")
|
||
|
|
||
|
if not output_dir:
|
||
|
raise ValueError("output_dir must be provided")
|
||
|
elif not isinstance(output_dir, Path):
|
||
|
raise TypeError(f"Expected output_dir to be {Path}, not {type(output_dir)}")
|
||
|
|
||
|
if not filename:
|
||
|
raise ValueError("filename must be provided")
|
||
|
elif not isinstance(filename, str):
|
||
|
raise TypeError(f"Expected filename to be {str}, not {type(filename)}")
|
||
|
|
||
|
if not isinstance(headers, (MutableMapping, type(None))):
|
||
|
raise TypeError(f"Expected headers to be {MutableMapping}, not {type(headers)}")
|
||
|
|
||
|
if not isinstance(cookies, (MutableMapping, CookieJar, type(None))):
|
||
|
raise TypeError(f"Expected cookies to be {MutableMapping} or {CookieJar}, not {type(cookies)}")
|
||
|
|
||
|
if not isinstance(proxy, (str, type(None))):
|
||
|
raise TypeError(f"Expected proxy to be {str}, not {type(proxy)}")
|
||
|
|
||
|
if not isinstance(max_workers, (int, type(None))):
|
||
|
raise TypeError(f"Expected max_workers to be {int}, not {type(max_workers)}")
|
||
|
|
||
|
if not isinstance(urls, list):
|
||
|
urls = [urls]
|
||
|
|
||
|
if not max_workers:
|
||
|
max_workers = min(32, (os.cpu_count() or 1) + 4)
|
||
|
|
||
|
urls = [
|
||
|
dict(
|
||
|
save_path=save_path,
|
||
|
**url
|
||
|
) if isinstance(url, dict) else dict(
|
||
|
url=url,
|
||
|
save_path=save_path
|
||
|
)
|
||
|
for i, url in enumerate(urls)
|
||
|
for save_path in [output_dir / filename.format(
|
||
|
i=i,
|
||
|
ext=get_extension(url["url"] if isinstance(url, dict) else url)
|
||
|
)]
|
||
|
]
|
||
|
|
||
|
session = Session()
|
||
|
session.mount("https://", HTTPAdapter(
|
||
|
pool_connections=max_workers,
|
||
|
pool_maxsize=max_workers,
|
||
|
pool_block=True
|
||
|
))
|
||
|
session.mount("http://", session.adapters["https://"])
|
||
|
|
||
|
if headers:
|
||
|
headers = {
|
||
|
k: v
|
||
|
for k, v in headers.items()
|
||
|
if k.lower() != "accept-encoding"
|
||
|
}
|
||
|
session.headers.update(headers)
|
||
|
if cookies:
|
||
|
session.cookies.update(cookies)
|
||
|
if proxy:
|
||
|
session.proxies.update({"all": proxy})
|
||
|
|
||
|
yield dict(total=len(urls))
|
||
|
|
||
|
try:
|
||
|
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||
|
for future in as_completed(
|
||
|
pool.submit(
|
||
|
download,
|
||
|
session=session,
|
||
|
segmented=False,
|
||
|
**url
|
||
|
)
|
||
|
for url in urls
|
||
|
):
|
||
|
try:
|
||
|
yield from future.result()
|
||
|
except KeyboardInterrupt:
|
||
|
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||
|
yield dict(downloaded="[yellow]CANCELLING")
|
||
|
pool.shutdown(wait=True, cancel_futures=True)
|
||
|
yield dict(downloaded="[yellow]CANCELLED")
|
||
|
# tell dl that it was cancelled
|
||
|
# the pool is already shut down, so exiting loop is fine
|
||
|
raise
|
||
|
except Exception:
|
||
|
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||
|
yield dict(downloaded="[red]FAILING")
|
||
|
pool.shutdown(wait=True, cancel_futures=True)
|
||
|
yield dict(downloaded="[red]FAILED")
|
||
|
# tell dl that it failed
|
||
|
# the pool is already shut down, so exiting loop is fine
|
||
|
raise
|
||
|
finally:
|
||
|
DOWNLOAD_SIZES.clear()
|
||
|
|
||
|
|
||
|
__all__ = ("requests",)
|