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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eaf_base_api
3
- Version: 4.2.1
3
+ Version: 4.2.3
4
4
  Summary: A base API for EchterAlsFake's Porn APIs
5
5
  Author: Johannes Habel
6
6
  Author-email: Johannes Habel <EchterAlsFake@proton.me>
@@ -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 = self._decode_response(response, url, self.logger)
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 False
3073
+ return await download_raw(self, url, configuration)
@@ -84,6 +84,7 @@ class DownloadConfigRAW(BaseConfigDownload):
84
84
  read_timeout: float = 120.0
85
85
  chunk_size: int = 1024
86
86
  max_retries: int = 5
87
+ resume_state_path: str | None = None
87
88
 
88
89
 
89
90
  @dataclass
@@ -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
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "eaf_base_api"
7
- version = "4.2.1"
7
+ version = "4.2.3"
8
8
  description = "A base API for EchterAlsFake's Porn APIs"
9
9
  requires-python = ">=3.12"
10
10
  license = "AGPL-3.0-or-later"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "eaf_base_api"
7
- version = "4.2.1"
7
+ version = "4.2.3"
8
8
  description = "A base API for EchterAlsFake's Porn APIs"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
File without changes
File without changes