eaf-base-api 4.2.1__tar.gz → 4.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/PKG-INFO +1 -1
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/base_api/__init__.py +5 -0
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/base_api/base.py +10 -256
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/base_api/modules/config.py +1 -0
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/base_api/modules/provider.py +5 -0
- eaf_base_api-4.2.3/base_api/modules/raw_download.py +305 -0
- eaf_base_api-4.2.3/base_api/modules/urls.py +51 -0
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/pyproject.toml +1 -1
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/pyproject.toml.orig +1 -1
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/LICENSE +0 -0
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/README.md +0 -0
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/base_api/modules/__init__.py +0 -0
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/base_api/modules/errors.py +0 -0
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/base_api/modules/ffmpeg.py +0 -0
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/base_api/modules/logger.py +0 -0
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/base_api/modules/progress_bars.py +0 -0
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/base_api/modules/static_functions.py +0 -0
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/base_api/modules/type_hints.py +0 -0
- {eaf_base_api-4.2.1 → eaf_base_api-4.2.3}/base_api/py.typed +0 -0
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
__all__ = [
|
|
2
|
+
"append_url_path",
|
|
3
|
+
"profile_base_url",
|
|
4
|
+
"replace_url_path",
|
|
5
|
+
"update_url_query",
|
|
2
6
|
"BaseCore",
|
|
3
7
|
"BaseMedia",
|
|
4
8
|
"Cache",
|
|
@@ -69,6 +73,7 @@ __all__ = [
|
|
|
69
73
|
|
|
70
74
|
|
|
71
75
|
from base_api.modules import errors
|
|
76
|
+
from base_api.modules.urls import append_url_path, profile_base_url, replace_url_path, update_url_query
|
|
72
77
|
from base_api.modules.progress_bars import Callback
|
|
73
78
|
from base_api.modules.errors import (
|
|
74
79
|
DataNotLoadedError,
|
|
@@ -29,6 +29,7 @@ from typing import (
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
# 1. Standardize on relative imports
|
|
32
|
+
from base_api.modules.urls import FetchedText
|
|
32
33
|
from base_api.modules.errors import *
|
|
33
34
|
from base_api.modules.type_hints import (
|
|
34
35
|
DownloadReport, ResultOrder, ErrorMode, ErrorAction, ScrapeStage,
|
|
@@ -422,6 +423,9 @@ class BaseMedia:
|
|
|
422
423
|
url: str
|
|
423
424
|
core: object
|
|
424
425
|
|
|
426
|
+
# Transport metadata is separate from lazily loaded fields and the caller's URL.
|
|
427
|
+
resolved_url: str | None = field(default=None, init=False, repr=False, compare=False)
|
|
428
|
+
|
|
425
429
|
loader_methods: ClassVar[Mapping[str, str]] = {}
|
|
426
430
|
|
|
427
431
|
_load_lock: asyncio.Lock = field(
|
|
@@ -2097,7 +2101,10 @@ class BaseCore:
|
|
|
2097
2101
|
params=params,
|
|
2098
2102
|
retry_non_idempotent=retry_non_idempotent,
|
|
2099
2103
|
)
|
|
2100
|
-
content =
|
|
2104
|
+
content = FetchedText(
|
|
2105
|
+
self._decode_response(response, url, self.logger),
|
|
2106
|
+
url=getattr(response, "url", None) or url,
|
|
2107
|
+
)
|
|
2101
2108
|
if key is not None:
|
|
2102
2109
|
self.cache.set_response(key, content)
|
|
2103
2110
|
assert pending is not None
|
|
@@ -3061,259 +3068,6 @@ class BaseCore:
|
|
|
3061
3068
|
raise DownloadFailed(f"Raw download failed for {url}: {error}", url=url) from error
|
|
3062
3069
|
|
|
3063
3070
|
async def _legacy_download(self, url: str, configuration: DownloadConfigRAW) -> bool:
|
|
3064
|
-
|
|
3065
|
-
Download a file using streaming with stall tolerance and resume.
|
|
3066
|
-
Supports fast concurrent range downloading if the server supports it and allow_multipart is True.
|
|
3067
|
-
Assumes self.session is an AsyncSession.
|
|
3068
|
-
"""
|
|
3069
|
-
path = configuration.path
|
|
3070
|
-
max_retries = configuration.max_retries
|
|
3071
|
-
read_timeout = configuration.read_timeout
|
|
3072
|
-
stop_event = configuration.stop_event
|
|
3073
|
-
allow_multipart = configuration.allow_multipart
|
|
3074
|
-
callback = configuration.callback
|
|
3075
|
-
chunk_size = configuration.chunk_size
|
|
3076
|
-
max_workers = configuration.max_workers
|
|
3077
|
-
|
|
3078
|
-
self.logger.info(
|
|
3079
|
-
"""Legacy download start: url=%s path=%s
|
|
3080
|
-
max_retries=%s read_timeout=%s
|
|
3081
|
-
stop_event_set=%s
|
|
3082
|
-
allow_multipart=%s""", url, path, max_retries, read_timeout, bool(stop_event and stop_event.is_set()),
|
|
3083
|
-
allow_multipart)
|
|
3084
|
-
|
|
3085
|
-
if stop_event is not None and stop_event.is_set():
|
|
3086
|
-
self.logger.warning("Stop event already set; cancelling legacy download.")
|
|
3087
|
-
raise DownloadCancelled("Download cancelled.")
|
|
3088
|
-
|
|
3089
|
-
# Ensure session is initialized
|
|
3090
|
-
if self.session is None:
|
|
3091
|
-
self.initialize_session()
|
|
3092
|
-
session = self.session
|
|
3093
|
-
assert session is not None
|
|
3094
|
-
|
|
3095
|
-
progress_bar = None
|
|
3096
|
-
if callback is None:
|
|
3097
|
-
progress_bar = Callback()
|
|
3098
|
-
self.logger.debug("legacy_download: no callback provided, using default progress bar")
|
|
3099
|
-
|
|
3100
|
-
timeout = read_timeout
|
|
3101
|
-
|
|
3102
|
-
# 1. Check if the server supports Range requests and get file size (if multipart is allowed)
|
|
3103
|
-
file_size = 0
|
|
3104
|
-
accept_ranges = ""
|
|
3105
|
-
|
|
3106
|
-
if allow_multipart:
|
|
3107
|
-
# We MUST request uncompressed content for range downloads, otherwise:
|
|
3108
|
-
# 1) Content-Length from HEAD reflects the compressed size, not the real file size.
|
|
3109
|
-
# 2) Mid-file Range requests on compressed streams cause libcurl error 61
|
|
3110
|
-
# ("incorrect header check") because partial gzip lacks a valid header.
|
|
3111
|
-
no_compress = {"Accept-Encoding": "identity"}
|
|
3112
|
-
try:
|
|
3113
|
-
head_resp = await session.head(url, timeout=timeout, allow_redirects=True, headers=no_compress)
|
|
3114
|
-
if getattr(head_resp, "url", None):
|
|
3115
|
-
url = str(head_resp.url)
|
|
3116
|
-
if head_resp.status_code == 405: # Method Not Allowed, fallback to streaming GET
|
|
3117
|
-
head_resp_stream = await session.request("GET", url, timeout=timeout, allow_redirects=True,
|
|
3118
|
-
stream=True, headers=no_compress)
|
|
3119
|
-
if getattr(head_resp_stream, "url", None):
|
|
3120
|
-
url = str(head_resp_stream.url)
|
|
3121
|
-
file_size = int(head_resp_stream.headers.get("Content-Length", 0))
|
|
3122
|
-
accept_ranges = head_resp_stream.headers.get("Accept-Ranges", "")
|
|
3123
|
-
else:
|
|
3124
|
-
file_size = int(head_resp.headers.get("Content-Length", 0))
|
|
3125
|
-
accept_ranges = head_resp.headers.get("Accept-Ranges", "")
|
|
3126
|
-
except Exception as e:
|
|
3127
|
-
self.logger.warning("HEAD request failed for %s; falling back to streaming: %s", url, e, exc_info=True)
|
|
3128
|
-
|
|
3129
|
-
# 2. Execute Fast Multipart Download if supported and allowed
|
|
3130
|
-
if allow_multipart and file_size > 0 and accept_ranges == "bytes":
|
|
3131
|
-
self.logger.info("Server supports Range requests. Starting fast multipart download"
|
|
3132
|
-
"or %s bytes.", file_size)
|
|
3133
|
-
|
|
3134
|
-
# Pre-allocate file
|
|
3135
|
-
def allocate_file() -> None:
|
|
3136
|
-
if not os.path.exists(path):
|
|
3137
|
-
with open(path, "wb") as file_alloc:
|
|
3138
|
-
file_alloc.truncate(file_size)
|
|
3139
|
-
elif os.path.getsize(path) != file_size:
|
|
3140
|
-
# File exists but size mismatch, truncate to correct size
|
|
3141
|
-
with open(path, "r+b") as file_alloc_size:
|
|
3142
|
-
file_alloc_size.truncate(file_size)
|
|
3143
|
-
await asyncio.to_thread(allocate_file)
|
|
3144
|
-
|
|
3145
|
-
# We will use an array to track progress of chunks
|
|
3146
|
-
# A chunk map: {chunk_index: bytes_downloaded}
|
|
3147
|
-
chunk_progress = {}
|
|
3148
|
-
total_downloaded = [0] # List to allow modification in inner func
|
|
3149
|
-
# Determine chunk sizes based on file size, but keep reasonable bounds
|
|
3150
|
-
# For massive files, don't create 10,000 workers.
|
|
3151
|
-
target_chunk_size = max(chunk_size, min(10 * 1024 * 1024, file_size // 10)) # Between 1MB and 10MB
|
|
3152
|
-
|
|
3153
|
-
semaphore = asyncio.Semaphore(max_workers)
|
|
3154
|
-
|
|
3155
|
-
async def download_chunk(start_chunk: int, end_chunk: int, chunk_idx_now: int) -> bool:
|
|
3156
|
-
nonlocal total_downloaded
|
|
3157
|
-
headers_chunk = {"Range": f"bytes={start_chunk}-{end_chunk}", "Accept-Encoding": "identity"}
|
|
3158
|
-
chunk_progress[chunk_idx_now] = 0
|
|
3159
|
-
|
|
3160
|
-
for attempt_chunk in range(max_retries + 1):
|
|
3161
|
-
if stop_event is not None and stop_event.is_set():
|
|
3162
|
-
return False
|
|
3163
|
-
|
|
3164
|
-
try:
|
|
3165
|
-
async with semaphore:
|
|
3166
|
-
resp = await cast(Any, session).request(
|
|
3167
|
-
"GET", url, headers=headers_chunk, timeout=timeout, allow_redirects=True, stream=True
|
|
3168
|
-
)
|
|
3169
|
-
resp.raise_for_status()
|
|
3170
|
-
|
|
3171
|
-
# Open file once for this chunk download attempt
|
|
3172
|
-
file = await asyncio.to_thread(lambda: open(path, "rb+"))
|
|
3173
|
-
try:
|
|
3174
|
-
await asyncio.to_thread(file.seek, start_chunk + chunk_progress[chunk_idx_now])
|
|
3175
|
-
async for data in resp.aiter_content():
|
|
3176
|
-
if stop_event is not None and stop_event.is_set():
|
|
3177
|
-
return False
|
|
3178
|
-
|
|
3179
|
-
await asyncio.to_thread(cast(Any, file).write, data)
|
|
3180
|
-
|
|
3181
|
-
data_len = len(data)
|
|
3182
|
-
chunk_progress[chunk_idx_now] += data_len
|
|
3183
|
-
total_downloaded[0] += data_len
|
|
3184
|
-
|
|
3185
|
-
if callback:
|
|
3186
|
-
callback(total_downloaded[0], file_size)
|
|
3187
|
-
elif progress_bar:
|
|
3188
|
-
progress_bar.text_progress_bar(downloaded=total_downloaded[0], total=file_size)
|
|
3189
|
-
finally:
|
|
3190
|
-
await asyncio.to_thread(file.close)
|
|
3191
|
-
|
|
3192
|
-
return True # Chunk success
|
|
3193
|
-
|
|
3194
|
-
except Exception as exc:
|
|
3195
|
-
if attempt_chunk < max_retries:
|
|
3196
|
-
self.logger.warning("Chunk %s failed for %s (attempt %s/%s): %s",
|
|
3197
|
-
chunk_idx_now, url, attempt_chunk + 1, max_retries, exc, exc_info=True)
|
|
3198
|
-
# Reset progress for this chunk before retry
|
|
3199
|
-
total_downloaded[0] -= chunk_progress[chunk_idx_now]
|
|
3200
|
-
chunk_progress[chunk_idx_now] = 0
|
|
3201
|
-
await asyncio.sleep(1 * attempt_chunk)
|
|
3202
|
-
else:
|
|
3203
|
-
self.logger.error("Chunk %s permanently failed for %s: %s", chunk_idx_now, url, exc, exc_info=True)
|
|
3204
|
-
return False
|
|
3205
|
-
return False
|
|
3206
|
-
|
|
3207
|
-
tasks = []
|
|
3208
|
-
chunk_idx = 0
|
|
3209
|
-
for start in range(0, file_size, target_chunk_size):
|
|
3210
|
-
end = min(start + target_chunk_size - 1, file_size - 1)
|
|
3211
|
-
tasks.append(download_chunk(start, end, chunk_idx))
|
|
3212
|
-
chunk_idx += 1
|
|
3213
|
-
|
|
3214
|
-
results = await asyncio.gather(*tasks)
|
|
3215
|
-
|
|
3216
|
-
if progress_bar:
|
|
3217
|
-
# We set it to None instead of del to avoid analyzer confusion about potential unassigned reference
|
|
3218
|
-
progress_bar = None
|
|
3219
|
-
|
|
3220
|
-
if stop_event is not None and stop_event.is_set():
|
|
3221
|
-
raise DownloadCancelled("Download cancelled.")
|
|
3222
|
-
|
|
3223
|
-
if not all(results):
|
|
3224
|
-
raise NetworkRequestError("One or more chunks failed to download completely.")
|
|
3225
|
-
|
|
3226
|
-
self.logger.info("Fast multipart download complete: path=%s", path)
|
|
3227
|
-
return True
|
|
3228
|
-
|
|
3229
|
-
# 3. Fallback to standard linear streaming download
|
|
3230
|
-
if not allow_multipart:
|
|
3231
|
-
self.logger.info("allow_multipart=False. Forcing linear streaming download.")
|
|
3232
|
-
else:
|
|
3233
|
-
self.logger.info("Server does not support Range requests or size is 0. Falling back to linear streaming.")
|
|
3234
|
-
|
|
3235
|
-
downloaded_so_far = 0
|
|
3236
|
-
attempt = 0
|
|
3237
|
-
etag = None
|
|
3238
|
-
|
|
3239
|
-
while True:
|
|
3240
|
-
if stop_event is not None and stop_event.is_set():
|
|
3241
|
-
self.logger.warning("Stop event set; cancelling legacy download.")
|
|
3242
|
-
raise DownloadCancelled("Download cancelled.")
|
|
3243
|
-
headers = {}
|
|
3244
|
-
if downloaded_so_far:
|
|
3245
|
-
headers["Range"] = f"bytes={downloaded_so_far}-"
|
|
3246
|
-
|
|
3247
|
-
try:
|
|
3248
|
-
response = await cast(Any, session).request(
|
|
3249
|
-
"GET", url, headers=headers, allow_redirects=True, timeout=timeout, stream=True
|
|
3250
|
-
)
|
|
3251
|
-
if downloaded_so_far and response.status_code == 200:
|
|
3252
|
-
self.logger.warning("Server ignored Range request; restarting download from scratch.")
|
|
3253
|
-
downloaded_so_far = 0
|
|
3254
|
-
response.raise_for_status()
|
|
3255
|
-
|
|
3256
|
-
etag_cur = response.headers.get("ETag")
|
|
3257
|
-
if etag is None:
|
|
3258
|
-
etag = etag_cur
|
|
3259
|
-
elif etag_cur and etag_cur != etag:
|
|
3260
|
-
raise RuntimeError("Remote content changed during download")
|
|
3261
|
-
|
|
3262
|
-
total = None
|
|
3263
|
-
cr = response.headers.get("Content-Range")
|
|
3264
|
-
if cr and "/" in cr:
|
|
3265
|
-
try: total = int(cr.rsplit("/", 1)[1])
|
|
3266
|
-
except ValueError: pass
|
|
3267
|
-
if total is None:
|
|
3268
|
-
try: total = int(response.headers.get("Content-Length", "0")) or None
|
|
3269
|
-
except ValueError: pass
|
|
3270
|
-
|
|
3271
|
-
# Fix fallback if total size is still missing
|
|
3272
|
-
if total is None:
|
|
3273
|
-
total = 0
|
|
3274
|
-
|
|
3275
|
-
mode = "ab" if downloaded_so_far else "wb"
|
|
3276
|
-
f = await asyncio.to_thread(cast(Any, open), path, mode)
|
|
3277
|
-
try:
|
|
3278
|
-
await asyncio.to_thread(f.seek, 0, 2) # Move to EOF
|
|
3279
|
-
async for chunk in response.aiter_content():
|
|
3280
|
-
if stop_event is not None and stop_event.is_set():
|
|
3281
|
-
raise DownloadCancelled("Download cancelled.")
|
|
3282
|
-
if not chunk:
|
|
3283
|
-
continue
|
|
3284
|
-
await asyncio.to_thread(f.write, chunk)
|
|
3285
|
-
downloaded_so_far += len(chunk)
|
|
3286
|
-
|
|
3287
|
-
if callback:
|
|
3288
|
-
callback(downloaded_so_far, total)
|
|
3289
|
-
elif progress_bar:
|
|
3290
|
-
progress_bar.text_progress_bar(downloaded=downloaded_so_far, total=total)
|
|
3291
|
-
finally:
|
|
3292
|
-
await asyncio.to_thread(f.close)
|
|
3293
|
-
|
|
3294
|
-
if progress_bar:
|
|
3295
|
-
progress_bar = None
|
|
3296
|
-
self.logger.info("Legacy download complete: bytes=%s path=%s", downloaded_so_far, path)
|
|
3297
|
-
return True
|
|
3298
|
-
except RequestsError as e:
|
|
3299
|
-
err_str = str(e).lower()
|
|
3300
|
-
if "timeout" in err_str or "read" in err_str or "reset" in err_str or "recv failure" in err_str or "connection" in err_str:
|
|
3301
|
-
attempt += 1
|
|
3302
|
-
if attempt > max_retries:
|
|
3303
|
-
raise
|
|
3304
|
-
backoff = min(2 ** attempt, 30)
|
|
3305
|
-
self.logger.warning("Download connection interrupted for %s (%s); retrying %s/%s in %ss", url, e, attempt, max_retries, backoff, exc_info=True)
|
|
3306
|
-
if stop_event is not None and stop_event.wait(backoff):
|
|
3307
|
-
raise DownloadCancelled("Download cancelled.") from e
|
|
3308
|
-
else:
|
|
3309
|
-
await asyncio.sleep(backoff)
|
|
3310
|
-
continue
|
|
3311
|
-
else:
|
|
3312
|
-
raise NetworkRequestError(f"Stream for: {url} was closed or failed: {e}") from e
|
|
3313
|
-
except DownloadCancelled:
|
|
3314
|
-
raise
|
|
3315
|
-
except Exception as exc:
|
|
3316
|
-
error = traceback.format_exc()
|
|
3317
|
-
raise NetworkRequestError(f"Unknown error for: {url} -->: {error}") from exc
|
|
3071
|
+
from base_api.modules.raw_download import download_raw
|
|
3318
3072
|
|
|
3319
|
-
return
|
|
3073
|
+
return await download_raw(self, url, configuration)
|
|
@@ -7,6 +7,7 @@ from functools import wraps
|
|
|
7
7
|
|
|
8
8
|
from base_api.modules import errors
|
|
9
9
|
from base_api.modules.logger import class_name, get_logger, log_context
|
|
10
|
+
from base_api.modules.urls import same_site_url
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
async def fetch_content(core, url: str, *, logger, owner=None, get_json=False,
|
|
@@ -20,6 +21,10 @@ async def fetch_content(core, url: str, *, logger, owner=None, get_json=False,
|
|
|
20
21
|
with log_context(owner, url):
|
|
21
22
|
try:
|
|
22
23
|
content = await core.fetch_text(url)
|
|
24
|
+
resolved = getattr(content, "url", None)
|
|
25
|
+
if (isinstance(resolved, str) and owner is not None
|
|
26
|
+
and getattr(owner, "url", None) == url and same_site_url(url, resolved)):
|
|
27
|
+
owner.resolved_url = resolved
|
|
23
28
|
return json.loads(content, strict=False) if get_json else content
|
|
24
29
|
except errors.DownloadCancelled:
|
|
25
30
|
raise
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Resumable RAW transfers for BaseCore, including persisted multipart ranges."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .errors import DownloadCancelled, NetworkRequestError
|
|
13
|
+
from .progress_bars import Callback
|
|
14
|
+
|
|
15
|
+
_RANGE = re.compile(r"bytes (\d+)-(\d+)/(\d+)")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _Checkpoint:
|
|
19
|
+
def __init__(self, path, source_url, output, mode, size, chunk_size, headers):
|
|
20
|
+
self.path = Path(path)
|
|
21
|
+
self.lock = asyncio.Lock()
|
|
22
|
+
self.restored = False
|
|
23
|
+
count = math.ceil(size / chunk_size) if mode == "multipart" else 1
|
|
24
|
+
self.data = {
|
|
25
|
+
"version": 1, "source": hashlib.sha256(source_url.encode()).hexdigest(),
|
|
26
|
+
"output": str(Path(output).resolve()), "mode": mode, "size": size,
|
|
27
|
+
"chunk_size": chunk_size, "progress": [0] * count,
|
|
28
|
+
"etag": headers.get("ETag"), "last_modified": headers.get("Last-Modified"),
|
|
29
|
+
}
|
|
30
|
+
try:
|
|
31
|
+
saved = json.loads(self.path.read_text())
|
|
32
|
+
if not isinstance(saved, dict):
|
|
33
|
+
return
|
|
34
|
+
if any(saved.get(key) != self.data[key] for key in ("version", "source", "output", "mode", "size")):
|
|
35
|
+
return
|
|
36
|
+
for name in ("etag", "last_modified"):
|
|
37
|
+
if saved.get(name) and saved[name] != self.data[name]:
|
|
38
|
+
return
|
|
39
|
+
width = saved["chunk_size"]
|
|
40
|
+
progress = saved["progress"]
|
|
41
|
+
if mode == "multipart":
|
|
42
|
+
if not isinstance(width, int) or width <= 0 or len(progress) != math.ceil(size / width):
|
|
43
|
+
return
|
|
44
|
+
limits = [min(width, size - index * width) for index in range(len(progress))]
|
|
45
|
+
else:
|
|
46
|
+
if len(progress) != 1:
|
|
47
|
+
return
|
|
48
|
+
limits = [size or Path(output).stat().st_size]
|
|
49
|
+
if any(not isinstance(value, int) or not 0 <= value <= limit
|
|
50
|
+
for value, limit in zip(progress, limits)):
|
|
51
|
+
return
|
|
52
|
+
length = Path(output).stat().st_size
|
|
53
|
+
if (mode == "multipart" and length != size) or (mode == "linear" and length < progress[0]):
|
|
54
|
+
return
|
|
55
|
+
self.data = saved
|
|
56
|
+
self.restored = True
|
|
57
|
+
except (OSError, ValueError, KeyError, TypeError, ZeroDivisionError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
async def save(self, progress):
|
|
61
|
+
async with self.lock:
|
|
62
|
+
payload = dict(self.data, progress=list(progress))
|
|
63
|
+
await asyncio.to_thread(self._write, payload)
|
|
64
|
+
|
|
65
|
+
def _write(self, payload):
|
|
66
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
temporary = self.path.with_name(self.path.name + ".tmp")
|
|
68
|
+
temporary.write_text(json.dumps(payload, sort_keys=True), encoding="utf-8")
|
|
69
|
+
os.replace(temporary, self.path)
|
|
70
|
+
|
|
71
|
+
def remove(self):
|
|
72
|
+
self.path.unlink(missing_ok=True)
|
|
73
|
+
self.path.with_name(self.path.name + ".tmp").unlink(missing_ok=True)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _stopped(event):
|
|
77
|
+
return event is not None and event.is_set()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _retry_delay(event, seconds):
|
|
81
|
+
if event is None:
|
|
82
|
+
await asyncio.sleep(seconds)
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
await asyncio.wait_for(event.wait(), seconds)
|
|
86
|
+
except TimeoutError:
|
|
87
|
+
return
|
|
88
|
+
raise DownloadCancelled("Download cancelled.")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _close_response(response):
|
|
92
|
+
if response is None:
|
|
93
|
+
return
|
|
94
|
+
# curl-cffi's aclose() waits for the streaming task; signal its supported
|
|
95
|
+
# quit event first so an interrupted response stops receiving its body.
|
|
96
|
+
quit_event = getattr(response, "quit_now", None)
|
|
97
|
+
if quit_event is not None:
|
|
98
|
+
quit_event.set()
|
|
99
|
+
await response.aclose()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _range(response, start, end=None, total=None):
|
|
103
|
+
match = _RANGE.fullmatch(response.headers.get("Content-Range", ""))
|
|
104
|
+
if (response.status_code != 206 or match is None or int(match[1]) != start
|
|
105
|
+
or not start <= int(match[2]) < int(match[3])
|
|
106
|
+
or (end is not None and int(match[2]) != end)
|
|
107
|
+
or (total is not None and int(match[3]) != total)):
|
|
108
|
+
raise NetworkRequestError("Server did not return the requested byte range")
|
|
109
|
+
return int(match[3])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def download_raw(core, url, configuration):
|
|
113
|
+
"""Download through the core's real session; checkpoints never infer sparse-file progress."""
|
|
114
|
+
output = Path(configuration.path)
|
|
115
|
+
checkpoint_path = configuration.resume_state_path or f"{output}.raw-state.json"
|
|
116
|
+
event = configuration.stop_event
|
|
117
|
+
if _stopped(event):
|
|
118
|
+
raise DownloadCancelled("Download cancelled.")
|
|
119
|
+
core.initialize_session()
|
|
120
|
+
session = core.session
|
|
121
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
original_url = url
|
|
123
|
+
size, ranges, headers = 0, False, {}
|
|
124
|
+
response = None
|
|
125
|
+
try:
|
|
126
|
+
response = await session.head(url, timeout=configuration.read_timeout,
|
|
127
|
+
headers={"Accept-Encoding": "identity"}, allow_redirects=True)
|
|
128
|
+
if response.status_code == 405:
|
|
129
|
+
await _close_response(response)
|
|
130
|
+
response = await session.request("GET", url, stream=True, timeout=configuration.read_timeout,
|
|
131
|
+
headers={"Accept-Encoding": "identity"}, allow_redirects=True)
|
|
132
|
+
response.raise_for_status()
|
|
133
|
+
headers = {name: response.headers.get(name) for name in ("ETag", "Last-Modified")}
|
|
134
|
+
size = int(response.headers.get("Content-Length", 0))
|
|
135
|
+
ranges = response.headers.get("Accept-Ranges", "").lower() == "bytes"
|
|
136
|
+
if response.url:
|
|
137
|
+
url = str(response.url)
|
|
138
|
+
except DownloadCancelled:
|
|
139
|
+
raise
|
|
140
|
+
except Exception:
|
|
141
|
+
# An outage must not turn a persisted partial download into a fresh file.
|
|
142
|
+
if Path(checkpoint_path).exists():
|
|
143
|
+
raise
|
|
144
|
+
core.logger.warning("RAW metadata unavailable; falling back to linear streaming", exc_info=True)
|
|
145
|
+
finally:
|
|
146
|
+
await _close_response(response)
|
|
147
|
+
mode = "multipart" if configuration.allow_multipart and ranges and size > 0 else "linear"
|
|
148
|
+
width = max(configuration.chunk_size, min(10 * 1024 * 1024, max(1, size // 10)))
|
|
149
|
+
state = _Checkpoint(checkpoint_path, original_url, output, mode, size, width, headers)
|
|
150
|
+
progress = list(state.data["progress"])
|
|
151
|
+
if not state.restored:
|
|
152
|
+
# Create the zero-progress checkpoint before preallocation. An existing
|
|
153
|
+
# full-size sparse file without a valid checkpoint is never considered complete.
|
|
154
|
+
await state.save(progress)
|
|
155
|
+
def allocate():
|
|
156
|
+
with output.open("wb") as file:
|
|
157
|
+
if mode == "multipart":
|
|
158
|
+
file.truncate(size)
|
|
159
|
+
await asyncio.to_thread(allocate)
|
|
160
|
+
elif mode == "linear":
|
|
161
|
+
with output.open("r+b") as file:
|
|
162
|
+
file.truncate(progress[0])
|
|
163
|
+
core.logger.info("RAW %s: mode=%s retained_bytes=%s total=%s", output, mode, sum(progress), size)
|
|
164
|
+
bar = Callback() if configuration.callback is None else None
|
|
165
|
+
|
|
166
|
+
def notify(position, total):
|
|
167
|
+
if configuration.callback:
|
|
168
|
+
configuration.callback(position, total)
|
|
169
|
+
elif bar:
|
|
170
|
+
bar.text_progress_bar(downloaded=position, total=total)
|
|
171
|
+
|
|
172
|
+
if sum(progress):
|
|
173
|
+
notify(sum(progress), size)
|
|
174
|
+
validator = state.data.get("etag")
|
|
175
|
+
if not validator or validator.startswith("W/"):
|
|
176
|
+
validator = state.data.get("last_modified")
|
|
177
|
+
|
|
178
|
+
if mode == "multipart":
|
|
179
|
+
width = state.data["chunk_size"]
|
|
180
|
+
durable = list(progress)
|
|
181
|
+
semaphore = asyncio.Semaphore(max(1, configuration.max_workers))
|
|
182
|
+
|
|
183
|
+
async def chunk(index):
|
|
184
|
+
start = index * width
|
|
185
|
+
end = min(start + width, size) - 1
|
|
186
|
+
for attempt in range(configuration.max_retries + 1):
|
|
187
|
+
if _stopped(event):
|
|
188
|
+
return False
|
|
189
|
+
offset = start + progress[index]
|
|
190
|
+
if offset > end:
|
|
191
|
+
return True
|
|
192
|
+
response = None
|
|
193
|
+
try:
|
|
194
|
+
async with semaphore:
|
|
195
|
+
# Workers queued before a stop must not issue requests afterwards.
|
|
196
|
+
if _stopped(event):
|
|
197
|
+
return False
|
|
198
|
+
request_headers = {"Range": f"bytes={offset}-{end}", "Accept-Encoding": "identity"}
|
|
199
|
+
if validator:
|
|
200
|
+
request_headers["If-Range"] = validator
|
|
201
|
+
response = await session.request("GET", url, headers=request_headers, stream=True,
|
|
202
|
+
timeout=configuration.read_timeout, allow_redirects=True)
|
|
203
|
+
response.raise_for_status()
|
|
204
|
+
_range(response, offset, end, size)
|
|
205
|
+
file = await asyncio.to_thread(output.open, "r+b")
|
|
206
|
+
try:
|
|
207
|
+
await asyncio.to_thread(file.seek, offset)
|
|
208
|
+
async for data in response.aiter_content():
|
|
209
|
+
if _stopped(event):
|
|
210
|
+
return False
|
|
211
|
+
if not data:
|
|
212
|
+
continue
|
|
213
|
+
if start + progress[index] + len(data) > end + 1:
|
|
214
|
+
raise NetworkRequestError("Server exceeded the requested byte range")
|
|
215
|
+
await asyncio.to_thread(file.write, data)
|
|
216
|
+
progress[index] += len(data)
|
|
217
|
+
notify(sum(progress), size)
|
|
218
|
+
finally:
|
|
219
|
+
await asyncio.to_thread(file.close)
|
|
220
|
+
if start + progress[index] != end + 1:
|
|
221
|
+
raise NetworkRequestError("Byte range ended before all requested bytes arrived")
|
|
222
|
+
# Only closed/flushed ranges enter checkpoints while other
|
|
223
|
+
# workers are active. Partial ranges are saved after all exit.
|
|
224
|
+
durable[index] = progress[index]
|
|
225
|
+
await state.save(durable)
|
|
226
|
+
return True
|
|
227
|
+
except DownloadCancelled:
|
|
228
|
+
return False
|
|
229
|
+
except Exception:
|
|
230
|
+
if attempt >= configuration.max_retries:
|
|
231
|
+
raise
|
|
232
|
+
core.logger.warning("Retrying RAW range %s at byte %s", index, start + progress[index],
|
|
233
|
+
exc_info=True)
|
|
234
|
+
finally:
|
|
235
|
+
await _close_response(response)
|
|
236
|
+
await _retry_delay(event, min(2 ** attempt, 30))
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
tasks = [asyncio.create_task(chunk(index)) for index in range(len(progress))]
|
|
240
|
+
try:
|
|
241
|
+
results = await asyncio.gather(*tasks)
|
|
242
|
+
finally:
|
|
243
|
+
for task in tasks:
|
|
244
|
+
if not task.done():
|
|
245
|
+
task.cancel()
|
|
246
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
247
|
+
await state.save(progress)
|
|
248
|
+
if _stopped(event):
|
|
249
|
+
raise DownloadCancelled("Download cancelled.")
|
|
250
|
+
if not all(results) or sum(progress) != size:
|
|
251
|
+
raise NetworkRequestError("RAW download did not complete every byte range")
|
|
252
|
+
else:
|
|
253
|
+
for attempt in range(configuration.max_retries + 1):
|
|
254
|
+
if _stopped(event):
|
|
255
|
+
raise DownloadCancelled("Download cancelled.")
|
|
256
|
+
offset = progress[0]
|
|
257
|
+
if size and offset == size:
|
|
258
|
+
break
|
|
259
|
+
response = None
|
|
260
|
+
request_headers = {"Accept-Encoding": "identity"}
|
|
261
|
+
if offset:
|
|
262
|
+
request_headers["Range"] = f"bytes={offset}-"
|
|
263
|
+
if validator:
|
|
264
|
+
request_headers["If-Range"] = validator
|
|
265
|
+
try:
|
|
266
|
+
response = await session.request("GET", url, headers=request_headers, stream=True,
|
|
267
|
+
timeout=configuration.read_timeout, allow_redirects=True)
|
|
268
|
+
response.raise_for_status()
|
|
269
|
+
if offset and response.status_code == 200:
|
|
270
|
+
core.logger.warning("Server ignored RAW resume range; restarting from byte zero")
|
|
271
|
+
progress[0] = 0
|
|
272
|
+
elif offset:
|
|
273
|
+
size = _range(response, offset, total=size or None)
|
|
274
|
+
else:
|
|
275
|
+
size = int(response.headers.get("Content-Length", 0))
|
|
276
|
+
state.data["size"] = size
|
|
277
|
+
file = await asyncio.to_thread(output.open, "ab" if progress[0] else "wb")
|
|
278
|
+
try:
|
|
279
|
+
async for data in response.aiter_content():
|
|
280
|
+
if _stopped(event):
|
|
281
|
+
raise DownloadCancelled("Download cancelled.")
|
|
282
|
+
if data:
|
|
283
|
+
await asyncio.to_thread(file.write, data)
|
|
284
|
+
progress[0] += len(data)
|
|
285
|
+
notify(progress[0], size)
|
|
286
|
+
finally:
|
|
287
|
+
await asyncio.to_thread(file.close)
|
|
288
|
+
await state.save(progress)
|
|
289
|
+
if size and progress[0] != size:
|
|
290
|
+
raise NetworkRequestError("RAW stream ended before its reported length")
|
|
291
|
+
break
|
|
292
|
+
except DownloadCancelled:
|
|
293
|
+
raise
|
|
294
|
+
except Exception:
|
|
295
|
+
if attempt >= configuration.max_retries:
|
|
296
|
+
raise
|
|
297
|
+
core.logger.warning("Retrying RAW stream from byte %s", progress[0], exc_info=True)
|
|
298
|
+
finally:
|
|
299
|
+
await _close_response(response)
|
|
300
|
+
await _retry_delay(event, min(2 ** attempt, 30))
|
|
301
|
+
if _stopped(event):
|
|
302
|
+
raise DownloadCancelled("Download cancelled.")
|
|
303
|
+
state.remove()
|
|
304
|
+
core.logger.info("RAW download complete: path=%s bytes=%s", output, sum(progress))
|
|
305
|
+
return True
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Small URL operations shared by provider pagination and profile adapters."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def update_url_query(url: str, **changes: str | int | None) -> str:
|
|
7
|
+
"""Replace named query keys without losing filters or treating anchors as HTTP."""
|
|
8
|
+
parsed = urlsplit(url)
|
|
9
|
+
pairs = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key not in changes]
|
|
10
|
+
pairs.extend((key, str(value)) for key, value in changes.items() if value is not None)
|
|
11
|
+
return urlunsplit(parsed._replace(query=urlencode(pairs), fragment=""))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def replace_url_path(url: str, path: str) -> str:
|
|
15
|
+
parsed = urlsplit(url)
|
|
16
|
+
return urlunsplit(parsed._replace(path=path, fragment=""))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def append_url_path(url: str, *parts: str | int) -> str:
|
|
20
|
+
path = urlsplit(url).path.rstrip("/")
|
|
21
|
+
return replace_url_path(url, path + "/" + "/".join(str(part).strip("/") for part in parts))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def profile_base_url(url: str, *, depth: int = 2) -> str:
|
|
25
|
+
"""Remove a provider's tab/page suffix, retaining its host, slug and filters."""
|
|
26
|
+
parsed = urlsplit(url)
|
|
27
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
28
|
+
if len(parts) < depth:
|
|
29
|
+
raise ValueError("An individual profile URL with a name or ID is required")
|
|
30
|
+
return update_url_query(replace_url_path(url, "/" + "/".join(parts[:depth])), page=None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def same_site_url(original: str, resolved: str) -> bool:
|
|
34
|
+
"""Accept response metadata on the same site, including localized subdomains."""
|
|
35
|
+
before, after = urlsplit(original), urlsplit(resolved)
|
|
36
|
+
if after.scheme not in {"http", "https"} or not before.hostname or not after.hostname:
|
|
37
|
+
return False
|
|
38
|
+
# The supported providers use these two-label domains, except RedTube Brazil.
|
|
39
|
+
depth = 3 if before.hostname.endswith(".com.br") else 2
|
|
40
|
+
return before.hostname.split(".")[-depth:] == after.hostname.split(".")[-depth:]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FetchedText(str):
|
|
44
|
+
"""A string that retains response identity through text caches and parsers."""
|
|
45
|
+
|
|
46
|
+
url: str
|
|
47
|
+
|
|
48
|
+
def __new__(cls, content: str, *, url: str):
|
|
49
|
+
value = super().__new__(cls, content)
|
|
50
|
+
value.url = url
|
|
51
|
+
return value
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|