eaf_base_api 3.2.4__tar.gz → 3.3.1__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.4
3
+ Version: 3.3.1
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>
@@ -10,8 +10,8 @@ Classifier: Programming Language :: Python
10
10
  Requires-Dist: curl-cffi
11
11
  Requires-Dist: tenacity>=9.1.2
12
12
  Requires-Dist: m3u8 ; extra == 'hls'
13
- Requires-Dist: av ; python_full_version >= '3.10' and extra == 'hls'
14
- Requires-Python: >=3.10
13
+ Requires-Dist: av ; python_full_version >= '3.12' and extra == 'hls'
14
+ Requires-Python: >=3.12
15
15
  Project-URL: Homepage, https://github.com/EchterAlsFake/eaf_base_api
16
16
  Project-URL: Repository, https://github.com/EchterAlsFake/eaf_base_api
17
17
  Provides-Extra: hls
@@ -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
@@ -1003,7 +1042,9 @@ a new Python file, import only m3u8 and see what error you get.
1003
1042
  # Resolve master content
1004
1043
  assert m3u8 is not None
1005
1044
 
1006
- if asyncio.iscoroutine(m3u8_url):
1045
+ if inspect.iscoroutinefunction(m3u8_url) or (callable(m3u8_url) and not isinstance(m3u8_url, str)):
1046
+ m3u8_url = m3u8_url()
1047
+ if inspect.iscoroutine(m3u8_url) or inspect.isawaitable(m3u8_url):
1007
1048
  m3u8_url = await m3u8_url
1008
1049
 
1009
1050
  if m3u8_url.lstrip().startswith("#EXTM3U"):
@@ -1038,6 +1079,12 @@ a new Python file, import only m3u8 and see what error you get.
1038
1079
  Inspect the master playlist and return sorted unique heights (e.g., [240, 360, 480, 720, 1080]).
1039
1080
  """
1040
1081
  assert m3u8 is not None
1082
+
1083
+ if inspect.iscoroutinefunction(m3u8_url) or (callable(m3u8_url) and not isinstance(m3u8_url, str)):
1084
+ m3u8_url = m3u8_url()
1085
+ if inspect.iscoroutine(m3u8_url) or inspect.isawaitable(m3u8_url):
1086
+ m3u8_url = await m3u8_url
1087
+
1041
1088
  if not m3u8_url.startswith("https://"):
1042
1089
  master = m3u8.loads(m3u8_url)
1043
1090
  else:
@@ -1207,8 +1254,10 @@ a new Python file, import only m3u8 and see what error you get.
1207
1254
 
1208
1255
  m3u8_url = getattr(video, "m3u8_base_url", None)
1209
1256
 
1210
- if asyncio.iscoroutine(m3u8_url):
1211
- m3u8_url = await m3u8_url # For youporn api
1257
+ if inspect.iscoroutinefunction(m3u8_url) or (callable(m3u8_url) and not isinstance(m3u8_url, str)):
1258
+ m3u8_url = m3u8_url()
1259
+ if inspect.iscoroutine(m3u8_url) or inspect.isawaitable(m3u8_url):
1260
+ m3u8_url = await m3u8_url
1212
1261
 
1213
1262
  self.logger.info(
1214
1263
  """
@@ -1239,7 +1288,8 @@ a new Python file, import only m3u8 and see what error you get.
1239
1288
  return_report=return_report,
1240
1289
  cleanup_on_stop=cleanup_on_stop,
1241
1290
  keep_segment_dir=keep_segment_dir,
1242
- ios_support=ios_support
1291
+ ios_support=ios_support,
1292
+ pre_resolved_m3u8_url=m3u8_url
1243
1293
  )
1244
1294
 
1245
1295
  def threaded(self, max_workers: int,
@@ -1262,7 +1312,8 @@ a new Python file, import only m3u8 and see what error you get.
1262
1312
  return_report: bool = False,
1263
1313
  cleanup_on_stop: bool = True,
1264
1314
  keep_segment_dir: bool = False,
1265
- ios_support: bool = False
1315
+ ios_support: bool = False,
1316
+ pre_resolved_m3u8_url: str | None = None
1266
1317
  ) -> DownloadReport | bool:
1267
1318
  """
1268
1319
  Threaded HLS segment downloader with optional resume state and stop flag.
@@ -1338,8 +1389,15 @@ a new Python file, import only m3u8 and see what error you get.
1338
1389
  )
1339
1390
 
1340
1391
  else:
1341
- m3u8_master = getattr(video, "m3u8_base_url")
1342
- assert m3u8_master is not None, "m3u8_base_url is missing from video object"
1392
+ if pre_resolved_m3u8_url is not None:
1393
+ m3u8_master = pre_resolved_m3u8_url
1394
+ else:
1395
+ m3u8_master = getattr(video, "m3u8_base_url")
1396
+ assert m3u8_master is not None, "m3u8_base_url is missing from video object"
1397
+ if inspect.iscoroutinefunction(m3u8_master) or (callable(m3u8_master) and not isinstance(m3u8_master, str)):
1398
+ m3u8_master = m3u8_master()
1399
+ if inspect.iscoroutine(m3u8_master) or inspect.isawaitable(m3u8_master):
1400
+ m3u8_master = await m3u8_master
1343
1401
  self.logger.info(f"Fetching segments for quality={quality} m3u8_url_master={m3u8_master}")
1344
1402
  segments = await self.get_segments(quality=quality, m3u8_url_master=m3u8_master)
1345
1403
  total_before = len(segments)
@@ -1923,7 +1981,7 @@ allow_multipart=%s""", url, path, max_retries, read_timeout, bool(stop_event and
1923
1981
  file_size = int(head_resp.headers.get("Content-Length", 0))
1924
1982
  accept_ranges = head_resp.headers.get("Accept-Ranges", "")
1925
1983
  except Exception as e:
1926
- self.logger.warning("Failed to fetch HEAD info for concurrent check: %S.", e)
1984
+ self.logger.warning("Failed to fetch HEAD info for concurrent check: %s.", e)
1927
1985
 
1928
1986
  # 2. Execute Fast Multipart Download if supported and allowed
1929
1987
  if allow_multipart and file_size > 0 and accept_ranges == "bytes":
@@ -4,10 +4,10 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "eaf_base_api"
7
- version = "3.2.4"
7
+ version = "3.3.1"
8
8
  description = "A base API for EchterAlsFake's Porn APIs"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
- requires-python = ">=3.10"
10
+ requires-python = ">=3.12"
11
11
  license = "LGPL-3.0-or-later"
12
12
  license-files = ["LICENSE*"]
13
13
 
@@ -28,7 +28,7 @@ dependencies = [
28
28
  hls = [
29
29
  "m3u8",
30
30
  # av needs Python >= 3.10, so guard it (otherwise `pip install ...[hls]` fails on 3.8/3.9)
31
- "av; python_version >= '3.10'",
31
+ "av; python_version >= '3.12'",
32
32
  ]
33
33
 
34
34
  [project.urls]
File without changes
File without changes