eaf_base_api 3.2.3__tar.gz → 3.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: 3.2.3
3
+ Version: 3.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>
@@ -6,6 +6,7 @@ import time
6
6
  import string
7
7
  import shutil
8
8
  import asyncio
9
+ import inspect
9
10
  import logging
10
11
  import traceback
11
12
  import threading
@@ -14,7 +15,7 @@ from itertools import islice
14
15
  from collections import deque
15
16
  from functools import lru_cache
16
17
  from urllib.parse import urljoin
17
- from typing import Union, Callable, Tuple, Iterable, TYPE_CHECKING, AsyncGenerator, Coroutine, cast, List, Dict, Any
18
+ from typing import Union, Callable, Tuple, Iterable, TYPE_CHECKING, AsyncGenerator, Coroutine, cast, List, Dict, Any, Awaitable
18
19
  from curl_cffi import CurlOpt # Used for DNS over HTTPS
19
20
  from curl_cffi.requests.errors import RequestsError
20
21
  from curl_cffi.requests import AsyncSession, Response
@@ -239,7 +240,7 @@ class Helper:
239
240
  resource_instance = await resource_instance
240
241
  return resource_instance
241
242
 
242
- async def _make_video_safe(self, video_url: str) -> Any:
243
+ async def _make_video_safe(self, video_url: str, on_video_error: Callable[[str, Exception, int], Awaitable[bool]] | None = None) -> Any:
243
244
  """
244
245
  Fetches the HTML for a video URL and creates a Video object safely.
245
246
 
@@ -247,33 +248,68 @@ class Helper:
247
248
  log errors, returning a VideoFetchError instead of crashing the iterator.
248
249
  """
249
250
  logger = self.logger
250
- start_timestamp = time.perf_counter()
251
- try:
252
- # Fetch the raw HTML content of the video page
253
- html_content = await self.core.fetch(video_url)
254
-
255
- # Instantiate the video object using the provided factory
256
- video_instance = self.video_factory(video_url, core=self.core, html_content=html_content)
257
-
258
- # Handle both synchronous and asynchronous constructors
259
- if asyncio.iscoroutine(video_instance):
260
- video_instance = await video_instance
261
-
262
- # Automatically call and await the init method if available (async initialization)
263
- if hasattr(video_instance, "init") and callable(video_instance.init):
264
- init_result = video_instance.init()
265
- if asyncio.iscoroutine(init_result):
266
- await init_result
267
-
268
- elapsed_ms = (time.perf_counter() - start_timestamp) * 1000
269
- logger.debug("video_init ok url=%s (%.2f ms)", video_url, elapsed_ms)
270
- return video_instance
251
+ attempt = 0
252
+ while True:
253
+ attempt += 1
254
+ start_timestamp = time.perf_counter()
255
+ try:
256
+ # Fetch the raw HTML content of the video page
257
+ html_content = await self.core.fetch(video_url)
258
+
259
+ if isinstance(html_content, Response):
260
+ if html_content.status_code == 404:
261
+ raise ResourceGone(f"Video returned 404 Not Found: {video_url}")
262
+ else:
263
+ raise NetworkingError(f"Unexpected response object with status {html_content.status_code} for {video_url}")
264
+
265
+ # Instantiate the video object using the provided factory
266
+ video_instance = self.video_factory(video_url, core=self.core, html_content=html_content)
267
+
268
+ # Handle both synchronous and asynchronous constructors
269
+ if asyncio.iscoroutine(video_instance):
270
+ video_instance = await video_instance
271
+
272
+ # Automatically call and await the init method if available (async initialization)
273
+ if hasattr(video_instance, "init") and callable(video_instance.init):
274
+ init_result = video_instance.init()
275
+ if asyncio.iscoroutine(init_result):
276
+ await init_result
277
+
278
+ elapsed_ms = (time.perf_counter() - start_timestamp) * 1000
279
+ logger.debug("video_init ok url=%s (%.2f ms) attempt=%d", video_url, elapsed_ms, attempt)
280
+ return video_instance
281
+
282
+ except Exception as error:
283
+ elapsed_ms = (time.perf_counter() - start_timestamp) * 1000
284
+ logger.warning("video_init FAILED url=%s (%.2f ms) attempt=%d: %s", video_url, elapsed_ms, attempt, error)
285
+
286
+ if on_video_error:
287
+ try:
288
+ should_retry = await on_video_error(video_url, error, attempt)
289
+ if should_retry:
290
+ continue
291
+ except Exception as e:
292
+ logger.exception("on_video_error callback failed for url=%s: %s", video_url, e)
293
+
294
+ # Return a specialized error object so the caller can decide how to handle it
295
+ return VideoFetchError(video_url, error)
271
296
 
