quasarr 2.6.0__tar.gz → 2.6.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.

Files changed (100) hide show
  1. {quasarr-2.6.0 → quasarr-2.6.1}/.github/workflows/Release.yml +6 -0
  2. {quasarr-2.6.0 → quasarr-2.6.1}/PKG-INFO +1 -1
  3. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/api/__init__.py +2 -2
  4. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/api/config/__init__.py +1 -1
  5. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/api/jdownloader/__init__.py +19 -12
  6. quasarr-2.6.1/quasarr/downloads/sources/wd.py +169 -0
  7. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/cloudflare.py +46 -1
  8. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/version.py +1 -1
  9. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/wd.py +29 -5
  10. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/storage/setup.py +13 -4
  11. quasarr-2.6.0/quasarr/downloads/sources/wd.py +0 -155
  12. {quasarr-2.6.0 → quasarr-2.6.1}/.github/FUNDING.yml +0 -0
  13. {quasarr-2.6.0 → quasarr-2.6.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  14. {quasarr-2.6.0 → quasarr-2.6.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  15. {quasarr-2.6.0 → quasarr-2.6.1}/.github/workflows/HostnameRedaction.yml +0 -0
  16. {quasarr-2.6.0 → quasarr-2.6.1}/.github/workflows/PullRequests.yml +0 -0
  17. {quasarr-2.6.0 → quasarr-2.6.1}/.gitignore +0 -0
  18. {quasarr-2.6.0 → quasarr-2.6.1}/.pre-commit-config.yaml +0 -0
  19. {quasarr-2.6.0 → quasarr-2.6.1}/CONTRIBUTING.md +0 -0
  20. {quasarr-2.6.0 → quasarr-2.6.1}/LICENSE +0 -0
  21. {quasarr-2.6.0 → quasarr-2.6.1}/Quasarr.png +0 -0
  22. {quasarr-2.6.0 → quasarr-2.6.1}/Quasarr.py +0 -0
  23. {quasarr-2.6.0 → quasarr-2.6.1}/README.md +0 -0
  24. {quasarr-2.6.0 → quasarr-2.6.1}/docker/Dockerfile +0 -0
  25. {quasarr-2.6.0 → quasarr-2.6.1}/docker/dev-services-compose.yml +0 -0
  26. {quasarr-2.6.0 → quasarr-2.6.1}/docker/docker-compose.yml +0 -0
  27. {quasarr-2.6.0 → quasarr-2.6.1}/pre-commit.py +0 -0
  28. {quasarr-2.6.0 → quasarr-2.6.1}/pyproject.toml +0 -0
  29. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/__init__.py +0 -0
  30. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/api/arr/__init__.py +0 -0
  31. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/api/captcha/__init__.py +0 -0
  32. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/api/packages/__init__.py +0 -0
  33. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/api/sponsors_helper/__init__.py +0 -0
  34. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/api/statistics/__init__.py +0 -0
  35. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/__init__.py +0 -0
  36. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/linkcrypters/__init__.py +0 -0
  37. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/linkcrypters/al.py +0 -0
  38. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/linkcrypters/filecrypt.py +0 -0
  39. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/linkcrypters/hide.py +0 -0
  40. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/packages/__init__.py +0 -0
  41. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/__init__.py +0 -0
  42. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/al.py +0 -0
  43. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/by.py +0 -0
  44. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/dd.py +0 -0
  45. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/dj.py +0 -0
  46. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/dl.py +0 -0
  47. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/dt.py +0 -0
  48. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/dw.py +0 -0
  49. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/he.py +0 -0
  50. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/hs.py +0 -0
  51. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/mb.py +0 -0
  52. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/nk.py +0 -0
  53. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/nx.py +0 -0
  54. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/sf.py +0 -0
  55. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/sj.py +0 -0
  56. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/sl.py +0 -0
  57. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/downloads/sources/wx.py +0 -0
  58. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/__init__.py +0 -0
  59. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/auth.py +0 -0
  60. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/hostname_issues.py +0 -0
  61. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/html_images.py +0 -0
  62. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/html_templates.py +0 -0
  63. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/imdb_metadata.py +0 -0
  64. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/jd_cache.py +0 -0
  65. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/log.py +0 -0
  66. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/myjd_api.py +0 -0
  67. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/notifications.py +0 -0
  68. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/obfuscated.py +0 -0
  69. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/sessions/__init__.py +0 -0
  70. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/sessions/al.py +0 -0
  71. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/sessions/dd.py +0 -0
  72. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/sessions/dl.py +0 -0
  73. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/sessions/nx.py +0 -0
  74. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/shared_state.py +0 -0
  75. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/statistics.py +0 -0
  76. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/utils.py +0 -0
  77. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/providers/web_server.py +0 -0
  78. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/__init__.py +0 -0
  79. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/__init__.py +0 -0
  80. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/al.py +0 -0
  81. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/by.py +0 -0
  82. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/dd.py +0 -0
  83. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/dj.py +0 -0
  84. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/dl.py +0 -0
  85. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/dt.py +0 -0
  86. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/dw.py +0 -0
  87. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/fx.py +0 -0
  88. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/he.py +0 -0
  89. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/hs.py +0 -0
  90. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/mb.py +0 -0
  91. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/nk.py +0 -0
  92. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/nx.py +0 -0
  93. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/sf.py +0 -0
  94. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/sj.py +0 -0
  95. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/sl.py +0 -0
  96. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/search/sources/wx.py +0 -0
  97. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/storage/__init__.py +0 -0
  98. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/storage/config.py +0 -0
  99. {quasarr-2.6.0 → quasarr-2.6.1}/quasarr/storage/sqlite_database.py +0 -0
  100. {quasarr-2.6.0 → quasarr-2.6.1}/uv.lock +0 -0
@@ -299,6 +299,12 @@ jobs:
299
299
  run: |
300
300
  TAG="v.$VERSION"
301
301
  RELEASE_BODY=$(gh release view "$TAG" --json body --jq .body)
302
+
303
+ # Truncate to 4000 characters to avoid Discord limits
304
+ if [ ${#RELEASE_BODY} -gt 4000 ]; then
305
+ RELEASE_BODY="${RELEASE_BODY:0:4000}..."
306
+ fi
307
+
302
308
  if [ -n "$DISCORD_WEBHOOK" ]; then
303
309
  jq -n --arg title "🚀 New Release: $TAG" --arg desc "$RELEASE_BODY" --arg url "https://github.com/$REPO/releases/tag/$TAG" '{content: null, embeds: [{title: $title, description: $desc, url: $url, color: 5763719}]}' > discord_payload.json
304
310
  curl -H "Content-Type: application/json" -d @discord_payload.json "$DISCORD_WEBHOOK"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quasarr
3
- Version: 2.6.0
3
+ Version: 2.6.1
4
4
  Summary: Quasarr connects JDownloader with Radarr, Sonarr and LazyLibrarian. It also decrypts links protected by CAPTCHAs.
5
5
  Author-email: rix1337 <rix1337@users.noreply.github.com>
6
6
  License-File: LICENSE
@@ -124,10 +124,10 @@ def get_api(shared_state_dict, shared_state_lock):
124
124
  # Status bars
125
125
  status_bars = f"""
126
126
  <div class="status-bar">
127
- <span class="status-pill {jd_status['status_class']}"
127
+ <span class="status-pill {jd_status["status_class"]}"
128
128
  onclick="openJDownloaderModal()"
129
129
  title="Click to configure JDownloader">
130
- {jd_status['status_text']}
130
+ {jd_status["status_text"]}
131
131
  </span>
132
132
  <span class="status-pill {hostname_status_class}"
133
133
  onclick="location.href='/hostnames'"
@@ -7,7 +7,7 @@ import signal
7
7
  import threading
8
8
  import time
9
9
 
10
- from bottle import request, response
10
+ from bottle import response
11
11
 
12
12
  from quasarr.providers.html_templates import render_button, render_form
13
13
  from quasarr.providers.log import info
@@ -16,9 +16,14 @@ def get_jdownloader_status(shared_state):
16
16
 
17
17
  jd_config = Config("JDownloader")
18
18
  jd_device = jd_config.get("device") or ""
19
-
19
+
20
20
  dev_name = jd_device if jd_device else "JDownloader"
21
- dev_name_safe = dev_name.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
21
+ dev_name_safe = (
22
+ dev_name.replace("&", "&amp;")
23
+ .replace("<", "&lt;")
24
+ .replace(">", "&gt;")
25
+ .replace('"', "&quot;")
26
+ )
22
27
 
23
28
  if jd_connected:
24
29
  status_text = f"✅ {dev_name_safe} connected"
@@ -34,7 +39,7 @@ def get_jdownloader_status(shared_state):
34
39
  "connected": jd_connected,
35
40
  "device_name": jd_device,
36
41
  "status_text": status_text,
37
- "status_class": status_class
42
+ "status_class": status_class,
38
43
  }
39
44
 
40
45
 
@@ -44,7 +49,7 @@ def get_jdownloader_modal_script():
44
49
  jd_user = jd_config.get("user") or ""
45
50
  jd_pass = jd_config.get("password") or ""
46
51
  jd_device = jd_config.get("device") or ""
47
-
52
+
48
53
  jd_user_js = jd_user.replace("\\", "\\\\").replace("'", "\\'")
49
54
  jd_pass_js = jd_pass.replace("\\", "\\\\").replace("'", "\\'")
50
55
  jd_device_js = jd_device.replace("\\", "\\\\").replace("'", "\\'")
@@ -166,13 +171,13 @@ def get_jdownloader_modal_script():
166
171
  def get_jdownloader_status_pill(shared_state):
167
172
  """Return the HTML for the JDownloader status pill."""
168
173
  status = get_jdownloader_status(shared_state)
169
-
174
+
170
175
  return f"""
171
- <span class="status-pill {status['status_class']}"
176
+ <span class="status-pill {status["status_class"]}"
172
177
  onclick="openJDownloaderModal()"
173
178
  style="cursor: pointer;"
174
179
  title="Click to configure JDownloader">
175
- {status['status_text']}
180
+ {status["status_text"]}
176
181
  </span>
177
182
  """
178
183
 
@@ -181,12 +186,14 @@ def get_jdownloader_disconnected_page(shared_state, back_url="/"):
181
186
  """Return a full error page when JDownloader is disconnected."""
182
187
  import quasarr.providers.html_images as images
183
188
  from quasarr.providers.html_templates import render_centered_html
184
-
189
+
185
190
  status_pill = get_jdownloader_status_pill(shared_state)
186
191
  modal_script = get_jdownloader_modal_script()
187
-
188
- back_btn = render_button("Back", "secondary", {"onclick": f"location.href='{back_url}'"})
189
-
192
+
193
+ back_btn = render_button(
194
+ "Back", "secondary", {"onclick": f"location.href='{back_url}'"}
195
+ )
196
+
190
197
  content = f'''
191
198
  <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
192
199
  <div class="status-bar">
@@ -228,5 +235,5 @@ def get_jdownloader_disconnected_page(shared_state, back_url="/"):
228
235
  </style>
229
236
  {modal_script}
230
237
  '''
231
-
238
+
232
239
  return render_centered_html(content)
@@ -0,0 +1,169 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import re
6
+ import uuid
7
+ from urllib.parse import urljoin
8
+
9
+ from bs4 import BeautifulSoup
10
+
11
+ from quasarr.providers.cloudflare import (
12
+ flaresolverr_create_session,
13
+ flaresolverr_destroy_session,
14
+ flaresolverr_get,
15
+ is_cloudflare_challenge,
16
+ )
17
+ from quasarr.providers.hostname_issues import mark_hostname_issue
18
+ from quasarr.providers.log import debug, info
19
+ from quasarr.providers.utils import is_flaresolverr_available
20
+
21
+ hostname = "wd"
22
+
23
+
24
+ def resolve_wd_redirect(shared_state, url, session_id=None):
25
+ """
26
+ Follow redirects for a WD mirror URL and return the final destination.
27
+ """
28
+ try:
29
+ # Use FlareSolverr to follow redirects as well, since the redirector might be protected
30
+ r = flaresolverr_get(shared_state, url, session_id=session_id)
31
+
32
+ # FlareSolverr follows redirects automatically and returns the final URL
33
+ if r.status_code == 200:
34
+ # Check if we landed on a 404 page (soft 404)
35
+ if r.url.endswith("/404.html"):
36
+ return None
37
+ return r.url
38
+ else:
39
+ info(f"WD blocked attempt to resolve {url}. Status: {r.status_code}")
40
+ except Exception as e:
41
+ info(f"Error fetching redirected URL for {url}: {e}")
42
+ mark_hostname_issue(
43
+ hostname, "download", str(e) if "e" in dir() else "Download error"
44
+ )
45
+ return None
46
+
47
+
48
+ def get_wd_download_links(shared_state, url, mirror, title, password):
49
+ """
50
+ KEEP THE SIGNATURE EVEN IF SOME PARAMETERS ARE UNUSED!
51
+
52
+ WD source handler - resolves redirects and returns protected download links.
53
+ """
54
+
55
+ wd = shared_state.values["config"]("Hostnames").get("wd")
56
+
57
+ if not is_flaresolverr_available(shared_state):
58
+ info(
59
+ "WD is protected by Cloudflare but FlareSolverr is not configured. "
60
+ "Please configure FlareSolverr in the web UI to access this site."
61
+ )
62
+ mark_hostname_issue(hostname, "download", "FlareSolverr required but missing.")
63
+ return {"links": [], "imdb_id": None}
64
+
65
+ # Create a temporary FlareSolverr session for this download attempt
66
+ session_id = str(uuid.uuid4())
67
+ created_session = flaresolverr_create_session(shared_state, session_id)
68
+ if not created_session:
69
+ info("Could not create FlareSolverr session. Proceeding without session...")
70
+ session_id = None
71
+ else:
72
+ debug(f"Created FlareSolverr session: {session_id}")
73
+
74
+ try:
75
+ r = flaresolverr_get(shared_state, url, session_id=session_id)
76
+ if r.status_code == 403 or is_cloudflare_challenge(r.text):
77
+ info("Could not bypass Cloudflare protection with FlareSolverr!")
78
+ mark_hostname_issue(hostname, "download", "Cloudflare challenge failed")
79
+ return {"links": [], "imdb_id": None}
80
+
81
+ if r.status_code >= 400:
82
+ mark_hostname_issue(
83
+ hostname, "download", f"Download error: {str(r.status_code)}"
84
+ )
85
+
86
+ soup = BeautifulSoup(r.text, "html.parser")
87
+
88
+ # extract IMDb id if present
89
+ imdb_id = None
90
+ a_imdb = soup.find("a", href=re.compile(r"imdb\.com/title/tt\d+"))
91
+ if a_imdb:
92
+ m = re.search(r"(tt\d+)", a_imdb["href"])
93
+ if m:
94
+ imdb_id = m.group(1)
95
+ debug(f"Found IMDb id: {imdb_id}")
96
+
97
+ # find Downloads card
98
+ header = soup.find(
99
+ "div",
100
+ class_="card-header",
101
+ string=re.compile(r"^\s*Downloads\s*$", re.IGNORECASE),
102
+ )
103
+ if not header:
104
+ info(
105
+ f"WD Downloads section not found. Grabbing download links for {title} not possible!"
106
+ )
107
+ return {"links": [], "imdb_id": None}
108
+
109
+ card = header.find_parent("div", class_="card")
110
+ body = card.find("div", class_="card-body")
111
+ link_tags = body.find_all(
112
+ "a", href=True, class_=lambda c: c and "background-" in c
113
+ )
114
+
115
+ results = []
116
+ try:
117
+ for a in link_tags:
118
+ raw_href = a["href"]
119
+ full_link = urljoin(f"https://{wd}", raw_href)
120
+
121
+ # resolve any redirects using the same session
122
+ resolved = resolve_wd_redirect(
123
+ shared_state, full_link, session_id=session_id
124
+ )
125
+
126
+ if resolved:
127
+ if resolved.endswith("/404.html"):
128
+ info(f"Link {resolved} is dead!")
129
+ continue
130
+
131
+ # determine hoster
132
+ hoster = a.get_text(strip=True) or None
133
+ if not hoster:
134
+ for cls in a.get("class", []):
135
+ if cls.startswith("background-"):
136
+ hoster = cls.split("-", 1)[1]
137
+ break
138
+
139
+ if mirror and mirror.lower() not in hoster.lower():
140
+ debug(
141
+ f'Skipping link from "{hoster}" (not the desired mirror "{mirror}")!'
142
+ )
143
+ continue
144
+
145
+ results.append([resolved, hoster])
146
+ except Exception as e:
147
+ info(
148
+ f"WD site has been updated. Parsing download links for {title} not possible! Error: {e}"
149
+ )
150
+
151
+ return {
152
+ "links": results,
153
+ "imdb_id": imdb_id,
154
+ }
155
+
156
+ except RuntimeError as e:
157
+ # Catch FlareSolverr not configured error
158
+ info(f"WD access failed: {e}")
159
+ return {"links": [], "imdb_id": None}
160
+ except Exception as e:
161
+ info(
162
+ f"WD site has been updated. Grabbing download links for {title} not possible! Error: {e}"
163
+ )
164
+ return {"links": [], "imdb_id": None}
165
+ finally:
166
+ # Always destroy the session
167
+ if session_id:
168
+ debug(f"Destroying FlareSolverr session: {session_id}")
169
+ flaresolverr_destroy_session(shared_state, session_id)
@@ -168,7 +168,7 @@ class FlareSolverrResponse:
168
168
  raise requests.HTTPError(f"{self.status_code} Error at {self.url}")
169
169
 
170
170
 
171
- def flaresolverr_get(shared_state, url, timeout=60):
171
+ def flaresolverr_get(shared_state, url, timeout=60, session_id=None):
172
172
  """
173
173
  Core function for performing a GET request via FlareSolverr only.
174
174
  Used internally by FlareSolverrSession.get()
@@ -186,6 +186,8 @@ def flaresolverr_get(shared_state, url, timeout=60):
186
186
  raise RuntimeError("FlareSolverr URL not configured in shared_state.")
187
187
 
188
188
  payload = {"cmd": "request.get", "url": url, "maxTimeout": timeout * 1000}
189
+ if session_id:
190
+ payload["session"] = session_id
189
191
 
190
192
  try:
191
193
  resp = requests.post(
@@ -219,3 +221,46 @@ def flaresolverr_get(shared_state, url, timeout=60):
219
221
  return FlareSolverrResponse(
220
222
  url=url, status_code=status_code, headers=fs_headers, text=html
221
223
  )
224
+
225
+
226
+ def flaresolverr_create_session(shared_state, session_id=None):
227
+ if not is_flaresolverr_available(shared_state):
228
+ return None
229
+
230
+ flaresolverr_url = shared_state.values["config"]("FlareSolverr").get("url")
231
+ payload = {"cmd": "sessions.create"}
232
+ if session_id:
233
+ payload["session"] = session_id
234
+
235
+ try:
236
+ resp = requests.post(
237
+ flaresolverr_url,
238
+ json=payload,
239
+ headers={"Content-Type": "application/json"},
240
+ timeout=10,
241
+ )
242
+ resp.raise_for_status()
243
+ data = resp.json()
244
+ if data.get("status") == "ok":
245
+ return data.get("session")
246
+ except Exception:
247
+ pass
248
+ return None
249
+
250
+
251
+ def flaresolverr_destroy_session(shared_state, session_id):
252
+ if not is_flaresolverr_available(shared_state):
253
+ return
254
+
255
+ flaresolverr_url = shared_state.values["config"]("FlareSolverr").get("url")
256
+ payload = {"cmd": "sessions.destroy", "session": session_id}
257
+
258
+ try:
259
+ requests.post(
260
+ flaresolverr_url,
261
+ json=payload,
262
+ headers={"Content-Type": "application/json"},
263
+ timeout=10,
264
+ )
265
+ except Exception:
266
+ pass
@@ -5,7 +5,7 @@
5
5
  import re
6
6
  import sys
7
7
 
8
- __version__ = "2.6.0"
8
+ __version__ = "2.6.1"
9
9
 
10
10
 
11
11
  def get_version():
@@ -9,12 +9,13 @@ from base64 import urlsafe_b64encode
9
9
  from datetime import datetime, timedelta
10
10
  from urllib.parse import quote, quote_plus
11
11
 
12
- import requests
13
12
  from bs4 import BeautifulSoup
14
13
 
14
+ from quasarr.providers.cloudflare import flaresolverr_get, is_cloudflare_challenge
15
15
  from quasarr.providers.hostname_issues import clear_hostname_issue, mark_hostname_issue
16
16
  from quasarr.providers.imdb_metadata import get_localized_title, get_year
17
17
  from quasarr.providers.log import debug, info
18
+ from quasarr.providers.utils import is_flaresolverr_available
18
19
 
19
20
  hostname = "wd"
20
21
  supported_mirrors = ["rapidgator", "ddownload", "katfile", "fikper", "turbobit"]
@@ -170,9 +171,21 @@ def wd_feed(shared_state, start_time, request_from, mirror=None):
170
171
  feed_type = "Serien"
171
172
 
172
173
  url = f"https://{wd}/{feed_type}"
173
- headers = {"User-Agent": shared_state.values["user_agent"]}
174
+
175
+ if not is_flaresolverr_available(shared_state):
176
+ info(
177
+ f"FlareSolverr is not configured. Cannot access {hostname.upper()} feed due to Cloudflare protection."
178
+ )
179
+ mark_hostname_issue(hostname, "feed", "FlareSolverr missing")
180
+ return []
181
+
174
182
  try:
175
- r = requests.get(url, headers=headers, timeout=10)
183
+ r = flaresolverr_get(shared_state, url)
184
+ if r.status_code == 403 or is_cloudflare_challenge(r.text):
185
+ info(f"Cloudflare challenge failed for {hostname} feed.")
186
+ mark_hostname_issue(hostname, "feed", "Cloudflare challenge failed")
187
+ return []
188
+
176
189
  r.raise_for_status()
177
190
  soup = BeautifulSoup(r.content, "html.parser")
178
191
  releases = _parse_rows(soup, shared_state, wd, password, mirror)
@@ -215,10 +228,21 @@ def wd_search(
215
228
 
216
229
  q = quote_plus(search_string)
217
230
  url = f"https://{wd}/search?q={q}"
218
- headers = {"User-Agent": shared_state.values["user_agent"]}
231
+
232
+ if not is_flaresolverr_available(shared_state):
233
+ info(
234
+ f"FlareSolverr is not configured. Cannot access {hostname.upper()} search due to Cloudflare protection."
235
+ )
236
+ mark_hostname_issue(hostname, "search", "FlareSolverr missing")
237
+ return []
219
238
 
220
239
  try:
221
- r = requests.get(url, headers=headers, timeout=10)
240
+ r = flaresolverr_get(shared_state, url)
241
+ if r.status_code == 403 or is_cloudflare_challenge(r.text):
242
+ info(f"Cloudflare challenge failed for {hostname} search.")
243
+ mark_hostname_issue(hostname, "search", "Cloudflare challenge failed")
244
+ return []
245
+
222
246
  r.raise_for_status()
223
247
  soup = BeautifulSoup(r.content, "html.parser")
224
248
  releases = _parse_rows(
@@ -1446,7 +1446,10 @@ def verify_jdownloader_credentials(shared_state):
1446
1446
  if device_names:
1447
1447
  return {"success": True, "devices": device_names}
1448
1448
  else:
1449
- return {"success": False, "message": "No devices found or invalid credentials"}
1449
+ return {
1450
+ "success": False,
1451
+ "message": "No devices found or invalid credentials",
1452
+ }
1450
1453
  except Exception as e:
1451
1454
  return {"success": False, "message": str(e)}
1452
1455
 
@@ -1470,19 +1473,25 @@ def save_jdownloader_settings(shared_state, is_setup=False):
1470
1473
  config.save("user", username)
1471
1474
  config.save("password", password)
1472
1475
  config.save("device", device)
1473
-
1476
+
1474
1477
  if is_setup:
1475
1478
  quasarr.providers.web_server.temp_server_success = True
1476
1479
  return render_reconnect_success("Credentials set")
1477
1480
  else:
1478
1481
  response.content_type = "application/json"
1479
- return {"success": True, "message": "JDownloader configured successfully"}
1482
+ return {
1483
+ "success": True,
1484
+ "message": "JDownloader configured successfully",
1485
+ }
1480
1486
  else:
1481
1487
  if is_setup:
1482
1488
  return render_fail("Could not connect to selected device!")
1483
1489
  else:
1484
1490
  response.content_type = "application/json"
1485
- return {"success": False, "message": "Could not connect to selected device"}
1491
+ return {
1492
+ "success": False,
1493
+ "message": "Could not connect to selected device",
1494
+ }
1486
1495
 
1487
1496
  if is_setup:
1488
1497
  return render_fail("Could not set credentials!")
@@ -1,155 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- # Quasarr
3
- # Project by https://github.com/rix1337
4
-
5
- import re
6
- from urllib.parse import urljoin
7
-
8
- import requests
9
- from bs4 import BeautifulSoup
10
-
11
- from quasarr.providers.cloudflare import flaresolverr_get, is_cloudflare_challenge
12
- from quasarr.providers.hostname_issues import mark_hostname_issue
13
- from quasarr.providers.log import debug, info
14
- from quasarr.providers.utils import is_flaresolverr_available
15
-
16
- hostname = "wd"
17
-
18
-
19
- def resolve_wd_redirect(url, user_agent):
20
- """
21
- Follow redirects for a WD mirror URL and return the final destination.
22
- """
23
- try:
24
- r = requests.get(
25
- url,
26
- allow_redirects=True,
27
- timeout=10,
28
- headers={"User-Agent": user_agent},
29
- )
30
- r.raise_for_status()
31
- if r.history:
32
- for resp in r.history:
33
- debug(f"Redirected from {resp.url} to {r.url}")
34
- return r.url
35
- else:
36
- info(
37
- f"WD blocked attempt to resolve {url}. Your IP may be banned. Try again later."
38
- )
39
- except Exception as e:
40
- info(f"Error fetching redirected URL for {url}: {e}")
41
- mark_hostname_issue(
42
- hostname, "download", str(e) if "e" in dir() else "Download error"
43
- )
44
- return None
45
-
46
-
47
- def get_wd_download_links(shared_state, url, mirror, title, password):
48
- """
49
- KEEP THE SIGNATURE EVEN IF SOME PARAMETERS ARE UNUSED!
50
-
51
- WD source handler - resolves redirects and returns protected download links.
52
- """
53
-
54
- wd = shared_state.values["config"]("Hostnames").get("wd")
55
- user_agent = shared_state.values["user_agent"]
56
-
57
- try:
58
- r = requests.get(url)
59
- if r.status_code >= 400 or is_cloudflare_challenge(r.text):
60
- if is_flaresolverr_available(shared_state):
61
- info(
62
- "WD is protected by Cloudflare. Using FlareSolverr to bypass protection."
63
- )
64
- r = flaresolverr_get(shared_state, url)
65
- else:
66
- info(
67
- "WD is protected by Cloudflare but FlareSolverr is not configured. "
68
- "Please configure FlareSolverr in the web UI to access this site."
69
- )
70
- mark_hostname_issue(
71
- hostname, "download", "FlareSolverr required but missing."
72
- )
73
- return {"links": [], "imdb_id": None}
74
-
75
- if r.status_code >= 400:
76
- mark_hostname_issue(
77
- hostname, "download", f"Download error: {str(r.status_code)}"
78
- )
79
-
80
- soup = BeautifulSoup(r.text, "html.parser")
81
-
82
- # extract IMDb id if present
83
- imdb_id = None
84
- a_imdb = soup.find("a", href=re.compile(r"imdb\.com/title/tt\d+"))
85
- if a_imdb:
86
- m = re.search(r"(tt\d+)", a_imdb["href"])
87
- if m:
88
- imdb_id = m.group(1)
89
- debug(f"Found IMDb id: {imdb_id}")
90
-
91
- # find Downloads card
92
- header = soup.find(
93
- "div",
94
- class_="card-header",
95
- string=re.compile(r"^\s*Downloads\s*$", re.IGNORECASE),
96
- )
97
- if not header:
98
- info(
99
- f"WD Downloads section not found. Grabbing download links for {title} not possible!"
100
- )
101
- return {"links": [], "imdb_id": None}
102
-
103
- card = header.find_parent("div", class_="card")
104
- body = card.find("div", class_="card-body")
105
- link_tags = body.find_all(
106
- "a", href=True, class_=lambda c: c and "background-" in c
107
- )
108
- except RuntimeError as e:
109
- # Catch FlareSolverr not configured error
110
- info(f"WD access failed: {e}")
111
- return {"links": [], "imdb_id": None}
112
- except Exception:
113
- info(
114
- f"WD site has been updated. Grabbing download links for {title} not possible!"
115
- )
116
- return {"links": [], "imdb_id": None}
117
-
118
- results = []
119
- try:
120
- for a in link_tags:
121
- raw_href = a["href"]
122
- full_link = urljoin(f"https://{wd}", raw_href)
123
-
124
- # resolve any redirects
125
- resolved = resolve_wd_redirect(full_link, user_agent)
126
-
127
- if resolved:
128
- if resolved.endswith("/404.html"):
129
- info(f"Link {resolved} is dead!")
130
- continue
131
-
132
- # determine hoster
133
- hoster = a.get_text(strip=True) or None
134
- if not hoster:
135
- for cls in a.get("class", []):
136
- if cls.startswith("background-"):
137
- hoster = cls.split("-", 1)[1]
138
- break
139
-
140
- if mirror and mirror.lower() not in hoster.lower():
141
- debug(
142
- f'Skipping link from "{hoster}" (not the desired mirror "{mirror}")!'
143
- )
144
- continue
145
-
146
- results.append([resolved, hoster])
147
- except Exception:
148
- info(
149
- f"WD site has been updated. Parsing download links for {title} not possible!"
150
- )
151
-
152
- return {
153
- "links": results,
154
- "imdb_id": imdb_id,
155
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes