quasarr 1.32.0__py3-none-any.whl → 2.1.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.

Files changed (38) hide show
  1. quasarr/api/__init__.py +324 -106
  2. quasarr/api/arr/__init__.py +56 -20
  3. quasarr/api/captcha/__init__.py +26 -1
  4. quasarr/api/config/__init__.py +1 -1
  5. quasarr/api/packages/__init__.py +435 -0
  6. quasarr/api/sponsors_helper/__init__.py +4 -0
  7. quasarr/downloads/__init__.py +96 -6
  8. quasarr/downloads/linkcrypters/filecrypt.py +1 -1
  9. quasarr/downloads/linkcrypters/hide.py +45 -6
  10. quasarr/providers/auth.py +250 -0
  11. quasarr/providers/html_templates.py +65 -10
  12. quasarr/providers/obfuscated.py +9 -7
  13. quasarr/providers/shared_state.py +24 -0
  14. quasarr/providers/version.py +1 -1
  15. quasarr/search/sources/al.py +1 -1
  16. quasarr/search/sources/by.py +1 -1
  17. quasarr/search/sources/dd.py +2 -1
  18. quasarr/search/sources/dj.py +2 -2
  19. quasarr/search/sources/dl.py +11 -4
  20. quasarr/search/sources/dt.py +1 -1
  21. quasarr/search/sources/dw.py +6 -7
  22. quasarr/search/sources/fx.py +4 -4
  23. quasarr/search/sources/he.py +1 -1
  24. quasarr/search/sources/mb.py +1 -1
  25. quasarr/search/sources/nk.py +1 -1
  26. quasarr/search/sources/nx.py +1 -1
  27. quasarr/search/sources/sf.py +4 -2
  28. quasarr/search/sources/sj.py +2 -2
  29. quasarr/search/sources/sl.py +3 -3
  30. quasarr/search/sources/wd.py +1 -1
  31. quasarr/search/sources/wx.py +4 -3
  32. quasarr/storage/setup.py +12 -0
  33. {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/METADATA +47 -24
  34. {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/RECORD +38 -36
  35. {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/WHEEL +0 -0
  36. {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/entry_points.txt +0 -0
  37. {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/licenses/LICENSE +0 -0
  38. {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/top_level.txt +0 -0
@@ -7,6 +7,7 @@ import os
7
7
  import re
8
8
  import time
9
9
  import traceback
10
+ import unicodedata
10
11
  from datetime import datetime, timedelta, date
11
12
  from urllib import parse
12
13
 
@@ -826,6 +827,8 @@ def get_recently_searched(shared_state, context, timeout_seconds):
826
827
 
827
828
 
828
829
  def download_package(links, title, password, package_id):
830
+ links = [sanitize_url(link) for link in links]
831
+
829
832
  device = get_device()
830
833
  downloaded = device.linkgrabber.add_links(params=[
831
834
  {
@@ -841,3 +844,24 @@ def download_package(links, title, password, package_id):
841
844
  }
842
845
  ])
843
846
  return downloaded
847
+
848
+
849
+ def sanitize_url(url: str) -> str:
850
+ # normalize first
851
+ url = unicodedata.normalize("NFKC", url)
852
+
853
+ # 1) real control characters (U+0000–U+001F, U+007F–U+009F)
854
+ _REAL_CTRL_RE = re.compile(r'[\u0000-\u001f\u007f-\u009f]')
855
+
856
+ # 2) *literal* escaped unicode junk: \u0010, \x10, repeated variants
857
+ _ESCAPED_CTRL_RE = re.compile(
858
+ r'(?:\\u00[0-1][0-9a-fA-F]|\\x[0-1][0-9a-fA-F])'
859
+ )
860
+
861
+ # remove literal escaped control sequences like "\u0010"
862
+ url = _ESCAPED_CTRL_RE.sub("", url)
863
+
864
+ # remove actual control characters if already decoded
865
+ url = _REAL_CTRL_RE.sub("", url)
866
+
867
+ return url.strip()
@@ -8,7 +8,7 @@ import requests
8
8
 
9
9
 
10
10
  def get_version():
11
- return "1.32.0"
11
+ return "2.1.0"
12
12
 
13
13
 
14
14
  def get_latest_version():
@@ -208,7 +208,7 @@ def al_feed(shared_state, start_time, request_from, mirror=None):
208
208
 
209
209
  # Build payload using final_title
210
210
  mb = 0 # size not available in feed
211
- raw = f"{final_title}|{url}|{mirror}|{mb}|{release_id}|".encode("utf-8")
211
+ raw = f"{final_title}|{url}|{mirror}|{mb}|{release_id}||{hostname}".encode("utf-8")
212
212
  payload = urlsafe_b64encode(raw).decode("utf-8")
213
213
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
214
214
 
@@ -124,7 +124,7 @@ def _parse_posts(soup, shared_state, base_url, password, mirror_filter,
124
124
  imdb_id = None
125
125
 
126
126
  payload = urlsafe_b64encode(
127
- f"{title}|{source}|{mirror_filter}|{mb}|{password}|{imdb_id}".encode()
127
+ f"{title}|{source}|{mirror_filter}|{mb}|{password}|{imdb_id}|{hostname}".encode()
128
128
  ).decode()
129
129
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
130
130
 
@@ -106,7 +106,8 @@ def dd_search(shared_state, start_time, request_from, search_string="", mirror=N
106
106
  mb = shared_state.convert_to_mb(size_item) * 1024 * 1024
107
107
  published = convert_to_rss_date(release.get("when"))
108
108
  payload = urlsafe_b64encode(
109
- f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}".encode("utf-8")).decode("utf-8")
109
+ f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode("utf-8")).decode(
110
+ "utf-8")
110
111
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
111
112
 
112
113
  releases.append({
@@ -68,7 +68,7 @@ def dj_feed(shared_state, start_time, request_from, mirror=None):
68
68
  imdb_id = None
69
69
 
70
70
  payload = urlsafe_b64encode(
71
- f"{title}|{series_url}|{mirror}|{mb}|{password}|{imdb_id}".encode("utf-8")
71
+ f"{title}|{series_url}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode("utf-8")
72
72
  ).decode("utf-8")
73
73
 
74
74
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
@@ -186,7 +186,7 @@ def dj_search(shared_state, start_time, request_from, search_string, mirror=None
186
186
  size = 0
187
187
 
188
188
  payload = urlsafe_b64encode(
189
- f"{title}|{series_url}|{mirror}|{mb}|{password}|{imdb_id}".encode("utf-8")
189
+ f"{title}|{series_url}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode("utf-8")
190
190
  ).decode("utf-8")
191
191
 
192
192
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
@@ -97,7 +97,7 @@ def dl_feed(shared_state, start_time, request_from, mirror=None):
97
97
  if not title_elem:
98
98
  continue
99
99
 
100
- title = title_elem.get_text(strip=True)
100
+ title = ''.join(title_elem.strings)
101
101
  if not title:
102
102
  continue
103
103
 
@@ -123,7 +123,7 @@ def dl_feed(shared_state, start_time, request_from, mirror=None):
123
123
  password = ""
124
124
 
125
125
  payload = urlsafe_b64encode(
126
- f"{title}|{thread_url}|{mirror}|{mb}|{password}|{imdb_id or ''}".encode("utf-8")
126
+ f"{title}|{thread_url}|{mirror}|{mb}|{password}|{imdb_id or ''}|{hostname}".encode("utf-8")
127
127
  ).decode("utf-8")
128
128
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
129
129
 
@@ -230,7 +230,13 @@ def _search_single_page(shared_state, host, search_string, search_id, page_num,
230
230
  if not title_elem:
231
231
  continue
232
232
 
233
- title = title_elem.get_text(separator=' ', strip=True)
233
+ # Skip "Wird gesucht" threads
234
+ label = item.select_one('.contentRow-minor .label')
235
+ if label and 'wird gesucht' in label.get_text(strip=True).lower():
236
+ continue
237
+
238
+ title = ''.join(title_elem.strings)
239
+
234
240
  title = re.sub(r'\s+', ' ', title)
235
241
  title = unescape(title)
236
242
  title_normalized = normalize_title_for_sonarr(title)
@@ -260,7 +266,8 @@ def _search_single_page(shared_state, host, search_string, search_id, page_num,
260
266
  password = ""
261
267
 
262
268
  payload = urlsafe_b64encode(
263
- f"{title_normalized}|{thread_url}|{mirror}|{mb}|{password}|{imdb_id or ''}".encode("utf-8")
269
+ f"{title_normalized}|{thread_url}|{mirror}|{mb}|{password}|{imdb_id or ''}|{hostname}".encode(
270
+ "utf-8")
264
271
  ).decode("utf-8")
265
272
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
266
273
 
@@ -111,7 +111,7 @@ def dt_feed(shared_state, start_time, request_from, mirror=None):
111
111
  published = parse_published_datetime(article)
112
112
 
113
113
  payload = urlsafe_b64encode(
114
- f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}".encode("utf-8")
114
+ f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode("utf-8")
115
115
  ).decode("utf-8")
116
116
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
117
117
 
@@ -98,7 +98,7 @@ def dw_feed(shared_state, start_time, request_from, mirror=None):
98
98
  date = article.parent.parent.find("span", {"class": "date updated"}).text.strip()
99
99
  published = convert_to_rss_date(date)
100
100
  payload = urlsafe_b64encode(
101
- f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}".encode("utf-8")).decode("utf-8")
101
+ f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode("utf-8")).decode("utf-8")
102
102
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
103
103
  except Exception as e:
104
104
  info(f"Error parsing {hostname.upper()} feed: {e}")
@@ -136,7 +136,6 @@ def dw_search(shared_state, start_time, request_from, search_string, mirror=None
136
136
  debug(f'Skipping {request_from} search on "{hostname.upper()}" (unsupported media type)!')
137
137
  return releases
138
138
 
139
-
140
139
  if "Radarr" in request_from:
141
140
  search_type = "videocategory=filme"
142
141
  else:
@@ -168,10 +167,10 @@ def dw_search(shared_state, start_time, request_from, search_string, mirror=None
168
167
  title = result.a.text.strip()
169
168
 
170
169
  if not shared_state.is_valid_release(title,
171
- request_from,
172
- search_string,
173
- season,
174
- episode):
170
+ request_from,
171
+ search_string,
172
+ season,
173
+ episode):
175
174
  continue
176
175
 
177
176
  if not imdb_id:
@@ -188,7 +187,7 @@ def dw_search(shared_state, start_time, request_from, search_string, mirror=None
188
187
  date = result.parent.parent.find("span", {"class": "date updated"}).text.strip()
189
188
  published = convert_to_rss_date(date)
190
189
  payload = urlsafe_b64encode(
191
- f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}".encode("utf-8")).decode("utf-8")
190
+ f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode("utf-8")).decode("utf-8")
192
191
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
193
192
  except Exception as e:
194
193
  info(f"Error parsing {hostname.upper()} search: {e}")
@@ -34,7 +34,6 @@ def fx_feed(shared_state, start_time, request_from, mirror=None):
34
34
  debug(f'Skipping {request_from} search on "{hostname.upper()}" (unsupported media type)!')
35
35
  return releases
36
36
 
37
-
38
37
  if mirror and mirror not in supported_mirrors:
39
38
  debug(f'Mirror "{mirror}" not supported by "{hostname.upper()}". Supported mirrors: {supported_mirrors}.'
40
39
  ' Skipping search!')
@@ -81,7 +80,8 @@ def fx_feed(shared_state, start_time, request_from, mirror=None):
81
80
  mb = shared_state.convert_to_mb(size_item)
82
81
  size = mb * 1024 * 1024
83
82
  payload = urlsafe_b64encode(
84
- f"{title}|{link}|{mirror}|{mb}|{password}|{imdb_id}".encode("utf-8")).decode("utf-8")
83
+ f"{title}|{link}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode("utf-8")).decode(
84
+ "utf-8")
85
85
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
86
86
  except:
87
87
  continue
@@ -125,7 +125,6 @@ def fx_search(shared_state, start_time, request_from, search_string, mirror=None
125
125
  debug(f'Skipping {request_from} search on "{hostname.upper()}" (unsupported media type)!')
126
126
  return releases
127
127
 
128
-
129
128
  if mirror and mirror not in supported_mirrors:
130
129
  debug(f'Mirror "{mirror}" not supported by "{hostname.upper()}". Supported mirrors: {supported_mirrors}.'
131
130
  ' Skipping search!')
@@ -188,7 +187,8 @@ def fx_search(shared_state, start_time, request_from, search_string, mirror=None
188
187
  mb = shared_state.convert_to_mb(size_item)
189
188
  size = mb * 1024 * 1024
190
189
  payload = urlsafe_b64encode(
191
- f"{title}|{link}|{mirror}|{mb}|{password}|{imdb_id}".encode("utf-8")).decode("utf-8")
190
+ f"{title}|{link}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode("utf-8")).decode(
191
+ "utf-8")
192
192
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
193
193
  except:
194
194
  continue
@@ -177,7 +177,7 @@ def he_search(shared_state, start_time, request_from, search_string="", mirror=N
177
177
 
178
178
  password = None
179
179
  payload = urlsafe_b64encode(
180
- f"{title}|{source}|{mirror}|{mb}|{password}|{release_imdb_id}".encode("utf-8")).decode()
180
+ f"{title}|{source}|{mirror}|{mb}|{password}|{release_imdb_id}|{hostname}".encode("utf-8")).decode()
181
181
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
182
182
 
183
183
  releases.append({
@@ -116,7 +116,7 @@ def _parse_posts(soup, shared_state, password, mirror_filter,
116
116
  size_bytes = mb * 1024 * 1024
117
117
 
118
118
  payload = urlsafe_b64encode(
119
- f"{title}|{source}|{mirror_filter}|{mb}|{password}|{imdb_id}".encode()
119
+ f"{title}|{source}|{mirror_filter}|{mb}|{password}|{imdb_id}|{hostname}".encode()
120
120
  ).decode()
121
121
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
122
122
 
@@ -168,7 +168,7 @@ def nk_search(shared_state, start_time, request_from, search_string="", mirror=N
168
168
  published = convert_to_rss_date(date_text) if date_text else ""
169
169
 
170
170
  payload = urlsafe_b64encode(
171
- f"{title}|{source}|{mirror}|{mb}|{password}|{release_imdb_id}".encode("utf-8")).decode()
171
+ f"{title}|{source}|{mirror}|{mb}|{password}|{release_imdb_id}|{hostname}".encode("utf-8")).decode()
172
172
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
173
173
 
174
174
  releases.append({
@@ -59,7 +59,7 @@ def nx_feed(shared_state, start_time, request_from, mirror=None):
59
59
  imdb_id = item.get('_media', {}).get('imdbid', None)
60
60
  mb = shared_state.convert_to_mb(item)
61
61
  payload = urlsafe_b64encode(
62
- f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}".encode("utf-8")).decode(
62
+ f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode("utf-8")).decode(
63
63
  "utf-8")
64
64
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
65
65
  except:
@@ -142,7 +142,8 @@ def sf_feed(shared_state, start_time, request_from, mirror=None):
142
142
  imdb_id = None # imdb info is missing here
143
143
 
144
144
  payload = urlsafe_b64encode(
145
- f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}".encode("utf-8")).decode("utf-8")
145
+ f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode("utf-8")).decode(
146
+ "utf-8")
146
147
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
147
148
  except:
148
149
  continue
@@ -349,7 +350,8 @@ def sf_search(shared_state, start_time, request_from, search_string, mirror=None
349
350
  episode):
350
351
  continue
351
352
 
352
- payload = urlsafe_b64encode(f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}".encode()).decode()
353
+ payload = urlsafe_b64encode(
354
+ f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode()).decode()
353
355
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
354
356
  size_bytes = mb * 1024 * 1024
355
357
 
@@ -68,7 +68,7 @@ def sj_feed(shared_state, start_time, request_from, mirror=None):
68
68
  imdb_id = None
69
69
 
70
70
  payload = urlsafe_b64encode(
71
- f"{title}|{series_url}|{mirror}|{mb}|{password}|{imdb_id}".encode("utf-8")
71
+ f"{title}|{series_url}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode("utf-8")
72
72
  ).decode("utf-8")
73
73
 
74
74
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
@@ -186,7 +186,7 @@ def sj_search(shared_state, start_time, request_from, search_string, mirror=None
186
186
  size = 0
187
187
 
188
188
  payload = urlsafe_b64encode(
189
- f"{title}|{series_url}|{mirror}|{mb}|{password}|{imdb_id}".encode("utf-8")
189
+ f"{title}|{series_url}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode("utf-8")
190
190
  ).decode("utf-8")
191
191
 
192
192
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
@@ -6,9 +6,9 @@ import datetime
6
6
  import html
7
7
  import re
8
8
  import time
9
- from concurrent.futures import ThreadPoolExecutor, as_completed
10
9
  import xml.etree.ElementTree as ET
11
10
  from base64 import urlsafe_b64encode
11
+ from concurrent.futures import ThreadPoolExecutor, as_completed
12
12
  from urllib.parse import quote_plus
13
13
 
14
14
  import requests
@@ -90,7 +90,7 @@ def sl_feed(shared_state, start_time, request_from, mirror=None):
90
90
  imdb_id = m.group(1) if m else None
91
91
 
92
92
  payload = urlsafe_b64encode(
93
- f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}".encode("utf-8")
93
+ f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id}|{hostname}".encode("utf-8")
94
94
  ).decode("utf-8")
95
95
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
96
96
 
@@ -214,7 +214,7 @@ def sl_search(shared_state, start_time, request_from, search_string, mirror=None
214
214
  imdb_id = None
215
215
 
216
216
  payload = urlsafe_b64encode(
217
- f"{title}|{source}|{mirror}|0|{password}|{imdb_id}".encode('utf-8')
217
+ f"{title}|{source}|{mirror}|0|{password}|{imdb_id}|{hostname}".encode('utf-8')
218
218
  ).decode('utf-8')
219
219
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
220
220
 
@@ -128,7 +128,7 @@ def _parse_rows(
128
128
  published = convert_to_rss_date(date_txt) if date_txt else one_hour_ago
129
129
 
130
130
  payload = urlsafe_b64encode(
131
- f"{title}|{source}|{mirror_filter}|{mb}|{password}|{imdb_id}".encode()
131
+ f"{title}|{source}|{mirror_filter}|{mb}|{password}|{imdb_id}|{hostname}".encode()
132
132
  ).decode()
133
133
  download_link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
134
134
 
@@ -96,7 +96,7 @@ def wx_feed(shared_state, start_time, request_from, mirror=None):
96
96
  password = host.upper()
97
97
 
98
98
  payload = urlsafe_b64encode(
99
- f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id or ''}".encode("utf-8")
99
+ f"{title}|{source}|{mirror}|{mb}|{password}|{imdb_id or ''}|{hostname}".encode("utf-8")
100
100
  ).decode("utf-8")
101
101
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
102
102
 
@@ -253,7 +253,8 @@ def wx_search(shared_state, start_time, request_from, search_string, mirror=None
253
253
  password = f"www.{host}"
254
254
 
255
255
  payload = urlsafe_b64encode(
256
- f"{title}|{source}|{mirror}|0|{password}|{item_imdb_id or ''}".encode("utf-8")
256
+ f"{title}|{source}|{mirror}|0|{password}|{item_imdb_id or ''}|{hostname}".encode(
257
+ "utf-8")
257
258
  ).decode("utf-8")
258
259
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
259
260
 
@@ -309,7 +310,7 @@ def wx_search(shared_state, start_time, request_from, search_string, mirror=None
309
310
  password = f"www.{host}"
310
311
 
311
312
  payload = urlsafe_b64encode(
312
- f"{release_title}|{release_source}|{mirror}|{release_size}|{password}|{item_imdb_id or ''}".encode(
313
+ f"{release_title}|{release_source}|{mirror}|{release_size}|{password}|{item_imdb_id or ''}|{hostname}".encode(
313
314
  "utf-8")
314
315
  ).decode("utf-8")
315
316
  link = f"{shared_state.values['internal_address']}/download/?payload={payload}"
quasarr/storage/setup.py CHANGED
@@ -15,6 +15,7 @@ import quasarr.providers.sessions.al
15
15
  import quasarr.providers.sessions.dd
16
16
  import quasarr.providers.sessions.dl
17
17
  import quasarr.providers.sessions.nx
18
+ from quasarr.providers.auth import add_auth_routes, add_auth_hook
18
19
  from quasarr.providers.html_templates import render_button, render_form, render_success, render_fail, \
19
20
  render_centered_html
20
21
  from quasarr.providers.log import info
@@ -91,9 +92,16 @@ def add_no_cache_headers(app):
91
92
  response.set_header('Expires', '0')
92
93
 
93
94
 
95
+ def setup_auth(app):
96
+ """Add authentication to setup app if enabled."""
97
+ add_auth_routes(app)
98
+ add_auth_hook(app)
99
+
100
+
94
101
  def path_config(shared_state):
95
102
  app = Bottle()
96
103
  add_no_cache_headers(app)
104
+ setup_auth(app)
97
105
 
98
106
  current_path = os.path.dirname(os.path.abspath(sys.argv[0]))
99
107
 
@@ -560,6 +568,7 @@ def save_hostnames(shared_state, timeout=5, first_run=True):
560
568
  def hostnames_config(shared_state):
561
569
  app = Bottle()
562
570
  add_no_cache_headers(app)
571
+ setup_auth(app)
563
572
 
564
573
  @app.get('/')
565
574
  def hostname_form():
@@ -638,6 +647,7 @@ def hostnames_config(shared_state):
638
647
  def hostname_credentials_config(shared_state, shorthand, domain):
639
648
  app = Bottle()
640
649
  add_no_cache_headers(app)
650
+ setup_auth(app)
641
651
 
642
652
  shorthand = shorthand.upper()
643
653
 
@@ -793,6 +803,7 @@ def hostname_credentials_config(shared_state, shorthand, domain):
793
803
  def flaresolverr_config(shared_state):
794
804
  app = Bottle()
795
805
  add_no_cache_headers(app)
806
+ setup_auth(app)
796
807
 
797
808
  @app.get('/')
798
809
  def url_form():
@@ -933,6 +944,7 @@ def flaresolverr_config(shared_state):
933
944
  def jdownloader_config(shared_state):
934
945
  app = Bottle()
935
946
  add_no_cache_headers(app)
947
+ setup_auth(app)
936
948
 
937
949
  @app.get('/')
938
950
  def jd_form():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quasarr
3
- Version: 1.32.0
3
+ Version: 2.1.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
@@ -25,7 +25,7 @@ Dynamic: license-file
25
25
  Dynamic: requires-dist
26
26
  Dynamic: summary
27
27
 
28
- #
28
+ #
29
29
 
30
30
  <img src="https://raw.githubusercontent.com/rix1337/Quasarr/main/Quasarr.png" data-canonical-src="https://raw.githubusercontent.com/rix1337/Quasarr/main/Quasarr.png" width="64" height="64" />
31
31
 
@@ -41,7 +41,8 @@ indexers. It simply does not know what NZB files are.
41
41
  Quasarr includes a solution to quickly and easily decrypt protected links.
42
42
  [Active monthly Sponsors get access to SponsorsHelper to do so automatically.](https://github.com/rix1337/Quasarr?tab=readme-ov-file#sponsorshelper)
43
43
  Alternatively, follow the link from the console output (or discord notification) to solve CAPTCHAs manually.
44
- Quasarr will confidently handle the rest. Some CAPTCHA types require [Tampermonkey](https://www.tampermonkey.net/) to be installed in your browser.
44
+ Quasarr will confidently handle the rest. Some CAPTCHA types require [Tampermonkey](https://www.tampermonkey.net/) to be
45
+ installed in your browser.
45
46
 
46
47
  # Instructions
47
48
 
@@ -56,9 +57,11 @@ Quasarr will confidently handle the rest. Some CAPTCHA types require [Tampermonk
56
57
 
57
58
  ## FlareSolverr (Optional)
58
59
 
59
- FlareSolverr is **optional** but **required for some sites** (e.g., AL) that use Cloudflare protection. You can skip FlareSolverr during setup and configure it later via the web UI.
60
+ FlareSolverr is **optional** but **required for some sites** (e.g., AL) that use Cloudflare protection. You can skip
61
+ FlareSolverr during setup and configure it later via the web UI.
60
62
 
61
63
  If using FlareSolverr, provide your URL including the version path:
64
+
62
65
  ```
63
66
  http://192.168.1.1:8191/v1
64
67
  ```
@@ -69,18 +72,26 @@ http://192.168.1.1:8191/v1
69
72
 
70
73
  ## Quasarr
71
74
 
72
- > ⚠️ Quasarr requires at least one valid hostname to start. It does not provide or endorse any specific sources, but community-maintained lists are available:
75
+ > ⚠️ Quasarr requires at least one valid hostname to start. It does not provide or endorse any specific sources, but
76
+ > community-maintained lists are available:
73
77
 
74
78
  🔗 **[https://quasarr-host.name](https://quasarr-host.name)** — community guide for finding hostnames
75
79
 
76
- 📋 Alternatively, browse community suggestions via [pastebin search](https://pastebin.com/search?q=hostnames+quasarr) (login required).
80
+ 📋 Alternatively, browse community suggestions via [pastebin search](https://pastebin.com/search?q=hostnames+quasarr) (
81
+ login required).
82
+
83
+ > Authentication is optional but strongly recommended.
84
+ >
85
+ > - 🔐 Set `USER` and `PASS` to enable form-based login (30-day session)
86
+ > - 🔑 Set `AUTH=basic` to use HTTP Basic Authentication instead
77
87
 
78
88
  ---
79
89
 
80
90
  ## JDownloader
81
91
 
82
- > ⚠️ If using Docker:
83
- > JDownloader's download path must be available to Radarr/Sonarr/LazyLibrarian with **identical internal and external path mappings**!
92
+ > ⚠️ If using Docker:
93
+ > JDownloader's download path must be available to Radarr/Sonarr/LazyLibrarian with **identical internal and external
94
+ path mappings**!
84
95
  > Matching only the external path is not sufficient.
85
96
 
86
97
  1. Start and connect JDownloader to [My JDownloader](https://my.jdownloader.org)
@@ -89,7 +100,8 @@ http://192.168.1.1:8191/v1
89
100
  <details>
90
101
  <summary>Fresh install recommended</summary>
91
102
 
92
- Consider setting up a fresh JDownloader instance. Quasarr will modify JDownloader's settings to enable Radarr/Sonarr/LazyLibrarian integration.
103
+ Consider setting up a fresh JDownloader instance. Quasarr will modify JDownloader's settings to enable
104
+ Radarr/Sonarr/LazyLibrarian integration.
93
105
 
94
106
  </details>
95
107
 
@@ -97,7 +109,8 @@ Consider setting up a fresh JDownloader instance. Quasarr will modify JDownloade
97
109
 
98
110
  ## Radarr / Sonarr
99
111
 
100
- > ⚠️ **Sonarr users:** Set all shows (including anime) to the **Standard** series type. Quasarr cannot find releases for shows set to Anime/Absolute.
112
+ > ⚠️ **Sonarr users:** Set all shows (including anime) to the **Standard** series type. Quasarr cannot find releases for
113
+ > shows set to Anime/Absolute.
101
114
 
102
115
 
103
116
  Add Quasarr as both a **Newznab Indexer** and **SABnzbd Download Client** using your Quasarr URL and API Key.
@@ -113,9 +126,11 @@ Add Quasarr as both a **Newznab Indexer** and **SABnzbd Download Client** using
113
126
  <summary>Restrict results to a specific mirror</summary>
114
127
 
115
128
  Append the mirror name to your Newznab URL:
129
+
116
130
  ```
117
131
  /api/dropbox/
118
132
  ```
133
+
119
134
  Only releases with `dropbox` in a link will be returned. If the mirror isn't available, the release will fail.
120
135
 
121
136
  </details>
@@ -131,27 +146,29 @@ Only releases with `dropbox` in a link will be returned. If the mirror isn't ava
131
146
 
132
147
  ### SABnzbd+ Downloader
133
148
 
134
- | Setting | Value |
135
- |---------|-------|
149
+ | Setting | Value |
150
+ |----------|----------------------------|
136
151
  | URL/Port | Your Quasarr host and port |
137
- | API Key | Your Quasarr API Key |
138
- | Category | `docs` |
152
+ | API Key | Your Quasarr API Key |
153
+ | Category | `docs` |
139
154
 
140
155
  ### Newznab Provider
141
156
 
142
- | Setting | Value |
143
- |---------|-------|
144
- | URL | Your Quasarr URL |
145
- | API | Your Quasarr API Key |
157
+ | Setting | Value |
158
+ |---------|----------------------|
159
+ | URL | Your Quasarr URL |
160
+ | API | Your Quasarr API Key |
146
161
 
147
162
  ### Fix Import & Processing
148
163
 
149
164
  **Importing:**
165
+
150
166
  - Enable `OpenLibrary api for book/author information`
151
167
  - Set Primary Information Source to `OpenLibrary`
152
168
  - Add to Import languages: `, Unknown` (German users: `, de, ger, de-DE`)
153
169
 
154
170
  **Processing → Folders:**
171
+
155
172
  - Add your Quasarr download path (typically `/downloads/Quasarr/`)
156
173
 
157
174
  </details>
@@ -170,7 +187,10 @@ docker run -d \
170
187
  -e 'INTERNAL_ADDRESS'='http://192.168.0.1:8080' \
171
188
  -e 'EXTERNAL_ADDRESS'='https://foo.bar/' \
172
189
  -e 'DISCORD'='https://discord.com/api/webhooks/1234567890/ABCDEFGHIJKLMN' \
173
- -e 'HOSTNAMES'='https://quasarr-host.name/ini?token=123...'
190
+ -e 'HOSTNAMES'='https://quasarr-host.name/ini?token=123...' \
191
+ -e 'USER'='admin' \
192
+ -e 'PASS'='change-me' \
193
+ -e 'AUTH'='form' \
174
194
  -e 'SILENT'='True' \
175
195
  -e 'DEBUG'='' \
176
196
  -e 'TZ'='Europe/Berlin' \
@@ -184,6 +204,8 @@ docker run -d \
184
204
  * Must be a publicly available `HTTP` or `HTTPs` link
185
205
  * Must be a raw `.ini` / text file (not HTML or JSON)
186
206
  * Must contain at least one valid Hostname per line `ab = xyz`
207
+ * `USER` / `PASS` are credentials to protect the web UI
208
+ * `AUTH` is the authentication mode (`form` or `basic`)
187
209
  * `SILENT` is optional and silences all discord notifications except for error messages from SponsorsHelper if `True`.
188
210
  * `DEBUG` is optional and enables debug logging if `True`.
189
211
  * `TZ` is optional, wrong timezone can cause HTTPS/SSL issues
@@ -226,7 +248,8 @@ Most feature requests can be satisfied by:
226
248
  - There are no hostname integrations in active development unless you see an open pull request
227
249
  [here](https://github.com/rix1337/Quasarr/pulls).
228
250
  - **Pull requests are welcome!** Especially for popular hostnames.
229
- - A short guide to set up required dev services is found in [/docker/dev-setup.md](https://github.com/rix1337/Quasarr/blob/main/docker/dev-setup.md)
251
+ - A short guide to set up required dev services is found
252
+ in [/docker/dev-setup.md](https://github.com/rix1337/Quasarr/blob/main/docker/dev-setup.md)
230
253
  - Always reach out on Discord before starting work on a new feature to prevent waste of time.
231
254
  - Please follow the existing code style and project structure.
232
255
  - Anti-bot measures must be circumvented fully by Quasarr. Thus, you will need to provide a working solution for new
@@ -248,11 +271,11 @@ Image access is limited to [active monthly GitHub sponsors](https://github.com/u
248
271
 
249
272
  1. Start your [sponsorship](https://github.com/users/rix1337/sponsorship) first.
250
273
  2. Open [GitHub Classic Token Settings](https://github.com/settings/tokens/new?type=classic)
251
- 3. Name it (e.g., `SponsorsHelper`) and choose unlimited expiration
274
+ 3. Name it (e.g., `SponsorsHelper`) and choose unlimited expiration
252
275
  4. Enable these scopes:
253
- - `read:packages`
254
- - `read:user`
255
- - `read:org`
276
+ - `read:packages`
277
+ - `read:user`
278
+ - `read:org`
256
279
  5. Click **Generate token** and copy it for the next steps
257
280
 
258
281
  ---