eaf-base-api 4.2.2__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.2
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>
@@ -3068,259 +3068,6 @@ class BaseCore:
3068
3068
  raise DownloadFailed(f"Raw download failed for {url}: {error}", url=url) from error
3069
3069
 
3070
3070
  async def _legacy_download(self, url: str, configuration: DownloadConfigRAW) -> bool:
3071
- """
3072
- Download a file using streaming with stall tolerance and resume.
3073
- Supports fast concurrent range downloading if the server supports it and allow_multipart is True.
3074
- Assumes self.session is an AsyncSession.
3075
- """
3076
- path = configuration.path
3077
- max_retries = configuration.max_retries
3078
- read_timeout = configuration.read_timeout
3079
- stop_event = configuration.stop_event
3080
- allow_multipart = configuration.allow_multipart
3081
- callback = configuration.callback
3082
- chunk_size = configuration.chunk_size
3083
- max_workers = configuration.max_workers
3084
-
3085
- self.logger.info(
3086
- """Legacy download start: url=%s path=%s
3087
- max_retries=%s read_timeout=%s
3088
- stop_event_set=%s
3089
- allow_multipart=%s""", url, path, max_retries, read_timeout, bool(stop_event and stop_event.is_set()),
3090
- allow_multipart)
3091
-
3092
- if stop_event is not None and stop_event.is_set():
3093
- self.logger.warning("Stop event already set; cancelling legacy download.")
3094
- raise DownloadCancelled("Download cancelled.")
3095
-
3096
- # Ensure session is initialized
3097
- if self.session is None:
3098
- self.initialize_session()
3099
- session = self.session
3100
- assert session is not None
3101
-
3102
- progress_bar = None
3103
- if callback is None:
3104
- progress_bar = Callback()
3105
- self.logger.debug("legacy_download: no callback provided, using default progress bar")
3106
-
3107
- timeout = read_timeout
3108
-
3109
- # 1. Check if the server supports Range requests and get file size (if multipart is allowed)
3110
- file_size = 0
3111
- accept_ranges = ""
3112
-
3113
- if allow_multipart:
3114
- # We MUST request uncompressed content for range downloads, otherwise:
3115
- # 1) Content-Length from HEAD reflects the compressed size, not the real file size.
3116
- # 2) Mid-file Range requests on compressed streams cause libcurl error 61
3117
- # ("incorrect header check") because partial gzip lacks a valid header.
3118
- no_compress = {"Accept-Encoding": "identity"}
3119
- try:
3120
- head_resp = await session.head(url, timeout=timeout, allow_redirects=True, headers=no_compress)
3121
- if getattr(head_resp, "url", None):
3122
- url = str(head_resp.url)
3123
- if head_resp.status_code == 405: # Method Not Allowed, fallback to streaming GET
3124
- head_resp_stream = await session.request("GET", url, timeout=timeout, allow_redirects=True,
3125
- stream=True, headers=no_compress)
3126
- if getattr(head_resp_stream, "url", None):
3127
- url = str(head_resp_stream.url)
3128
- file_size = int(head_resp_stream.headers.get("Content-Length", 0))
3129
- accept_ranges = head_resp_stream.headers.get("Accept-Ranges", "")
3130
- else:
3131
- file_size = int(head_resp.headers.get("Content-Length", 0))
3132
- accept_ranges = head_resp.headers.get("Accept-Ranges", "")
3133
- except Exception as e:
3134
- self.logger.warning("HEAD request failed for %s; falling back to streaming: %s", url, e, exc_info=True)
3135
-
3136
- # 2. Execute Fast Multipart Download if supported and allowed
3137
- if allow_multipart and file_size > 0 and accept_ranges == "bytes":
3138
- self.logger.info("Server supports Range requests. Starting fast multipart download"
3139
- "or %s bytes.", file_size)
3140
-
3141
- # Pre-allocate file
3142
- def allocate_file() -> None:
3143
- if not os.path.exists(path):
3144
- with open(path, "wb") as file_alloc:
3145
- file_alloc.truncate(file_size)
3146
- elif os.path.getsize(path) != file_size:
3147
- # File exists but size mismatch, truncate to correct size
3148
- with open(path, "r+b") as file_alloc_size:
3149
- file_alloc_size.truncate(file_size)
3150
- await asyncio.to_thread(allocate_file)
3151
-
3152
- # We will use an array to track progress of chunks
3153
- # A chunk map: {chunk_index: bytes_downloaded}
3154
- chunk_progress = {}
3155
- total_downloaded = [0] # List to allow modification in inner func
3156
- # Determine chunk sizes based on file size, but keep reasonable bounds
3157
- # For massive files, don't create 10,000 workers.
3158
- target_chunk_size = max(chunk_size, min(10 * 1024 * 1024, file_size // 10)) # Between 1MB and 10MB
3159
-
3160
- semaphore = asyncio.Semaphore(max_workers)
3161
-
3162
- async def download_chunk(start_chunk: int, end_chunk: int, chunk_idx_now: int) -> bool:
3163
- nonlocal total_downloaded
3164
- headers_chunk = {"Range": f"bytes={start_chunk}-{end_chunk}", "Accept-Encoding": "identity"}
3165
- chunk_progress[chunk_idx_now] = 0
3166
-
3167
- for attempt_chunk in range(max_retries + 1):
3168
- if stop_event is not None and stop_event.is_set():
3169
- return False
3170
-
3171
- try:
3172
- async with semaphore:
3173
- resp = await cast(Any, session).request(
3174
- "GET", url, headers=headers_chunk, timeout=timeout, allow_redirects=True, stream=True
3175
- )
3176
- resp.raise_for_status()
3177
-
3178
- # Open file once for this chunk download attempt
3179
- file = await asyncio.to_thread(lambda: open(path, "rb+"))
3180
- try:
3181
- await asyncio.to_thread(file.seek, start_chunk + chunk_progress[chunk_idx_now])
3182
- async for data in resp.aiter_content():
3183
- if stop_event is not None and stop_event.is_set():
3184
- return False
3185
-
3186
- await asyncio.to_thread(cast(Any, file).write, data)
3187
-
3188
- data_len = len(data)
3189
- chunk_progress[chunk_idx_now] += data_len
3190
- total_downloaded[0] += data_len
3191
-
3192
- if callback:
3193
- callback(total_downloaded[0], file_size)
3194
- elif progress_bar:
3195
- progress_bar.text_progress_bar(downloaded=total_downloaded[0], total=file_size)
3196
- finally:
3197
- await asyncio.to_thread(file.close)
3198
-
3199
- return True # Chunk success
3200
-
3201
- except Exception as exc:
3202
- if attempt_chunk < max_retries:
3203
- self.logger.warning("Chunk %s failed for %s (attempt %s/%s): %s",
3204
- chunk_idx_now, url, attempt_chunk + 1, max_retries, exc, exc_info=True)
3205
- # Reset progress for this chunk before retry
3206
- total_downloaded[0] -= chunk_progress[chunk_idx_now]
3207
- chunk_progress[chunk_idx_now] = 0
3208
- await asyncio.sleep(1 * attempt_chunk)
3209
- else:
3210
- self.logger.error("Chunk %s permanently failed for %s: %s", chunk_idx_now, url, exc, exc_info=True)
3211
- return False
3212
- return False
3213
-
3214
- tasks = []
3215
- chunk_idx = 0
3216
- for start in range(0, file_size, target_chunk_size):
3217
- end = min(start + target_chunk_size - 1, file_size - 1)
3218
- tasks.append(download_chunk(start, end, chunk_idx))
3219
- chunk_idx += 1
3220
-
3221
- results = await asyncio.gather(*tasks)
3222
-
3223
- if progress_bar:
3224
- # We set it to None instead of del to avoid analyzer confusion about potential unassigned reference
3225
- progress_bar = None
3226
-
3227
- if stop_event is not None and stop_event.is_set():
3228
- raise DownloadCancelled("Download cancelled.")
3229
-
3230
- if not all(results):
3231
- raise NetworkRequestError("One or more chunks failed to download completely.")
3232
-
3233
- self.logger.info("Fast multipart download complete: path=%s", path)
3234
- return True
3235
-
3236
- # 3. Fallback to standard linear streaming download
3237
- if not allow_multipart:
3238
- self.logger.info("allow_multipart=False. Forcing linear streaming download.")
3239
- else:
3240
- self.logger.info("Server does not support Range requests or size is 0. Falling back to linear streaming.")
3241
-
3242
- downloaded_so_far = 0
3243
- attempt = 0
3244
- etag = None
3245
-
3246
- while True:
3247
- if stop_event is not None and stop_event.is_set():
3248
- self.logger.warning("Stop event set; cancelling legacy download.")
3249
- raise DownloadCancelled("Download cancelled.")
3250
- headers = {}
3251
- if downloaded_so_far:
3252
- headers["Range"] = f"bytes={downloaded_so_far}-"
3253
-
3254
- try:
3255
- response = await cast(Any, session).request(
3256
- "GET", url, headers=headers, allow_redirects=True, timeout=timeout, stream=True
3257
- )
3258
- if downloaded_so_far and response.status_code == 200:
3259
- self.logger.warning("Server ignored Range request; restarting download from scratch.")
3260
- downloaded_so_far = 0
3261
- response.raise_for_status()
3262
-
3263
- etag_cur = response.headers.get("ETag")
3264
- if etag is None:
3265
- etag = etag_cur
3266
- elif etag_cur and etag_cur != etag:
3267
- raise RuntimeError("Remote content changed during download")
3268
-
3269
- total = None
3270
- cr = response.headers.get("Content-Range")
3271
- if cr and "/" in cr:
3272
- try: total = int(cr.rsplit("/", 1)[1])
3273
- except ValueError: pass
3274
- if total is None:
3275
- try: total = int(response.headers.get("Content-Length", "0")) or None
3276
- except ValueError: pass
3277
-
3278
- # Fix fallback if total size is still missing
3279
- if total is None:
3280
- total = 0
3281
-
3282
- mode = "ab" if downloaded_so_far else "wb"
3283
- f = await asyncio.to_thread(cast(Any, open), path, mode)
3284
- try:
3285
- await asyncio.to_thread(f.seek, 0, 2) # Move to EOF
3286
- async for chunk in response.aiter_content():
3287
- if stop_event is not None and stop_event.is_set():
3288
- raise DownloadCancelled("Download cancelled.")
3289
- if not chunk:
3290
- continue
3291
- await asyncio.to_thread(f.write, chunk)
3292
- downloaded_so_far += len(chunk)
3293
-
3294
- if callback:
3295
- callback(downloaded_so_far, total)
3296
- elif progress_bar:
3297
- progress_bar.text_progress_bar(downloaded=downloaded_so_far, total=total)
3298
- finally:
3299
- await asyncio.to_thread(f.close)
3300
-
3301
- if progress_bar:
3302
- progress_bar = None
3303
- self.logger.info("Legacy download complete: bytes=%s path=%s", downloaded_so_far, path)
3304
- return True
3305
- except RequestsError as e:
3306
- err_str = str(e).lower()
3307
- 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:
3308
- attempt += 1
3309
- if attempt > max_retries:
3310
- raise
3311
- backoff = min(2 ** attempt, 30)
3312
- self.logger.warning("Download connection interrupted for %s (%s); retrying %s/%s in %ss", url, e, attempt, max_retries, backoff, exc_info=True)
3313
- if stop_event is not None and stop_event.wait(backoff):
3314
- raise DownloadCancelled("Download cancelled.") from e
3315
- else:
3316
- await asyncio.sleep(backoff)
3317
- continue
3318
- else:
3319
- raise NetworkRequestError(f"Stream for: {url} was closed or failed: {e}") from e
3320
- except DownloadCancelled:
3321
- raise
3322
- except Exception as exc:
3323
- error = traceback.format_exc()
3324
- raise NetworkRequestError(f"Unknown error for: {url} -->: {error}") from exc
3071
+ from base_api.modules.raw_download import download_raw
3325
3072
 
3326
- 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
@@ -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
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "eaf_base_api"
7
- version = "4.2.2"
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.2"
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