eaf-base-api 2.2.9__tar.gz → 2.3.2__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: 2.2.9
3
+ Version: 2.3.2
4
4
  Summary: A base API for EchterAlsFake's Porn APIs
5
5
  Home-page: https://github.com/EchterAlsFake/eaf_base_api
6
6
  Author: Johannes Habel
@@ -3,12 +3,10 @@ import os
3
3
  import sys
4
4
  import time
5
5
  import m3u8
6
- import json
7
6
  import httpx
8
7
  import logging
9
8
  import traceback
10
9
  import threading
11
- import http.client
12
10
 
13
11
  from typing import Union
14
12
  from functools import lru_cache
@@ -17,10 +15,12 @@ from ffmpeg_progress_yield import FfmpegProgress
17
15
  from concurrent.futures import ThreadPoolExecutor, as_completed
18
16
 
19
17
  try:
18
+ from modules.errors import *
20
19
  from modules.config import config
21
20
  from modules.progress_bars import Callback
22
21
 
23
22
  except (ModuleNotFoundError, ImportError):
23
+ from .modules.errors import *
24
24
  from .modules.config import config
25
25
  from .modules.progress_bars import Callback
26
26
 
@@ -59,14 +59,11 @@ class ColoredFormatter(logging.Formatter):
59
59
 
60
60
 
61
61
  def send_log_message(ip, port, message):
62
- """Sends a log message to a remote server via HTTP."""
62
+ """Sends a log message to a remote server via HTTP or HTTPS."""
63
63
  try:
64
- conn = http.client.HTTPConnection(ip, port, timeout=5)
65
- headers = {'Content-Type': 'application/json'}
66
- data = json.dumps({"message": message})
67
- conn.request("POST", "/log", body=data, headers=headers)
68
- conn.getresponse() # Read response to ensure completion
69
- conn.close()
64
+ url = f"https://{ip}:{port}/feedback"
65
+ with httpx.Client(timeout=5) as client:
66
+ client.post(url, json={"message": message})
70
67
  except Exception as e:
71
68
  print(f"Failed to send log to {ip}:{port} - {e}", file=sys.stderr)
72
69
 
@@ -152,7 +149,7 @@ class Cache:
152
149
 
153
150
  def handle_cache(self, url):
154
151
  if url is None:
155
- return
152
+ return None
156
153
 
157
154
  with self.lock:
158
155
  content = self.cache_dictionary.get(url, None)
@@ -194,6 +191,10 @@ class BaseCore:
194
191
  self.config.rotate_user_agent()
195
192
  self.session.headers.update({"User-Agent": self.config.headers["User-Agent"]})
196
193
 
194
+ def update_cookies(self):
195
+ """Updates cookies dynamically"""
196
+ self.session.cookies.update(self.config.cookies)
197
+
197
198
  def enable_kill_switch(self):
198
199
  """This is a function that will check and verify if your proxy is working before every request.
199
200
  As soon as there's a mismatch in the actual response IP and the proxy IP, the program will exit."""
@@ -215,6 +216,7 @@ class BaseCore:
215
216
 
216
217
  else:
217
218
  self.logger.critical("Proxy is INVALID! Exiting.")
219
+ raise InvalidProxy("The Proxy is invalid, all requests will be aborted, please check your configuration!")
218
220
 
219
221
  self.logger.info("Checking if proxy is working...")
220
222
  self.logger.info("Doing request to httpbin.org to get your real IP to compare it with another request with activated proxy")
@@ -228,7 +230,7 @@ class BaseCore:
228
230
 
229
231
  if your_ip == proxy_ip_:
230
232
  self.logger.critical("IP is the same on both requests... Proxy is not working, exiting!")
231
- raise "CRITICAL PROXY ERROR, CHECK LOGS!"
233
+ raise KillSwitch("CRITICAL PROXY ERROR, CHECK LOGS!")
232
234
 
233
235
 
234
236
  def initialize_session(self, verify=True): # Disable SSL verification only if you really need it....
@@ -240,6 +242,9 @@ class BaseCore:
240
242
  verify=verify
241
243
  )
242
244
 
245
+ def update_headers(self, headers: dict):
246
+ self.session.headers.update(headers)
247
+
243
248
  def enforce_delay(self):
244
249
  """Enforces the specified delay in consts.REQUEST_DELAY"""
245
250
  delay = self.config.request_delay
@@ -250,6 +255,7 @@ class BaseCore:
250
255
  sleep_time = delay - time_since_last_request
