quasarr 1.26.7__py3-none-any.whl → 1.28.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of quasarr might be problematic. Click here for more details.

@@ -10,6 +10,7 @@ from bs4 import BeautifulSoup
10
10
 
11
11
  from quasarr.providers.cloudflare import flaresolverr_get, is_cloudflare_challenge
12
12
  from quasarr.providers.log import info, debug
13
+ from quasarr.providers.utils import is_flaresolverr_available
13
14
 
14
15
 
15
16
  def resolve_wd_redirect(url, user_agent):
@@ -47,8 +48,13 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
47
48
  try:
48
49
  output = requests.get(url)
49
50
  if output.status_code == 403 or is_cloudflare_challenge(output.text):
50
- info("WD is protected by Cloudflare. Using FlareSolverr to bypass protection.")
51
- output = flaresolverr_get(shared_state, url)
51
+ if is_flaresolverr_available(shared_state):
52
+ info("WD is protected by Cloudflare. Using FlareSolverr to bypass protection.")
53
+ output = flaresolverr_get(shared_state, url)
54
+ else:
55
+ info("WD is protected by Cloudflare but FlareSolverr is not configured. "
56
+ "Please configure FlareSolverr in the web UI to access this site.")
57
+ return {"links": [], "imdb_id": None}
52
58
 
53
59
  soup = BeautifulSoup(output.text, "html.parser")
54
60
 
@@ -76,6 +82,10 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
76
82
  link_tags = body.find_all(
77
83
  "a", href=True, class_=lambda c: c and "background-" in c
78
84
  )
85
+ except RuntimeError as e:
86
+ # Catch FlareSolverr not configured error
87
+ info(f"WD access failed: {e}")
88
+ return {"links": [], "imdb_id": None}
79
89
  except Exception:
80
90
  info(f"WD site has been updated. Grabbing download links for {title} not possible!")
81
91
  return {"links": [], "imdb_id": None}
@@ -5,6 +5,8 @@
5
5
  import requests
6
6
  from bs4 import BeautifulSoup
7
7
 
8
+ from quasarr.providers.utils import is_flaresolverr_available
9
+
8
10
 
9
11
  def is_cloudflare_challenge(html: str) -> bool:
10
12
  soup = BeautifulSoup(html, "html.parser")
@@ -39,9 +41,14 @@ def update_session_via_flaresolverr(info,
39
41
  sess,
40
42
  target_url: str,
41
43
  timeout: int = 60):
44
+ # Check if FlareSolverr is available
45
+ if not is_flaresolverr_available(shared_state):
46
+ info("FlareSolverr is not configured. Cannot bypass Cloudflare protection.")
47
+ return False
48
+
42
49
  flaresolverr_url = shared_state.values["config"]('FlareSolverr').get('url')
43
50
  if not flaresolverr_url:
44
- info("Cannot proceed without FlareSolverr. Please set it up to try again!")
51
+ info("Cannot proceed without FlareSolverr. Please configure it in the web UI!")
45
52
  return False
46
53
 