272
- except Exception as error:
273
- elapsed_ms = (time.perf_counter() - start_timestamp) * 1000
274
- logger.exception("video_init FAILED url=%s (%.2f ms): %s", video_url, elapsed_ms, error)
275
- # Return a specialized error object so the caller can decide how to handle it
276
- return VideoFetchError(video_url, error)
297
+ async def _fetch_page_safe(self, url: str, method: str, on_page_error: Callable[[str, Exception, int], Awaitable[bool]] | None = None) -> Any:
298
+ attempt = 0
299
+ while True:
300
+ attempt += 1
301
+ try:
302
+ return await self.core.fetch(url, method=method)
303
+ except Exception as error:
304
+ self.logger.warning("PAGE FAILED url=%s attempt=%d: %s", url, attempt, error)
305
+ if on_page_error:
306
+ try:
307
+ should_retry = await on_page_error(url, error, attempt)
308
+ if should_retry:
309
+ continue
310
+ except Exception as e:
311
+ self.logger.exception("on_page_error callback failed for url=%s: %s", url, e)
312
+ raise
277
313
 
278
314
  async def iterator(
279
315
  self,
@@ -284,6 +320,9 @@ class Helper:
284
320
  use_alternative_constructor: bool = False,
285
321
  page_request_method: str = "GET",
286
322
  video_request_method: str = "GET",
323
+ ignore_errors: bool = True,
324
+ on_video_error: Callable[[str, Exception, int], Awaitable[bool]] | None = None,
325
+ on_page_error: Callable[[str, Exception, int], Awaitable[bool]] | None = None,
287
326
  ) -> AsyncGenerator[Any, None]:
288
327
  """
289
328
  The main scraping engine that orchestrates concurrent page and video processing.
@@ -368,7 +407,8 @@ class Helper:
368
407
  "[%s] yield_ordered_results yielding pidx=%d vidx=%d (buffer size: %d)",
369
408
  execution_id, current_key[0], current_key[1], len(result_buffer)
370
409
  )
371
- ready_items.append(item)
410
+ if not (ignore_errors and isinstance(item, (VideoFetchError, PageFetchError))):
411
+ ready_items.append(item)
372
412
  count_flushed += 1
373
413
  yield_video_cursor += 1
374
414
 
@@ -394,7 +434,7 @@ class Helper:
394
434
  if use_alternative_constructor:
395
435
  task = asyncio.create_task(self._create_alternative_resource(video_url))
396
436
  else:
397
- task = asyncio.create_task(self._make_video_safe(video_url))
437
+ task = asyncio.create_task(self._make_video_safe(video_url, on_video_error=on_video_error))
398
438
 
399
439
  pending_video_tasks[task] = (page_idx, video_idx)
400
440
  scheduled_count += 1
@@ -410,8 +450,7 @@ class Helper:
410
450
  for _ in range(max_page_concurrency):
411
451
  try:
412
452
  p_idx, p_url = next(page_source_iterator)
413
- # Note: self.fetch_core.fetch is used here directly
414
- task = asyncio.create_task(self.core.fetch(p_url, method=page_request_method))
453
+ task = asyncio.create_task(self._fetch_page_safe(p_url, method=page_request_method, on_page_error=on_page_error))
415
454
  pending_page_tasks[task] = (p_idx, p_url)
416
455
  logger.debug(
417
456
  "[%s] initial PAGE scheduled pidx=%d url=%s",
@@ -467,7 +506,7 @@ class Helper:
467
506
  if not should_stop_paging:
468
507
  try:
469
508
  next_p_idx, next_p_url = next(page_source_iterator)
470
- next_task = asyncio.create_task(self.core.fetch(next_p_url, method=page_request_method))
509
+ next_task = asyncio.create_task(self._fetch_page_safe(next_p_url, method=page_request_method, on_page_error=on_page_error))
471
510
  pending_page_tasks[next_task] = (next_p_idx, next_p_url)
472
511
  except StopIteration:
473
512
  pass
@@ -544,7 +583,7 @@ class Helper:
544
583
  if not should_stop_paging:
545
584
  try:
546
585
  n_idx, n_url = next(page_source_iterator)
547
- n_task = asyncio.create_task(self.core.fetch(n_url, method=page_request_method))
586
+ n_task = asyncio.create_task(self._fetch_page_safe(n_url, method=page_request_method, on_page_error=on_page_error))
548
587
  pending_page_tasks[n_task] = (n_idx, n_url)
549
588
  logger.debug("[%s] scheduled NEXT PAGE pidx=%d", execution_id, n_idx)
550
589
  except StopIteration:
@@ -708,7 +747,7 @@ class BaseCore:
708
747
  delay = self.configuration.request_delay
709
748
  if delay and delay > 0:
710
749
  time_since_last_request = time.time() - self.last_request_time
711
- self.logger.debug("Time since last request: {:.2f seconds}.".format(time_since_last_request))
750
+ self.logger.debug("Time since last request: {:.2f} seconds.".format(time_since_last_request))
712
751
  if time_since_last_request < delay:
713
752
  sleep_time = delay - time_since_last_request
714
753
  self.logger.debug("Enforcing delay of {:.2f} seconds.".format(sleep_time))
@@ -735,7 +774,7 @@ class BaseCore:
735
774
  self.initialize_session()
736
775
  session = self.session
737
776
  assert session is not None
738
- cookies: Dict[str, Any] = cast(Dict[str, Any], cast(Any, dict(session.cookies)))
777
+ cookies: Dict[str, Any] = cast(Dict[str, Any], cast(Any, session.cookies.get_dict()))
739
778
  if override:
740
779
  cookies.update(override)
741
780
  return cookies
@@ -1002,6 +1041,12 @@ a new Python file, import only m3u8 and see what error you get.
1002
1041
 
1003
1042
  # Resolve master content
1004
1043
  assert m3u8 is not None
1044
+
1045
+ if inspect.iscoroutinefunction(m3u8_url) or (callable(m3u8_url) and not isinstance(m3u8_url, str)):
1046
+ m3u8_url = await m3u8_url()
1047
+ elif inspect.iscoroutine(m3u8_url):
1048
+ m3u8_url = await m3u8_url
1049
+
1005
1050
  if m3u8_url.lstrip().startswith("#EXTM3U"):
1006
1051
  master = m3u8.loads(m3u8_url)
1007
1052
  self.logger.debug("Resolved inline/custom m3u8 master content.")
@@ -1203,8 +1248,10 @@ a new Python file, import only m3u8 and see what error you get.
1203
1248
 
1204
1249
  m3u8_url = getattr(video, "m3u8_base_url", None)
1205
1250
 
1206
- if asyncio.iscoroutine(m3u8_url):
1207
- m3u8_url = await m3u8_url # For youporn api
1251
+ if inspect.iscoroutinefunction(m3u8_url) or (callable(m3u8_url) and not isinstance(m3u8_url, str)):
1252
+ m3u8_url = await m3u8_url()
1253
+ elif inspect.iscoroutine(m3u8_url):
1254
+ m3u8_url = await m3u8_url
1208
1255
 
1209
1256
  self.logger.info(
1210
1257
  """
