quasarr 1.23.0__tar.gz → 1.24.0__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.

Files changed (86) hide show
  1. {quasarr-1.23.0 → quasarr-1.24.0}/PKG-INFO +2 -2
  2. {quasarr-1.23.0 → quasarr-1.24.0}/README.md +1 -1
  3. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/api/captcha/__init__.py +44 -19
  4. quasarr-1.24.0/quasarr/downloads/__init__.py +272 -0
  5. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/al.py +28 -3
  6. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/by.py +8 -2
  7. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/dd.py +15 -8
  8. quasarr-1.24.0/quasarr/downloads/sources/dj.py +16 -0
  9. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/dl.py +49 -57
  10. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/dt.py +34 -12
  11. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/dw.py +9 -3
  12. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/he.py +10 -4
  13. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/mb.py +10 -4
  14. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/nk.py +9 -3
  15. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/nx.py +31 -10
  16. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/sf.py +61 -55
  17. quasarr-1.24.0/quasarr/downloads/sources/sj.py +16 -0
  18. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/sl.py +22 -9
  19. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/wd.py +9 -3
  20. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/wx.py +12 -13
  21. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/obfuscated.py +27 -23
  22. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/sessions/al.py +38 -10
  23. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/version.py +1 -1
  24. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/dl.py +10 -6
  25. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr.egg-info/PKG-INFO +2 -2
  26. quasarr-1.23.0/quasarr/downloads/__init__.py +0 -333
  27. quasarr-1.23.0/quasarr/downloads/sources/dj.py +0 -7
  28. quasarr-1.23.0/quasarr/downloads/sources/sj.py +0 -7
  29. {quasarr-1.23.0 → quasarr-1.24.0}/LICENSE +0 -0
  30. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/__init__.py +0 -0
  31. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/api/__init__.py +0 -0
  32. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/api/arr/__init__.py +0 -0
  33. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/api/config/__init__.py +0 -0
  34. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/api/sponsors_helper/__init__.py +0 -0
  35. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/api/statistics/__init__.py +0 -0
  36. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/linkcrypters/__init__.py +0 -0
  37. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/linkcrypters/al.py +0 -0
  38. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/linkcrypters/filecrypt.py +0 -0
  39. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/linkcrypters/hide.py +0 -0
  40. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/packages/__init__.py +0 -0
  41. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/downloads/sources/__init__.py +0 -0
  42. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/__init__.py +0 -0
  43. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/cloudflare.py +0 -0
  44. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/html_images.py +0 -0
  45. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/html_templates.py +0 -0
  46. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/imdb_metadata.py +0 -0
  47. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/log.py +0 -0
  48. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/myjd_api.py +0 -0
  49. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/notifications.py +0 -0
  50. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/sessions/__init__.py +0 -0
  51. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/sessions/dd.py +0 -0
  52. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/sessions/dl.py +0 -0
  53. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/sessions/nx.py +0 -0
  54. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/shared_state.py +0 -0
  55. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/statistics.py +0 -0
  56. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/providers/web_server.py +0 -0
  57. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/__init__.py +0 -0
  58. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/__init__.py +0 -0
  59. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/al.py +0 -0
  60. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/by.py +0 -0
  61. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/dd.py +0 -0
  62. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/dj.py +0 -0
  63. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/dt.py +0 -0
  64. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/dw.py +0 -0
  65. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/fx.py +0 -0
  66. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/he.py +0 -0
  67. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/mb.py +0 -0
  68. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/nk.py +0 -0
  69. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/nx.py +0 -0
  70. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/sf.py +0 -0
  71. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/sj.py +0 -0
  72. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/sl.py +0 -0
  73. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/wd.py +0 -0
  74. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/search/sources/wx.py +0 -0
  75. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/storage/__init__.py +0 -0
  76. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/storage/config.py +0 -0
  77. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/storage/setup.py +0 -0
  78. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr/storage/sqlite_database.py +0 -0
  79. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr.egg-info/SOURCES.txt +0 -0
  80. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr.egg-info/dependency_links.txt +0 -0
  81. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr.egg-info/entry_points.txt +0 -0
  82. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr.egg-info/not-zip-safe +0 -0
  83. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr.egg-info/requires.txt +0 -0
  84. {quasarr-1.23.0 → quasarr-1.24.0}/quasarr.egg-info/top_level.txt +0 -0
  85. {quasarr-1.23.0 → quasarr-1.24.0}/setup.cfg +0 -0
  86. {quasarr-1.23.0 → quasarr-1.24.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quasarr
3
- Version: 1.23.0
3
+ Version: 1.24.0
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) 3.4.4 or later.
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) 3.4.4 or later.
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
- has_junkies_links = any(
86
- (sj and sj in link) or (dj and dj in link)
87
- for link in prioritized_links
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="_blank">🔗 Obtain the download links here!</a></strong><br><br>
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
- # KeepLinks uses nested arrays like FileCrypt: [["url", "mirror"]]
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
- # ToLink uses nested arrays like FileCrypt: [["url", "mirror"]]
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="_blank">🔗 Obtain the download links here!</a></strong><br><br>
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
- info(f"Decompression error: {e}, trying with header...")
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 also failed with header: {e2}")
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
- package_exists = shared_state.get_db("protected").retrieve(package_id)
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
- release_id): # signature cant align with other download link functions!
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": 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): # signature must align with other download link functions!
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): # signature must align with other download link functions!
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
- url = f'https://{dd}/index/search/keyword/{title}/qualities/{','.join(qualities)}/from/{page}/search'
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(url, headers=headers, timeout=10).json()
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 # Skip adding duplicate `.mkv` links from the same hostname
70
+ continue
65
71
  filtered_links.append(link)
66
72
 
67
- links = [link["url"] for link in filtered_links]
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
+ }