eaf-base-api 2.3.8__tar.gz → 2.4.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.
- eaf_base_api-2.4.2/MANIFEST.in +2 -0
- {eaf_base_api-2.3.8/eaf_base_api.egg-info → eaf_base_api-2.4.2}/PKG-INFO +6 -16
- {eaf_base_api-2.3.8 → eaf_base_api-2.4.2}/base_api/base.py +476 -99
- eaf_base_api-2.4.2/base_api/modules/config.py +15 -0
- {eaf_base_api-2.3.8 → eaf_base_api-2.4.2}/base_api/modules/errors.py +14 -1
- {eaf_base_api-2.3.8 → eaf_base_api-2.4.2/eaf_base_api.egg-info}/PKG-INFO +6 -16
- {eaf_base_api-2.3.8 → eaf_base_api-2.4.2}/eaf_base_api.egg-info/SOURCES.txt +2 -1
- {eaf_base_api-2.3.8 → eaf_base_api-2.4.2}/eaf_base_api.egg-info/requires.txt +3 -3
- eaf_base_api-2.4.2/eaf_base_api.egg-info/top_level.txt +3 -0
- eaf_base_api-2.4.2/pyproject.toml +29 -0
- eaf_base_api-2.3.8/base_api/modules/config.py +0 -37
- eaf_base_api-2.3.8/eaf_base_api.egg-info/top_level.txt +0 -1
- eaf_base_api-2.3.8/setup.py +0 -29
- {eaf_base_api-2.3.8 → eaf_base_api-2.4.2}/LICENSE +0 -0
- {eaf_base_api-2.3.8 → eaf_base_api-2.4.2}/README.md +0 -0
- {eaf_base_api-2.3.8 → eaf_base_api-2.4.2}/base_api/__init__.py +0 -0
- {eaf_base_api-2.3.8 → eaf_base_api-2.4.2}/base_api/modules/__init__.py +0 -0
- {eaf_base_api-2.3.8 → eaf_base_api-2.4.2}/base_api/modules/progress_bars.py +0 -0
- {eaf_base_api-2.3.8 → eaf_base_api-2.4.2}/eaf_base_api.egg-info/dependency_links.txt +0 -0
- {eaf_base_api-2.3.8 → eaf_base_api-2.4.2}/setup.cfg +0 -0
|
@@ -1,32 +1,22 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: eaf_base_api
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.2
|
|
4
4
|
Summary: A base API for EchterAlsFake's Porn APIs
|
|
5
|
-
|
|
6
|
-
Author: Johannes Habel
|
|
7
|
-
Author-email: EchterAlsFake@proton.me
|
|
5
|
+
Author-email: Johannes Habel <EchterAlsFake@proton.me>
|
|
8
6
|
License: LGPLv3
|
|
7
|
+
Project-URL: Homepage, https://github.com/EchterAlsFake/eaf_base_api
|
|
9
8
|
Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)
|
|
10
9
|
Classifier: Programming Language :: Python
|
|
10
|
+
Requires-Python: >=3.6
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
13
|
Requires-Dist: m3u8
|
|
14
14
|
Requires-Dist: httpx
|
|
15
|
-
Provides-Extra:
|
|
16
|
-
Requires-Dist: av; extra == "
|
|
15
|
+
Provides-Extra: av
|
|
16
|
+
Requires-Dist: av; extra == "av"
|
|
17
17
|
Provides-Extra: ffmpeg
|
|
18
18
|
Requires-Dist: ffmpeg-progress-yield; extra == "ffmpeg"
|
|
19
|
-
Dynamic: author
|
|
20
|
-
Dynamic: author-email
|
|
21
|
-
Dynamic: classifier
|
|
22
|
-
Dynamic: description
|
|
23
|
-
Dynamic: description-content-type
|
|
24
|
-
Dynamic: home-page
|
|
25
|
-
Dynamic: license
|
|
26
19
|
Dynamic: license-file
|
|
27
|
-
Dynamic: provides-extra
|
|
28
|
-
Dynamic: requires-dist
|
|
29
|
-
Dynamic: summary
|
|
30
20
|
|
|
31
21
|
# EAF Base API
|
|
32
22
|
|
|
@@ -4,11 +4,12 @@ import sys
|
|
|
4
4
|
import time
|
|
5
5
|
import m3u8
|
|
6
6
|
import httpx
|
|
7
|
+
import random
|
|
7
8
|
import logging
|
|
8
9
|
import traceback
|
|
9
10
|
import threading
|
|
10
11
|
|
|
11
|
-
from typing import Union
|
|
12
|
+
from typing import Any, Dict, List, Optional, Union
|
|
12
13
|
from functools import lru_cache
|
|
13
14
|
from urllib.parse import urljoin
|
|
14
15
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
@@ -23,11 +24,215 @@ except (ModuleNotFoundError, ImportError):
|
|
|
23
24
|
from .modules.config import config
|
|
24
25
|
from .modules.progress_bars import Callback
|
|
25
26
|
|
|
27
|
+
user_agents = [
|
|
28
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
29
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.1",
|
|
30
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3",
|
|
31
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.3",
|
|
32
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0."
|
|
33
|
+
]
|
|
34
|
+
|
|
26
35
|
loggers = {}
|
|
36
|
+
HEIGHT_FROM_URI = re.compile(r'(?<!\d)(\d{3,4})[pP](?!\d)') # e.g., 1080p, 720P
|
|
37
|
+
|
|
38
|
+
def _height_from_variant(variant) -> Optional[int]:
|
|
39
|
+
"""Extract height from a variant:
|
|
40
|
+
1) stream_info.resolution (w, h)
|
|
41
|
+
2) URI pattern like .../720p/...
|
|
42
|
+
"""
|
|
43
|
+
if getattr(variant, "stream_info", None) and variant.stream_info.resolution:
|
|
44
|
+
_, h = variant.stream_info.resolution # (w, h)
|
|
45
|
+
return int(h)
|
|
46
|
+
|
|
47
|
+
if variant.uri:
|
|
48
|
+
m = HEIGHT_FROM_URI.search(variant.uri)
|
|
49
|
+
if m:
|
|
50
|
+
return int(m.group(1))
|
|
51
|
+
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
def _is_video_playlist(variant) -> bool:
|
|
55
|
+
"""Filter out I-frames/audio-only playlists."""
|
|
56
|
+
# m3u8 lib sometimes sets is_iframe if EXT-X-I-FRAME-STREAM-INF is present.
|
|
57
|
+
if getattr(variant, "is_iframe", False):
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
# If codecs known and contain only audio (mp4a, ac-3, ec-3, etc.)
|
|
61
|
+
codecs = getattr(variant.stream_info, "codecs", None) if getattr(variant, "stream_info", None) else None
|
|
62
|
+
if codecs:
|
|
63
|
+
# very light heuristic: if no video codec substring, probably audio-only.
|
|
64
|
+
# video: avc1, hvc1, hev1, vp9, av01, dvh
|
|
65
|
+
if not any(v in codecs.lower() for v in ("avc1", "hvc1", "hev1", "av01", "vp9", "dvh")):
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
def _collect_variants(master: m3u8.M3U8) -> List[Dict[str, Any]]:
|
|
71
|
+
"""Normalize playlist variants to a comparable list."""
|
|
72
|
+
items: List[Dict[str, Any]] = []
|
|
73
|
+
for v in master.playlists:
|
|
74
|
+
if not _is_video_playlist(v):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
h = _height_from_variant(v)
|
|
78
|
+
bw = getattr(v.stream_info, "bandwidth", 0) if getattr(v, "stream_info", None) else 0
|
|
79
|
+
fr = getattr(v.stream_info, "frame_rate", 0.0) if getattr(v, "stream_info", None) else 0.0
|
|
80
|
+
items.append({
|
|
81
|
+
"uri": v.uri,
|
|
82
|
+
"height": h, # may be None
|
|
83
|
+
"bandwidth": int(bw or 0),
|
|
84
|
+
"frame_rate": float(fr or 0.0),
|
|
85
|
+
"resolution": getattr(v.stream_info, "resolution", None) if getattr(v, "stream_info", None) else None,
|
|
86
|
+
"raw": v
|
|
87
|
+
})
|
|
88
|
+
return items
|
|
89
|
+
|
|
90
|
+
def _pick_by_label(variants: List[Dict[str, Any]], label: str) -> Dict[str, Any]:
|
|
91
|
+
"""best / worst / half based on a combined rank by (height, bandwidth)."""
|
|
92
|
+
# rank by height first, then bandwidth as tiebreaker
|
|
93
|
+
def key_fn(v):
|
|
94
|
+
return (v["height"] or 0, v["bandwidth"])
|
|
95
|
+
ordered = sorted(variants, key=key_fn)
|
|
96
|
+
|
|
97
|
+
if not ordered:
|
|
98
|
+
raise ValueError("No video variants available in master playlist.")
|
|
99
|
+
|
|
100
|
+
if label == "worst":
|
|
101
|
+
return ordered[0]
|
|
102
|
+
elif label == "half":
|
|
103
|
+
return ordered[len(ordered)//2]
|
|
104
|
+
elif label == "best":
|
|
105
|
+
return ordered[-1]
|
|
106
|
+
else:
|
|
107
|
+
raise ValueError("Invalid quality label.")
|
|
108
|
+
|
|
109
|
+
def _normalize_quality(quality: Union[str, int]) -> Union[str, int]:
|
|
110
|
+
"""Convert '1080p'->1080, '720'->720, keep labels as-is."""
|
|
111
|
+
if isinstance(quality, int):
|
|
112
|
+
return quality
|
|
113
|
+
q = str(quality).strip().lower()
|
|
114
|
+
if q in {"best", "worst", "half"}:
|
|
115
|
+
return q
|
|
116
|
+
m = re.search(r'(\d{3,4})', q)
|
|
117
|
+
if m:
|
|
118
|
+
return int(m.group(1))
|
|
119
|
+
raise ValueError(f"Invalid quality value: {quality!r}")
|
|
120
|
+
|
|
121
|
+
def _pick_by_height(variants: List[Dict[str, Any]], target: int) -> Dict[str, Any]:
|
|
122
|
+
"""Choose the highest height ≤ target; else closest by absolute diff (ties -> higher)."""
|
|
123
|
+
with_height = [v for v in variants if v["height"] is not None]
|
|
124
|
+
if with_height:
|
|
125
|
+
# Prefer height <= target
|
|
126
|
+
below_eq = [v for v in with_height if v["height"] <= target]
|
|
127
|
+
if below_eq:
|
|
128
|
+
# Among same height, prefer higher bandwidth then higher fps
|
|
129
|
+
best = sorted(below_eq, key=lambda v: (v["height"], v["bandwidth"], v["frame_rate"]))[-1]
|
|
130
|
+
return best
|
|
131
|
+
|
|
132
|
+
# Fallback: closest by absolute diff; ties -> higher height
|
|
133
|
+
def diff_key(v):
|
|
134
|
+
return (abs(v["height"] - target), -v["height"], v["bandwidth"], v["frame_rate"])
|
|
135
|
+
return sorted(with_height, key=diff_key)[0]
|
|
136
|
+
|
|
137
|
+
# If we have no heights at all, fall back to bandwidth ranking
|
|
138
|
+
return sorted(variants, key=lambda v: v["bandwidth"])[-1]
|
|
139
|
+
|
|
140
|
+
|
|
27
141
|
|
|
28
142
|
# Default formatter
|
|
29
143
|
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
30
144
|
|
|
145
|
+
_CF_BODY_MARKERS = (
|
|
146
|
+
"cf-browser-verification",
|
|
147
|
+
"cf_chl_", # legacy CF challenge tokens
|
|
148
|
+
"challenges.cloudflare.com", # Turnstile/challenge host
|
|
149
|
+
"cf-turnstile", # Turnstile widget
|
|
150
|
+
"ddos protection by",
|
|
151
|
+
"just a moment...",
|
|
152
|
+
"please enable javascript and cookies",
|
|
153
|
+
"attention required!",
|
|
154
|
+
)
|
|
155
|
+
# Other common WAF/CDN markers
|
|
156
|
+
_OTHER_BODY_MARKERS = (
|
|
157
|
+
"akamai bot manager",
|
|
158
|
+
"bm_sz=", "abck=", "ak_bmsc", "ak_bm", # Akamai cookies
|
|
159
|
+
"request unsuccessful. incapsula incident id", # Imperva/Incapsula
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def _is_text_html(resp) -> bool:
|
|
163
|
+
ct = resp.headers.get("content-type", "").lower()
|
|
164
|
+
return "text/html" in ct or ct == "" # some sites omit CT but send HTML
|
|
165
|
+
|
|
166
|
+
def _header_has_any(hdr_val: str, *needles: str) -> bool:
|
|
167
|
+
s = (hdr_val or "").lower()
|
|
168
|
+
return any(n in s for n in needles)
|
|
169
|
+
|
|
170
|
+
def _body_has_any(body: str, markers: tuple) -> bool:
|
|
171
|
+
b = (body or "").lower()
|
|
172
|
+
return any(m in b for m in markers)
|
|
173
|
+
|
|
174
|
+
def _looks_like_challenge(resp) -> str | None:
|
|
175
|
+
"""
|
|
176
|
+
Return a human-readable reason if the response looks like a bot/WAF challenge.
|
|
177
|
+
Return None if it looks like a normal page.
|
|
178
|
+
"""
|
|
179
|
+
status = resp.status_code
|
|
180
|
+
server = resp.headers.get("server", "")
|
|
181
|
+
set_cookie = resp.headers.get("set-cookie", "")
|
|
182
|
+
|
|
183
|
+
# Collect cheap, header-based indicators first
|
|
184
|
+
has_cf_cookie = _header_has_any(set_cookie, "__cf_bm", "cf_clearance", "__cf_chl")
|
|
185
|
+
served_by_cf = _header_has_any(server, "cloudflare")
|
|
186
|
+
served_by_akamai = _header_has_any(server, "akamai")
|
|
187
|
+
served_by_incapsula = _header_has_any(server, "incapsula")
|
|
188
|
+
|
|
189
|
+
# History-based indicators (redirect to /cdn-cgi/ etc.)
|
|
190
|
+
history_urls = []
|
|
191
|
+
try:
|
|
192
|
+
for h in getattr(resp, "history", []) or []:
|
|
193
|
+
history_urls.append(str(getattr(h, "url", "")))
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
redirected_via_cdn_cgi = any("/cdn-cgi/" in u for u in history_urls)
|
|
197
|
+
|
|
198
|
+
# Read a *small* slice of the body only if it’s HTML-like
|
|
199
|
+
body_snippet = ""
|
|
200
|
+
if _is_text_html(resp):
|
|
201
|
+
try:
|
|
202
|
+
# Read up to 20KB to capture challenge markup without huge memory hit
|
|
203
|
+
text = resp.text
|
|
204
|
+
body_snippet = text[:20000]
|
|
205
|
+
except Exception:
|
|
206
|
+
body_snippet = ""
|
|
207
|
+
|
|
208
|
+
has_cf_body_markers = _body_has_any(body_snippet, _CF_BODY_MARKERS)
|
|
209
|
+
has_other_waf_markers = _body_has_any(body_snippet, _OTHER_BODY_MARKERS)
|
|
210
|
+
|
|
211
|
+
# Heuristics:
|
|
212
|
+
# 1) Hard failure statuses usually mean a challenge/waf
|
|
213
|
+
if status in (403, 429, 503):
|
|
214
|
+
if served_by_cf or has_cf_cookie or redirected_via_cdn_cgi or has_cf_body_markers or has_other_waf_markers:
|
|
215
|
+
return (f"HTTP {status} with WAF indicators "
|
|
216
|
+
f"(server={server!r}, cf_cookie={has_cf_cookie}, "
|
|
217
|
+
f"cdn_cgi_redirect={redirected_via_cdn_cgi}, body_markers={has_cf_body_markers or has_other_waf_markers})")
|
|
218
|
+
|
|
219
|
+
# 2) Status 200 but body is a *challenge/interstitial*
|
|
220
|
+
# Require strong evidence in the body or cookies, not just 'Server: cloudflare'.
|
|
221
|
+
if status == 200:
|
|
222
|
+
if has_cf_cookie or redirected_via_cdn_cgi or has_cf_body_markers or has_other_waf_markers:
|
|
223
|
+
return (f"200 OK but challenge content detected "
|
|
224
|
+
f"(server={server!r}, cf_cookie={has_cf_cookie}, "
|
|
225
|
+
f"cdn_cgi_redirect={redirected_via_cdn_cgi}, body_markers={has_cf_body_markers or has_other_waf_markers})")
|
|
226
|
+
|
|
227
|
+
# 3) Generic: if served by a known WAF/CDN and body is *obviously* an interstitial
|
|
228
|
+
if served_by_cf and has_cf_body_markers:
|
|
229
|
+
return "Cloudflare challenge markers present in body"
|
|
230
|
+
if served_by_akamai and has_other_waf_markers:
|
|
231
|
+
return "Akamai Bot Manager markers present in body"
|
|
232
|
+
if served_by_incapsula and has_other_waf_markers:
|
|
233
|
+
return "Imperva/Incapsula challenge markers present in body"
|
|
234
|
+
|
|
235
|
+
return None
|
|
31
236
|
|
|
32
237
|
def is_android():
|
|
33
238
|
"""Detects if the script is running on an Android device."""
|
|
@@ -50,6 +255,7 @@ LOG_COLORS = {
|
|
|
50
255
|
}
|
|
51
256
|
RESET_COLOR = "\033[0m"
|
|
52
257
|
|
|
258
|
+
|
|
53
259
|
class ColoredFormatter(logging.Formatter):
|
|
54
260
|
def format(self, record):
|
|
55
261
|
log_message = super().format(record)
|
|
@@ -168,7 +374,7 @@ class BaseCore:
|
|
|
168
374
|
"""
|
|
169
375
|
The base class which has all necessary functions for other API packages
|
|
170
376
|
"""
|
|
171
|
-
def __init__(self, config=config, auto_init: bool = True):
|
|
377
|
+
def __init__(self, config=config, auto_init: bool = True, headers: dict = None):
|
|
172
378
|
self.last_request_time = time.time()
|
|
173
379
|
self.total_requests = 0 # Tracks how many requests have been made
|
|
174
380
|
self.session = None
|
|
@@ -177,7 +383,7 @@ class BaseCore:
|
|
|
177
383
|
self.cache = Cache(self.config)
|
|
178
384
|
self.logger = setup_logger("BASE API - [BaseCore]", log_file=False, level=logging.ERROR)
|
|
179
385
|
if auto_init:
|
|
180
|
-
self.initialize_session()
|
|
386
|
+
self.initialize_session(headers)
|
|
181
387
|
|
|
182
388
|
def enable_logging(self, log_file=None, level=logging.DEBUG, log_ip=None, log_port=None):
|
|
183
389
|
"""
|
|
@@ -186,11 +392,6 @@ class BaseCore:
|
|
|
186
392
|
self.logger = setup_logger(name="BASE API - [BaseCore]", log_file=log_file, level=level, http_ip=log_ip, http_port=log_port)
|
|
187
393
|
self.cache.logger = setup_logger(name="BASE API - [Cache]", log_file=log_file, level=level, http_ip=log_ip, http_port=log_port)
|
|
188
394
|
|
|
189
|
-
def update_user_agent(self):
|
|
190
|
-
"""Updates the User-Agent"""
|
|
191
|
-
self.config.rotate_user_agent()
|
|
192
|
-
self.session.headers.update({"User-Agent": self.config.headers["User-Agent"]})
|
|
193
|
-
|
|
194
395
|
def update_cookies(self):
|
|
195
396
|
"""Updates cookies dynamically"""
|
|
196
397
|
self.session.cookies.update(self.config.cookies)
|
|
@@ -204,7 +405,7 @@ class BaseCore:
|
|
|
204
405
|
proxy_ip = self.config.proxy
|
|
205
406
|
pattern = re.compile(
|
|
206
407
|
r'^(?P<scheme>http|socks5)://'
|
|
207
|
-
r'(
|
|
408
|
+
r'(?:\w+:\w+@)?' # optional user:pass@
|
|
208
409
|
r'(?P<host>[a-zA-Z0-9.-]+)'
|
|
209
410
|
r':(?P<port>\d{1,5})$'
|
|
210
411
|
)
|
|
@@ -233,15 +434,22 @@ class BaseCore:
|
|
|
233
434
|
raise KillSwitch("CRITICAL PROXY ERROR, CHECK LOGS!")
|
|
234
435
|
|
|
235
436
|
|
|
236
|
-
def initialize_session(self): # Disable SSL verification only if you really need it....
|
|
437
|
+
def initialize_session(self, headers: dict = None, cookies: dict = None): # Disable SSL verification only if you really need it....
|
|
237
438
|
self.session = httpx.Client(
|
|
238
439
|
proxy=self.config.proxy,
|
|
239
|
-
headers=self.config.headers,
|
|
240
440
|
timeout=self.config.timeout,
|
|
241
441
|
follow_redirects=True,
|
|
242
442
|
verify=self.config.verify_ssl
|
|
243
443
|
)
|
|
244
444
|
|
|
445
|
+
self.session.headers.update({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"})
|
|
446
|
+
|
|
447
|
+
if headers:
|
|
448
|
+
self.session.headers.update(headers)
|
|
449
|
+
|
|
450
|
+
if cookies:
|
|
451
|
+
self.session.cookies.update(cookies)
|
|
452
|
+
|
|
245
453
|
def update_headers(self, headers: dict):
|
|
246
454
|
self.session.headers.update(headers)
|
|
247
455
|
|
|
@@ -275,6 +483,7 @@ class BaseCore:
|
|
|
275
483
|
support for proxies and custom timeout.
|
|
276
484
|
"""
|
|
277
485
|
# Check cache first
|
|
486
|
+
last_error = None
|
|
278
487
|
|
|
279
488
|
if timeout is None:
|
|
280
489
|
timeout = self.config.timeout # pls don't ask thanks
|
|
@@ -289,10 +498,6 @@ class BaseCore:
|
|
|
289
498
|
time.sleep(1.5 * attempt) # Sleeping for 1.5 seconds to minimize site overload when doing a lot of requests
|
|
290
499
|
|
|
291
500
|
try:
|
|
292
|
-
# Update user agent periodically
|
|
293
|
-
if self.total_requests % 30 == 0: # Changing all 30 attempts
|
|
294
|
-
self.update_user_agent()
|
|
295
|
-
|
|
296
501
|
self.enforce_delay()
|
|
297
502
|
|
|
298
503
|
if self.kill_switch and not bypass_kill_switch:
|
|
@@ -303,6 +508,23 @@ class BaseCore:
|
|
|
303
508
|
follow_redirects=allow_redirects, data=data)
|
|
304
509
|
self.total_requests += 1
|
|
305
510
|
|
|
511
|
+
# NEW: bot/challenge detection
|
|
512
|
+
reason = _looks_like_challenge(response)
|
|
513
|
+
if reason:
|
|
514
|
+
# Log a concise diagnostic with a small body preview
|
|
515
|
+
body_preview = ""
|
|
516
|
+
try:
|
|
517
|
+
if _is_text_html(response):
|
|
518
|
+
body_preview = response.text[:400]
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
self.logger.error(
|
|
522
|
+
"Bot protection detected for %s: %s | Body[:400]=%r",
|
|
523
|
+
url, reason, body_preview
|
|
524
|
+
)
|
|
525
|
+
if config.raise_bot_protection:
|
|
526
|
+
raise BotProtectionDetected(f"{reason} at {url}")
|
|
527
|
+
|
|
306
528
|
# Log and handle non-200 status codes
|
|
307
529
|
if response.status_code != 200:
|
|
308
530
|
self.logger.warning(
|
|
@@ -312,10 +534,22 @@ class BaseCore:
|
|
|
312
534
|
self.logger.error(f"URL: {url} Resource not found (404). This may indicate the content is unavailable.")
|
|
313
535
|
return response # Return None for unavailable resources
|
|
314
536
|
|
|
537
|
+
if response.status_code == 403:
|
|
538
|
+
time.sleep(2) # Somehow fixes 403 on missav idk man
|
|
539
|
+
self.session.headers.update({
|
|
540
|
+
"User-Agent": random.choice(user_agents)
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
self.logger.warning(f"Switched User agent to: {self.session.headers['User-Agent']}")
|
|
544
|
+
|
|
545
|
+
|
|
315
546
|
elif response.status_code == 403 and attempt >= 2:
|
|
316
547
|
self.logger.error(f"The website rejected access after {attempt} tries. Aborting!")
|
|
317
548
|
return None # Return None for forbidden resources
|
|
318
549
|
|
|
550
|
+
elif response.status_code == 410:
|
|
551
|
+
raise ResourceGone(f"Resource: {url} is Gone.")
|
|
552
|
+
|
|
319
553
|
self.logger.info(f"Retrying ({attempt}/{self.config.max_retries}) for URL: {url}")
|
|
320
554
|
continue # Retry for other non-200 status codes
|
|
321
555
|
|
|
@@ -392,11 +626,14 @@ class BaseCore:
|
|
|
392
626
|
self.logger.error(f"Proxy Error, please switch to another Proxy, it seems to be bad -->: {e}")
|
|
393
627
|
raise KillSwitch("Proxy error when trying a request, Aborting!")
|
|
394
628
|
|
|
395
|
-
except Exception:
|
|
629
|
+
except Exception as e:
|
|
630
|
+
last_error = e
|
|
396
631
|
self.logger.error(f"Attempt {attempt}: Unexpected error for URL {url}: {traceback.format_exc()}")
|
|
397
632
|
raise UnknownError(f"Unexpected error for URL {url}: {traceback.format_exc()}")
|
|
398
633
|
|
|
399
634
|
self.logger.error(f"Failed to fetch URL {url} after {self.config.max_retries} attempts.")
|
|
635
|
+
if last_error:
|
|
636
|
+
pass
|
|
400
637
|
return None # Return None if all attempts fail
|
|
401
638
|
|
|
402
639
|
@classmethod
|
|
@@ -430,67 +667,88 @@ class BaseCore:
|
|
|
430
667
|
return sanitized[:max_length]
|
|
431
668
|
|
|
432
669
|
@lru_cache(maxsize=250)
|
|
433
|
-
def get_m3u8_by_quality(self, m3u8_url: str, quality: str) -> str:
|
|
434
|
-
"""
|
|
435
|
-
|
|
436
|
-
if "#" in m3u8_url:
|
|
437
|
-
playlist = m3u8.loads(m3u8_url)
|
|
438
|
-
self.logger.debug("Resolved custom m3u8")
|
|
670
|
+
def get_m3u8_by_quality(self, m3u8_url: str, quality: Union[str, int]) -> str:
|
|
671
|
+
"""
|
|
672
|
+
Return the media-playlist URL for the requested quality.
|
|
439
673
|
|
|
674
|
+
quality:
|
|
675
|
+
- 'best' | 'half' | 'worst'
|
|
676
|
+
- 1080 / '1080' / '1080p' (and similar)
|
|
677
|
+
"""
|
|
678
|
+
# Resolve master content
|
|
679
|
+
if m3u8_url.lstrip().startswith("#EXTM3U"):
|
|
680
|
+
master = m3u8.loads(m3u8_url)
|
|
681
|
+
self.logger.debug("Resolved inline/custom m3u8 master content.")
|
|
682
|
+
base_for_join = "" # URIs should be absolute in inline cases; join will handle if relative
|
|
440
683
|
else:
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
if not playlist.is_variant:
|
|
446
|
-
raise ValueError("Provided URL is not a master playlist.")
|
|
447
|
-
|
|
448
|
-
# Extract available qualities and URLs
|
|
449
|
-
qualities = {
|
|
450
|
-
(variant.stream_info.resolution or "unknown"): variant.uri
|
|
451
|
-
for variant in playlist.playlists
|
|
452
|
-
} # Fetches the available qualities
|
|
684
|
+
content = self.fetch(url=m3u8_url)
|
|
685
|
+
master = m3u8.loads(content)
|
|
686
|
+
base_for_join = m3u8_url
|
|
687
|
+
self.logger.debug(f"Resolved m3u8 master: {m3u8_url}")
|
|
453
688
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
qualities.items(),
|
|
457
|
-
key=lambda x: (x[0][0] * x[0][1]) if x[0] != "unknown" else 0
|
|
458
|
-
) # Sorts qualities to support different HLS naming schemes
|
|
689
|
+
if not master.is_variant:
|
|
690
|
+
raise ValueError("Provided URL/content is not a master playlist.")
|
|
459
691
|
|
|
460
|
-
|
|
461
|
-
if
|
|
462
|
-
|
|
692
|
+
variants = _collect_variants(master)
|
|
693
|
+
if not variants:
|
|
694
|
+
raise ValueError("No usable video variants found in master playlist.")
|
|
463
695
|
|
|
464
|
-
|
|
465
|
-
|
|
696
|
+
q = _normalize_quality(quality)
|
|
697
|
+
if isinstance(q, str): # 'best'/'half'/'worst'
|
|
698
|
+
chosen = _pick_by_label(variants, q)
|
|
699
|
+
else: # numeric height like 1080, 720, etc.
|
|
700
|
+
chosen = _pick_by_height(variants, q)
|
|
466
701
|
|
|
467
|
-
|
|
468
|
-
|
|
702
|
+
full_url = urljoin(base_for_join or m3u8_url, chosen["uri"])
|
|
703
|
+
return full_url
|
|
469
704
|
|
|
705
|
+
def list_available_qualities(self, m3u8_url: str) -> List[int]:
|
|
706
|
+
"""
|
|
707
|
+
Inspect the master playlist and return sorted unique heights (e.g., [240, 360, 480, 720, 1080]).
|
|
708
|
+
"""
|
|
709
|
+
if m3u8_url.lstrip().startswith("#EXTM3U"):
|
|
710
|
+
master = m3u8.loads(m3u8_url)
|
|
470
711
|
else:
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
712
|
+
content = self.fetch(url=m3u8_url)
|
|
713
|
+
master = m3u8.loads(content)
|
|
714
|
+
|
|
715
|
+
if not master.is_variant:
|
|
716
|
+
return []
|
|
717
|
+
|
|
718
|
+
heights = {h for h in (_height_from_variant(v) for v in master.playlists) if h is not None}
|
|
719
|
+
if heights:
|
|
720
|
+
return sorted(heights)
|
|
721
|
+
# fallback: bandwidth-only (roughly infer tiers)
|
|
722
|
+
by_bw = sorted(
|
|
723
|
+
(getattr(v.stream_info, "bandwidth", 0) for v in master.playlists if _is_video_playlist(v)),
|
|
724
|
+
key=int
|
|
725
|
+
)
|
|
726
|
+
# Return rank numbers instead of heights if we truly can't infer—kept simple:
|
|
727
|
+
return [i for i, _ in enumerate(by_bw, start=1)]
|
|
475
728
|
|
|
476
|
-
|
|
477
|
-
def get_segments(self, m3u8_url_master: str, quality: str) -> list:
|
|
478
|
-
"""Gets all video segments from a given quality and the primary m3u8 URL"""
|
|
729
|
+
def get_segments(self, m3u8_url_master: str, quality: Union[str, int]) -> list:
|
|
479
730
|
m3u8_url = self.get_m3u8_by_quality(m3u8_url=m3u8_url_master, quality=quality)
|
|
480
|
-
self.logger.debug(f"Trying to fetch segment from m3u8
|
|
731
|
+
self.logger.debug(f"Trying to fetch segment from m3u8 -> {m3u8_url}")
|
|
481
732
|
content = self.fetch(url=m3u8_url)
|
|
482
733
|
|
|
734
|
+
segments = []
|
|
483
735
|
m3u8_processed = m3u8.loads(content)
|
|
484
736
|
|
|
485
737
|
if m3u8_processed.is_variant:
|
|
486
|
-
self.logger.warning("
|
|
738
|
+
self.logger.warning("Media playlist expected; got variant. Resolving to first sub-playlist...")
|
|
487
739
|
new_m3u8 = urljoin(m3u8_url, m3u8_processed.playlists[0].uri)
|
|
488
740
|
self.logger.info(f"Resolved to new URL: {new_m3u8}")
|
|
489
741
|
content = self.fetch(url=new_m3u8)
|
|
490
742
|
m3u8_processed = m3u8.loads(content)
|
|
491
743
|
|
|
492
|
-
|
|
493
|
-
|
|
744
|
+
if getattr(m3u8_processed, "segment_map", None):
|
|
745
|
+
init_uri = m3u8_processed.segment_map[0].uri
|
|
746
|
+
init_url = urljoin(m3u8_url, init_uri)
|
|
747
|
+
segments.append(init_url)
|
|
748
|
+
self.logger.debug(f"Found init segment: {init_url}")
|
|
749
|
+
|
|
750
|
+
segments.extend(urljoin(m3u8_url, seg.uri) for seg in m3u8_processed.segments)
|
|
751
|
+
self.logger.debug(f"Fetched {len(segments)} segments from m3u8 URL (including init if present)")
|
|
494
752
|
return segments
|
|
495
753
|
|
|
496
754
|
def download_segment(self, url: str, timeout: int) -> tuple[str, bytes, bool]:
|
|
@@ -597,31 +855,92 @@ class BaseCore:
|
|
|
597
855
|
|
|
598
856
|
def _convert_ts_to_mp4(self, input_path: str, output_path: str, callback=None):
|
|
599
857
|
try:
|
|
600
|
-
import
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
858
|
+
from av import open as av_open
|
|
859
|
+
from av.audio.resampler import AudioResampler
|
|
860
|
+
except (ModuleNotFoundError, ImportError) as e:
|
|
861
|
+
raise ModuleNotFoundError(f"PyAV is required for remuxing. Install with pip install av. Not supported on Termux! {e}")
|
|
862
|
+
|
|
863
|
+
input_ = av_open(input_path)
|
|
864
|
+
output = av_open(output_path, mode="w", format="mp4", options={"movflags": "faststart"})
|
|
865
|
+
|
|
866
|
+
# --- VIDEO: keep your exact remux approach ---
|
|
867
|
+
in_video = input_.streams.video[0]
|
|
868
|
+
out_video = output.add_stream_from_template(template=in_video)
|
|
869
|
+
|
|
870
|
+
# --- AUDIO: copy if MP4-compatible; else transcode to AAC ---
|
|
871
|
+
in_audio = next((s for s in input_.streams if s.type == "audio"), None)
|
|
872
|
+
out_audio = None
|
|
873
|
+
transcode_audio = False
|
|
874
|
+
resampler = None
|
|
875
|
+
|
|
876
|
+
if in_audio:
|
|
877
|
+
# Common MP4-safe audio codecs to copy without transcoding.
|
|
878
|
+
copy_ok = {"aac", "alac", "mp3"}
|
|
879
|
+
codec_name = (in_audio.codec_context.name or "").lower()
|
|
880
|
+
|
|
881
|
+
if codec_name in copy_ok:
|
|
882
|
+
out_audio = output.add_stream_from_template(template=in_audio)
|
|
883
|
+
else:
|
|
884
|
+
# Build an AAC encoder stream. Match input rate/layout where possible.
|
|
885
|
+
transcode_audio = True
|
|
886
|
+
sample_rate = in_audio.codec_context.sample_rate or 48000
|
|
887
|
+
layout = (
|
|
888
|
+
in_audio.codec_context.layout.name
|
|
889
|
+
if getattr(in_audio.codec_context, "layout", None)
|
|
890
|
+
else "stereo"
|
|
891
|
+
)
|
|
892
|
+
out_audio = output.add_stream("aac", rate=sample_rate)
|
|
893
|
+
# Setting layout/channels helps encoder pick sensible defaults.
|
|
894
|
+
try:
|
|
895
|
+
out_audio.layout = layout
|
|
896
|
+
except Exception:
|
|
897
|
+
pass
|
|
606
898
|
|
|
607
|
-
|
|
608
|
-
|
|
899
|
+
# Ensure frames handed to the AAC encoder are in a compatible sample format.
|
|
900
|
+
# ('fltp' is the usual format for AAC encoders.)
|
|
901
|
+
resampler = AudioResampler(format="fltp", layout=layout, rate=sample_rate)
|
|
609
902
|
|
|
610
|
-
|
|
903
|
+
# --- DEMUX both streams so audio is included ---
|
|
904
|
+
demux_streams = [in_video] + ([in_audio] if in_audio else [])
|
|
905
|
+
packets = list(input_.demux(demux_streams)) # keeps your progress logic
|
|
611
906
|
total = len(packets)
|
|
612
907
|
|
|
613
908
|
for idx, packet in enumerate(packets):
|
|
614
909
|
if packet.dts is None:
|
|
910
|
+
if callback:
|
|
911
|
+
callback(idx + 1, total)
|
|
615
912
|
continue
|
|
616
|
-
|
|
617
|
-
|
|
913
|
+
|
|
914
|
+
if packet.stream == in_video:
|
|
915
|
+
# Your original video remux path (no explicit rescale).
|
|
916
|
+
packet.stream = out_video
|
|
917
|
+
output.mux(packet)
|
|
918
|
+
|
|
919
|
+
elif in_audio and packet.stream == in_audio:
|
|
920
|
+
if not transcode_audio:
|
|
921
|
+
# Audio is already MP4-compatible; just remux.
|
|
922
|
+
packet.stream = out_audio
|
|
923
|
+
output.mux(packet)
|
|
924
|
+
else:
|
|
925
|
+
# Decode -> (optionally resample) -> encode AAC -> mux.
|
|
926
|
+
for frame in packet.decode():
|
|
927
|
+
# Resample to match encoder expectations.
|
|
928
|
+
frames = resampler.resample(frame) if resampler else [frame]
|
|
929
|
+
for f in frames:
|
|
930
|
+
for enc_pkt in out_audio.encode(f):
|
|
931
|
+
output.mux(enc_pkt)
|
|
932
|
+
|
|
618
933
|
if callback:
|
|
619
934
|
callback(idx + 1, total)
|
|
620
935
|
|
|
936
|
+
# Flush audio encoder if we transcoded.
|
|
937
|
+
if transcode_audio and out_audio:
|
|
938
|
+
for enc_pkt in out_audio.encode(None):
|
|
939
|
+
output.mux(enc_pkt)
|
|
940
|
+
|
|
621
941
|
input_.close()
|
|
622
942
|
output.close()
|
|
623
943
|
|
|
624
|
-
|
|
625
944
|
def FFMPEG(self, video, quality: str, callback, path: str) -> bool:
|
|
626
945
|
try:
|
|
627
946
|
from ffmpeg_progress_yield import FfmpegProgress
|
|
@@ -656,39 +975,91 @@ class BaseCore:
|
|
|
656
975
|
|
|
657
976
|
return False
|
|
658
977
|
|
|
659
|
-
def legacy_download(self, path: str, url: str, callback=None
|
|
978
|
+
def legacy_download(self, path: str, url: str, callback=None,
|
|
979
|
+
chunk_size: int = 1 << 20, # 1 MiB
|
|
980
|
+
max_retries: int = 5,
|
|
981
|
+
read_timeout: float = 120.0) -> bool:
|
|
660
982
|
"""
|
|
661
|
-
Download a file using streaming
|
|
983
|
+
Download a file using streaming with stall tolerance and resume.
|
|
984
|
+
Assumes self.session is an httpx.Client.
|
|
662
985
|
"""
|
|
663
986
|
try:
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
987
|
+
# progress UI fallback
|
|
988
|
+
progress_bar = None
|
|
989
|
+
if callback is None:
|
|
990
|
+
progress_bar = Callback()
|
|
667
991
|
|
|
668
|
-
|
|
669
|
-
|
|
992
|
+
# how many bytes we already have (for resume)
|
|
993
|
+
downloaded_so_far = os.path.getsize(path) if os.path.exists(path) else 0
|
|
994
|
+
etag = None
|
|
995
|
+
attempt = 0
|
|
670
996
|
|
|
671
|
-
|
|
997
|
+
# prefer a longer read timeout to ride out throttling pauses
|
|
998
|
+
timeout = httpx.Timeout(connect=10.0, read=read_timeout, write=30.0, pool=30.0)
|
|
672
999
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1000
|
+
while True:
|
|
1001
|
+
headers = {
|
|
1002
|
+
}
|
|
1003
|
+
if downloaded_so_far:
|
|
1004
|
+
headers["Range"] = f"bytes={downloaded_so_far}-"
|
|
1005
|
+
|
|
1006
|
+
# stream the next segment (or whole file if starting fresh)
|
|
1007
|
+
with self.session.stream("GET", url, headers=headers,
|
|
1008
|
+
follow_redirects=True, timeout=timeout) as response:
|
|
1009
|
+
# If we asked for a Range and got 200, server ignored it: start over.
|
|
1010
|
+
if downloaded_so_far and response.status_code == 200:
|
|
1011
|
+
downloaded_so_far = 0 # restart from scratch
|
|
1012
|
+
response.raise_for_status()
|
|
1013
|
+
|
|
1014
|
+
# track ETag to detect mid-download content changes
|
|
1015
|
+
etag_cur = response.headers.get("ETag")
|
|
1016
|
+
if etag is None:
|
|
1017
|
+
etag = etag_cur
|
|
1018
|
+
elif etag_cur and etag_cur != etag:
|
|
1019
|
+
raise RuntimeError("Remote content changed during download")
|
|
1020
|
+
|
|
1021
|
+
total = None
|
|
1022
|
+
# Prefer Content-Range total when resuming; else fall back to Content-Length
|
|
1023
|
+
cr = response.headers.get("Content-Range") # e.g., "bytes start-end/total"
|
|
1024
|
+
if cr and "/" in cr:
|
|
1025
|
+
try:
|
|
1026
|
+
total = int(cr.rsplit("/", 1)[1])
|
|
1027
|
+
except ValueError:
|
|
1028
|
+
total = None
|
|
1029
|
+
if total is None:
|
|
1030
|
+
try:
|
|
1031
|
+
total = int(response.headers.get("Content-Length", "0")) or None
|
|
1032
|
+
except ValueError:
|
|
1033
|
+
total = None
|
|
1034
|
+
|
|
1035
|
+
mode = "ab" if downloaded_so_far else "wb"
|
|
1036
|
+
with open(path, mode) as file:
|
|
1037
|
+
try:
|
|
1038
|
+
for chunk in response.iter_bytes(chunk_size=chunk_size):
|
|
1039
|
+
if not chunk:
|
|
1040
|
+
continue
|
|
1041
|
+
file.write(chunk)
|
|
1042
|
+
downloaded_so_far += len(chunk)
|
|
1043
|
+
|
|
1044
|
+
# update progress
|
|
1045
|
+
if callback:
|
|
1046
|
+
callback(downloaded_so_far, total or 0)
|
|
1047
|
+
|
|
1048
|
+
else:
|
|
1049
|
+
progress_bar.text_progress_bar(downloaded=downloaded_so_far, total=total or 0)
|
|
1050
|
+
|
|
1051
|
+
# finished successfully
|
|
1052
|
+
if progress_bar:
|
|
1053
|
+
del progress_bar
|
|
1054
|
+
return True
|
|
1055
|
+
|
|
1056
|
+
except httpx.ReadTimeout:
|
|
1057
|
+
# stall: back off and retry from current offset
|
|
1058
|
+
attempt += 1
|
|
1059
|
+
if attempt > max_retries:
|
|
1060
|
+
raise
|
|
1061
|
+
time.sleep(min(2 ** attempt, 30))
|
|
1062
|
+
continue # loop retries with Range # let him coook!!!
|
|
692
1063
|
|
|
693
1064
|
except httpx.StreamClosed:
|
|
694
1065
|
self.logger.error(f"Stream for: {url} was closed. This should not happen...")
|
|
@@ -711,9 +1082,15 @@ class BaseCore:
|
|
|
711
1082
|
|
|
712
1083
|
return path
|
|
713
1084
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1085
|
+
def truncate(self, name: str, max_bytes: int = 245) -> str: # only 245, because we need to append .mp4
|
|
1086
|
+
encoded = name.encode("utf-8")
|
|
1087
|
+
if len(encoded) > max_bytes:
|
|
1088
|
+
encoded = encoded[:max_bytes]
|
|
1089
|
+
# Ensure not to cut in middle of a UTF-8 sequence
|
|
1090
|
+
while encoded[-1] & 0b11000000 == 0b10000000:
|
|
1091
|
+
encoded = encoded[:-1]
|
|
1092
|
+
return encoded.decode("utf-8", errors="ignore")
|
|
1093
|
+
return name
|
|
717
1094
|
|
|
718
1095
|
@staticmethod
|
|
719
1096
|
def str_to_bool(value):
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class RuntimeConfig:
|
|
2
|
+
def __init__(self):
|
|
3
|
+
self.max_cache_items = 200
|
|
4
|
+
self.max_retries = 4
|
|
5
|
+
self.request_delay = 0
|
|
6
|
+
self.timeout = 20
|
|
7
|
+
self.max_bandwidth_mb = None # Set speed limit in megabytes per second e.g, 2.0, 3.5 etc...
|
|
8
|
+
self.ffmpeg_path = "ffmpeg"
|
|
9
|
+
self.proxy = None
|
|
10
|
+
self.raise_bot_protection = False
|
|
11
|
+
self.verify_ssl = True
|
|
12
|
+
self.cookies = None
|
|
13
|
+
|
|
14
|
+
# Singleton instance needed for my Porn Fetch project
|
|
15
|
+
config = RuntimeConfig()
|
|
@@ -48,4 +48,17 @@ class ProxySSLError(Exception):
|
|
|
48
48
|
Raises if a proxy request fails due to self-signed certificates or invalid TLS verification
|
|
49
49
|
"""
|
|
50
50
|
def __init__(self, message):
|
|
51
|
-
self.message = message
|
|
51
|
+
self.message = message
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ResourceGone(Exception):
|
|
55
|
+
"""
|
|
56
|
+
Raises if a resource is gone (http 410 error)
|
|
57
|
+
"""
|
|
58
|
+
def __init__(self, message):
|
|
59
|
+
self.message = message
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BotProtectionDetected(Exception):
|
|
63
|
+
"""Raised when Cloudflare or similar bot protection is detected."""
|
|
64
|
+
pass
|
|
@@ -1,32 +1,22 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: eaf_base_api
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.2
|
|
4
4
|
Summary: A base API for EchterAlsFake's Porn APIs
|
|
5
|
-
|
|
6
|
-
Author: Johannes Habel
|
|
7
|
-
Author-email: EchterAlsFake@proton.me
|
|
5
|
+
Author-email: Johannes Habel <EchterAlsFake@proton.me>
|
|
8
6
|
License: LGPLv3
|
|
7
|
+
Project-URL: Homepage, https://github.com/EchterAlsFake/eaf_base_api
|
|
9
8
|
Classifier: License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)
|
|
10
9
|
Classifier: Programming Language :: Python
|
|
10
|
+
Requires-Python: >=3.6
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
13
|
Requires-Dist: m3u8
|
|
14
14
|
Requires-Dist: httpx
|
|
15
|
-
Provides-Extra:
|
|
16
|
-
Requires-Dist: av; extra == "
|
|
15
|
+
Provides-Extra: av
|
|
16
|
+
Requires-Dist: av; extra == "av"
|
|
17
17
|
Provides-Extra: ffmpeg
|
|
18
18
|
Requires-Dist: ffmpeg-progress-yield; extra == "ffmpeg"
|
|
19
|
-
Dynamic: author
|
|
20
|
-
Dynamic: author-email
|
|
21
|
-
Dynamic: classifier
|
|
22
|
-
Dynamic: description
|
|
23
|
-
Dynamic: description-content-type
|
|
24
|
-
Dynamic: home-page
|
|
25
|
-
Dynamic: license
|
|
26
19
|
Dynamic: license-file
|
|
27
|
-
Dynamic: provides-extra
|
|
28
|
-
Dynamic: requires-dist
|
|
29
|
-
Dynamic: summary
|
|
30
20
|
|
|
31
21
|
# EAF Base API
|
|
32
22
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "eaf_base_api"
|
|
7
|
+
version = "2.4.2"
|
|
8
|
+
description = "A base API for EchterAlsFake's Porn APIs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.6"
|
|
11
|
+
license = { text = "LGPLv3" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Johannes Habel", email = "EchterAlsFake@proton.me" }
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"m3u8",
|
|
17
|
+
"httpx",
|
|
18
|
+
]
|
|
19
|
+
optional-dependencies = {av = ["av"], ffmpeg = ["ffmpeg-progress-yield"]}
|
|
20
|
+
classifiers = [
|
|
21
|
+
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
|
|
22
|
+
"Programming Language :: Python",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/EchterAlsFake/eaf_base_api"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
packages = { find = {} }
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import random
|
|
2
|
-
|
|
3
|
-
class RuntimeConfig:
|
|
4
|
-
def __init__(self):
|
|
5
|
-
self.max_cache_items = 200
|
|
6
|
-
self.max_retries = 4
|
|
7
|
-
self.request_delay = 0
|
|
8
|
-
self.timeout = 20
|
|
9
|
-
self.max_bandwidth_mb = None # Set speed limit in megabytes per second e.g, 2.0, 3.5 etc...
|
|
10
|
-
self.ffmpeg_path = "ffmpeg"
|
|
11
|
-
self.proxy = None
|
|
12
|
-
self.verify_ssl = True
|
|
13
|
-
self.user_agents = [
|
|
14
|
-
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
15
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.1",
|
|
16
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3",
|
|
17
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.3",
|
|
18
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0."
|
|
19
|
-
]
|
|
20
|
-
self.cookies = None
|
|
21
|
-
self.headers = {
|
|
22
|
-
"User-Agent": random.choice(self.user_agents)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
def rotate_user_agent(self):
|
|
26
|
-
self.headers["User-Agent"] = random.choice(self.user_agents)
|
|
27
|
-
|
|
28
|
-
def as_httpx_kwargs(self):
|
|
29
|
-
return {
|
|
30
|
-
"proxies": self.proxy,
|
|
31
|
-
"verify": self.verify_ssl,
|
|
32
|
-
"headers": self.headers,
|
|
33
|
-
"timeout": self.timeout
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
# Singleton instance needed for my Porn Fetch project
|
|
37
|
-
config = RuntimeConfig()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
base_api
|
eaf_base_api-2.3.8/setup.py
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
from setuptools import setup, find_packages
|
|
2
|
-
|
|
3
|
-
setup(
|
|
4
|
-
name="eaf_base_api",
|
|
5
|
-
version="2.3.8",
|
|
6
|
-
packages=find_packages(),
|
|
7
|
-
install_requires=["m3u8", "httpx"],
|
|
8
|
-
extras_require={
|
|
9
|
-
"remux": ["av"],
|
|
10
|
-
"ffmpeg": ["ffmpeg-progress-yield"],
|
|
11
|
-
},
|
|
12
|
-
entry_points={
|
|
13
|
-
'console_scripts': [
|
|
14
|
-
# If you want to create any executable scripts
|
|
15
|
-
],
|
|
16
|
-
},
|
|
17
|
-
author="Johannes Habel",
|
|
18
|
-
author_email="EchterAlsFake@proton.me",
|
|
19
|
-
description="A base API for EchterAlsFake's Porn APIs",
|
|
20
|
-
long_description=open('README.md').read(),
|
|
21
|
-
long_description_content_type='text/markdown',
|
|
22
|
-
license="LGPLv3",
|
|
23
|
-
url="https://github.com/EchterAlsFake/eaf_base_api",
|
|
24
|
-
classifiers=[
|
|
25
|
-
# Classifiers help users find your project on PyPI
|
|
26
|
-
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
|
|
27
|
-
"Programming Language :: Python",
|
|
28
|
-
],
|
|
29
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|