47
54
  fs_payload = {
@@ -104,6 +111,12 @@ def ensure_session_cf_bypassed(info, shared_state, session, url, headers):
104
111
 
105
112
  # If page is protected, try FlareSolverr
106
113
  if resp.status_code == 403 or is_cloudflare_challenge(resp.text):
114
+ # Check if FlareSolverr is available before attempting bypass
115
+ if not is_flaresolverr_available(shared_state):
116
+ info("Cloudflare protection detected but FlareSolverr is not configured. "
117
+ "Please configure FlareSolverr in the web UI to access this site.")
118
+ return None, None, None
119
+
107
120
  info("Encountered Cloudflare protection. Solving challenge with FlareSolverr...")
108
121
  flaresolverr_result = update_session_via_flaresolverr(info, shared_state, session, url)
109
122
  if not flaresolverr_result:
@@ -156,7 +169,13 @@ def flaresolverr_get(shared_state, url, timeout=60):
156
169
  """
157
170
  Core function for performing a GET request via FlareSolverr only.
158
171
  Used internally by FlareSolverrSession.get()
172
+
173
+ Returns None if FlareSolverr is not available.
159
174
  """
175
+ # Check if FlareSolverr is available
176
+ if not is_flaresolverr_available(shared_state):
177
+ raise RuntimeError("FlareSolverr is not configured. Please configure it in the web UI.")
178
+
160
179
  flaresolverr_url = shared_state.values["config"]('FlareSolverr').get('url')
161
180
  if not flaresolverr_url:
162
181
  raise RuntimeError("FlareSolverr URL not configured in shared_state.")
@@ -13,6 +13,18 @@ from bs4 import BeautifulSoup
13
13
  from requests.exceptions import Timeout, RequestException
14
14
 
15
15
  from quasarr.providers.log import info, debug
16
+ from quasarr.providers.utils import is_site_usable, is_flaresolverr_available
17
+
18
+
19
+ class SkippedSiteError(Exception):
20
+ """Raised when a site is skipped due to missing credentials or login being skipped."""
21
+ pass
22
+
23
+
24
+ class FlareSolverrNotAvailableError(Exception):
25
+ """Raised when FlareSolverr is required but not available."""
26
+ pass
27
+
16
28
 
17
29
  hostname = "al"
18
30
 
@@ -20,6 +32,12 @@ SESSION_MAX_AGE_SECONDS = 24 * 60 * 60 # 24 hours
20
32
 
21
33
 
22
34
  def create_and_persist_session(shared_state):
35
+ # AL requires FlareSolverr - check availability first
36
+ if not is_flaresolverr_available(shared_state):
37
+ info(f'"{hostname.upper()}" requires FlareSolverr which is not configured. '
38
+ f'Please configure FlareSolverr in the web UI to use this site.')
39
+ return None
40
+
23
41
  cfg = shared_state.values["config"]("Hostnames")
24
42
  host = cfg.get(hostname)
25
43
  credentials_cfg = shared_state.values["config"](hostname.upper())
@@ -106,6 +124,14 @@ def create_and_persist_session(shared_state):
106
124
 
107
125
 
108
126
  def retrieve_and_validate_session(shared_state):
127
+ if not is_site_usable(shared_state, hostname):
128
+ return None
129
+
130
+ # AL requires FlareSolverr - check availability
131
+ if not is_flaresolverr_available(shared_state):
132
+ info(f'"{hostname.upper()}" requires FlareSolverr which is not configured')
133
+ return None
134
+
109
135
  db = shared_state.values["database"]("sessions")
110
136
  stored = db.retrieve(hostname)
111
137
  if not stored:
@@ -213,9 +239,32 @@ def fetch_via_flaresolverr(shared_state,
213
239
  – post_data: dict of form‐fields if method=="POST"
214
240
  – timeout: seconds (FlareSolverr's internal maxTimeout = timeout*1000 ms)
215
241
  """
242
+ # Check if FlareSolverr is available
243
+ if not is_flaresolverr_available(shared_state):
244
+ info(f'"{hostname.upper()}" requires FlareSolverr which is not configured. '
245
+ f'Please configure FlareSolverr in the web UI.')
246
+ return {
247
+ "status_code": None,
248
+ "headers": {},
249
+ "json": None,
250
+ "text": "",
251
+ "cookies": [],
252
+ "error": "FlareSolverr is not configured"
253
+ }
254
+
216
255
  flaresolverr_url = shared_state.values["config"]('FlareSolverr').get('url')
217
256
 
218
257
  sess = retrieve_and_validate_session(shared_state)
258
+ if not sess:
259
+ debug(f"Skipping {hostname}: site not usable (login skipped or no credentials)")
260
+ return {
261
+ "status_code": None,
262
+ "headers": {},
263
+ "json": None,
264
+ "text": "",
265
+ "cookies": [],
266
+ "error": f"Site '{hostname}' is not usable (login skipped or no credentials)"
267
+ }
219
268
 
220
269
  cmd = "request.get" if method.upper() == "GET" else "request.post"
221
270
  fs_payload = {
@@ -301,6 +350,8 @@ def fetch_via_requests_session(shared_state, method: str, target_url: str, post_
301
350
  – timeout: seconds
302
351
  """
303
352
  sess = retrieve_and_validate_session(shared_state)
353
+ if not sess:
354
+ raise SkippedSiteError(f"{hostname}: site not usable (login skipped or no credentials)")
304
355
 
305
356
  # Execute request
306
357
  if method.upper() == "GET":
@@ -7,7 +7,10 @@ import pickle
7
7
 
8
8
  import requests
9
9
 
10
- from quasarr.providers.log import info
10
+ from quasarr.providers.log import info, debug
11
+ from quasarr.providers.utils import is_site_usable
12
+
13
+ hostname = "dd"
11
14
 
12
15
 
13
16
  def create_and_persist_session(shared_state):
@@ -62,6 +65,10 @@ def create_and_persist_session(shared_state):
62
65
 
63
66
 
64
67
  def retrieve_and_validate_session(shared_state):
68
+ if not is_site_usable(shared_state, hostname):
69
+ debug(f"Skipping {hostname}: site not usable (login skipped or no credentials)")
70
+ return None
71
+
65
72
  session_string = shared_state.values["database"]("sessions").retrieve("dd")
66
73
  if not session_string:
67
74
  dd_session = create_and_persist_session(shared_state)
@@ -9,6 +9,13 @@ import requests
9
9
  from bs4 import BeautifulSoup
10
10
 
11
11
  from quasarr.providers.log import info, debug
12
+ from quasarr.providers.utils import is_site_usable
13
+
14
+
15
+ class SkippedSiteError(Exception):
16
+ """Raised when a site is skipped due to missing credentials or login being skipped."""
17
+ pass
18
+
12
19
 
13
20
  hostname = "dl"
14
21
 
@@ -16,17 +23,17 @@ hostname = "dl"
16
23
  def create_and_persist_session(shared_state):
17
24
  """
18
25
  Create and persist a session using user and password.
19
-
26
+
20
27
  Args:
21
28
  shared_state: Shared state object
22
-
29
+
23
30
  Returns:
24
31
  requests.Session or None
25
32
  """
26
33
  cfg = shared_state.values["config"]("Hostnames")
27
34
  host = cfg.get(hostname)
28
35
  credentials_cfg = shared_state.values["config"](hostname.upper())
29
-
36
+
30
37
  user = credentials_cfg.get("user")
31
38
  password = credentials_cfg.get("password")
32
39
 
@@ -35,30 +42,30 @@ def create_and_persist_session(shared_state):
35
42
  return None
36
43
 
37
44
  sess = requests.Session()
38
-
45
+
39
46
  # Set user agent
40
47
  ua = shared_state.values["user_agent"]
41
48
  sess.headers.update({'User-Agent': ua})
42
-
49
+
43
50
  try:
44
51
  # Step 1: Get login page to retrieve CSRF token
45
52
  login_page_url = f'https://www.{host}/login/'
46
53
  login_page = sess.get(login_page_url, timeout=30)
47
-
54
+
48
55
  if login_page.status_code != 200:
49
56
  info(f'Failed to load login page for: "{hostname}" - Status {login_page.status_code}')
50
57
  return None
51
-
58
+
52
59
  # Extract CSRF token from login form
53
60
  soup = BeautifulSoup(login_page.text, 'html.parser')
54
61
  csrf_input = soup.find('input', {'name': '_xfToken'})
55
-
62
+
56
63
  if not csrf_input or not csrf_input.get('value'):
57
64
  info(f'Could not find CSRF token on login page for: "{hostname}"')
58
65
  return None
59
-
66
+
60
67
  csrf_token = csrf_input['value']
61
-
68
+
62
69
  # Step 2: Submit login form
63
70
  login_data = {
64
71
  'login': user,
@@ -67,18 +74,18 @@ def create_and_persist_session(shared_state):
67
74
  'remember': '1',
68
75
  '_xfRedirect': f'https://www.{host}/'
69
76
  }
70
-
77
+
71
78
  login_url = f'https://www.{host}/login/login'
72
79
  login_response = sess.post(login_url, data=login_data, timeout=30)
73
-
80
+
74
81
  # Step 3: Verify login success
75
82
  # Check if we're logged in by accessing the main page
76
83
  verify_response = sess.get(f'https://www.{host}/', timeout=30)
77
-
84
+
78
85
  if 'data-logged-in="true"' not in verify_response.text:
79
86
  info(f'Login verification failed for: "{hostname}" - invalid credentials or login failed')
80
87
  return None
81
-
88
+
82
89
  info(f'Session successfully created for: "{hostname}" using user/password')
83
90
  except Exception as e:
84
91
  info(f'Failed to create session for: "{hostname}" - {e}')
@@ -88,20 +95,23 @@ def create_and_persist_session(shared_state):
88
95
  blob = pickle.dumps(sess)
89
96
  token = base64.b64encode(blob).decode("utf-8")
90
97
  shared_state.values["database"]("sessions").update_store(hostname, token)
91
-
98
+
92
99
  return sess
93
100
 
94
101
 
95
102
  def retrieve_and_validate_session(shared_state):
96
103
  """
97
104
  Retrieve session from database or create a new one.
98
-
105
+
99
106
  Args:
100
107
  shared_state: Shared state object
101
-
108
+
102
109
  Returns:
103
110
  requests.Session or None
104
111
  """
112
+ if not is_site_usable(shared_state, hostname):
113
+ return None
114
+
105
115
  db = shared_state.values["database"]("sessions")
106
116
  token = db.retrieve(hostname)
107
117
  if not token:
@@ -122,7 +132,7 @@ def retrieve_and_validate_session(shared_state):
122
132
  def invalidate_session(shared_state):
123
133
  """
124
134
  Invalidate the current session.
125
-
135
+
126
136
  Args:
127
137
  shared_state: Shared state object
128
138
  """
@@ -134,7 +144,7 @@ def invalidate_session(shared_state):
134
144
  def _persist_session_to_db(shared_state, sess):
135
145
  """
136
146
  Serialize & store the given requests.Session into the database under `hostname`.
137
-
147
+
138
148
  Args:
139
149
  shared_state: Shared state object
140
150
  sess: requests.Session to persist
@@ -144,10 +154,11 @@ def _persist_session_to_db(shared_state, sess):
144
154
  shared_state.values["database"]("sessions").update_store(hostname, token)
145
155
 
146
156
 
147
- def fetch_via_requests_session(shared_state, method: str, target_url: str, post_data: dict = None, get_params: dict = None, timeout: int = 30):
157
+ def fetch_via_requests_session(shared_state, method: str, target_url: str, post_data: dict = None,
158
+ get_params: dict = None, timeout: int = 30):
148
159
  """
149
160
  Execute request using the session.
150
-
161
+
151
162
  Args:
152
163
  shared_state: Shared state object
153
164
  method: "GET" or "POST"
@@ -155,13 +166,13 @@ def fetch_via_requests_session(shared_state, method: str, target_url: str, post_
155
166
  post_data: POST data (for POST requests)
156
167
  get_params: URL parameters (for GET requests)
157
168
  timeout: Request timeout in seconds
158
-
169
+
159
170
  Returns:
160
171
  Response object
161
172
  """
162
173
  sess = retrieve_and_validate_session(shared_state)
163
174
  if not sess:
164
- raise Exception(f"Could not retrieve valid session for {hostname}")
175
+ raise SkippedSiteError(f"{hostname}: site not usable (login skipped or no credentials)")
165
176
 
166
177
  # Execute request
167
178
  if method.upper() == "GET":
@@ -7,7 +7,10 @@ import pickle
7
7
 
8
8
  import requests
9
9
 
10
- from quasarr.providers.log import info
10
+ from quasarr.providers.log import info, debug
11
+ from quasarr.providers.utils import is_site_usable
12
+
13
+ hostname = "nx"
11
14
 
12
15
 
13
16
  def create_and_persist_session(shared_state):
@@ -60,6 +63,10 @@ def create_and_persist_session(shared_state):
60
63
 
61
64
 
62
65
  def retrieve_and_validate_session(shared_state):
66
+ if not is_site_usable(shared_state, hostname):
67
+ debug(f"Skipping {hostname}: site not usable (login skipped or no credentials)")
68
+ return None
69
+
63
70
  session_string = shared_state.values["database"]("sessions").retrieve("nx")
64
71
  if not session_string:
65
72
  nx_session = create_and_persist_session(shared_state)
@@ -0,0 +1,190 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ import re
6
+ import socket
7
+ import sys
8
+ from urllib.parse import urlparse
9
+
10
+ import requests
11
+
12
+ # Fallback user agent when FlareSolverr is not available
13
+ FALLBACK_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
14
+
15
+
16
+ class Unbuffered(object):
17
+ def __init__(self, stream):
18
+ self.stream = stream
19
+
20
+ def write(self, data):
21
+ self.stream.write(data)
22
+ self.stream.flush()
23
+
24
+ def writelines(self, datas):
25
+ self.stream.writelines(datas)
26
+ self.stream.flush()
27
+
28
+ def __getattr__(self, attr):
29
+ return getattr(self.stream, attr)
30
+
31
+
32
+ def is_valid_url(url):
33
+ """Validate if a URL is properly formatted."""
34
+ if "/raw/eX4Mpl3" in url:
35
+ print("Example URL detected. Please provide a valid URL found on pastebin or any other public site!")
36
+ return False
37
+
38
+ parsed = urlparse(url)
39
+ return parsed.scheme in ("http", "https") and bool(parsed.netloc)
40
+
41
+
42
+ def extract_allowed_keys(config, section):
43
+ """
44
+ Extracts allowed keys from the specified section in the configuration.
45
+
46
+ :param config: The configuration dictionary.
47
+ :param section: The section from which to extract keys.
48
+ :return: A list of allowed keys.
49
+ """
50
+ if section not in config:
51
+ raise ValueError(f"Section '{section}' not found in configuration.")
52
+ return [key for key, *_ in config[section]]
53
+
54
+
55
+ def extract_kv_pairs(input_text, allowed_keys):
56
+ """
57
+ Extracts key-value pairs from the given text where keys match allowed_keys.
58
+
59
+ :param input_text: The input text containing key-value pairs.
60
+ :param allowed_keys: A list of allowed two-letter shorthand keys.
61
+ :return: A dictionary of extracted key-value pairs.
62
+ """
63
+ kv_pattern = re.compile(rf"^({'|'.join(map(re.escape, allowed_keys))})\s*=\s*(.*)$")
64
+ kv_pairs = {}
65
+
66
+ for line in input_text.splitlines():
67
+ match = kv_pattern.match(line.strip())
68
+ if match:
69
+ key, value = match.groups()
70
+ kv_pairs[key] = value
71
+ elif "[Hostnames]" in line:
72
+ pass
73
+ else:
74
+ print(f"Skipping line because it does not contain any supported hostname: {line}")
75
+
76
+ return kv_pairs
77
+
78
+
79
+ def check_ip():
80
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
81
+ try:
82
+ s.connect(('10.255.255.255', 0))
83
+ ip = s.getsockname()[0]
84
+ except:
85
+ ip = '127.0.0.1'
86
+ finally:
87
+ s.close()
88
+ return ip
89
+
90
+
91
+ def check_flaresolverr(shared_state, flaresolverr_url):
92
+ # Ensure it ends with /v<digit+>
93
+ if not re.search(r"/v\d+$", flaresolverr_url):
94
+ print(f"FlareSolverr URL does not end with /v#: {flaresolverr_url}")
95
+ return False
96
+
97
+ # Try sending a simple test request
98
+ headers = {"Content-Type": "application/json"}
99
+ data = {
100
+ "cmd": "request.get",
101
+ "url": "http://www.google.com/",
102
+ "maxTimeout": 10000
103
+ }
104
+
105
+ try:
106
+ response = requests.post(flaresolverr_url, headers=headers, json=data, timeout=10)
107
+ response.raise_for_status()
108
+ json_data = response.json()
109
+
110
+ # Check if the structure looks like a valid FlareSolverr response
111
+ if "status" in json_data and json_data["status"] == "ok":
112
+ solution = json_data["solution"]
113
+ solution_ua = solution.get("userAgent", None)
114
+ if solution_ua:
115
+ shared_state.update("user_agent", solution_ua)
116
+ return True
117
+ else:
118
+ print(f"Unexpected FlareSolverr response: {json_data}")
119
+ return False
120
+
121
+ except Exception as e:
122
+ print(f"Failed to connect to FlareSolverr: {e}")
123
+ return False
124
+
125
+
126
+ def validate_address(address, name):
127
+ if not address.startswith("http"):
128
+ sys.exit(f"Error: {name} '{address}' is invalid. It must start with 'http'.")
129
+
130
+ colon_count = address.count(":")
131
+ if colon_count < 1 or colon_count > 2:
132
+ sys.exit(
133
+ f"Error: {name} '{address}' is invalid. It must contain 1 or 2 colons, but it has {colon_count}.")
134
+
135
+
136
+ def is_flaresolverr_available(shared_state):
137
+ """
138
+ Check if FlareSolverr is configured and available.
139
+
140
+ Returns:
141
+ bool: True if FlareSolverr URL is set and not skipped, False otherwise
142
+ """
143
+ # Check if FlareSolverr was skipped
144
+ if shared_state.values["database"]("skip_flaresolverr").retrieve("skipped"):
145
+ return False
146
+
147
+ # Check if FlareSolverr URL is configured
148
+ flaresolverr_url = shared_state.values["config"]('FlareSolverr').get('url')
149
+ if not flaresolverr_url:
150
+ return False
151
+
152
+ return True
153
+
154
+
155
+ def is_site_usable(shared_state, shorthand):
156
+ """
157
+ Check if a site is fully configured and usable.
158
+
159
+ For sites that don't require login, just checks if hostname is set.
160
+ For login-required sites (al, dd, dl, nx), also checks that login wasn't skipped
161
+ and that credentials exist.
162
+
163
+ Args:
164
+ shared_state: Shared state object
165
+ shorthand: Site shorthand (e.g., 'al', 'dd', etc.)
166
+
167
+ Returns:
168
+ bool: True if site is usable, False otherwise
169
+ """
170
+ shorthand = shorthand.lower()
171
+
172
+ # Check if hostname is set
173
+ hostname = shared_state.values["config"]('Hostnames').get(shorthand)
174
+ if not hostname:
175
+ return False
176
+
177
+ login_required_sites = ['al', 'dd', 'dl', 'nx']
178
+ if shorthand not in login_required_sites:
179
+ return True # No login needed, hostname is enough
180
+
181
+ # Check if login was skipped
182
+ if shared_state.values["database"]("skip_login").retrieve(shorthand):
183
+ return False # Hostname set but login was skipped
184
+
185
+ # Check for credentials
186
+ config = shared_state.values["config"](shorthand.upper())
187
+ user = config.get('user')
188
+ password = config.get('password')
189
+
190
+ return bool(user and password)
@@ -8,7 +8,7 @@ import requests
8
8
 
9
9
 
10
10
  def get_version():
11
- return "1.26.7"
11
+ return "1.28.0"
12
12
 
13
13
 
14
14
  def get_latest_version():
@@ -2,10 +2,8 @@
2
2
  # Quasarr
3
3
  # Project by https://github.com/rix1337
4
4
 
5
- import re
6
5
  import time
7
6
  from base64 import urlsafe_b64encode
8
- from datetime import datetime, timedelta
9
7
  from html import unescape
10
8
  from urllib.parse import urljoin, quote_plus
11
9
 
quasarr/storage/config.py CHANGED
@@ -25,6 +25,9 @@ class Config(object):
25
25
  ("password", "secret", ""),
26
26
  ("device", "str", ""),
27
27
  ],
28
+ 'Settings': [
29
+ ("hostnames_url", "secret", ""),
30
+ ],
28
31
  'Hostnames': [
29
32
  ("al", "secret", ""),
30
33
  ("by", "secret", ""),