251
256
  self.logger.debug(f"Enforcing delay of {sleep_time:.2f} seconds.")
252
257
  time.sleep(sleep_time)
258
+ self.last_request_time = time.time()
253
259
 
254
260
  def fetch(
255
261
  self,
@@ -260,7 +266,9 @@ class BaseCore:
260
266
  save_cache: bool = True,
261
267
  cookies: dict = None,
262
268
  allow_redirects: bool = True,
263
- bypass_kill_switch: bool = False # prevents infinite loop
269
+ bypass_kill_switch: bool = False, # prevents infinite loop
270
+ data: dict = None,
271
+ method: str = "GET",
264
272
  ) -> Union[bytes, str, httpx.Response, None]:
265
273
  """
266
274
  Fetches content in UTF-8 Text, Bytes, or as a stream using multiple request attempts,
@@ -276,9 +284,9 @@ class BaseCore:
276
284
  self.logger.info(f"Fetched content for: {url} from cache!")
277
285
  return content
278
286
 
279
- for attempt in range(1, self.config.max_retries + 1):
287
+ for attempt in range(1, self.config.max_retries + 1): # +1 because I feel like it
280
288
  if attempt != 1:
281
- time.sleep(1.5) # Sleeping for 1.5 seconds to minimize site overload when doing a lot of requests
289
+ time.sleep(1.5 * attempt) # Sleeping for 1.5 seconds to minimize site overload when doing a lot of requests
282
290
 
283
291
  try:
284
292
  # Update user agent periodically
@@ -291,8 +299,8 @@ class BaseCore:
291
299
  self.check_kill_switch()
292
300
 
293
301
  # Perform the request with stream handling
294
- response = self.session.get(url, timeout=timeout, cookies=cookies,
295
- follow_redirects=allow_redirects)
302
+ response = self.session.request(method=method, url=url, timeout=timeout, cookies=cookies,
303
+ follow_redirects=allow_redirects, data=data)
296
304
  self.total_requests += 1
297
305
 
298
306
  # Log and handle non-200 status codes
@@ -301,13 +309,14 @@ class BaseCore:
301
309
  f"Attempt {attempt}: Unexpected status code {response.status_code} for URL: {url}")
302
310
 
303
311
  if response.status_code == 404:
304
- self.logger.error("Resource not found (404). This may indicate the content is unavailable.")
312
+ self.logger.error(f"URL: {url} Resource not found (404). This may indicate the content is unavailable.")
305
313
  return None # Return None for unavailable resources
306
314
 
307
315
  elif response.status_code == 403 and attempt >= 2:
308
316
  self.logger.error(f"The website rejected access after {attempt} tries. Aborting!")
309
317
  return None # Return None for forbidden resources
310
318
 
319
+ self.logger.info(f"Retrying ({attempt}/{self.config.max_retries}) for URL: {url}")
311
320
  continue # Retry for other non-200 status codes
312
321
 
313
322
  self.logger.debug(f"Attempt {attempt}: Successfully fetched URL: {url}")
@@ -328,6 +337,7 @@ class BaseCore:
328
337
  content = raw_content.decode("utf-8")
329
338
 
330
339
  except UnicodeDecodeError:
340
+ self.logger.warning(f"Content could not be decoded as utf-8 ({url}), decoding in 'latin1' instead!")
331
341
  content = raw_content.decode("latin1") # Fallback, hope that works somehow idk
332
342
 
333
343
  if save_cache and not get_bytes:
@@ -336,36 +346,78 @@ class BaseCore:
336
346
 
337
347
  return content
338
348
 
349
+ except httpx.CloseError:
350
+ self.logger.error(f"Attempt {attempt}: The connection has been unexpectedly closed by: {url}. Retrying...")
351
+ continue
352
+
339
353
  except httpx.RequestError as e:
340
354
  self.logger.error(f"Attempt {attempt}: Request error for URL {url}: {e}")
355
+ self.logger.info(f"Retrying ({attempt}/{self.config.max_retries}) for URL: {url}")
356
+ continue # Continuing even failed requests, because they may succeed on another try (maybe idk)
357
+
358
+ except (httpx.TimeoutException, httpx.ConnectTimeout) as e:
359
+ self.logger.error(f"Attempt {attempt}: Timeout error for URL {url}: {e}. Please increase your timeout in "
360
+ f"the configuration options to prevent that, or get a stable internet connection ;)")
361
+ self.logger.info(f"Retrying ({attempt}/{self.config.max_retries}) for URL: {url}")
362
+ continue
363
+
364
+ except httpx.CookieConflict as e:
365
+ self.logger.error(f"Some cookies are conflicting. Connection aborted! Please report this error immediately -->: {e}")
366
+ sys.exit(1)
367
+
368
+ except httpx.ProxyError as e:
369
+ self.logger.error(f"Proxy Error, please switch to another Proxy, it seems to be bad -->: {e}")
370
+ raise KillSwitch("Proxy error when trying a request, Aborting!")
341
371
 
342
372
  except Exception:
343
373
  self.logger.error(f"Attempt {attempt}: Unexpected error for URL {url}: {traceback.format_exc()}")
344
-
345
- self.logger.info(f"Retrying ({attempt}/{self.config.max_retries}) for URL: {url}")
374
+ raise UnknownError(f"Unexpected error for URL {url}: {traceback.format_exc()}")
346
375
 
347
376
  self.logger.error(f"Failed to fetch URL {url} after {self.config.max_retries} attempts.")
348
377
  return None # Return None if all attempts fail
349
378
 
350
379
  @classmethod
351
- def strip_title(cls, title: str) -> str:
380
+ def strip_title(cls, title: str, max_length: int = 255) -> str:
352
381
  """
