quasarr 2.6.1__py3-none-any.whl → 2.7.1__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 (54) hide show
  1. quasarr/__init__.py +71 -61
  2. quasarr/api/__init__.py +1 -2
  3. quasarr/api/arr/__init__.py +66 -57
  4. quasarr/api/captcha/__init__.py +203 -154
  5. quasarr/downloads/__init__.py +12 -8
  6. quasarr/downloads/linkcrypters/al.py +4 -4
  7. quasarr/downloads/linkcrypters/filecrypt.py +1 -2
  8. quasarr/downloads/packages/__init__.py +62 -88
  9. quasarr/downloads/sources/al.py +3 -3
  10. quasarr/downloads/sources/by.py +3 -3
  11. quasarr/downloads/sources/he.py +8 -9
  12. quasarr/downloads/sources/nk.py +3 -3
  13. quasarr/downloads/sources/sl.py +6 -1
  14. quasarr/downloads/sources/wd.py +93 -37
  15. quasarr/downloads/sources/wx.py +11 -17
  16. quasarr/providers/auth.py +9 -13
  17. quasarr/providers/cloudflare.py +5 -4
  18. quasarr/providers/imdb_metadata.py +1 -3
  19. quasarr/providers/jd_cache.py +64 -90
  20. quasarr/providers/log.py +226 -8
  21. quasarr/providers/myjd_api.py +116 -94
  22. quasarr/providers/sessions/al.py +20 -22
  23. quasarr/providers/sessions/dd.py +1 -1
  24. quasarr/providers/sessions/dl.py +8 -10
  25. quasarr/providers/sessions/nx.py +1 -1
  26. quasarr/providers/shared_state.py +26 -15
  27. quasarr/providers/utils.py +15 -6
  28. quasarr/providers/version.py +1 -1
  29. quasarr/search/__init__.py +113 -82
  30. quasarr/search/sources/al.py +19 -23
  31. quasarr/search/sources/by.py +6 -6
  32. quasarr/search/sources/dd.py +8 -10
  33. quasarr/search/sources/dj.py +15 -18
  34. quasarr/search/sources/dl.py +25 -37
  35. quasarr/search/sources/dt.py +13 -15
  36. quasarr/search/sources/dw.py +24 -16
  37. quasarr/search/sources/fx.py +25 -11
  38. quasarr/search/sources/he.py +16 -14
  39. quasarr/search/sources/hs.py +7 -7
  40. quasarr/search/sources/mb.py +7 -7
  41. quasarr/search/sources/nk.py +24 -25
  42. quasarr/search/sources/nx.py +22 -15
  43. quasarr/search/sources/sf.py +18 -9
  44. quasarr/search/sources/sj.py +7 -7
  45. quasarr/search/sources/sl.py +26 -14
  46. quasarr/search/sources/wd.py +61 -31
  47. quasarr/search/sources/wx.py +33 -47
  48. quasarr/storage/config.py +1 -3
  49. {quasarr-2.6.1.dist-info → quasarr-2.7.1.dist-info}/METADATA +4 -1
  50. quasarr-2.7.1.dist-info/RECORD +84 -0
  51. quasarr-2.6.1.dist-info/RECORD +0 -84
  52. {quasarr-2.6.1.dist-info → quasarr-2.7.1.dist-info}/WHEEL +0 -0
  53. {quasarr-2.6.1.dist-info → quasarr-2.7.1.dist-info}/entry_points.txt +0 -0
  54. {quasarr-2.6.1.dist-info → quasarr-2.7.1.dist-info}/licenses/LICENSE +0 -0
quasarr/__init__.py CHANGED
@@ -15,7 +15,14 @@ import requests
15
15
  import quasarr.providers.web_server
16
16
  from quasarr.api import get_api
17
17
  from quasarr.providers import shared_state, version
18
- from quasarr.providers.log import info
18
+ from quasarr.providers.log import (
19
+ crit,
20
+ debug,
21
+ error,
22
+ get_log_level,
23
+ info,
24
+ log_level_names,
25
+ )
19
26
  from quasarr.providers.notifications import send_discord_message
