quasarr 0.1.6__py3-none-any.whl → 1.23.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 (77) hide show
  1. quasarr/__init__.py +316 -42
  2. quasarr/api/__init__.py +187 -0
  3. quasarr/api/arr/__init__.py +387 -0
  4. quasarr/api/captcha/__init__.py +1189 -0
  5. quasarr/api/config/__init__.py +23 -0
  6. quasarr/api/sponsors_helper/__init__.py +166 -0
  7. quasarr/api/statistics/__init__.py +196 -0
  8. quasarr/downloads/__init__.py +319 -256
  9. quasarr/downloads/linkcrypters/__init__.py +0 -0
  10. quasarr/downloads/linkcrypters/al.py +237 -0
  11. quasarr/downloads/linkcrypters/filecrypt.py +444 -0
  12. quasarr/downloads/linkcrypters/hide.py +123 -0
  13. quasarr/downloads/packages/__init__.py +476 -0
  14. quasarr/downloads/sources/al.py +697 -0
  15. quasarr/downloads/sources/by.py +106 -0
  16. quasarr/downloads/sources/dd.py +76 -0
  17. quasarr/downloads/sources/dj.py +7 -0
  18. quasarr/downloads/sources/dl.py +199 -0
  19. quasarr/downloads/sources/dt.py +66 -0
  20. quasarr/downloads/sources/dw.py +14 -7
  21. quasarr/downloads/sources/he.py +112 -0
  22. quasarr/downloads/sources/mb.py +47 -0
  23. quasarr/downloads/sources/nk.py +54 -0
  24. quasarr/downloads/sources/nx.py +42 -83
  25. quasarr/downloads/sources/sf.py +159 -0
  26. quasarr/downloads/sources/sj.py +7 -0
  27. quasarr/downloads/sources/sl.py +90 -0
  28. quasarr/downloads/sources/wd.py +110 -0
  29. quasarr/downloads/sources/wx.py +127 -0
  30. quasarr/providers/cloudflare.py +204 -0
  31. quasarr/providers/html_images.py +22 -0
  32. quasarr/providers/html_templates.py +211 -104
  33. quasarr/providers/imdb_metadata.py +108 -3
  34. quasarr/providers/log.py +19 -0
  35. quasarr/providers/myjd_api.py +201 -40
  36. quasarr/providers/notifications.py +99 -11
  37. quasarr/providers/obfuscated.py +65 -0
  38. quasarr/providers/sessions/__init__.py +0 -0
  39. quasarr/providers/sessions/al.py +286 -0
  40. quasarr/providers/sessions/dd.py +78 -0
  41. quasarr/providers/sessions/dl.py +175 -0
  42. quasarr/providers/sessions/nx.py +76 -0
  43. quasarr/providers/shared_state.py +656 -79
  44. quasarr/providers/statistics.py +154 -0
  45. quasarr/providers/version.py +60 -1
  46. quasarr/providers/web_server.py +1 -1
  47. quasarr/search/__init__.py +144 -15
  48. quasarr/search/sources/al.py +448 -0
  49. quasarr/search/sources/by.py +204 -0
  50. quasarr/search/sources/dd.py +135 -0
  51. quasarr/search/sources/dj.py +213 -0
  52. quasarr/search/sources/dl.py +354 -0
  53. quasarr/search/sources/dt.py +265 -0
  54. quasarr/search/sources/dw.py +94 -67
  55. quasarr/search/sources/fx.py +89 -33
  56. quasarr/search/sources/he.py +196 -0
  57. quasarr/search/sources/mb.py +195 -0
  58. quasarr/search/sources/nk.py +188 -0
  59. quasarr/search/sources/nx.py +75 -21
  60. quasarr/search/sources/sf.py +374 -0
  61. quasarr/search/sources/sj.py +213 -0
  62. quasarr/search/sources/sl.py +246 -0
  63. quasarr/search/sources/wd.py +208 -0
  64. quasarr/search/sources/wx.py +337 -0
  65. quasarr/storage/config.py +39 -10
  66. quasarr/storage/setup.py +269 -97
  67. quasarr/storage/sqlite_database.py +6 -1
  68. quasarr-1.23.0.dist-info/METADATA +306 -0
  69. quasarr-1.23.0.dist-info/RECORD +77 -0
  70. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/WHEEL +1 -1
  71. quasarr/arr/__init__.py +0 -423
  72. quasarr/captcha_solver/__init__.py +0 -284
  73. quasarr-0.1.6.dist-info/METADATA +0 -81
  74. quasarr-0.1.6.dist-info/RECORD +0 -31
  75. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/entry_points.txt +0 -0
  76. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info/licenses}/LICENSE +0 -0
  77. {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/top_level.txt +0 -0
quasarr/__init__.py CHANGED
@@ -10,12 +10,18 @@ import socket
10
10
  import sys
11
11
  import tempfile
12
12
  import time
13
+ from urllib.parse import urlparse
13
14
 
14
- from quasarr.arr import api
15
+ import requests
16
+
17
+ from quasarr.api import get_api
18
+ from quasarr.providers import shared_state, version
19
+ from quasarr.providers.log import info, debug
20
+ from quasarr.providers.notifications import send_discord_message
15
21
  from quasarr.storage.config import Config, get_clean_hostnames
22
+ from quasarr.storage.setup import path_config, hostnames_config, hostname_credentials_config, flaresolverr_config, \
23
+ jdownloader_config
16
24
  from quasarr.storage.sqlite_database import DataBase
17
- from quasarr.providers import shared_state, version
18
- from quasarr.storage.setup import path_config, hostnames_config, nx_credentials_config, jdownloader_config
19
25
 
20
26
 
21
27
  def run():
@@ -29,6 +35,7 @@ def run():
29
35
  parser.add_argument("--internal_address", help="Must be provided when running in Docker")
30
36
  parser.add_argument("--external_address", help="External address for CAPTCHA notifications")
31
37
  parser.add_argument("--discord", help="Discord Webhook URL")
38
+ parser.add_argument("--hostnames", help="Public HTTP(s) Link that contains hostnames definition.")
32
39
  arguments = parser.parse_args()
33
40
 
34
41
  sys.stdout = Unbuffered(sys.stdout)
@@ -38,8 +45,13 @@ def run():
38
45
  https://github.com/rix1337/Quasarr
39
46
  └────────────────────────────────────┘""")
40
47
 
41
- port = int('8080')
48
+ print("\n===== Recommended Services =====")
49
+ print('For convenient universal premium downloads use: "https://linksnappy.com/?ref=397097"')
50
+ print(
51
+ 'Sponsors get automated CAPTCHA solutions: "https://github.com/rix1337/Quasarr?tab=readme-ov-file#sponsorshelper"')
42
52
 
53
+ print("\n===== Startup Info =====")
54
+ port = int('8080')
43
55
  config_path = ""
44
56
  if os.environ.get('DOCKER'):
45
57
  config_path = "/config"
@@ -52,7 +64,7 @@ def run():
52
64
  else:
53
65
  if arguments.port:
54
66
  port = int(arguments.port)
55
- internal_address = f'http://{check_ip()}'
67
+ internal_address = f'http://{check_ip()}:{port}'
56
68
 
57
69
  if arguments.internal_address:
58
70
  internal_address = arguments.internal_address
@@ -61,6 +73,9 @@ def run():
61
73
  else:
62
74
  external_address = internal_address
63
75
 
76
+ validate_address(internal_address, "--internal_address")
77
+ validate_address(external_address, "--external_address")
78
+
64
79
  shared_state.set_connection_info(internal_address, external_address, port)
65
80
 
66
81
  if not config_path:
@@ -83,22 +98,95 @@ def run():
83
98
  shared_state.set_files(config_path)
84
99
  shared_state.update("config", Config)
85
100
  shared_state.update("database", DataBase)
86
- shared_state.update("user_agent",
87
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
101
+ supported_hostnames = extract_allowed_keys(Config._DEFAULT_CONFIG, 'Hostnames')
102
+ shared_state.update("sites", [key.upper() for key in supported_hostnames])
103
+ shared_state.update("user_agent", "") # will be set by FlareSolverr
104
+ shared_state.update("helper_active", False)
88
105
 
89
106
  print(f'Config path: "{config_path}"')
90
107
 
91
- shared_state.set_sites()
108
+ flaresolverr_url = Config('FlareSolverr').get('url')
109
+ if not flaresolverr_url:
110
+ flaresolverr_config(shared_state)
111
+ else:
112
+ print(f'Flaresolverr URL: "{flaresolverr_url}"')
113
+ flaresolverr_check = check_flaresolverr(shared_state, flaresolverr_url)
114
+ if flaresolverr_check:
115
+ print(f'User Agent: "{shared_state.values["user_agent"]}"')
116
+
117
+ print("\n===== Hostnames =====")
118
+ try:
119
+ if arguments.hostnames:
120
+ hostnames_link = make_raw_pastebin_link(arguments.hostnames)
121
+
122
+ if is_valid_url(hostnames_link):
123
+ print(f"Extracting hostnames from {hostnames_link}...")
124
+ allowed_keys = supported_hostnames
125
+ max_keys = len(allowed_keys)
126
+ shorthand_list = ', '.join(
127
+ [f'"{key}"' for key in allowed_keys[:-1]]) + ' and ' + f'"{allowed_keys[-1]}"'
128
+ print(f'There are up to {max_keys} hostnames currently supported: {shorthand_list}')
129
+ data = requests.get(hostnames_link).text
130
+ results = extract_kv_pairs(data, allowed_keys)
131
+
132
+ extracted_hostnames = 0
133
+
134
+ if results:
135
+ hostnames = Config('Hostnames')
136
+ for shorthand, hostname in results.items():
137
+ domain_check = shared_state.extract_valid_hostname(hostname, shorthand)
138
+ valid_domain = domain_check.get('domain', None)
139
+ if valid_domain:
140
+ hostnames.save(shorthand, hostname)
141
+ extracted_hostnames += 1
142
+ print(f'Hostname for "{shorthand}" successfully set to "{hostname}"')
143
+ else:
144
+ print(f'Skipping invalid hostname for "{shorthand}" ("{hostname}")')
145
+ if extracted_hostnames == max_keys:
146
+ print(f'All {max_keys} hostnames successfully extracted!')
147
+ print('You can now remove the hostnames link from the command line / environment variable.')
148
+ else:
149
+ print(f'No Hostnames found at "{hostnames_link}". '
150
+ 'Ensure to pass a plain hostnames list, not html or json!')
151
+ else:
152
+ print(f'Invalid hostnames URL: "{hostnames_link}"')
153
+ except Exception as e:
154
+ print(f'Error parsing hostnames link: "{e}"')
92
155
 
93
- if not get_clean_hostnames(shared_state):
156
+ hostnames = get_clean_hostnames(shared_state)
157
+ if not hostnames:
94
158
  hostnames_config(shared_state)
95
- get_clean_hostnames(shared_state)
159
+ hostnames = get_clean_hostnames(shared_state)
160
+ print(f"You have [{len(hostnames)} of {len(Config._DEFAULT_CONFIG['Hostnames'])}] supported hostnames set up")
161
+ print(f"For efficiency it is recommended to set up as few hostnames as needed.")
162
+
163
+ al = Config('Hostnames').get('al')
164
+ if al:
165
+ user = Config('AL').get('user')
166
+ password = Config('AL').get('password')
167
+ if not user or not password:
168
+ hostname_credentials_config(shared_state, "AL", al)
169
+
170
+ dd = Config('Hostnames').get('dd')
171
+ if dd:
172
+ user = Config('DD').get('user')
173
+ password = Config('DD').get('password')
174
+ if not user or not password:
175
+ hostname_credentials_config(shared_state, "DD", dd)
96
176
 
97
- if Config('Hostnames').get('nx'):
177
+ nx = Config('Hostnames').get('nx')
178
+ if nx:
98
179
  user = Config('NX').get('user')
99
180
  password = Config('NX').get('password')
100
181
  if not user or not password:
101
- nx_credentials_config(shared_state)
182
+ hostname_credentials_config(shared_state, "NX", nx)
183
+
184
+ dl = Config('Hostnames').get('dl')
185
+ if dl:
186
+ user = Config('DL').get('user')
187
+ password = Config('DL').get('password')
188
+ if not user or not password:
189
+ hostname_credentials_config(shared_state, "DL", dl)
102
190
 
103
191
  config = Config('JDownloader')
104
192
  user = config.get('user')
@@ -108,12 +196,13 @@ def run():
108
196
  if not user or not password or not device:
109
197
  jdownloader_config(shared_state)
110
198
 
199
+ print("\n===== Notifications =====")
111
200
  discord_url = ""
112
201
  if arguments.discord:
113
202
  discord_webhook_pattern = r'^https://discord\.com/api/webhooks/\d+/[\w-]+$'
114
203
  if re.match(discord_webhook_pattern, arguments.discord):
115
204
  shared_state.update("webhook", arguments.discord)
116
- print(f"Using Discord Webhook URL for CAPTCHA notifications")
205
+ print(f"Using Discord Webhook URL for notifications.")
117
206
  discord_url = arguments.discord
118
207
  else:
119
208
  print(f"Invalid Discord Webhook URL provided: {arguments.discord}")
@@ -121,48 +210,123 @@ def run():
121
210
  print("No Discord Webhook URL provided")
122
211
  shared_state.update("discord", discord_url)
123
212
 
124
- jdownloader = multiprocessing.Process(target=jdownloader_connection,
125
- args=(shared_state_dict, shared_state_lock))
126
- jdownloader.start()
213
+ print("\n===== API Information =====")
214
+ api_key = Config('API').get('key')
215
+ if not api_key:
216
+ api_key = shared_state.generate_api_key()
217
+
218
+ print('Setup instructions: "https://github.com/rix1337/Quasarr?tab=readme-ov-file#instructions"')
219
+ print(f'URL: "{shared_state.values['internal_address']}"')
220
+ print(f'API key: "{api_key}" (without quotes)')
127
221
 
128
- print(f'\nQuasarr API now running at "{shared_state.values["internal_address"]}"')
129
- print('Use this exact URL as "Newznab Indexer" and "SABnzbd Download Client" in Sonarr/Radarr')
130
- print("Leave settings at default and use this API key: 'quasarr'")
222
+ if external_address != internal_address:
223
+ print(f'External URL: "{shared_state.values["external_address"]}"')
224
+
225
+ print("\n===== Quasarr Info Log =====")
226
+ if os.getenv('DEBUG'):
227
+ print("===== / Debug Log =====")
131
228
 
132
229
  protected = shared_state.get_db("protected").retrieve_all_titles()
133
230
  if protected:
134
231
  package_count = len(protected)
135
- print(f"\nCAPTCHA-Solution required for {package_count} package{'s' if package_count > 1 else ''} at "
136
- f'{shared_state.values["external_address"]}/captcha"!\n')
232
+ info(f'CAPTCHA-Solution required for {package_count} package{'s' if package_count > 1 else ''} at: '
233
+ f'"{shared_state.values["external_address"]}/captcha"!')
234
+
235
+ jdownloader = multiprocessing.Process(
236
+ target=jdownloader_connection,
237
+ args=(shared_state_dict, shared_state_lock)
238
+ )
239
+ jdownloader.start()
240
+
241
+ updater = multiprocessing.Process(
242
+ target=update_checker,
243
+ args=(shared_state_dict, shared_state_lock)
244
+ )
245
+ updater.start()
137
246
 
138
247
  try:
139
- api(shared_state_dict, shared_state_lock)
248
+ get_api(shared_state_dict, shared_state_lock)
140
249
  except KeyboardInterrupt:
250
+ jdownloader.kill()
251
+ updater.kill()
141
252
  sys.exit(0)
142
253
 
143
254
 
144
- def jdownloader_connection(shared_state_dict, shared_state_lock):
145
- shared_state.set_state(shared_state_dict, shared_state_lock)
146
-
147
- shared_state.set_device_from_config()
148
-
149
- connection_established = shared_state.get_device() and shared_state.get_device().name
150
- if not connection_established:
151
- i = 0
152
- while i < 10:
153
- i += 1
154
- print(f'Connection {i} to JDownloader failed. Device name: "{shared_state.values["device"]}"')
155
- time.sleep(60)
156
- shared_state.set_device_from_config()
157
- connection_established = shared_state.get_device() and shared_state.get_device().name
158
- if connection_established:
159
- break
255
+ def update_checker(shared_state_dict, shared_state_lock):
256
+ try:
257
+ shared_state.set_state(shared_state_dict, shared_state_lock)
258
+
259
+ message = "!!! UPDATE AVAILABLE !!!"
260
+ link = "https://github.com/rix1337/Quasarr/releases/latest"
261
+
262
+ shared_state.update("last_checked_version", f"v.{version.get_version()}")
263
+
264
+ while True:
265
+ try:
266
+ update_available = version.newer_version_available()
267
+ except Exception as e:
268
+ info(f"Error getting latest version: {e}")
269
+ info(f'Please manually check: "{link}" for more information!')
270
+ update_available = None
271
+
272
+ if update_available and shared_state.values["last_checked_version"] != update_available:
273
+ shared_state.update("last_checked_version", update_available)
274
+ info(message)
275
+ info(f"Please update to {update_available} as soon as possible!")
276
+ info(f'Release notes at: "{link}"')
277
+ update_available = {
278
+ "version": update_available,
279
+ "link": link
280
+ }
281
+ send_discord_message(shared_state, message, "quasarr_update", details=update_available)
282
+
283
+ # wait one hour before next check
284
+ time.sleep(60 * 60)
285
+ except KeyboardInterrupt:
286
+ pass
160
287
 
288
+
289
+ def jdownloader_connection(shared_state_dict, shared_state_lock):
161
290
  try:
162
- print(f'Connection to JDownloader successful. Device name: "{shared_state.get_device().name}"')
163
- except:
164
- print('Error connecting to JDownloader! Stopping Quasarr!')
165
- sys.exit(1)
291
+ shared_state.set_state(shared_state_dict, shared_state_lock)
292
+
293
+ shared_state.set_device_from_config()
294
+
295
+ connection_established = shared_state.get_device() and shared_state.get_device().name
296
+ if not connection_established:
297
+ i = 0
298
+ while i < 10:
299
+ i += 1
300
+ info(f'Connection {i} to JDownloader failed. Device name: "{shared_state.values["device"]}"')
301
+ time.sleep(60)
302
+ shared_state.set_device_from_config()
303
+ connection_established = shared_state.get_device() and shared_state.get_device().name
304
+ if connection_established:
305
+ break
306
+
307
+ try:
308
+ info(f'Connection to JDownloader successful. Device name: "{shared_state.get_device().name}"')
309
+ except Exception as e:
310
+ info(f'Error connecting to JDownloader: {e}! Stopping Quasarr!')
311
+ sys.exit(1)
312
+
313
+ try:
314
+ shared_state.set_device_settings()
315
+ except Exception as e:
316
+ print(f"Error checking settings: {e}")
317
+
318
+ try:
319
+ shared_state.update_jdownloader()
320
+ except Exception as e:
321
+ print(f"Error updating JDownloader: {e}")
322
+
323
+ try:
324
+ shared_state.start_downloads()
325
+ except Exception as e:
326
+ print(f"Error starting downloads: {e}")
327
+
328
+ except KeyboardInterrupt:
329
+ pass
166
330
 
167
331
 
168
332
  class Unbuffered(object):
@@ -191,3 +355,113 @@ def check_ip():
191
355
  finally:
192
356
  s.close()
193
357
  return ip
358
+
359
+
360
+ def check_flaresolverr(shared_state, flaresolverr_url):
361
+ # Ensure it ends with /v<digit+>
362
+ if not re.search(r"/v\d+$", flaresolverr_url):
363
+ print(f"FlareSolverr URL does not end with /v#: {flaresolverr_url}")
364
+ return False
365
+
366
+ # Try sending a simple test request
367
+ headers = {"Content-Type": "application/json"}
368
+ data = {
369
+ "cmd": "request.get",
370
+ "url": "http://www.google.com/",
371
+ "maxTimeout": 10000
372
+ }
373
+
374
+ try:
375
+ response = requests.post(flaresolverr_url, headers=headers, json=data, timeout=10)
376
+ response.raise_for_status()
377
+ json_data = response.json()
378
+
379
+ # Check if the structure looks like a valid FlareSolverr response
380
+ if "status" in json_data and json_data["status"] == "ok":
381
+ solution = json_data["solution"]
382
+ solution_ua = solution.get("userAgent", None)
383
+ if solution_ua:
384
+ shared_state.update("user_agent", solution_ua)
385
+ return True
386
+ else:
387
+ print(f"Unexpected FlareSolverr response: {json_data}")
388
+ return False
389
+
390
+ except Exception as e:
391
+ print(f"Failed to connect to FlareSolverr: {e}")
392
+ return False
393
+
394
+
395
+ def make_raw_pastebin_link(url):
396
+ """
397
+ Takes a Pastebin URL and ensures it is a raw link.
398
+ If it's not a Pastebin URL, it returns the URL unchanged.
399
+ """
400
+ # Check if the URL is already a raw Pastebin link
401
+ if re.match(r"https?://(?:www\.)?pastebin\.com/raw/\w+", url):
402
+ return url # Already raw, return as is
403
+
404
+ # Check if the URL is a standard Pastebin link
405
+ pastebin_pattern = r"https?://(?:www\.)?pastebin\.com/(\w+)"
406
+ match = re.match(pastebin_pattern, url)
407
+
408
+ if match:
409
+ paste_id = match.group(1)
410
+ print(f"The link you provided is not a raw Pastebin link. Attempting to convert it to a raw link from {url}...")
411
+ return f"https://pastebin.com/raw/{paste_id}"
412
+
413
+ return url # Not a Pastebin link, return unchanged
414
+
415
+
416
+ def is_valid_url(url):
417
+ if "https://pastebin.com/raw/eX4Mpl3" in url:
418
+ print("Example URL detected. Please provide a valid URL found on pastebin or another public site!")
419
+ return False
420
+
421
+ parsed = urlparse(url)
422
+ return parsed.scheme in ("http", "https") and bool(parsed.netloc)
423
+
424
+
425
+ def validate_address(address, name):
426
+ if not address.startswith("http"):
427
+ sys.exit(f"Error: {name} '{address}' is invalid. It must start with 'http'.")
428
+
429
+ colon_count = address.count(":")
430
+ if colon_count < 1 or colon_count > 2:
431
+ sys.exit(
432
+ f"Error: {name} '{address}' is invalid. It must contain 1 or 2 colons, but it has {colon_count}.")
433
+
434
+
435
+ def extract_allowed_keys(config, section):
436
+ """
437
+ Extracts allowed keys from the specified section in the configuration.
438
+
439
+ :param config: The configuration dictionary.
440
+ :param section: The section from which to extract keys.
441
+ :return: A list of allowed keys.
442
+ """
443
+ if section not in config:
444
+ raise ValueError(f"Section '{section}' not found in configuration.")
445
+ return [key for key, *_ in config[section]]
446
+
447
+
448
+ def extract_kv_pairs(input_text, allowed_keys):
449
+ """
450
+ Extracts key-value pairs from the given text where keys match allowed_keys.
451
+
452
+ :param input_text: The input text containing key-value pairs.
453
+ :param allowed_keys: A list of allowed two-letter shorthand keys.
454
+ :return: A dictionary of extracted key-value pairs.
455
+ """
456
+ kv_pattern = re.compile(rf"^({'|'.join(map(re.escape, allowed_keys))})\s*=\s*(.*)$")
457
+ kv_pairs = {}
458
+
459
+ for line in input_text.splitlines():
460
+ match = kv_pattern.match(line.strip())
461
+ if match:
462
+ key, value = match.groups()
463
+ kv_pairs[key] = value
464
+ else:
465
+ print(f"Skipping line because it does not contain any supported hostname: {line}")
466
+
467
+ return kv_pairs
@@ -0,0 +1,187 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Quasarr
3
+ # Project by https://github.com/rix1337
4
+
5
+ from bottle import Bottle
6
+
7
+ import quasarr.providers.html_images as images
8
+ from quasarr.api.arr import setup_arr_routes
9
+ from quasarr.api.captcha import setup_captcha_routes
10
+ from quasarr.api.config import setup_config
11
+ from quasarr.api.sponsors_helper import setup_sponsors_helper_routes
12
+ from quasarr.api.statistics import setup_statistics
13
+ from quasarr.providers import shared_state
14
+ from quasarr.providers.html_templates import render_button, render_centered_html
15
+ from quasarr.providers.web_server import Server
16
+ from quasarr.storage.config import Config
17
+
18
+
19
+ def get_api(shared_state_dict, shared_state_lock):
20
+ shared_state.set_state(shared_state_dict, shared_state_lock)
21
+
22
+ app = Bottle()
23
+
24
+ setup_arr_routes(app)
25
+ setup_captcha_routes(app)
26
+ setup_config(app, shared_state)
27
+ setup_statistics(app, shared_state)
28
+ setup_sponsors_helper_routes(app)
29
+
30
+ @app.get('/')
31
+ def index():
32
+ protected = shared_state.get_db("protected").retrieve_all_titles()
33
+ api_key = Config('API').get('key')
34
+
35
+ captcha_hint = ""
36
+ if protected:
37
+ plural = 's' if len(protected) > 1 else ''
38
+ captcha_hint += f"""
39
+ <div class="section">
40
+ <h2>🔒 Link{plural} waiting for CAPTCHA solution</h2>
41
+ """
42
+
43
+ if not shared_state.values.get("helper_active"):
44
+ captcha_hint += f"""
45
+ <p>
46
+ <a href="https://github.com/rix1337/Quasarr?tab=readme-ov-file#sponsorshelper" target="_blank">
47
+ Sponsors get automated CAPTCHA solutions!
48
+ </a>
49
+ </p>
50
+ """
51
+
52
+ plural = 's' if len(protected) > 1 else ''
53
+ captcha_hint += f"""
54
+ <p>{render_button(f"Solve CAPTCHA{plural}", 'primary', {'onclick': "location.href='/captcha'"})}</p>
55
+ </div>
56
+ <hr>
57
+ """
58
+
59
+ info = f"""
60
+ <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
61
+
62
+ {captcha_hint}
63
+
64
+ <div class="section">
65
+ <h2>📖 Setup Instructions</h2>
66
+ <p>
67
+ <a href="https://github.com/rix1337/Quasarr?tab=readme-ov-file#instructions" target="_blank">
68
+ Refer to the README for detailed instructions.
69
+ </a>
70
+ </p>
71
+ </div>
72
+
73
+ <hr>
74
+
75
+ <div class="section">
76
+ <h2>⚙️ API Configuration</h2>
77
+ <p>Use the URL and API Key below to set up a <strong>Newznab Indexer</strong> and <strong>SABnzbd Download Client</strong> in Radarr/Sonarr:</p>
78
+
79
+ <details id="apiDetails">
80
+ <summary id="apiSummary">Show API Settings</summary>
81
+ <div class="api-settings">
82
+
83
+ <h3>URL</h3>
84
+ <div class="url-wrapper">
85
+ <input id="urlInput" class="copy-input" type="text" readonly value="{shared_state.values['internal_address']}" />
86
+ <button id="copyUrl" class="btn-primary small">Copy</button>
87
+ </div>
88
+
89
+ <h3>API Key</h3>
90
+ <div class="api-key-wrapper">
91
+ <input id="apiKeyInput" class="copy-input" type="password" readonly value="{api_key}" />
92
+ <button id="toggleKey" class="btn-secondary small">Show</button>
93
+ <button id="copyKey" class="btn-primary small">Copy</button>
94
+ </div>
95
+
96
+ <p>{render_button("Regenerate API key", "secondary", {"onclick": "if(confirm('Regenerate API key?')) location.href='/regenerate-api-key';"})}</p>
97
+ </div>
98
+ </details>
99
+ </div>
100
+
101
+ <hr>
102
+
103
+ <div class="section">
104
+ <h2>🔧 Quick Actions</h2>
105
+ <p><button class="btn-primary" onclick="location.href='/hostnames'">Update Hostnames</button></p>
106
+ <p><button class="btn-primary" onclick="location.href='/statistics'">View Statistics</button></p>
107
+ </div>
108
+
109
+ <style>
110
+ .section {{ margin: 20px 0; }}
111
+ .api-settings {{ padding: 15px 0; }}
112
+ hr {{ margin: 25px 0; border: none; border-top: 1px solid #ddd; }}
113
+ details {{ margin: 10px 0; }}
114
+ summary {{
115
+ cursor: pointer;
116
+ padding: 8px 0;
117
+ font-weight: 500;
118
+ }}
119
+ summary:hover {{
120
+ color: #0066cc;
121
+ }}
122
+ </style>
123
+
124
+ <script>
125
+ const urlInput = document.getElementById('urlInput');
126
+ const copyUrlBtn = document.getElementById('copyUrl');
127
+
128
+ if (copyUrlBtn) {{
129
+ copyUrlBtn.onclick = () => {{
130
+ urlInput.select();
131
+ document.execCommand('copy');
132
+ copyUrlBtn.innerText = 'Copied!';
133
+ setTimeout(() => {{ copyUrlBtn.innerText = 'Copy'; }}, 2000);
134
+ }};
135
+ }}
136
+
137
+ const apiInput = document.getElementById('apiKeyInput');
138
+ const toggleBtn = document.getElementById('toggleKey');
139
+ const copyBtn = document.getElementById('copyKey');
140
+
141
+ if (toggleBtn) {{
142
+ toggleBtn.onclick = () => {{
143
+ const isHidden = apiInput.type === 'password';
144
+ apiInput.type = isHidden ? 'text' : 'password';
145
+ toggleBtn.innerText = isHidden ? 'Hide' : 'Show';
146
+ }};
147
+ }}
148
+
149
+ if (copyBtn) {{
150
+ copyBtn.onclick = () => {{
151
+ apiInput.type = 'text';
152
+ apiInput.select();
153
+ document.execCommand('copy');
154
+ copyBtn.innerText = 'Copied!';
155
+ toggleBtn.innerText = 'Hide';
156
+ setTimeout(() => {{ copyBtn.innerText = 'Copy'; }}, 2000);
157
+ }};
158
+ }}
159
+
160
+ // Handle details toggle
161
+ const apiDetails = document.getElementById('apiDetails');
162
+ const apiSummary = document.getElementById('apiSummary');
163
+
164
+ if (apiDetails && apiSummary) {{
165
+ apiDetails.addEventListener('toggle', () => {{
166
+ if (apiDetails.open) {{
167
+ apiSummary.textContent = 'Hide API Settings';
168
+ }} else {{
169
+ apiSummary.textContent = 'Show API Settings';
170
+ }}
171
+ }});
172
+ }}
173
+ </script>
174
+ """
175
+ return render_centered_html(info)
176
+
177
+ @app.get('/regenerate-api-key')
178
+ def regenerate_api_key():
179
+ api_key = shared_state.generate_api_key()
180
+ return f"""
181
+ <script>
182
+ alert('API key replaced with: {api_key}');
183
+ window.location.href = '/';
184
+ </script>
185
+ """
186
+
187
+ Server(app, listen='0.0.0.0', port=shared_state.values["port"]).serve_forever()