353
- :param title:
354
- :return: str: strips out non UTF-8 chars of the title
382
+ Sanitize a filename to be safe across Windows, macOS, Linux, and Android.
383
+ Replaces or strips illegal characters and trims to a safe length.
355
384
  """
356
- illegal_chars = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']
357
- for char in illegal_chars:
358
- title = title.replace(char, "")
359
385
 
360
- return title
386
+ # Reserved characters on Windows + `/` for Unix/macOS
387
+ illegal_chars = r'[<>:"/\\|?*\x00-\x1F]'
388
+ sanitized = re.sub(illegal_chars, "_", title)
389
+
390
+ # Strip invisible zero-width characters
391
+ sanitized = re.sub(r'[\u200B-\u200D\uFEFF]', '', sanitized)
392
+
393
+ # Strip trailing periods or spaces (Windows)
394
+ sanitized = sanitized.rstrip(" .")
395
+
396
+ # Prevent reserved Windows filenames
397
+ reserved_names = {
398
+ "CON", "PRN", "AUX", "NUL",
399
+ *(f"COM{i}" for i in range(1, 10)),
400
+ *(f"LPT{i}" for i in range(1, 10)),
401
+ }
402
+ name_only = sanitized.split('.')[0].upper()
403
+ if name_only in reserved_names:
404
+ sanitized = f"_{sanitized}"
405
+
406
+ # Trim to max length
407
+ return sanitized[:max_length]
361
408
 
362
409
  @lru_cache(maxsize=250)
363
410
  def get_m3u8_by_quality(self, m3u8_url: str, quality: str) -> str:
364
411
  """Fetches the m3u8 URL for a given quality by extracting all possible sub-m3u8 URLs from the primary