20
27
  from quasarr.providers.utils import (
21
28
  FALLBACK_USER_AGENT,
@@ -67,26 +74,21 @@ def run():
67
74
  └────────────────────────────────────┘""")
68
75
 
69
76
  print("\n===== Recommended Services =====")
77
+ print('👉 Fast premium downloads: "https://linksnappy.com/?ref=397097" 👈')
70
78
  print(
71
- 'For convenient universal premium downloads use: "https://linksnappy.com/?ref=397097"'
72
- )
73
- print(
74
- 'Sponsors get automated CAPTCHA solutions: "https://github.com/rix1337/Quasarr?tab=readme-ov-file#sponsorshelper"'
79
+ '👉 Automated CAPTCHA solutions: "https://github.com/rix1337/Quasarr?tab=readme-ov-file#sponsorshelper" 👈'
75
80
  )
76
81
 
77
- print("\n===== Startup Info =====")
78
82
  port = int("8080")
79
83
  config_path = ""
80
84
  if os.environ.get("DOCKER"):
81
85
  config_path = "/config"
82
86
  if not arguments.internal_address:
83
- print(
87
+ error(
84
88
  "You must set the INTERNAL_ADDRESS variable to a locally reachable URL, e.g. http://192.168.1.1:8080"
89
+ + " The local URL will be used by Radarr/Sonarr to connect to Quasarr"
90
+ + " Stopping Quasarr..."
85
91
  )
86
- print(
87
- "The local URL will be used by Radarr/Sonarr to connect to Quasarr"
88
- )
89
- print("Stopping Quasarr...")
90
92
  sys.exit(1)
91
93
  else:
92
94
  if arguments.port:
@@ -118,7 +120,7 @@ def run():
118
120
  temp_file = tempfile.TemporaryFile(dir=config_path)
119
121
  temp_file.close()
120
122
  except Exception as e:
121
- print(f'Could not access "{config_path}": {e}"Stopping Quasarr...')
123
+ error(f'Could not access "{config_path}": {e}"Stopping Quasarr...')
122
124
  sys.exit(1)
123
125
 
124
126
  shared_state.set_files(config_path)
@@ -130,16 +132,13 @@ def run():
130
132
  shared_state.update("user_agent", FALLBACK_USER_AGENT)
131
133
  shared_state.update("helper_active", False)
132
134
 
133
- print(f'Config path: "{config_path}"')
134
-
135
- print("\n===== Hostnames =====")
136
135
  try:
137
136
  if arguments.hostnames:
138
137
  hostnames_link = arguments.hostnames
139
138
  if is_valid_url(hostnames_link):
140
139
  # Store the hostnames URL for later use in web UI
141
140
  Config("Settings").save("hostnames_url", hostnames_link)
142
- print(f"Extracting hostnames from {hostnames_link}...")
141
+ info(f"Extracting hostnames from {hostnames_link}...")
143
142
  allowed_keys = supported_hostnames
144
143
  max_keys = len(allowed_keys)
145
144
  shorthand_list = (
@@ -147,7 +146,7 @@ def run():
147
146
  + " and "
148
147
  + f'"{allowed_keys[-1]}"'
149
148
  )
150
- print(
149
+ info(
151
150
  f"There are up to {max_keys} hostnames currently supported: {shorthand_list}"
152
151
  )
153
152
  data = requests.get(hostnames_link).text
@@ -165,36 +164,32 @@ def run():
165
164
  if valid_domain:
166
165
  hostnames.save(shorthand, hostname)
167
166
  extracted_hostnames += 1
168
- print(
167
+ info(
169
168
  f'Hostname for "{shorthand}" successfully set to "{hostname}"'
170
169
  )
171
170
  else:
172
- print(
171
+ info(
173
172
  f'Skipping invalid hostname for "{shorthand}" ("{hostname}")'
174
173
  )
175
174
  if extracted_hostnames == max_keys:
176
- print(f"All {max_keys} hostnames successfully extracted!")
177
- print(
175
+ info(f"All {max_keys} hostnames successfully extracted!")
176
+ info(
178
177
  "You can now remove the hostnames link from the command line / environment variable."
179
178
  )
180
179
  else:
181
- print(
180
+ info(
182
181
  f'No Hostnames found at "{hostnames_link}". '
183
182
  "Ensure to pass a plain hostnames list, not html or json!"
184
183
  )
185
184
  else:
186
- print(f'Invalid hostnames URL: "{hostnames_link}"')
185
+ error(f'Invalid hostnames URL: "{hostnames_link}"')
187
186
  except Exception as e:
188
- print(f'Error parsing hostnames link: "{e}"')
187
+ error(f'Error parsing hostnames link: "{e}"')
189
188
 
190
189
  hostnames = get_clean_hostnames(shared_state)
191
190
  if not hostnames:
192
191
  hostnames_config(shared_state)
193
192
  hostnames = get_clean_hostnames(shared_state)
194
- print(
195
- f"You have [{len(hostnames)} of {len(Config._DEFAULT_CONFIG['Hostnames'])}] supported hostnames set up"
196
- )
197
- print("For efficiency it is recommended to set up as few hostnames as needed.")
198
193
 
199
194
  # Check credentials for login-required hostnames
200
195
  skip_login_db = DataBase("skip_login")
@@ -237,43 +232,54 @@ def run():
237
232
  if not user or not password or not device:
238
233
  jdownloader_config(shared_state)
239
234
 
240
- print("\n===== Notifications =====")
241
235
  discord_url = ""
242
236
  if arguments.discord:
243
237
  discord_webhook_pattern = r"^https://discord\.com/api/webhooks/\d+/[\w-]+$"
244
238
  if re.match(discord_webhook_pattern, arguments.discord):
245
239
  shared_state.update("webhook", arguments.discord)
246
- print("Using Discord Webhook URL for notifications.")
247
240
  discord_url = arguments.discord
248
241
  else:
249
- print(f"Invalid Discord Webhook URL provided: {arguments.discord}")
250
- else:
251
- print("No Discord Webhook URL provided")
242
+ error(f"Invalid Discord Webhook URL provided: {arguments.discord}")
252
243
  shared_state.update("discord", discord_url)
253
244
 
254
- print("\n===== API Information =====")
255
245
  api_key = Config("API").get("key")
256
246
  if not api_key:
257
247
  api_key = shared_state.generate_api_key()
248
+ info("API-Key generated: <g>" + api_key + "</g>")
258
249
 
259
- print(
260
- 'Setup instructions: "https://github.com/rix1337/Quasarr?tab=readme-ov-file#instructions"'
261
- )
262
- print(f'URL: "{shared_state.values["internal_address"]}"')
263
- print(f'API Key: "{api_key}" (without quotes)')
250
+ print(f"\n===== Quasarr {log_level_names[get_log_level()]} Log =====")
264
251
 
265
- if external_address != internal_address:
266
- print(f'External URL: "{shared_state.values["external_address"]}"')
252
+ # Start Logging
253
+ info(f"Web UI: <blue>{shared_state.values['external_address']}</blue>")
254
+ debug(f'Config path: "{config_path}"')
267
255
 
268
- print("\n===== Quasarr Info Log =====")
269
- if os.getenv("DEBUG"):
270
- print("===== / Debug Log =====")
256
+ # Hostnames log
257
+ hostnames_log = []
258
+ set_hostnames_count = 0
259
+ for key in supported_hostnames:
260
+ if key in hostnames:
261
+ hostnames_log.append(
262
+ f"<bg green><black>{key.upper()}</black></bg green>"
263
+ )
264
+ set_hostnames_count += 1
265
+ else:
266
+ hostnames_log.append(
267
+ f"<bg black><white>{key.upper()}</white></bg black>"
268
+ )
269
+
270
+ total_hostnames_count = len(supported_hostnames)
271
+ if set_hostnames_count == total_hostnames_count:
272
+ count_str = f"<g>{set_hostnames_count}</g>/<g>{total_hostnames_count}</g>"
273
+ else:
274
+ count_str = f"<y>{set_hostnames_count}</y>/<g>{total_hostnames_count}</g>"
275
+
276
+ info(f"Hostnames: [{' '.join(hostnames_log)}] {count_str} set")
271
277
 
272
278
  protected = shared_state.get_db("protected").retrieve_all_titles()
273
279
  if protected:
274
280
  package_count = len(protected)
275
281
  info(
276
- f"CAPTCHA-Solution required for {package_count} package{'s' if package_count > 1 else ''} at: "
282
+ f"CAPTCHA-Solution required for <y>{package_count}</y> package{'s' if package_count > 1 else ''} at: "
277
283
  f'"{shared_state.values["external_address"]}/captcha"!'
278
284
  )
279
285
 
@@ -329,21 +335,26 @@ def flaresolverr_checker(shared_state_dict, shared_state_lock):
329
335
  "Some sites (AL) will not work without FlareSolverr. Configure it later in the web UI."
330
336
  )
331
337
  elif flaresolverr_url:
332
- info(f'Checking FlareSolverr at URL: "{flaresolverr_url}"')
333
- flaresolverr_check = check_flaresolverr(shared_state, flaresolverr_url)
334
- if flaresolverr_check:
338
+ debug(f"Checking FlareSolverr at URL: <blue>{flaresolverr_url}</blue>")
339
+ flaresolverr_version_checked = check_flaresolverr(
340
+ shared_state, flaresolverr_url
341
+ )
342
+ if flaresolverr_version_checked:
335
343
  info(
336
- f'FlareSolverr connection successful. Using User-Agent: "{shared_state.values["user_agent"]}"'
344
+ f"FlareSolverr connection successful: <g>v.{flaresolverr_version_checked}</g>"
345
+ )
346
+ debug(
347
+ f"Using Flaresolverr's User-Agent: <g>{shared_state.values['user_agent']}</g>"
337
348
  )
338
349
  else:
339
- info("FlareSolverr check failed - using fallback user agent")
350
+ error("FlareSolverr check failed - using fallback user agent")
340
351
  # Fallback user agent is already set in main process, but we log it
341
352
  info(f'User Agent (fallback): "{FALLBACK_USER_AGENT}"')
342
353
 
343
354
  except KeyboardInterrupt:
344
355
  pass
345
356
  except Exception as e:
346
- info(f"An unexpected error occurred in FlareSolverr checker: {e}")
357
+ error(f"An unexpected error occurred in FlareSolverr checker: {e}")
347
358
 
348
359
 
349
360
  def update_checker(shared_state_dict, shared_state_lock):
@@ -359,8 +370,9 @@ def update_checker(shared_state_dict, shared_state_lock):
359
370
  try:
360
371
  update_available = version.newer_version_available()
361
372
  except Exception as e:
362
- info(f"Error getting latest version: {e}")
363
- info(f'Please manually check: "{link}" for more information!')
373
+ error(
374
+ f"Error getting latest version: {e}!\nPlease manually check: <blue>{link}</blue> for more information!"
375
+ )
364
376
  update_available = None
365
377
 
366
378
  if (
@@ -392,27 +404,25 @@ def jdownloader_connection(shared_state_dict, shared_state_lock):
392
404
  device = shared_state.get_device()
393
405
 
394
406
  try:
395
- info(
396
- f'Connection to JDownloader successful. Device name: "{device.name}"'
397
- )
407
+ info(f"Connection to JDownloader successful: <g>{device.name}</g>")
398
408
  except Exception as e:
399
- info(f"Error connecting to JDownloader: {e}! Stopping Quasarr...")
409
+ crit(f"Error connecting to JDownloader: {e}! Stopping Quasarr...")
400
410
  sys.exit(1)
401
411
 
402
412
  try:
403
413
  shared_state.set_device_settings()
404
414
  except Exception as e:
405
- print(f"Error checking settings: {e}")
415
+ error(f"Error checking settings: {e}")
406
416
 
407
417
  try:
408
418
  shared_state.update_jdownloader()
409
419
  except Exception as e:
410
- print(f"Error updating JDownloader: {e}")
420
+ error(f"Error updating JDownloader: {e}")
411
421
 
412
422
  try:
413
423
  shared_state.start_downloads()
414
424
  except Exception as e:
415
- print(f"Error starting downloads: {e}")
425
+ error(f"Error starting downloads: {e}")
416
426
 
417
427
  while True:
418
428
  time.sleep(300)
@@ -420,7 +430,7 @@ def jdownloader_connection(shared_state_dict, shared_state_lock):
420
430
  shared_state.values.get("device")
421
431
  )
422
432
  if not device_state:
423
- info("Lost connection to JDownloader. Reconnecting...")
433
+ error("Lost connection to JDownloader. Reconnecting...")
424
434
  shared_state.update("device", False)
425
435
  break
426
436
 
quasarr/api/__init__.py CHANGED
@@ -34,8 +34,7 @@ def get_api(shared_state_dict, shared_state_lock):
34
34
  add_auth_routes(app)
35
35
  add_auth_hook(
36
36
  app,
37
- whitelist_prefixes=["/api", "/api/", "/sponsors_helper/", "/download/"],
38
- whitelist_suffixes=[".user.js"],
37
+ whitelist=["/api", "/api/", "/sponsors_helper/", "/download/", ".user.js"],
39
38
  )
40
39
 
41
40
  setup_arr_routes(app)
@@ -59,7 +59,8 @@ def setup_arr_routes(app):
59
59
  def check_user_agent():
60
60
  user_agent = request.headers.get("User-Agent") or ""
61
61
  if not any(
62
- tool in user_agent.lower() for tool in ["radarr", "sonarr", "lazylibrarian"]
62
+ tool in user_agent.lower()
63
+ for tool in ["radarr", "sonarr", "lazylibrarian", "python-requests"]
63
64
  ):
64
65
  msg = f"Unsupported User-Agent: {user_agent}. Quasarr as a compatibility layer must be called by Radarr, Sonarr or LazyLibrarian directly."
65
66
  info(msg)
@@ -107,7 +108,7 @@ def setup_arr_routes(app):
107
108
  imdb_id = root.find(".//file").attrib.get("imdb_id")
108
109
  source_key = root.find(".//file").attrib.get("source_key") or None
109
110
 
110
- info(f'Attempting download for "{title}"')
111
+ info(f"Attempting download for <y>{title}</y>")
111
112
  downloaded = download(
112
113
  shared_state,
113
114
  request_from,
@@ -125,12 +126,12 @@ def setup_arr_routes(app):
125
126
  title = downloaded["title"]
126
127
 
127
128
  if success:
128
- info(f'"{title}" added successfully!')
129
+ info(f"<y>{title}</y> added successfully!")
129
130
  else:
130
- info(f'"{title}" added unsuccessfully! See log for details.')
131
+ info(f"<y>{title}</y> added unsuccessfully! See log for details.")
131
132
  nzo_ids.append(package_id)
132
133
  except KeyError:
133
- info(f'Failed to download "{title}" - no package_id returned')
134
+ info(f"Failed to download <y>{title}</y> - no package_id returned")
134
135
 
135
136
  return {"status": True, "nzo_ids": nzo_ids}
136
137
 
@@ -209,7 +210,7 @@ def setup_arr_routes(app):
209
210
  abort(400, f"invalid payload format: {e}")
210
211
 
211
212
  nzo_ids = []
212
- info(f'Attempting download for "{parsed_payload["title"]}"')
213
+ info(f"Attempting download for <y>{parsed_payload['title']}</y>")
213
214
 
214
215
  downloaded = download(
215
216
  shared_state,
@@ -229,11 +230,9 @@ def setup_arr_routes(app):
229
230
  title = downloaded.get("title", parsed_payload["title"])
230
231
 
231
232
  if success:
232
- info(f'"{title}" added successfully!')
233
+ info(f'"{title} added successfully!')
233
234
  else:
234
- info(
235
- f'"{title}" added unsuccessfully! See log for details.'
236
- )
235
+ info(f'"{title} added unsuccessfully! See log for details.')
237
236
  nzo_ids.append(package_id)
238
237
  except KeyError:
239
238
  info(
@@ -306,66 +305,76 @@ def setup_arr_routes(app):
306
305
  releases = []
307
306
 
308
307
  try:
309
- offset = int(getattr(request.query, "offset", 0))
310
- except (AttributeError, ValueError):
308
+ offset = int(getattr(request.query, "offset", 0) or 0)
309
+ except (AttributeError, ValueError) as e:
310
+ debug(f"Error parsing offset parameter: {e}")
311
311
  offset = 0
312
312
 
313
- if offset > 0:
314
- debug(
315
- f"Ignoring offset parameter: {offset} - it leads to redundant requests"
313
+ try:
314
+ limit = int(getattr(request.query, "limit", 9999) or 9999)
315
+ except (AttributeError, ValueError) as e:
316
+ debug(f"Error parsing limit parameter: {e}")
317
+ limit = 1000
318
+
319
+ if mode == "movie":
320
+ # supported params: imdbid
321
+ imdb_id = getattr(request.query, "imdbid", "")
322
+ releases = get_search_results(
323
+ shared_state,
324
+ request_from,
325
+ imdb_id=imdb_id,
326
+ mirror=mirror,
327
+ offset=offset,
328
+ limit=limit,
316
329
  )
317
330
 
318
- else:
319
- if mode == "movie":
320
- # supported params: imdbid
321
- imdb_id = getattr(request.query, "imdbid", "")
331
+ elif mode == "tvsearch":
332
+ # supported params: imdbid, season, ep
333
+ imdb_id = getattr(request.query, "imdbid", "")
334
+ season = getattr(request.query, "season", None)
335
+ episode = getattr(request.query, "ep", None)
336
+ releases = get_search_results(
337
+ shared_state,
338
+ request_from,
339
+ imdb_id=imdb_id,
340
+ mirror=mirror,
341
+ season=season,
342
+ episode=episode,
343
+ offset=offset,
344
+ limit=limit,
345
+ )
322
346
 
323
- releases = get_search_results(
324
- shared_state,
325
- request_from,
326
- imdb_id=imdb_id,
327
- mirror=mirror,
328
- )
347
+ elif mode == "book":
348
+ author = getattr(request.query, "author", "")
349
+ title = getattr(request.query, "title", "")
350
+ search_phrase = " ".join(filter(None, [author, title]))
351
+ releases = get_search_results(
352
+ shared_state,
353
+ request_from,
354
+ search_phrase=search_phrase,
355
+ mirror=mirror,
356
+ offset=offset,
357
+ limit=limit,
358
+ )
329
359
 
330
- elif mode == "tvsearch":
331
- # supported params: imdbid, season, ep
332
- imdb_id = getattr(request.query, "imdbid", "")
333
- season = getattr(request.query, "season", None)
334
- episode = getattr(request.query, "ep", None)
335
- releases = get_search_results(
336
- shared_state,
337
- request_from,
338
- imdb_id=imdb_id,
339
- mirror=mirror,
340
- season=season,
341
- episode=episode,
342
- )
343
- elif mode == "book":
344
- author = getattr(request.query, "author", "")
345
- title = getattr(request.query, "title", "")
346
- search_phrase = " ".join(filter(None, [author, title]))
360
+ elif mode == "search":
361
+ if "lazylibrarian" in request_from.lower():
362
+ search_phrase = getattr(request.query, "q", "")
347
363
  releases = get_search_results(
348
364
  shared_state,
349
365
  request_from,
350
366
  search_phrase=search_phrase,
351
367
  mirror=mirror,
368
+ offset=offset,
369
+ limit=limit,
370
+ )
371
+ else:
372
+ # sonarr expects this but we will not support non-imdbid searches
373
+ debug(
374
+ f"Ignoring search request from {request_from} - only imdbid searches are supported"
352
375
  )
353
376
 
354
- elif mode == "search":
355
- if "lazylibrarian" in request_from.lower():
356
- search_phrase = getattr(request.query, "q", "")
357
- releases = get_search_results(
358
- shared_state,
359
- request_from,
360
- search_phrase=search_phrase,
361
- mirror=mirror,
362
- )
363
- else:
364
- # sonarr expects this but we will not support non-imdbid searches
365
- debug(
366
- f"Ignoring search request from {request_from} - only imdbid searches are supported"
367
- )
368
-
377
+ # XML Generation (releases are already sliced)
369
378
  items = ""
370
379
  for release in releases:
371
380
  release = release.get("details", {})