quasarr 1.3.5__py3-none-any.whl → 1.20.4__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 (67) hide show
  1. quasarr/__init__.py +157 -56
  2. quasarr/api/__init__.py +141 -36
  3. quasarr/api/arr/__init__.py +197 -78
  4. quasarr/api/captcha/__init__.py +897 -42
  5. quasarr/api/config/__init__.py +23 -0
  6. quasarr/api/sponsors_helper/__init__.py +84 -22
  7. quasarr/api/statistics/__init__.py +196 -0
  8. quasarr/downloads/__init__.py +237 -434
  9. quasarr/downloads/linkcrypters/al.py +237 -0
  10. quasarr/downloads/linkcrypters/filecrypt.py +178 -31
  11. quasarr/downloads/linkcrypters/hide.py +123 -0
  12. quasarr/downloads/packages/__init__.py +461 -0
  13. quasarr/downloads/sources/al.py +697 -0
  14. quasarr/downloads/sources/by.py +106 -0
  15. quasarr/downloads/sources/dd.py +6 -78
  16. quasarr/downloads/sources/dj.py +7 -0
  17. quasarr/downloads/sources/dt.py +1 -1
  18. quasarr/downloads/sources/dw.py +2 -2
  19. quasarr/downloads/sources/he.py +112 -0
  20. quasarr/downloads/sources/mb.py +47 -0
  21. quasarr/downloads/sources/nk.py +51 -0
  22. quasarr/downloads/sources/nx.py +36 -81
  23. quasarr/downloads/sources/sf.py +27 -4
  24. quasarr/downloads/sources/sj.py +7 -0
  25. quasarr/downloads/sources/sl.py +90 -0
  26. quasarr/downloads/sources/wd.py +110 -0
  27. quasarr/providers/cloudflare.py +204 -0
  28. quasarr/providers/html_images.py +20 -0
  29. quasarr/providers/html_templates.py +210 -108
  30. quasarr/providers/imdb_metadata.py +15 -2
  31. quasarr/providers/myjd_api.py +36 -5
  32. quasarr/providers/notifications.py +30 -5
  33. quasarr/providers/obfuscated.py +35 -0
  34. quasarr/providers/sessions/__init__.py +0 -0
  35. quasarr/providers/sessions/al.py +286 -0
  36. quasarr/providers/sessions/dd.py +78 -0
  37. quasarr/providers/sessions/nx.py +76 -0
  38. quasarr/providers/shared_state.py +368 -23
  39. quasarr/providers/statistics.py +154 -0
  40. quasarr/providers/version.py +60 -1
  41. quasarr/search/__init__.py +112 -36
  42. quasarr/search/sources/al.py +448 -0
  43. quasarr/search/sources/by.py +203 -0
  44. quasarr/search/sources/dd.py +17 -6
  45. quasarr/search/sources/dj.py +213 -0
  46. quasarr/search/sources/dt.py +37 -7
  47. quasarr/search/sources/dw.py +27 -47
  48. quasarr/search/sources/fx.py +27 -29
  49. quasarr/search/sources/he.py +196 -0
  50. quasarr/search/sources/mb.py +195 -0
  51. quasarr/search/sources/nk.py +188 -0
  52. quasarr/search/sources/nx.py +22 -6
  53. quasarr/search/sources/sf.py +143 -151
  54. quasarr/search/sources/sj.py +213 -0
  55. quasarr/search/sources/sl.py +246 -0
  56. quasarr/search/sources/wd.py +208 -0
  57. quasarr/storage/config.py +20 -4
  58. quasarr/storage/setup.py +224 -56
  59. quasarr-1.20.4.dist-info/METADATA +304 -0
  60. quasarr-1.20.4.dist-info/RECORD +72 -0
  61. {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/WHEEL +1 -1
  62. quasarr/providers/tvmaze_metadata.py +0 -23
  63. quasarr-1.3.5.dist-info/METADATA +0 -174
  64. quasarr-1.3.5.dist-info/RECORD +0 -43
  65. {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/entry_points.txt +0 -0
  66. {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/licenses/LICENSE +0 -0
  67. {quasarr-1.3.5.dist-info → quasarr-1.20.4.dist-info}/top_level.txt +0 -0
quasarr/__init__.py CHANGED
@@ -17,8 +17,10 @@ import requests
17
17
  from quasarr.api import get_api
18
18
  from quasarr.providers import shared_state, version
19
19
  from quasarr.providers.log import info, debug
20
+ from quasarr.providers.notifications import send_discord_message
20
21
  from quasarr.storage.config import Config, get_clean_hostnames
21
- from quasarr.storage.setup import path_config, hostnames_config, hostname_credentials_config, jdownloader_config
22
+ from quasarr.storage.setup import path_config, hostnames_config, hostname_credentials_config, flaresolverr_config, \
23
+ jdownloader_config
22
24
  from quasarr.storage.sqlite_database import DataBase
23
25
 
24
26
 
@@ -44,13 +46,12 @@ def run():
44
46
  └────────────────────────────────────┘""")
45
47
 
46
48
  print("\n===== Recommended Services =====")
47
- print('- For automated CAPTCHA solutions use SponsorsHelper: "https://github.com/users/rix1337/sponsorship"')
48
- print('- For convenient universal premium downloads use: "https://linksnappy.com/?ref=397097"')
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"')
49
52
 
50
53
  print("\n===== Startup Info =====")
51
-
52
54
  port = int('8080')
53
-
54
55
  config_path = ""
55
56
  if os.environ.get('DOCKER'):
56
57
  config_path = "/config"
@@ -99,12 +100,21 @@ def run():
99
100
  shared_state.update("database", DataBase)
100
101
  supported_hostnames = extract_allowed_keys(Config._DEFAULT_CONFIG, 'Hostnames')
101
102
  shared_state.update("sites", [key.upper() for key in supported_hostnames])
102
- shared_state.update("user_agent",
103
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36")
103
+ shared_state.update("user_agent", "") # will be set by FlareSolverr
104
104
  shared_state.update("helper_active", False)
105
105
 
106
106
  print(f'Config path: "{config_path}"')
107
107
 
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 =====")
108
118
  try:
109
119
  if arguments.hostnames:
110
120
  hostnames_link = make_raw_pastebin_link(arguments.hostnames)
@@ -124,7 +134,8 @@ def run():
124
134
  if results:
125
135
  hostnames = Config('Hostnames')
126
136
  for shorthand, hostname in results.items():
127
- valid_domain = shared_state.extract_valid_hostname(hostname, shorthand)
137
+ domain_check = shared_state.extract_valid_hostname(hostname, shorthand)
138
+ valid_domain = domain_check.get('domain', None)
128
139
  if valid_domain:
129
140
  hostnames.save(shorthand, hostname)
130
141
  extracted_hostnames += 1
@@ -142,17 +153,19 @@ def run():
142
153
  except Exception as e:
143
154
  print(f'Error parsing hostnames link: "{e}"')
144
155
 
145
- print("\n===== Configuration =====")
146
- api_key = Config('API').get('key')
147
- if not api_key:
148
- api_key = shared_state.generate_api_key()
149
-
150
156
  hostnames = get_clean_hostnames(shared_state)
151
157
  if not hostnames:
152
158
  hostnames_config(shared_state)
153
159
  hostnames = get_clean_hostnames(shared_state)
154
160
  print(f"You have [{len(hostnames)} of {len(Config._DEFAULT_CONFIG['Hostnames'])}] supported hostnames set up")
155
- print(f"For efficiency it is recommended to set up as few hostnames as needed.\n")
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)
156
169
 
157
170
  dd = Config('Hostnames').get('dd')
158
171
  if dd:
@@ -176,6 +189,7 @@ def run():
176
189
  if not user or not password or not device:
177
190
  jdownloader_config(shared_state)
178
191
 
192
+ print("\n===== Notifications =====")
179
193
  discord_url = ""
180
194
  if arguments.discord:
181
195
  discord_webhook_pattern = r'^https://discord\.com/api/webhooks/\d+/[\w-]+$'
@@ -189,17 +203,17 @@ def run():
189
203
  print("No Discord Webhook URL provided")
190
204
  shared_state.update("discord", discord_url)
191
205
 
192
- jdownloader = multiprocessing.Process(target=jdownloader_connection,
193
- args=(shared_state_dict, shared_state_lock))
194
- jdownloader.start()
195
-
196
206
  print("\n===== API Information =====")
197
- print(f'Quasarr API now running at: "{shared_state.values['external_address']}"')
198
- print('Use the above URL to set up a "Newznab Indexer" and "SABnzbd Download Client" in Radarr/Sonarr')
199
- print(f'Leave all settings at default and use this API key: "{api_key}" (without quotes)')
200
- print(
201
- 'Optionally set one desired mirror in "API Path" at the advanced indexer settings, e.g. "/api/dropbox/" instead of "/api/"')
202
- print('For more details, check https://github.com/rix1337/Quasarr?tab=readme-ov-file#instructions.')
207
+ api_key = Config('API').get('key')
208
+ if not api_key:
209
+ api_key = shared_state.generate_api_key()
210
+
211
+ print('Setup instructions: "https://github.com/rix1337/Quasarr?tab=readme-ov-file#instructions"')
212
+ print(f'URL: "{shared_state.values['internal_address']}"')
213
+ print(f'API key: "{api_key}" (without quotes)')
214
+
215
+ if external_address != internal_address:
216
+ print(f'External URL: "{shared_state.values["external_address"]}"')
203
217
 
204
218
  print("\n===== Quasarr Info Log =====")
205
219
  if os.getenv('DEBUG'):
@@ -211,49 +225,101 @@ def run():
211
225
  info(f'CAPTCHA-Solution required for {package_count} package{'s' if package_count > 1 else ''} at: '
212
226
  f'"{shared_state.values["external_address"]}/captcha"!')
213
227
 
228
+ jdownloader = multiprocessing.Process(
229
+ target=jdownloader_connection,
230
+ args=(shared_state_dict, shared_state_lock)
231
+ )
232
+ jdownloader.start()
233
+
234
+ updater = multiprocessing.Process(
235
+ target=update_checker,
236
+ args=(shared_state_dict, shared_state_lock)
237
+ )
238
+ updater.start()
239
+
214
240
  try:
215
241
  get_api(shared_state_dict, shared_state_lock)
216
242
  except KeyboardInterrupt:
243
+ jdownloader.kill()
244
+ updater.kill()
217
245
  sys.exit(0)
218
246
 
219
247
 
220
- def jdownloader_connection(shared_state_dict, shared_state_lock):
221
- shared_state.set_state(shared_state_dict, shared_state_lock)
222
-
223
- shared_state.set_device_from_config()
224
-
225
- connection_established = shared_state.get_device() and shared_state.get_device().name
226
- if not connection_established:
227
- i = 0
228
- while i < 10:
229
- i += 1
230
- info(f'Connection {i} to JDownloader failed. Device name: "{shared_state.values["device"]}"')
231
- time.sleep(60)
232
- shared_state.set_device_from_config()
233
- connection_established = shared_state.get_device() and shared_state.get_device().name
234
- if connection_established:
235
- break
236
-
248
+ def update_checker(shared_state_dict, shared_state_lock):
237
249
  try:
238
- info(f'Connection to JDownloader successful. Device name: "{shared_state.get_device().name}"')
239
- except Exception as e:
240
- info(f'Error connecting to JDownloader: {e}! Stopping Quasarr!')
241
- sys.exit(1)
250
+ shared_state.set_state(shared_state_dict, shared_state_lock)
242
251
 
243
- try:
244
- shared_state.set_device_settings()
245
- except Exception as e:
246
- print(f"Error checking settings: {e}")
252
+ message = "!!! UPDATE AVAILABLE !!!"
253
+ link = "https://github.com/rix1337/Quasarr/releases/latest"
247
254
 
248
- try:
249
- shared_state.update_jdownloader()
250
- except Exception as e:
251
- print(f"Error updating JDownloader: {e}")
255
+ shared_state.update("last_checked_version", f"v.{version.get_version()}")
256
+
257
+ while True:
258
+ try:
259
+ update_available = version.newer_version_available()
260
+ except Exception as e:
261
+ info(f"Error getting latest version: {e}")
262
+ info(f'Please manually check: "{link}" for more information!')
263
+ update_available = None
264
+
265
+ if update_available and shared_state.values["last_checked_version"] != update_available:
266
+ shared_state.update("last_checked_version", update_available)
267
+ info(message)
268
+ info(f"Please update to {update_available} as soon as possible!")
269
+ info(f'Release notes at: "{link}"')
270
+ update_available = {
271
+ "version": update_available,
272
+ "link": link
273
+ }
274
+ send_discord_message(shared_state, message, "quasarr_update", details=update_available)
252
275
 
276
+ # wait one hour before next check
277
+ time.sleep(60 * 60)
278
+ except KeyboardInterrupt:
279
+ pass
280
+
281
+
282
+ def jdownloader_connection(shared_state_dict, shared_state_lock):
253
283
  try:
254
- shared_state.start_downloads()
255
- except Exception as e:
256
- print(f"Error starting downloads: {e}")
284
+ shared_state.set_state(shared_state_dict, shared_state_lock)
285
+
286
+ shared_state.set_device_from_config()
287
+
288
+ connection_established = shared_state.get_device() and shared_state.get_device().name
289
+ if not connection_established:
290
+ i = 0
291
+ while i < 10:
292
+ i += 1
293
+ info(f'Connection {i} to JDownloader failed. Device name: "{shared_state.values["device"]}"')
294
+ time.sleep(60)
295
+ shared_state.set_device_from_config()
296
+ connection_established = shared_state.get_device() and shared_state.get_device().name
297
+ if connection_established:
298
+ break
299
+
300
+ try:
301
+ info(f'Connection to JDownloader successful. Device name: "{shared_state.get_device().name}"')
302
+ except Exception as e:
303
+ info(f'Error connecting to JDownloader: {e}! Stopping Quasarr!')
304
+ sys.exit(1)
305
+
306
+ try:
307
+ shared_state.set_device_settings()
308
+ except Exception as e:
309
+ print(f"Error checking settings: {e}")
310
+
311
+ try:
312
+ shared_state.update_jdownloader()
313
+ except Exception as e:
314
+ print(f"Error updating JDownloader: {e}")
315
+
316
+ try:
317
+ shared_state.start_downloads()
318
+ except Exception as e:
319
+ print(f"Error starting downloads: {e}")
320
+
321
+ except KeyboardInterrupt:
322
+ pass
257
323
 
258
324
 
259
325
  class Unbuffered(object):
@@ -284,6 +350,41 @@ def check_ip():
284
350
  return ip
285
351
 
286
352
 
353
+ def check_flaresolverr(shared_state, flaresolverr_url):
354
+ # Ensure it ends with /v<digit+>
355
+ if not re.search(r"/v\d+$", flaresolverr_url):
356
+ print(f"FlareSolverr URL does not end with /v#: {flaresolverr_url}")
357
+ return False
358
+
359
+ # Try sending a simple test request
360
+ headers = {"Content-Type": "application/json"}
361
+ data = {
362
+ "cmd": "request.get",
363
+ "url": "http://www.google.com/",
364
+ "maxTimeout": 10000
365
+ }
366
+
367
+ try:
368
+ response = requests.post(flaresolverr_url, headers=headers, json=data, timeout=10)
369
+ response.raise_for_status()
370
+ json_data = response.json()
371
+
372
+ # Check if the structure looks like a valid FlareSolverr response
373
+ if "status" in json_data and json_data["status"] == "ok":
374
+ solution = json_data["solution"]
375
+ solution_ua = solution.get("userAgent", None)
376
+ if solution_ua:
377
+ shared_state.update("user_agent", solution_ua)
378
+ return True
379
+ else:
380
+ print(f"Unexpected FlareSolverr response: {json_data}")
381
+ return False
382
+
383
+ except Exception as e:
384
+ print(f"Failed to connect to FlareSolverr: {e}")
385
+ return False
386
+
387
+
287
388
  def make_raw_pastebin_link(url):
288
389
  """
289
390
  Takes a Pastebin URL and ensures it is a raw link.
quasarr/api/__init__.py CHANGED
@@ -4,9 +4,12 @@
4
4
 
5
5
  from bottle import Bottle
6
6
 
7
+ import quasarr.providers.html_images as images
7
8
  from quasarr.api.arr import setup_arr_routes
8
9
  from quasarr.api.captcha import setup_captcha_routes
10
+ from quasarr.api.config import setup_config
9
11
  from quasarr.api.sponsors_helper import setup_sponsors_helper_routes
12
+ from quasarr.api.statistics import setup_statistics
10
13
  from quasarr.providers import shared_state
11
14
  from quasarr.providers.html_templates import render_button, render_centered_html
12
15
  from quasarr.providers.web_server import Server
@@ -20,6 +23,8 @@ def get_api(shared_state_dict, shared_state_lock):
20
23
 
21
24
  setup_arr_routes(app)
22
25
  setup_captcha_routes(app)
26
+ setup_config(app, shared_state)
27
+ setup_statistics(app, shared_state)
23
28
  setup_sponsors_helper_routes(app)
24
29
 
25
30
  @app.get('/')
@@ -29,43 +34,143 @@ def get_api(shared_state_dict, shared_state_lock):
29
34
 
30
35
  captcha_hint = ""
31
36
  if protected:
32
- package_count = len(protected)
33
- package_text = f"Package{'s' if package_count > 1 else ''} protected by CAPTCHA"
34
- amount_info = f": {package_count}" if package_count > 1 else ""
35
- button_text = f"Solve CAPTCHA{'s' if package_count > 1 else ''} manually to decrypt links!"
36
-
37
- captcha_hint = f'''
38
- <h2>Protected links</h2>
39
- <p>{package_text}{amount_info}</p>
40
- <p>{render_button(button_text, "primary", {"onclick": "location.href='/captcha'"})}</p>
41
- <a href="https://github.com/users/rix1337/sponsorship" target="_blank">
42
- For automated CAPTCHA Solutions use SponsorsHelper!
43
- </a>
44
- '''
45
-
46
- small = 'small style="background-color: #f0f0f0; padding: 5px; border-radius: 3px;"'
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
+ """
47
58
 
48
59
  info = f"""
49
- <h1>Quasarr</h1>
60
+ <h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
61
+
50
62
  {captcha_hint}
51
- <h2>Setup Instructions</h2>
52
- <p>
53
- <h3>Radarr/Sonarr</h3>
54
- Use this exact URL as <{small}>Newznab Indexer</small> and <{small}>SABnzbd Download Client</small>:<br><br>
55
- <code style="background-color: #f0f0f0; padding: 5px; border-radius: 3px;">
56
- {shared_state.values["internal_address"]}
57
- </code>
58
- </p>
59
- <p>
60
- Leave settings at default and use this API key:<br><br>
61
- <{small}>{api_key}</small>
62
- </p>
63
- <p>
64
- {render_button("Regenerate API key",
65
- "secondary",
66
- {"onclick": "if(confirm('Are you sure you want to regenerate the API key?')) { location.href='/regenerate-api-key'; }"})}
67
- </p>
68
- <p>Some JDownloader settings will be enforced by Quasarr on startup.</p>
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>
69
174
  """
70
175
  return render_centered_html(info)
71
176
 
@@ -74,8 +179,8 @@ def get_api(shared_state_dict, shared_state_lock):
74
179
  api_key = shared_state.generate_api_key()
75
180
  return f"""
76
181
  <script>
77
- alert('API key replaced with: "{api_key}!"');
78
- window.location.href = '/';
182
+ alert('API key replaced with: {api_key}');
183
+ window.location.href = '/';
79
184
  </script>
80
185
  """
81
186