365
412
  m3u8 playlist"""
366
- playlist_content = self.fetch(url=m3u8_url)
367
- playlist = m3u8.loads(playlist_content)
368
- self.logger.debug(f"Resolved m3u8 playlist: {m3u8_url}")
413
+ if "#" in m3u8_url:
414
+ playlist = m3u8.loads(m3u8_url)
415
+ self.logger.debug("Resolved custom m3u8")
416
+
417
+ else:
418
+ playlist_content = self.fetch(url=m3u8_url)
419
+ playlist = m3u8.loads(playlist_content)
420
+ self.logger.debug(f"Resolved m3u8 playlist: {m3u8_url}")
369
421
 
370
422
  if not playlist.is_variant:
371
423
  raise ValueError("Provided URL is not a master playlist.")
@@ -404,12 +456,17 @@ class BaseCore:
404
456
  m3u8_url = self.get_m3u8_by_quality(m3u8_url=m3u8_url_master, quality=quality)
405
457
  self.logger.debug(f"Trying to fetch segment from m3u8 ->: {m3u8_url}")
406
458
  content = self.fetch(url=m3u8_url)
407
- segments_ = m3u8.loads(content).segments
408
- segments = []
409
459
 
410
- for segment in segments_:
411
- segments.append(urljoin(m3u8_url, segment.uri)) # Get the full URL path to segment
460
+ m3u8_processed = m3u8.loads(content)
412
461
 
462
+ if m3u8_processed.is_variant:
463
+ self.logger.warning("The M3U8 Playlist is not a media playlist. Trying to resolve to the first m3u8...")
464
+ new_m3u8 = urljoin(m3u8_url, m3u8_processed.playlists[0].uri)
465
+ self.logger.info(f"Resolved to new URL: {new_m3u8}")
466
+ content = self.fetch(url=new_m3u8)
467
+ m3u8_processed = m3u8.loads(content)
468
+
469
+ segments = [urljoin(m3u8_url, seg.uri) for seg in m3u8_processed.segments]
413
470
  self.logger.debug(f"Fetched {len(segments)} segments from m3u8 URL")
414
471
  return segments
415
472
 
@@ -474,6 +531,7 @@ class BaseCore:
474
531
  callback(completed, length) # Update progress callback
475
532
  except Exception as e:
476
533
  self.logger.error(f"Error processing segment {hls_part}: {e}")
534
+ raise SegmentError(f"Error processing segment {hls_part}: {e}")
477
535
 
478
536
  # Write only successful segments to the output file
479
537
  with open(path, 'wb') as file:
@@ -573,12 +631,14 @@ class BaseCore:
573
631
 
574
632
  return True
575
633
 
576
- except httpx.RequestError as e:
577
- self.logger.error(f"Request failed for URL {url}: {e}")
578
- return False
579
- except Exception as e:
580
- self.logger.error(f"Unexpected error during download: {e}")
581
- return False
634
+ except httpx.StreamClosed:
635
+ self.logger.error(f"Stream for: {url} was closed. This should not happen...")
636
+ raise NetworkingError(f"Stream for: {url} was closed, if this happens again, please report it!")
637
+
638
+ except Exception:
639
+ error = traceback.format_exc()
640
+ self.logger.error(f"Unknown (network) error for: {url}. Please report this! -->: {error}")
641
+ raise NetworkingError(f"Unknown error for: {url}. Please report this! -->: {error}")
582
642
 
583
643
  @classmethod
584
644
  def return_path(cls, video, args) -> str:
@@ -0,0 +1,44 @@
1
+ # This file contains all custom exceptions for base api. They should be handled by each API individually.
2
+
3
+ class KillSwitch(Exception):
4
+ """
5
+ Raised when a kill switch is triggered due to an IP Leak.
6
+ """
7
+ def __init__(self, message):
8
+ self.message = message
9
+
10
+
11
+
12
+ class InvalidProxy(Exception):
13
+ """
14
+ Raised when a proxy set by the user is invalid. A basic scheme with regular expressions will check for possible
15
+ proxy configurations such as http, https and socks5 and if the entered proxy follows the certain scheme.
16
+ If it doesn't this exception will be raised.
17
+ """
18
+ def __init__(self, message):
19
+ self.message = message
20
+
21
+
22
+ class UnknownError(Exception):
23
+ """
24
+ Raised when an unknown error occurs that I don't know about yet.
25
+ """
26
+ def __init__(self, message):
27
+ self.message = message
28
+
29
+
30
+ class SegmentError(Exception):
31
+ """
32
+ Raises when a segment fails to get processed. I never that happen, but you never know.
33
+ """
34
+ def __init__(self, message):
35
+ self.message = message
36
+
37
+
38
+ class NetworkingError(Exception):
39
+ """
40
+ Raises for all general network errors that are usually the fault of the user's internet connection.
41
+ """
42
+ def __init__(self, message):
43
+ self.message = message
44
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eaf_base_api
3
- Version: 2.2.9
3
+ Version: 2.3.2
4
4
  Summary: A base API for EchterAlsFake's Porn APIs
5
5
  Home-page: https://github.com/EchterAlsFake/eaf_base_api
6
6
  Author: Johannes Habel
@@ -5,6 +5,7 @@ base_api/__init__.py
5
5
  base_api/base.py
6
6
  base_api/modules/__init__.py
7
7
  base_api/modules/config.py
8
+ base_api/modules/errors.py
8
9
  base_api/modules/progress_bars.py
9
10
  eaf_base_api.egg-info/PKG-INFO
10
11
  eaf_base_api.egg-info/SOURCES.txt
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="eaf_base_api",
5
- version="2.2.9",
5
+ version="2.3.2",
6
6
  packages=find_packages(),
7
7
  install_requires=["ffmpeg-progress-yield", "m3u8", "httpx"],
8
8
  entry_points={
File without changes
File without changes
File without changes