quasarr 1.3.5__py3-none-any.whl → 1.20.4__py3-none-any.whl
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.
Potentially problematic release.
This version of quasarr might be problematic. Click here for more details.
- quasarr/__init__.py +157 -56
- quasarr/api/__init__.py +141 -36
- quasarr/api/arr/__init__.py +197 -78
- quasarr/api/captcha/__init__.py +897 -42
- quasarr/api/config/__init__.py +23 -0
- quasarr/api/sponsors_helper/__init__.py +84 -22
- quasarr/api/statistics/__init__.py +196 -0
- quasarr/downloads/__init__.py +237 -434
- quasarr/downloads/linkcrypters/al.py +237 -0
- quasarr/downloads/linkcrypters/filecrypt.py +178 -31
- quasarr/downloads/linkcrypters/hide.py +123 -0
- quasarr/downloads/packages/__init__.py +461 -0
- quasarr/downloads/sources/al.py +697 -0
- quasarr/downloads/sources/by.py +106 -0
- quasarr/downloads/sources/dd.py +6 -78
- quasarr/downloads/sources/dj.py +7 -0
- quasarr/downloads/sources/dt.py +1 -1
- quasarr/downloads/sources/dw.py +2 -2
- quasarr/downloads/sources/he.py +112 -0
- quasarr/downloads/sources/mb.py +47 -0
- quasarr/downloads/sources/nk.py +51 -0
- quasarr/downloads/sources/nx.py +36 -81
- quasarr/downloads/sources/sf.py +27 -4
- quasarr/downloads/sources/sj.py +7 -0
- quasarr/downloads/sources/sl.py +90 -0
- quasarr/downloads/sources/wd.py +110 -0
- quasarr/providers/cloudflare.py +204 -0
- quasarr/providers/html_images.py +20 -0
- quasarr/providers/html_templates.py +210 -108
- quasarr/providers/imdb_metadata.py +15 -2
- quasarr/providers/myjd_api.py +36 -5
- quasarr/providers/notifications.py +30 -5
- quasarr/providers/obfuscated.py +35 -0
- quasarr/providers/sessions/__init__.py +0 -0
- quasarr/providers/sessions/al.py +286 -0
- quasarr/providers/sessions/dd.py +78 -0
- quasarr/providers/sessions/nx.py +76 -0
- quasarr/providers/shared_state.py +368 -23
- quasarr/providers/statistics.py +154 -0
- quasarr/providers/version.py +60 -1
- quasarr/search/__init__.py +112 -36
- quasarr/search/sources/al.py +448 -0
- quasarr/search/sources/by.py +203 -0
- quasarr/search/sources/dd.py +17 -6
- quasarr/search/sources/dj.py +213 -0
- quasarr/search/sources/dt.py +37 -7
- quasarr/search/sources/dw.py +27 -47
- quasarr/search/sources/fx.py +27 -29
- quasarr/search/sources/he.py +196 -0
- quasarr/search/sources/mb.py +195 -0
- quasarr/search/sources/nk.py +188 -0
- quasarr/search/sources/nx.py +22 -6
- quasarr/search/sources/sf.py +143 -151
- quasarr/search/sources/sj.py +213 -0
- quasarr/search/sources/sl.py +246 -0
- quasarr/search/sources/wd.py +208 -0
- quasarr/storage/config.py +20 -4
- quasarr/storage/setup.py +224 -56
- quasarr-1.20.4.dist-info/METADATA +304 -0
- quasarr-1.20.4.dist-info/RECORD +72 -0
- {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/WHEEL +1 -1
- quasarr/providers/tvmaze_metadata.py +0 -23
- quasarr-1.3.5.dist-info/METADATA +0 -174
- quasarr-1.3.5.dist-info/RECORD +0 -43
- {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/entry_points.txt +0 -0
- {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/licenses/LICENSE +0 -0
- {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
|
|
8
|
+
from Cryptodome.Cipher import AES
|
|
9
|
+
from PIL import Image, ImageChops
|
|
10
|
+
|
|
11
|
+
from quasarr.providers.log import info, debug
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CNL:
|
|
15
|
+
"""
|
|
16
|
+
Given a dict with the same structure as your `chosen_data` (i.e.
|
|
17
|
+
{
|
|
18
|
+
"links": [...],
|
|
19
|
+
"cnl": {
|
|
20
|
+
"jk": "<obfuscated_hex_string>",
|
|
21
|
+
"crypted": "<base64_ciphertext>"
|
|
22
|
+
}
|
|
23
|
+
}),
|
|
24
|
+
this class will decrypt the Base64 payload, strip padding, and return a list of URLs.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, chosen_data: dict):
|
|
28
|
+
"""
|
|
29
|
+
chosen_data should contain at least:
|
|
30
|
+
- "cnl": {
|
|
31
|
+
"jk": "<hex‐encoded string, length > 16>",
|
|
32
|
+
"crypted": "<Base64‐encoded ciphertext>"
|
|
33
|
+
}
|
|
34
|
+
"""
|
|
35
|
+
self.cnl_info = chosen_data.get("cnl", {})
|
|
36
|
+
self.jk = self.cnl_info.get("jk")
|
|
37
|
+
self.crypted_blob = self.cnl_info.get("crypted")
|
|
38
|
+
|
|
39
|
+
if not self.jk or not self.crypted_blob:
|
|
40
|
+
raise KeyError("Missing 'jk' or 'crypted' fields in JSON.")
|
|
41
|
+
|
|
42
|
+
# Swap positions 15 and 16 in the hex string
|
|
43
|
+
k_list = list(self.jk)
|
|
44
|
+
if len(k_list) <= 16:
|
|
45
|
+
raise ValueError("Invalid 'jk' string length; must be > 16 characters.")
|
|
46
|
+
k_list[15], k_list[16] = k_list[16], k_list[15]
|
|
47
|
+
self.fixed_key_hex = "".join(k_list)
|
|
48
|
+
|
|
49
|
+
def _aes_decrypt(self, data_b64: str, key_hex: str) -> bytes:
|
|
50
|
+
"""
|
|
51
|
+
Decode the Base64‐encoded payload, interpret key_hex as hex,
|
|
52
|
+
then use AES-CBC with IV=key_bytes to decrypt.
|
|
53
|
+
Returns raw bytes (still possibly containing padding).
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
encrypted_data = base64.b64decode(data_b64)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
raise ValueError("Failed to decode base64 data") from e
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
key_bytes = bytes.fromhex(key_hex)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
raise ValueError("Failed to convert key to bytes (invalid hex)") from e
|
|
64
|
+
|
|
65
|
+
iv = key_bytes
|
|
66
|
+
cipher = AES.new(key_bytes, AES.MODE_CBC, iv)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
decrypted = cipher.decrypt(encrypted_data)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
raise ValueError("AES decryption failed") from e
|
|
72
|
+
|
|
73
|
+
return decrypted
|
|
74
|
+
|
|
75
|
+
def decrypt(self) -> list[str]:
|
|
76
|
+
"""
|
|
77
|
+
Runs the full decryption pipeline and returns a list of non‐empty URLs.
|
|
78
|
+
Strips out null and backspace padding bytes, decodes to UTF-8, and
|
|
79
|
+
splits on CRLF.
|
|
80
|
+
"""
|
|
81
|
+
raw_plain = self._aes_decrypt(self.crypted_blob, self.fixed_key_hex)
|
|
82
|
+
|
|
83
|
+
# Remove any 0x00 or 0x08 bytes
|
|
84
|
+
try:
|
|
85
|
+
cleaned = raw_plain.replace(b"\x00", b"").replace(b"\x08", b"")
|
|
86
|
+
text = cleaned.decode("utf-8")
|
|
87
|
+
except Exception as e:
|
|
88
|
+
raise ValueError("Failed to decode decrypted data to UTF-8") from e
|
|
89
|
+
|
|
90
|
+
# Split on CRLF, discard any empty lines
|
|
91
|
+
urls = [line for line in text.splitlines() if line.strip()]
|
|
92
|
+
return urls
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def decrypt_content(content_items: list[dict], mirror: str | None) -> list[str]:
|
|
96
|
+
"""
|
|
97
|
+
Go through every item in `content_items`, but if `mirror` is not None,
|
|
98
|
+
only attempt to decrypt those whose "hoster" field contains `mirror`.
|
|
99
|
+
If no items match that filter, falls back to decrypting every single item.
|
|
100
|
+
|
|
101
|
+
Returns a flat list of all decrypted URLs.
|
|
102
|
+
"""
|
|
103
|
+
if mirror:
|
|
104
|
+
filtered = [item for item in content_items if mirror in item.get("hoster", "")]
|
|
105
|
+
else:
|
|
106
|
+
filtered = []
|
|
107
|
+
|
|
108
|
+
if mirror and not filtered:
|
|
109
|
+
info(f"No items found for mirror='{mirror}'. Falling back to all content_items.")
|
|
110
|
+
filtered = content_items.copy()
|
|
111
|
+
|
|
112
|
+
if not mirror:
|
|
113
|
+
filtered = content_items.copy()
|
|
114
|
+
|
|
115
|
+
decrypted_links: list[str] = []
|
|
116
|
+
|
|
117
|
+
# If 'filtered' is a dictionary, iterate over its values; otherwise, assume it's a list.
|
|
118
|
+
items_to_process = filtered.values() if isinstance(filtered, dict) else filtered
|
|
119
|
+
|
|
120
|
+
for idx, item in enumerate(items_to_process):
|
|
121
|
+
if not isinstance(item, dict):
|
|
122
|
+
info(f"[Item {idx}] Invalid item format; expected dict, got {type(item).__name__}")
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
hoster_name = item.get("hoster", "<unknown>")
|
|
126
|
+
cnl_info = item.get("cnl", {})
|
|
127
|
+
jnk = cnl_info.get("jk", "")
|
|
128
|
+
crypted = cnl_info.get("crypted", "")
|
|
129
|
+
|
|
130
|
+
if not jnk or not crypted:
|
|
131
|
+
info(f"[Item {idx} | hoster={hoster_name}] Missing 'jk' or 'crypted' → skipping")
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
decryptor = CNL(item)
|
|
136
|
+
urls = decryptor.decrypt()
|
|
137
|
+
decrypted_links.extend(urls)
|
|
138
|
+
debug(f"[Item {idx} | hoster={hoster_name}] Decrypted {len(urls)} URLs")
|
|
139
|
+
except Exception as e:
|
|
140
|
+
# Log and keep going; one bad item won’t stop the rest.
|
|
141
|
+
info(f"[Item {idx} | hoster={hoster_name}] Error during decryption: {e}")
|
|
142
|
+
|
|
143
|
+
return decrypted_links
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def calculate_pixel_based_difference(img1, img2):
|
|
147
|
+
"""Pillow-based absolute-difference % over all channels."""
|
|
148
|
+
# ensure same mode and size
|
|
149
|
+
diff = ImageChops.difference(img1, img2).convert("RGB")
|
|
150
|
+
w, h = diff.size
|
|
151
|
+
# histogram is [R0, R1, ..., R255, G0, ..., B255]
|
|
152
|
+
hist = diff.histogram()
|
|
153
|
+
zero_R = hist[0]
|
|
154
|
+
zero_G = hist[256]
|
|
155
|
+
zero_B = hist[512]
|
|
156
|
+
total_elements = w * h * 3
|
|
157
|
+
zero_elements = zero_R + zero_G + zero_B
|
|
158
|
+
non_zero = total_elements - zero_elements
|
|
159
|
+
return (non_zero * 100) / total_elements
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def solve_captcha(hostname, shared_state, fetch_via_flaresolverr, fetch_via_requests_session):
|
|
163
|
+
al = shared_state.values["config"]("Hostnames").get(hostname)
|
|
164
|
+
captcha_base = f"https://www.{al}/files/captcha"
|
|
165
|
+
|
|
166
|
+
result = fetch_via_flaresolverr(
|
|
167
|
+
shared_state,
|
|
168
|
+
method="POST",
|
|
169
|
+
target_url=captcha_base,
|
|
170
|
+
post_data={"cID": 0, "rT": 1},
|
|
171
|
+
timeout=30
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
image_ids = result["json"]
|
|
176
|
+
except ValueError:
|
|
177
|
+
raise RuntimeError(f"Cannot decode captcha IDs: {result['text']}")
|
|
178
|
+
|
|
179
|
+
if not isinstance(image_ids, list) or len(image_ids) < 2:
|
|
180
|
+
raise RuntimeError("Unexpected captcha IDs format.")
|
|
181
|
+
|
|
182
|
+
# Download each image
|
|
183
|
+
images = []
|
|
184
|
+
for img_id in image_ids:
|
|
185
|
+
img_url = f"{captcha_base}?cid=0&hash={img_id}"
|
|
186
|
+
r_img = fetch_via_requests_session(shared_state, method="GET", target_url=img_url, timeout=30)
|
|
187
|
+
if r_img.status_code != 200:
|
|
188
|
+
raise RuntimeError(f"Failed to download captcha image {img_id} (HTTP {r_img.status_code})")
|
|
189
|
+
elif not r_img.content:
|
|
190
|
+
raise RuntimeError(f"Captcha image {img_id} is empty or invalid.")
|
|
191
|
+
images.append((img_id, r_img.content))
|
|
192
|
+
|
|
193
|
+
# Convert to internal representation
|
|
194
|
+
image_objects = []
|
|
195
|
+
for image_id, raw_bytes in images:
|
|
196
|
+
img = Image.open(BytesIO(raw_bytes))
|
|
197
|
+
|
|
198
|
+
# if it’s a palette (P) image with an indexed transparency, go through RGBA
|
|
199
|
+
if img.mode == "P" and "transparency" in img.info:
|
|
200
|
+
img = img.convert("RGBA")
|
|
201
|
+
|
|
202
|
+
# if it has an alpha channel, composite it over white
|
|
203
|
+
if img.mode == "RGBA":
|
|
204
|
+
background = Image.new("RGB", img.size, (255, 255, 255))
|
|
205
|
+
background.paste(img, mask=img.split()[3])
|
|
206
|
+
img = background
|
|
207
|
+
else:
|
|
208
|
+
# for all other modes, just convert to plain RGB
|
|
209
|
+
img = img.convert("RGB")
|
|
210
|
+
|
|
211
|
+
image_objects.append((image_id, img))
|
|
212
|
+
|
|
213
|
+
images_pixel_differences = []
|
|
214
|
+
for idx_i, (img_id_i, img_i) in enumerate(image_objects):
|
|
215
|
+
total_difference = 0.0
|
|
216
|
+
for idx_j, (img_id_j, img_j) in enumerate(image_objects):
|
|
217
|
+
if idx_i == idx_j:
|
|
218
|
+
continue # skip self-comparison
|
|
219
|
+
total_difference += calculate_pixel_based_difference(img_i, img_j)
|
|
220
|
+
images_pixel_differences.append((img_id_i, total_difference))
|
|
221
|
+
|
|
222
|
+
identified_captcha_image, cumulated_percentage = max(images_pixel_differences, key=lambda x: x[1])
|
|
223
|
+
different_pixels_percentage = int(cumulated_percentage / len(images)) if images else int(cumulated_percentage)
|
|
224
|
+
info(f'CAPTCHA image "{identified_captcha_image}" - difference to others: {different_pixels_percentage}%')
|
|
225
|
+
|
|
226
|
+
result = fetch_via_flaresolverr(
|
|
227
|
+
shared_state,
|
|
228
|
+
method="POST",
|
|
229
|
+
target_url=captcha_base,
|
|
230
|
+
post_data={"cID": 0, "pC": identified_captcha_image, "rT": 2},
|
|
231
|
+
timeout=60
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
"response": result["text"],
|
|
236
|
+
"captcha_id": identified_captcha_image
|
|
237
|
+
}
|