quasarr 1.23.0__tar.gz → 1.24.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.
Potentially problematic release.
This version of quasarr might be problematic. Click here for more details.
- {quasarr-1.23.0 → quasarr-1.24.1}/PKG-INFO +2 -2
- {quasarr-1.23.0 → quasarr-1.24.1}/README.md +1 -1
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/api/captcha/__init__.py +44 -19
- quasarr-1.24.1/quasarr/downloads/__init__.py +272 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/al.py +28 -3
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/by.py +8 -2
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/dd.py +15 -8
- quasarr-1.24.1/quasarr/downloads/sources/dj.py +16 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/dl.py +49 -57
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/dt.py +34 -12
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/dw.py +9 -3
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/he.py +10 -4
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/mb.py +10 -4
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/nk.py +9 -3
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/nx.py +31 -10
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/sf.py +61 -55
- quasarr-1.24.1/quasarr/downloads/sources/sj.py +16 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/sl.py +22 -9
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/wd.py +9 -3
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/wx.py +12 -13
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/obfuscated.py +28 -23
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/sessions/al.py +38 -10
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/version.py +1 -1
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/dl.py +10 -6
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr.egg-info/PKG-INFO +2 -2
- quasarr-1.23.0/quasarr/downloads/__init__.py +0 -333
- quasarr-1.23.0/quasarr/downloads/sources/dj.py +0 -7
- quasarr-1.23.0/quasarr/downloads/sources/sj.py +0 -7
- {quasarr-1.23.0 → quasarr-1.24.1}/LICENSE +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/__init__.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/api/__init__.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/api/arr/__init__.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/api/config/__init__.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/api/sponsors_helper/__init__.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/api/statistics/__init__.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/linkcrypters/__init__.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/linkcrypters/al.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/linkcrypters/filecrypt.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/linkcrypters/hide.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/packages/__init__.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/downloads/sources/__init__.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/__init__.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/cloudflare.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/html_images.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/html_templates.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/imdb_metadata.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/log.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/myjd_api.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/notifications.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/sessions/__init__.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/sessions/dd.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/sessions/dl.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/sessions/nx.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/shared_state.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/statistics.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/providers/web_server.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/__init__.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/__init__.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/al.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/by.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/dd.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/dj.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/dt.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/dw.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/fx.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/he.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/mb.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/nk.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/nx.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/sf.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/sj.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/sl.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/wd.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/search/sources/wx.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/storage/__init__.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/storage/config.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/storage/setup.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr/storage/sqlite_database.py +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr.egg-info/SOURCES.txt +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr.egg-info/dependency_links.txt +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr.egg-info/entry_points.txt +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr.egg-info/not-zip-safe +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr.egg-info/requires.txt +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/quasarr.egg-info/top_level.txt +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/setup.cfg +0 -0
- {quasarr-1.23.0 → quasarr-1.24.1}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quasarr
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.24.1
|
|
4
4
|
Summary: Quasarr connects JDownloader with Radarr, Sonarr and LazyLibrarian. It also decrypts links protected by CAPTCHAs.
|
|
5
5
|
Home-page: https://github.com/rix1337/Quasarr
|
|
6
6
|
Author: rix1337
|
|
@@ -44,7 +44,7 @@ Alternatively, follow the link from the console output (or discord notification)
|
|
|
44
44
|
Quasarr will confidently handle the rest.
|
|
45
45
|
|
|
46
46
|
# Instructions
|
|
47
|
-
1. Set up and run [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr)
|
|
47
|
+
1. Set up and run [FlareSolverr 3](https://github.com/FlareSolverr/FlareSolverr).
|
|
48
48
|
2. Set up and run [JDownloader 2](https://jdownloader.org/download/index).
|
|
49
49
|
3. Follow the next steps.
|
|
50
50
|
|
|
@@ -17,7 +17,7 @@ Alternatively, follow the link from the console output (or discord notification)
|
|
|
17
17
|
Quasarr will confidently handle the rest.
|
|
18
18
|
|
|
19
19
|
# Instructions
|
|
20
|
-
1. Set up and run [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr)
|
|
20
|
+
1. Set up and run [FlareSolverr 3](https://github.com/FlareSolverr/FlareSolverr).
|
|
21
21
|
2. Set up and run [JDownloader 2](https://jdownloader.org/download/index).
|
|
22
22
|
3. Follow the next steps.
|
|
23
23
|
|
|
@@ -8,7 +8,7 @@ from base64 import urlsafe_b64encode, urlsafe_b64decode
|
|
|
8
8
|
from urllib.parse import quote, unquote, urljoin
|
|
9
9
|
|
|
10
10
|
import requests
|
|
11
|
-
from bottle import request, response, redirect
|
|
11
|
+
from bottle import request, response, redirect, HTTPResponse
|
|
12
12
|
|
|
13
13
|
import quasarr.providers.html_images as images
|
|
14
14
|
from quasarr.downloads.linkcrypters.filecrypt import get_filecrypt_links, DLC
|
|
@@ -24,6 +24,21 @@ def js_single_quoted_string_safe(text):
|
|
|
24
24
|
return text.replace('\\', '\\\\').replace("'", "\\'")
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def check_package_exists(package_id):
|
|
28
|
+
if not shared_state.get_db("protected").retrieve(package_id):
|
|
29
|
+
raise HTTPResponse(
|
|
30
|
+
status=404,
|
|
31
|
+
body=render_centered_html(f'''
|
|
32
|
+
<h1><img src="{images.logo}" class="logo"/>Quasarr</h1>
|
|
33
|
+
<p><b>Error:</b> Package not found or already solved.</p>
|
|
34
|
+
<p>
|
|
35
|
+
{render_button("Back", "secondary", {"onclick": "location.href='/captcha'"})}
|
|
36
|
+
</p>
|
|
37
|
+
'''),
|
|
38
|
+
content_type="text/html"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
27
42
|
def setup_captcha_routes(app):
|
|
28
43
|
@app.get('/captcha')
|
|
29
44
|
def check_captcha():
|
|
@@ -82,10 +97,16 @@ def setup_captcha_routes(app):
|
|
|
82
97
|
|
|
83
98
|
sj = shared_state.values["config"]("Hostnames").get("sj")
|
|
84
99
|
dj = shared_state.values["config"]("Hostnames").get("dj")
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
|
|
101
|
+
def is_junkies_link(link):
|
|
102
|
+
"""Check if link is a junkies link (handles [[url, mirror]] format)."""
|
|
103
|
+
url = link[0] if isinstance(link, (list, tuple)) else link
|
|
104
|
+
mirror = link[1] if isinstance(link, (list, tuple)) and len(link) > 1 else ""
|
|
105
|
+
if mirror == "junkies":
|
|
106
|
+
return True
|
|
107
|
+
return (sj and sj in url) or (dj and dj in url)
|
|
108
|
+
|
|
109
|
+
has_junkies_links = any(is_junkies_link(link) for link in prioritized_links)
|
|
89
110
|
|
|
90
111
|
# KeepLinks uses nested arrays like FileCrypt: [["url", "mirror"]]
|
|
91
112
|
has_keeplinks_links = any(
|
|
@@ -182,7 +203,7 @@ def setup_captcha_routes(app):
|
|
|
182
203
|
<a href="#" id="show-setup-btn" style="color: #58a6ff;">ℹ️ Show instructions again</a>
|
|
183
204
|
</div>
|
|
184
205
|
|
|
185
|
-
<strong><a href="{url_with_quick_transfer_params}" target="
|
|
206
|
+
<strong><a href="{url_with_quick_transfer_params}" target="_self">🔗 Obtain the download links here!</a></strong><br><br>
|
|
186
207
|
|
|
187
208
|
<form id="bypass-form" action="/captcha/bypass-submit" method="post" enctype="multipart/form-data">
|
|
188
209
|
<input type="hidden" name="package_id" value="{package_id}" />
|
|
@@ -242,7 +263,9 @@ def setup_captcha_routes(app):
|
|
|
242
263
|
title = payload.get("title")
|
|
243
264
|
password = payload.get("password")
|
|
244
265
|
urls = payload.get("links")
|
|
245
|
-
url = urls[0]
|
|
266
|
+
url = urls[0][0] if isinstance(urls[0], (list, tuple)) else urls[0]
|
|
267
|
+
|
|
268
|
+
check_package_exists(package_id)
|
|
246
269
|
|
|
247
270
|
return render_centered_html(f"""
|
|
248
271
|
<!DOCTYPE html>
|
|
@@ -276,7 +299,9 @@ def setup_captcha_routes(app):
|
|
|
276
299
|
title = payload.get("title")
|
|
277
300
|
password = payload.get("password")
|
|
278
301
|
urls = payload.get("links")
|
|
279
|
-
|
|
302
|
+
|
|
303
|
+
check_package_exists(package_id)
|
|
304
|
+
|
|
280
305
|
url = urls[0][0] if isinstance(urls[0], (list, tuple)) else urls[0]
|
|
281
306
|
|
|
282
307
|
return render_centered_html(f"""
|
|
@@ -311,7 +336,9 @@ def setup_captcha_routes(app):
|
|
|
311
336
|
title = payload.get("title")
|
|
312
337
|
password = payload.get("password")
|
|
313
338
|
urls = payload.get("links")
|
|
314
|
-
|
|
339
|
+
|
|
340
|
+
check_package_exists(package_id)
|
|
341
|
+
|
|
315
342
|
url = urls[0][0] if isinstance(urls[0], (list, tuple)) else urls[0]
|
|
316
343
|
|
|
317
344
|
return render_centered_html(f"""
|
|
@@ -400,7 +427,7 @@ def setup_captcha_routes(app):
|
|
|
400
427
|
<a href="#" id="show-setup-btn" style="color: #58a6ff;">ℹ️ Show instructions again</a>
|
|
401
428
|
</div>
|
|
402
429
|
|
|
403
|
-
<strong><a href="{url_with_quick_transfer_params}" target="
|
|
430
|
+
<strong><a href="{url_with_quick_transfer_params}" target="_self">🔗 Obtain the download links here!</a></strong><br><br>
|
|
404
431
|
|
|
405
432
|
<form id="bypass-form" action="/captcha/bypass-submit" method="post" enctype="multipart/form-data">
|
|
406
433
|
<input type="hidden" name="package_id" value="{package_id}" />
|
|
@@ -501,12 +528,12 @@ def setup_captcha_routes(app):
|
|
|
501
528
|
try:
|
|
502
529
|
decompressed = zlib.decompress(decoded, -15) # -15 = raw deflate, no zlib header
|
|
503
530
|
except Exception as e:
|
|
504
|
-
|
|
531
|
+
debug(f"Decompression error: {e}, trying with header...")
|
|
505
532
|
try:
|
|
506
533
|
# Fallback: try with zlib header
|
|
507
534
|
decompressed = zlib.decompress(decoded)
|
|
508
535
|
except Exception as e2:
|
|
509
|
-
info(f"Decompression
|
|
536
|
+
info(f"Decompression failed without and with header: {e2}")
|
|
510
537
|
return render_centered_html(f'''<h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
|
|
511
538
|
<p><b>Error:</b> Failed to decompress data: {str(e)}</p>
|
|
512
539
|
<p>
|
|
@@ -635,6 +662,8 @@ def setup_captcha_routes(app):
|
|
|
635
662
|
desired_mirror = payload.get("mirror")
|
|
636
663
|
prioritized_links = payload.get("links")
|
|
637
664
|
|
|
665
|
+
check_package_exists(package_id)
|
|
666
|
+
|
|
638
667
|
if not prioritized_links:
|
|
639
668
|
# No links found, show an error message
|
|
640
669
|
return render_centered_html(f'''
|
|
@@ -858,13 +887,7 @@ def setup_captcha_routes(app):
|
|
|
858
887
|
{render_button("Back", "secondary", {"onclick": "location.href='/captcha'"})}
|
|
859
888
|
</p>''')
|
|
860
889
|
|
|
861
|
-
|
|
862
|
-
if not package_exists:
|
|
863
|
-
return render_centered_html(f'''<h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
|
|
864
|
-
<p><b>Error:</b> Package not found or already solved.</p>
|
|
865
|
-
<p>
|
|
866
|
-
{render_button("Back", "secondary", {"onclick": "location.href='/captcha'"})}
|
|
867
|
-
</p>''')
|
|
890
|
+
check_package_exists(package_id)
|
|
868
891
|
|
|
869
892
|
# Process links input
|
|
870
893
|
if links_input:
|
|
@@ -1041,6 +1064,8 @@ def setup_captcha_routes(app):
|
|
|
1041
1064
|
original_url = payload.get("original_url", "")
|
|
1042
1065
|
url = payload.get("links")[0] if payload.get("links") else None
|
|
1043
1066
|
|
|
1067
|
+
check_package_exists(package_id)
|
|
1068
|
+
|
|
1044
1069
|
if not url or not session_id or not package_id:
|
|
1045
1070
|
response.status = 400
|
|
1046
1071
|
return "Missing required parameters"
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from quasarr.downloads.linkcrypters.hide import decrypt_links_if_hide
|
|
9
|
+
from quasarr.downloads.sources.al import get_al_download_links
|
|
10
|
+
from quasarr.downloads.sources.by import get_by_download_links
|
|
11
|
+
from quasarr.downloads.sources.dd import get_dd_download_links
|
|
12
|
+
from quasarr.downloads.sources.dj import get_dj_download_links
|
|
13
|
+
from quasarr.downloads.sources.dl import get_dl_download_links
|
|
14
|
+
from quasarr.downloads.sources.dt import get_dt_download_links
|
|
15
|
+
from quasarr.downloads.sources.dw import get_dw_download_links
|
|
16
|
+
from quasarr.downloads.sources.he import get_he_download_links
|
|
17
|
+
from quasarr.downloads.sources.mb import get_mb_download_links
|
|
18
|
+
from quasarr.downloads.sources.nk import get_nk_download_links
|
|
19
|
+
from quasarr.downloads.sources.nx import get_nx_download_links
|
|
20
|
+
from quasarr.downloads.sources.sf import get_sf_download_links
|
|
21
|
+
from quasarr.downloads.sources.sj import get_sj_download_links
|
|
22
|
+
from quasarr.downloads.sources.sl import get_sl_download_links
|
|
23
|
+
from quasarr.downloads.sources.wd import get_wd_download_links
|
|
24
|
+
from quasarr.downloads.sources.wx import get_wx_download_links
|
|
25
|
+
from quasarr.providers.log import info
|
|
26
|
+
from quasarr.providers.notifications import send_discord_message
|
|
27
|
+
from quasarr.providers.statistics import StatsHelper
|
|
28
|
+
|
|
29
|
+
# =============================================================================
|
|
30
|
+
# CRYPTER CONFIGURATION
|
|
31
|
+
# =============================================================================
|
|
32
|
+
|
|
33
|
+
# Patterns match crypter name only - TLDs may change
|
|
34
|
+
AUTO_DECRYPT_PATTERNS = {
|
|
35
|
+
'hide': re.compile(r'hide\.', re.IGNORECASE),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
PROTECTED_PATTERNS = {
|
|
39
|
+
'filecrypt': re.compile(r'filecrypt\.', re.IGNORECASE),
|
|
40
|
+
'tolink': re.compile(r'tolink\.', re.IGNORECASE),
|
|
41
|
+
'keeplinks': re.compile(r'keeplinks\.', re.IGNORECASE),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Source key -> getter function mapping
|
|
45
|
+
# All getters have signature: (shared_state, url, mirror, title, password)
|
|
46
|
+
# AL uses password as release_id, others ignore it
|
|
47
|
+
SOURCE_GETTERS = {
|
|
48
|
+
'al': get_al_download_links,
|
|
49
|
+
'by': get_by_download_links,
|
|
50
|
+
'dd': get_dd_download_links,
|
|
51
|
+
'dj': get_dj_download_links,
|
|
52
|
+
'dl': get_dl_download_links,
|
|
53
|
+
'dt': get_dt_download_links,
|
|
54
|
+
'dw': get_dw_download_links,
|
|
55
|
+
'he': get_he_download_links,
|
|
56
|
+
'mb': get_mb_download_links,
|
|
57
|
+
'nk': get_nk_download_links,
|
|
58
|
+
'nx': get_nx_download_links,
|
|
59
|
+
'sf': get_sf_download_links,
|
|
60
|
+
'sj': get_sj_download_links,
|
|
61
|
+
'sl': get_sl_download_links,
|
|
62
|
+
'wd': get_wd_download_links,
|
|
63
|
+
'wx': get_wx_download_links,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# =============================================================================
|
|
68
|
+
# LINK CLASSIFICATION
|
|
69
|
+
# =============================================================================
|
|
70
|
+
|
|
71
|
+
def detect_crypter(url):
|
|
72
|
+
"""Returns (crypter_name, 'auto'|'protected') or (None, None)."""
|
|
73
|
+
for name, pattern in AUTO_DECRYPT_PATTERNS.items():
|
|
74
|
+
if pattern.search(url):
|
|
75
|
+
return name, 'auto'
|
|
76
|
+
for name, pattern in PROTECTED_PATTERNS.items():
|
|
77
|
+
if pattern.search(url):
|
|
78
|
+
return name, 'protected'
|
|
79
|
+
return None, None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_junkies_link(url, shared_state):
|
|
83
|
+
"""Check if URL is a junkies (sj/dj) link."""
|
|
84
|
+
sj = shared_state.values["config"]("Hostnames").get("sj")
|
|
85
|
+
dj = shared_state.values["config"]("Hostnames").get("dj")
|
|
86
|
+
url_lower = url.lower()
|
|
87
|
+
return (sj and sj.lower() in url_lower) or (dj and dj.lower() in url_lower)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def classify_links(links, shared_state):
|
|
91
|
+
"""
|
|
92
|
+
Classify links into direct/auto/protected categories.
|
|
93
|
+
Direct = anything that's not a known crypter or junkies link.
|
|
94
|
+
Mirror names from source are preserved.
|
|
95
|
+
"""
|
|
96
|
+
classified = {'direct': [], 'auto': [], 'protected': []}
|
|
97
|
+
|
|
98
|
+
for link in links:
|
|
99
|
+
url = link[0]
|
|
100
|
+
|
|
101
|
+
if is_junkies_link(url, shared_state):
|
|
102
|
+
classified['protected'].append(link)
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
crypter, crypter_type = detect_crypter(url)
|
|
106
|
+
if crypter_type == 'auto':
|
|
107
|
+
classified['auto'].append(link)
|
|
108
|
+
elif crypter_type == 'protected':
|
|
109
|
+
classified['protected'].append(link)
|
|
110
|
+
else:
|
|
111
|
+
# Not a known crypter = direct hoster link
|
|
112
|
+
classified['direct'].append(link)
|
|
113
|
+
|
|
114
|
+
return classified
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# =============================================================================
|
|
118
|
+
# LINK PROCESSING
|
|
119
|
+
# =============================================================================
|
|
120
|
+
|
|
121
|
+
def handle_direct_links(shared_state, links, title, password, package_id):
|
|
122
|
+
"""Send direct hoster links to JDownloader."""
|
|
123
|
+
urls = [link[0] for link in links]
|
|
124
|
+
info(f"Sending {len(urls)} direct download links for {title}")
|
|
125
|
+
|
|
126
|
+
if shared_state.download_package(urls, title, password, package_id):
|
|
127
|
+
StatsHelper(shared_state).increment_package_with_links(urls)
|
|
128
|
+
return {"success": True}
|
|
129
|
+
return {"success": False, "reason": f'Failed to add {len(urls)} links to linkgrabber'}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def handle_auto_decrypt_links(shared_state, links, title, password, package_id):
|
|
133
|
+
"""Decrypt hide.cx links and send to JDownloader."""
|
|
134
|
+
result = decrypt_links_if_hide(shared_state, links)
|
|
135
|
+
|
|
136
|
+
if result.get("status") != "success":
|
|
137
|
+
return {"success": False, "reason": "Auto-decrypt failed"}
|
|
138
|
+
|
|
139
|
+
decrypted_urls = result.get("results", [])
|
|
140
|
+
if not decrypted_urls:
|
|
141
|
+
return {"success": False, "reason": "No links decrypted"}
|
|
142
|
+
|
|
143
|
+
info(f"Decrypted {len(decrypted_urls)} download links for {title}")
|
|
144
|
+
|
|
145
|
+
if shared_state.download_package(decrypted_urls, title, password, package_id):
|
|
146
|
+
StatsHelper(shared_state).increment_package_with_links(decrypted_urls)
|
|
147
|
+
return {"success": True}
|
|
148
|
+
return {"success": False, "reason": "Failed to add decrypted links to linkgrabber"}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def store_protected_links(shared_state, links, title, password, package_id, size_mb=None, original_url=None):
|
|
152
|
+
"""Store protected links for CAPTCHA UI."""
|
|
153
|
+
blob_data = {"title": title, "links": links, "password": password, "size_mb": size_mb}
|
|
154
|
+
if original_url:
|
|
155
|
+
blob_data["original_url"] = original_url
|
|
156
|
+
|
|
157
|
+
shared_state.values["database"]("protected").update_store(package_id, json.dumps(blob_data))
|
|
158
|
+
info(f'CAPTCHA-Solution required for "{title}" at: "{shared_state.values["external_address"]}/captcha"')
|
|
159
|
+
return {"success": True}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def process_links(shared_state, source_result, title, password, package_id, imdb_id, source_url, size_mb, label):
|
|
163
|
+
"""
|
|
164
|
+
Central link processor with priority: direct → auto-decrypt → protected.
|
|
165
|
+
If ANY direct links exist, use them and ignore crypted fallbacks.
|
|
166
|
+
"""
|
|
167
|
+
if not source_result:
|
|
168
|
+
return fail(title, package_id, shared_state,
|
|
169
|
+
reason=f'Source returned no data for "{title}" on {label} - "{source_url}"')
|
|
170
|
+
|
|
171
|
+
links = source_result.get("links", [])
|
|
172
|
+
password = source_result.get("password") or password
|
|
173
|
+
imdb_id = imdb_id or source_result.get("imdb_id")
|
|
174
|
+
title = source_result.get("title") or title
|
|
175
|
+
|
|
176
|
+
if not links:
|
|
177
|
+
return fail(title, package_id, shared_state,
|
|
178
|
+
reason=f'No links found for "{title}" on {label} - "{source_url}"')
|
|
179
|
+
|
|
180
|
+
# Filter out 404 links
|
|
181
|
+
valid_links = [link for link in links if "/404.html" not in link[0]]
|
|
182
|
+
if not valid_links:
|
|
183
|
+
return fail(title, package_id, shared_state,
|
|
184
|
+
reason=f'All links are offline or IP is banned for "{title}" on {label} - "{source_url}"')
|
|
185
|
+
links = valid_links
|
|
186
|
+
|
|
187
|
+
classified = classify_links(links, shared_state)
|
|
188
|
+
|
|
189
|
+
# PRIORITY 1: Direct hoster links
|
|
190
|
+
if classified['direct']:
|
|
191
|
+
info(f"Found {len(classified['direct'])} direct hoster links for {title}")
|
|
192
|
+
send_discord_message(shared_state, title=title, case="unprotected", imdb_id=imdb_id, source=source_url)
|
|
193
|
+
result = handle_direct_links(shared_state, classified['direct'], title, password, package_id)
|
|
194
|
+
if result["success"]:
|
|
195
|
+
return {"success": True, "title": title}
|
|
196
|
+
return fail(title, package_id, shared_state, reason=result.get("reason"))
|
|
197
|
+
|
|
198
|
+
# PRIORITY 2: Auto-decryptable (hide.cx)
|
|
199
|
+
if classified['auto']:
|
|
200
|
+
info(f"Found {len(classified['auto'])} auto-decryptable links for {title}")
|
|
201
|
+
result = handle_auto_decrypt_links(shared_state, classified['auto'], title, password, package_id)
|
|
202
|
+
if result["success"]:
|
|
203
|
+
send_discord_message(shared_state, title=title, case="unprotected", imdb_id=imdb_id, source=source_url)
|
|
204
|
+
return {"success": True, "title": title}
|
|
205
|
+
info(f"Auto-decrypt failed for {title}, checking for protected fallback...")
|
|
206
|
+
|
|
207
|
+
# PRIORITY 3: Protected (filecrypt, tolink, keeplinks, junkies)
|
|
208
|
+
if classified['protected']:
|
|
209
|
+
info(f"Found {len(classified['protected'])} protected links for {title}")
|
|
210
|
+
send_discord_message(shared_state, title=title, case="captcha", imdb_id=imdb_id, source=source_url)
|
|
211
|
+
store_protected_links(shared_state, classified['protected'], title, password, package_id,
|
|
212
|
+
size_mb=size_mb, original_url=source_url)
|
|
213
|
+
return {"success": True, "title": title}
|
|
214
|
+
|
|
215
|
+
return fail(title, package_id, shared_state,
|
|
216
|
+
reason=f'No usable links found for "{title}" on {label} - "{source_url}"')
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# =============================================================================
|
|
220
|
+
# MAIN ENTRY POINT
|
|
221
|
+
# =============================================================================
|
|
222
|
+
|
|
223
|
+
def download(shared_state, request_from, title, url, mirror, size_mb, password, imdb_id=None):
|
|
224
|
+
"""Main download entry point."""
|
|
225
|
+
category = "docs" if "lazylibrarian" in request_from.lower() else \
|
|
226
|
+
"movies" if "radarr" in request_from.lower() else "tv"
|
|
227
|
+
package_id = f"Quasarr_{category}_{str(hash(title + url)).replace('-', '')}"
|
|
228
|
+
|
|
229
|
+
if imdb_id and imdb_id.lower() == "none":
|
|
230
|
+
imdb_id = None
|
|
231
|
+
|
|
232
|
+
config = shared_state.values["config"]("Hostnames")
|
|
233
|
+
|
|
234
|
+
# Find matching source - all getters have unified signature
|
|
235
|
+
source_result = None
|
|
236
|
+
label = None
|
|
237
|
+
|
|
238
|
+
for key, getter in SOURCE_GETTERS.items():
|
|
239
|
+
hostname = config.get(key)
|
|
240
|
+
if hostname and hostname.lower() in url.lower():
|
|
241
|
+
source_result = getter(shared_state, url, mirror, title, password)
|
|
242
|
+
label = key.upper()
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
# No source matched - check if URL is a known crypter directly
|
|
246
|
+
if source_result is None:
|
|
247
|
+
crypter, crypter_type = detect_crypter(url)
|
|
248
|
+
if crypter_type:
|
|
249
|
+
# For direct crypter URLs, we only know the crypter type, not the hoster inside
|
|
250
|
+
source_result = {"links": [[url, crypter]]}
|
|
251
|
+
label = crypter.upper()
|
|
252
|
+
|
|
253
|
+
if source_result is None:
|
|
254
|
+
info(f'Could not find matching source for "{title}" - "{url}"')
|
|
255
|
+
StatsHelper(shared_state).increment_failed_downloads()
|
|
256
|
+
return {"success": False, "package_id": package_id, "title": title}
|
|
257
|
+
|
|
258
|
+
result = process_links(shared_state, source_result, title, password, package_id, imdb_id, url, size_mb, label)
|
|
259
|
+
return {"package_id": package_id, **result}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def fail(title, package_id, shared_state, reason="Unknown error"):
|
|
263
|
+
"""Mark download as failed."""
|
|
264
|
+
try:
|
|
265
|
+
info(f"Reason for failure: {reason}")
|
|
266
|
+
StatsHelper(shared_state).increment_failed_downloads()
|
|
267
|
+
blob = json.dumps({"title": title, "error": reason})
|
|
268
|
+
shared_state.get_db("failed").store(package_id, json.dumps(blob))
|
|
269
|
+
info(f'Package "{title}" marked as failed!')
|
|
270
|
+
except Exception as e:
|
|
271
|
+
info(f'Error marking package "{package_id}" as failed: {e}')
|
|
272
|
+
return {"success": False, "title": title}
|
|
@@ -8,6 +8,7 @@ import re
|
|
|
8
8
|
import time
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
from typing import Optional, List
|
|
11
|
+
from urllib.parse import urlparse
|
|
11
12
|
|
|
12
13
|
from bs4 import BeautifulSoup
|
|
13
14
|
|
|
@@ -50,6 +51,17 @@ def roman_to_int(r: str) -> int:
|
|
|
50
51
|
return total
|
|
51
52
|
|
|
52
53
|
|
|
54
|
+
def derive_mirror(url):
|
|
55
|
+
try:
|
|
56
|
+
hostname = urlparse(url).netloc.lower()
|
|
57
|
+
if hostname.startswith('www.'):
|
|
58
|
+
hostname = hostname[4:]
|
|
59
|
+
parts = hostname.split('.')
|
|
60
|
+
return parts[-2] if len(parts) >= 2 else hostname
|
|
61
|
+
except:
|
|
62
|
+
return "unknown"
|
|
63
|
+
|
|
64
|
+
|
|
53
65
|
def extract_season_from_synonyms(soup):
|
|
54
66
|
"""
|
|
55
67
|
Returns the first season found as "Season N" in the Synonym(s) <td>, or None.
|
|
@@ -529,8 +541,19 @@ def extract_episode(title: str) -> int | None:
|
|
|
529
541
|
return None
|
|
530
542
|
|
|
531
543
|
|
|
532
|
-
def get_al_download_links(shared_state, url, mirror, title,
|
|
533
|
-
|
|
544
|
+
def get_al_download_links(shared_state, url, mirror, title, password):
|
|
545
|
+
"""
|
|
546
|
+
KEEP THE SIGNATURE EVEN IF SOME PARAMETERS ARE UNUSED!
|
|
547
|
+
|
|
548
|
+
AL source handler. Returns plain download links automatically by solving CAPTCHA.
|
|
549
|
+
|
|
550
|
+
Note: The 'password' parameter is intentionally repurposed as release_id
|
|
551
|
+
to ensure we download the correct release from the search results.
|
|
552
|
+
This is set by the search module, not a user password.
|
|
553
|
+
"""
|
|
554
|
+
|
|
555
|
+
release_id = password # password field carries release_id for AL
|
|
556
|
+
|
|
534
557
|
al = shared_state.values["config"]("Hostnames").get(hostname)
|
|
535
558
|
|
|
536
559
|
sess = retrieve_and_validate_session(shared_state)
|
|
@@ -690,8 +713,10 @@ def get_al_download_links(shared_state, url, mirror, title,
|
|
|
690
713
|
else:
|
|
691
714
|
StatsHelper(shared_state).increment_failed_decryptions_automatic()
|
|
692
715
|
|
|
716
|
+
links_with_mirrors = [[url, derive_mirror(url)] for url in links]
|
|
717
|
+
|
|
693
718
|
return {
|
|
694
|
-
"links":
|
|
719
|
+
"links": links_with_mirrors,
|
|
695
720
|
"password": f"www.{al}",
|
|
696
721
|
"title": title
|
|
697
722
|
}
|
|
@@ -13,7 +13,13 @@ from bs4 import BeautifulSoup
|
|
|
13
13
|
from quasarr.providers.log import info, debug
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def get_by_download_links(shared_state, url, mirror, title):
|
|
16
|
+
def get_by_download_links(shared_state, url, mirror, title, password):
|
|
17
|
+
"""
|
|
18
|
+
KEEP THE SIGNATURE EVEN IF SOME PARAMETERS ARE UNUSED!
|
|
19
|
+
|
|
20
|
+
BY source handler - fetches protected download links from BY iframes.
|
|
21
|
+
"""
|
|
22
|
+
|
|
17
23
|
by = shared_state.values["config"]("Hostnames").get("by")
|
|
18
24
|
headers = {
|
|
19
25
|
'User-Agent': shared_state.values["user_agent"],
|
|
@@ -103,4 +109,4 @@ def get_by_download_links(shared_state, url, mirror, title): # signature must a
|
|
|
103
109
|
except Exception as e:
|
|
104
110
|
info(f"Error loading BY download links: {e}")
|
|
105
111
|
|
|
106
|
-
return links
|
|
112
|
+
return {"links": links}
|
|
@@ -6,13 +6,19 @@ from quasarr.providers.log import info, debug
|
|
|
6
6
|
from quasarr.providers.sessions.dd import create_and_persist_session, retrieve_and_validate_session
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def get_dd_download_links(shared_state, url, mirror, title):
|
|
9
|
+
def get_dd_download_links(shared_state, url, mirror, title, password):
|
|
10
|
+
"""
|
|
11
|
+
KEEP THE SIGNATURE EVEN IF SOME PARAMETERS ARE UNUSED!
|
|
12
|
+
|
|
13
|
+
Returns plain download links from DD API.
|
|
14
|
+
"""
|
|
15
|
+
|
|
10
16
|
dd = shared_state.values["config"]("Hostnames").get("dd")
|
|
11
17
|
|
|
12
18
|
dd_session = retrieve_and_validate_session(shared_state)
|
|
13
19
|
if not dd_session:
|
|
14
20
|
info(f"Could not retrieve valid session for {dd}")
|
|
15
|
-
return []
|
|
21
|
+
return {"links": []}
|
|
16
22
|
|
|
17
23
|
links = []
|
|
18
24
|
|
|
@@ -35,9 +41,9 @@ def get_dd_download_links(shared_state, url, mirror, title): # signature must al
|
|
|
35
41
|
try:
|
|
36
42
|
release_list = []
|
|
37
43
|
for page in range(0, 100, 20):
|
|
38
|
-
|
|
44
|
+
api_url = f'https://{dd}/index/search/keyword/{title}/qualities/{",".join(qualities)}/from/{page}/search'
|
|
39
45
|
|
|
40
|
-
releases_on_page = dd_session.get(
|
|
46
|
+
releases_on_page = dd_session.get(api_url, headers=headers, timeout=10).json()
|
|
41
47
|
if releases_on_page:
|
|
42
48
|
release_list.extend(releases_on_page)
|
|
43
49
|
|
|
@@ -46,7 +52,7 @@ def get_dd_download_links(shared_state, url, mirror, title): # signature must al
|
|
|
46
52
|
if release.get("fake"):
|
|
47
53
|
debug(f"Release {release.get('release')} marked as fake. Invalidating DD session...")
|
|
48
54
|
create_and_persist_session(shared_state)
|
|
49
|
-
return []
|
|
55
|
+
return {"links": []}
|
|
50
56
|
elif release.get("release") == title:
|
|
51
57
|
filtered_links = []
|
|
52
58
|
for link in release["links"]:
|
|
@@ -61,10 +67,11 @@ def get_dd_download_links(shared_state, url, mirror, title): # signature must al
|
|
|
61
67
|
for existing_link in filtered_links
|
|
62
68
|
):
|
|
63
69
|
debug(f"Skipping duplicate `.mkv` link from {link['hostname']}")
|
|
64
|
-
continue
|
|
70
|
+
continue
|
|
65
71
|
filtered_links.append(link)
|
|
66
72
|
|
|
67
|
-
|
|
73
|
+
# Build [[url, mirror], ...] format
|
|
74
|
+
links = [[link["url"], link["hostname"]] for link in filtered_links]
|
|
68
75
|
break
|
|
69
76
|
except Exception as e:
|
|
70
77
|
info(f"Error parsing DD download: {e}")
|
|
@@ -73,4 +80,4 @@ def get_dd_download_links(shared_state, url, mirror, title): # signature must al
|
|
|
73
80
|
except Exception as e:
|
|
74
81
|
info(f"Error loading DD download: {e}")
|
|
75
82
|
|
|
76
|
-
return links
|
|
83
|
+
return {"links": links}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_dj_download_links(shared_state, url, mirror, title, password):
|
|
7
|
+
"""
|
|
8
|
+
KEEP THE SIGNATURE EVEN IF SOME PARAMETERS ARE UNUSED!
|
|
9
|
+
|
|
10
|
+
DJ source handler - the site itself acts as a protected crypter.
|
|
11
|
+
Returns the URL for CAPTCHA solving via userscript.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
"links": [[url, "junkies"]]
|
|
16
|
+
}
|