@@ -1235,7 +1282,8 @@ a new Python file, import only m3u8 and see what error you get.
1235
1282
  return_report=return_report,
1236
1283
  cleanup_on_stop=cleanup_on_stop,
1237
1284
  keep_segment_dir=keep_segment_dir,
1238
- ios_support=ios_support
1285
+ ios_support=ios_support,
1286
+ pre_resolved_m3u8_url=m3u8_url
1239
1287
  )
1240
1288
 
1241
1289
  def threaded(self, max_workers: int,
@@ -1258,7 +1306,8 @@ a new Python file, import only m3u8 and see what error you get.
1258
1306
  return_report: bool = False,
1259
1307
  cleanup_on_stop: bool = True,
1260
1308
  keep_segment_dir: bool = False,
1261
- ios_support: bool = False
1309
+ ios_support: bool = False,
1310
+ pre_resolved_m3u8_url: str | None = None
1262
1311
  ) -> DownloadReport | bool:
1263
1312
  """
1264
1313
  Threaded HLS segment downloader with optional resume state and stop flag.
@@ -1334,8 +1383,15 @@ a new Python file, import only m3u8 and see what error you get.
1334
1383
  )
1335
1384
 
1336
1385
  else:
1337
- m3u8_master = getattr(video, "m3u8_base_url")
1338
- assert m3u8_master is not None, "m3u8_base_url is missing from video object"
1386
+ if pre_resolved_m3u8_url is not None:
1387
+ m3u8_master = pre_resolved_m3u8_url
1388
+ else:
1389
+ m3u8_master = getattr(video, "m3u8_base_url")
1390
+ assert m3u8_master is not None, "m3u8_base_url is missing from video object"
1391
+ if inspect.iscoroutinefunction(m3u8_master) or (callable(m3u8_master) and not isinstance(m3u8_master, str)):
1392
+ m3u8_master = await m3u8_master()
1393
+ elif inspect.iscoroutine(m3u8_master):
1394
+ m3u8_master = await m3u8_master
1339
1395
  self.logger.info(f"Fetching segments for quality={quality} m3u8_url_master={m3u8_master}")
1340
1396
  segments = await self.get_segments(quality=quality, m3u8_url_master=m3u8_master)
1341
1397
  total_before = len(segments)
@@ -1919,7 +1975,7 @@ allow_multipart=%s""", url, path, max_retries, read_timeout, bool(stop_event and
1919
1975
  file_size = int(head_resp.headers.get("Content-Length", 0))
1920
1976
  accept_ranges = head_resp.headers.get("Accept-Ranges", "")
1921
1977
  except Exception as e:
1922
- self.logger.warning("Failed to fetch HEAD info for concurrent check: %S.", e)
1978
+ self.logger.warning("Failed to fetch HEAD info for concurrent check: %s.", e)
1923
1979
 
1924
1980
  # 2. Execute Fast Multipart Download if supported and allowed
1925
1981
  if allow_multipart and file_size > 0 and accept_ranges == "bytes":
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "eaf_base_api"
7
- version = "3.2.3"
7
+ version = "3.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.10"
File without changes
File without changes