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.

Files changed (67) hide show
  1. quasarr/__init__.py +157 -56
  2. quasarr/api/__init__.py +141 -36
  3. quasarr/api/arr/__init__.py +197 -78
  4. quasarr/api/captcha/__init__.py +897 -42
  5. quasarr/api/config/__init__.py +23 -0
  6. quasarr/api/sponsors_helper/__init__.py +84 -22
  7. quasarr/api/statistics/__init__.py +196 -0
  8. quasarr/downloads/__init__.py +237 -434
  9. quasarr/downloads/linkcrypters/al.py +237 -0
  10. quasarr/downloads/linkcrypters/filecrypt.py +178 -31
  11. quasarr/downloads/linkcrypters/hide.py +123 -0
  12. quasarr/downloads/packages/__init__.py +461 -0
  13. quasarr/downloads/sources/al.py +697 -0
  14. quasarr/downloads/sources/by.py +106 -0
  15. quasarr/downloads/sources/dd.py +6 -78
  16. quasarr/downloads/sources/dj.py +7 -0
  17. quasarr/downloads/sources/dt.py +1 -1
  18. quasarr/downloads/sources/dw.py +2 -2
  19. quasarr/downloads/sources/he.py +112 -0
  20. quasarr/downloads/sources/mb.py +47 -0
  21. quasarr/downloads/sources/nk.py +51 -0
  22. quasarr/downloads/sources/nx.py +36 -81
  23. quasarr/downloads/sources/sf.py +27 -4
  24. quasarr/downloads/sources/sj.py +7 -0
  25. quasarr/downloads/sources/sl.py +90 -0
  26. quasarr/downloads/sources/wd.py +110 -0
  27. quasarr/providers/cloudflare.py +204 -0
  28. quasarr/providers/html_images.py +20 -0
  29. quasarr/providers/html_templates.py +210 -108
  30. quasarr/providers/imdb_metadata.py +15 -2
  31. quasarr/providers/myjd_api.py +36 -5
  32. quasarr/providers/notifications.py +30 -5
  33. quasarr/providers/obfuscated.py +35 -0
  34. quasarr/providers/sessions/__init__.py +0 -0
  35. quasarr/providers/sessions/al.py +286 -0
  36. quasarr/providers/sessions/dd.py +78 -0
  37. quasarr/providers/sessions/nx.py +76 -0
  38. quasarr/providers/shared_state.py +368 -23
  39. quasarr/providers/statistics.py +154 -0
  40. quasarr/providers/version.py +60 -1
  41. quasarr/search/__init__.py +112 -36
  42. quasarr/search/sources/al.py +448 -0
  43. quasarr/search/sources/by.py +203 -0
  44. quasarr/search/sources/dd.py +17 -6
  45. quasarr/search/sources/dj.py +213 -0
  46. quasarr/search/sources/dt.py +37 -7
  47. quasarr/search/sources/dw.py +27 -47
  48. quasarr/search/sources/fx.py +27 -29
  49. quasarr/search/sources/he.py +196 -0
  50. quasarr/search/sources/mb.py +195 -0
  51. quasarr/search/sources/nk.py +188 -0
  52. quasarr/search/sources/nx.py +22 -6
  53. quasarr/search/sources/sf.py +143 -151
  54. quasarr/search/sources/sj.py +213 -0
  55. quasarr/search/sources/sl.py +246 -0
  56. quasarr/search/sources/wd.py +208 -0
  57. quasarr/storage/config.py +20 -4
  58. quasarr/storage/setup.py +224 -56
  59. quasarr-1.20.4.dist-info/METADATA +304 -0
  60. quasarr-1.20.4.dist-info/RECORD +72 -0
  61. {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/WHEEL +1 -1
  62. quasarr/providers/tvmaze_metadata.py +0 -23
  63. quasarr-1.3.5.dist-info/METADATA +0 -174
  64. quasarr-1.3.5.dist-info/RECORD +0 -43
  65. {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/entry_points.txt +0 -0
  66. {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/licenses/LICENSE +0 -0
  67. {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
+ }