quasarr 2.4.10__py3-none-any.whl → 2.5.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.

quasarr/storage/setup.py CHANGED
@@ -3,6 +3,7 @@
3
3
  # Project by https://github.com/rix1337
4
4
 
5
5
  import os
6
+ import re
6
7
  import sys
7
8
  from urllib.parse import urlparse
8
9
 
@@ -28,6 +29,7 @@ from quasarr.providers.log import info
28
29
  from quasarr.providers.shared_state import extract_valid_hostname
29
30
  from quasarr.providers.utils import (
30
31
  FALLBACK_USER_AGENT,
32
+ check_flaresolverr,
31
33
  extract_allowed_keys,
32
34
  extract_kv_pairs,
33
35
  )
@@ -189,11 +191,9 @@ def _escape_js_for_html_attr(s):
189
191
  )
190
192
 
191
193
 
192
- def hostname_form_html(
193
- shared_state, message, show_restart_button=False, show_skip_management=False
194
- ):
194
+ def hostname_form_html(shared_state, message, show_skip_management=False):
195
195
  hostname_fields = """
196
- <label for="{id}" onclick="showStatusDetail(\'{id}\', \'{label}\', \'{status}\', \'{error_details_for_modal}\', \'{timestamp}\', \'{operation}\', \'{url}\')"
196
+ <label for="{id}" onclick="showStatusDetail(\'{id}\', \'{label}\', \'{status}\', \'{error_details_for_modal}\', \'{timestamp}\', \'{operation}\', \'{url}\', \'{user}\', \'{password}\', {supports_login})"
197
197
  style="cursor:pointer; display:inline-flex; align-items:center; gap:4px;" title="{status_title}">
198
198
  <span class="status-indicator" id="status-{id}" data-status="{status}">{status_emoji}</span>
199
199
  {label}
@@ -203,8 +203,7 @@ def hostname_form_html(
203
203
 
204
204
  skip_indicator = """
205
205
  <div class="skip-indicator" id="skip-indicator-{id}" style="margin-top:-0.5rem; margin-bottom:0.75rem; padding:0.5rem; background:var(--code-bg, #f8f9fa); border-radius:0.25rem; font-size:0.875rem;">
206
- <span style="color:#dc3545;">⚠️ Login skipped</span>
207
- <button type="button" class="btn-subtle" style="margin-left:0.5rem; padding:0.25rem 0.5rem; font-size:0.75rem;" onclick="clearSkipLogin('{id}', this)">Clear &amp; require login</button>
206
+ <span style="color:#dc3545;">⚠️ Login skipped. Please click header to enable!</span>
208
207
  </div>
209
208
  """
210
209
 
@@ -258,6 +257,16 @@ def hostname_form_html(
258
257
  status_title = "Working normally"
259
258
  error_details_for_modal = "Configured and working normally."
260
259
 
260
+ # Get credentials
261
+ user = ""
262
+ password = ""
263
+ supports_login = "false"
264
+ if field_id in login_required_sites:
265
+ supports_login = "true"
266
+ site_config = Config(field_id.upper())
267
+ user = site_config.get("user") or ""
268
+ password = site_config.get("password") or ""
269
+
261
270
  field_html.append(
262
271
  hostname_fields.format(
263
272
  id=field_id,
@@ -272,6 +281,9 @@ def hostname_form_html(
272
281
  timestamp=timestamp,
273
282
  operation=_escape_js_for_html_attr(operation),
274
283
  url=_escape_js_for_html_attr(current_value),
284
+ user=_escape_js_for_html_attr(user),
285
+ password=_escape_js_for_html_attr(password),
286
+ supports_login=supports_login,
275
287
  )
276
288
  )
277
289
 
@@ -288,15 +300,9 @@ def hostname_form_html(
288
300
  # Get stored hostnames URL if available
289
301
  stored_url = Config("Settings").get("hostnames_url") or ""
290
302
 
291
- # Build restart button HTML if needed
292
- restart_section = ""
293
- if show_restart_button:
294
- restart_section = f"""
295
- <div class="section-divider" style="margin-top:1.5rem; padding-top:1rem; border-top:1px solid var(--divider-color, #dee2e6);">
296
- <p style="font-size:0.875rem; color:var(--secondary, #6c757d);">Restart required after changing login-required hostnames (AL, DD, DL, NX)</p>
297
- {render_button("Restart Quasarr", "secondary", {"type": "button", "onclick": "confirmRestart()"})}
298
- </div>
299
- """
303
+ # Check if FlareSolverr is skipped
304
+ skip_flaresolverr_db = DataBase("skip_flaresolverr")
305
+ is_flaresolverr_skipped = bool(skip_flaresolverr_db.retrieve("skipped"))
300
306
 
301
307
  template = """
302
308
  <style>
@@ -376,10 +382,9 @@ def hostname_form_html(
376
382
  {button}
377
383
  </form>
378
384
 
379
- {restart_section}
380
-
381
385
  <script>
382
386
  var formSubmitted = false;
387
+ var isFlaresolverrSkipped = {is_flaresolverr_skipped};
383
388
 
384
389
  function validateHostnames(form) {{
385
390
  if (formSubmitted) return false;
@@ -468,23 +473,6 @@ def hostname_form_html(
468
473
  }});
469
474
  }}
470
475
 
471
- function clearSkipLogin(shorthand, btnElement) {{
472
- fetch('/api/skip-login/' + shorthand, {{ method: 'DELETE' }})
473
- .then(response => response.json())
474
- .then(data => {{
475
- if (data.success) {{
476
- var indicator = btnElement.closest('.skip-indicator');
477
- if (indicator) indicator.remove();
478
- showStatusDetail(shorthand, shorthand.toUpperCase(), 'info', 'Login requirement restored. Restart Quasarr to be prompted for credentials.', '', '', '');
479
- }} else {{
480
- showStatusDetail(shorthand, shorthand.toUpperCase(), 'error', 'Failed to clear skip preference', '', '', '');
481
- }}
482
- }})
483
- .catch(error => {{
484
- showStatusDetail(shorthand, shorthand.toUpperCase(), 'error', 'Error: ' + error.message, '', '', '');
485
- }});
486
- }}
487
-
488
476
  function confirmRestart() {{
489
477
  showModal('Restart Quasarr?', 'Are you sure you want to restart Quasarr now? Any unsaved changes will be lost.',
490
478
  `<button class="btn-secondary" onclick="closeModal()">Cancel</button>
@@ -569,7 +557,7 @@ def hostname_form_html(
569
557
  }}
570
558
  </script>
571
559
  <script>
572
- function showStatusDetail(id, label, status, error_details, timestamp, operation, url) {{
560
+ function showStatusDetail(id, label, status, error_details, timestamp, operation, url, user, password, supports_login) {{
573
561
  var statusTextMap = {{
574
562
  ok: 'Operational',
575
563
  error: 'Error',
@@ -611,7 +599,41 @@ def hostname_form_html(
611
599
  }}
612
600
  }}
613
601
 
614
- var content = content_html + timestamp_html;
602
+ var credentials_html = '';
603
+ if (url && supports_login) {{
604
+ var flaresolverrWarning = '';
605
+ if (id === 'al' && isFlaresolverrSkipped) {{
606
+ flaresolverrWarning = `
607
+ <div style="margin-bottom: 1rem; padding: 0.75rem; background: #fff3cd; border: 1px solid #ffeeba; border-radius: 0.25rem; color: #856404; font-size: 0.875rem;">
608
+ <strong>⚠️ FlareSolverr Required</strong><br>
609
+ This site requires FlareSolverr, but it was skipped. You must configure it first.
610
+ <div style="margin-top: 0.5rem;">
611
+ <button class="btn-secondary" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" onclick="window.location.href='/flaresolverr'">Configure FlareSolverr</button>
612
+ </div>
613
+ </div>
614
+ `;
615
+ }}
616
+
617
+ credentials_html = `
618
+ <div style="margin-top: 1rem; border-top: 1px solid var(--divider-color, #dee2e6); padding-top: 1rem;">
619
+ <h4 style="margin-top:0; font-size:1rem;">Credentials</h4>
620
+ ${{flaresolverrWarning}}
621
+ <div style="margin-bottom: 0.5rem;">
622
+ <label style="display:block; font-size: 0.875rem;">Username</label>
623
+ <input type="text" id="cred-user-${{id}}" value="${{user}}" style="width: 100%; padding: 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem;">
624
+ </div>
625
+ <div style="margin-bottom: 0.5rem;">
626
+ <label style="display:block; font-size: 0.875rem;">Password</label>
627
+ <input type="password" id="cred-pass-${{id}}" value="${{password}}" style="width: 100%; padding: 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem;">
628
+ </div>
629
+ <div id="cred-status-${{id}}" style="margin-bottom: 0.5rem; font-size: 0.875rem; min-height: 1.25em;"></div>
630
+ <button class="btn-primary" onclick="saveAndCheckCredentials('${{id}}')">Check & Save Session</button>
631
+ <div style="margin-top: 1rem; border-bottom: 1px solid var(--divider-color, #dee2e6);"></div>
632
+ </div>
633
+ `;
634
+ }}
635
+
636
+ var content = content_html + timestamp_html + credentials_html;
615
637
  var title = '<span>' + (emojiMap[status] || 'ℹ️') + '</span> ' + label + ' - ' + (statusTextMap[status] || status);
616
638
 
617
639
  var buttons = '';
@@ -621,7 +643,7 @@ def hostname_form_html(
621
643
  href = 'https://' + href;
622
644
  }}
623
645
  buttons = `
624
- <button class="btn-primary" style="margin-right: auto;" onclick="window.open('${{href}}', '_blank')">Check ${{id.toUpperCase()}}</button>
646
+ <button class="btn-primary" style="margin-right: auto;" onclick="window.open('${{href}}', '_blank')">Open ${{id.toUpperCase()}}</button>
625
647
  <button class="btn-secondary" onclick="closeModal()">Close</button>
626
648
  `;
627
649
  }} else {{
@@ -630,6 +652,46 @@ def hostname_form_html(
630
652
 
631
653
  showModal(title, content, buttons);
632
654
  }}
655
+
656
+ function saveAndCheckCredentials(id) {{
657
+ var user = document.getElementById('cred-user-' + id).value;
658
+ var pass = document.getElementById('cred-pass-' + id).value;
659
+ var statusDiv = document.getElementById('cred-status-' + id);
660
+
661
+ statusDiv.innerHTML = 'Checking...';
662
+ statusDiv.style.color = 'var(--secondary, #6c757d)';
663
+
664
+ fetch('/api/hostnames/check-credentials/' + id, {{
665
+ method: 'POST',
666
+ headers: {{ 'Content-Type': 'application/json' }},
667
+ body: JSON.stringify({{ user: user, password: pass }})
668
+ }})
669
+ .then(response => response.json())
670
+ .then(data => {{
671
+ if (data.success) {{
672
+ statusDiv.innerHTML = '✅ ' + data.message;
673
+ statusDiv.style.color = '#198754';
674
+ // Update the status indicator in the main list
675
+ var indicator = document.getElementById('status-' + id);
676
+ if (indicator) {{
677
+ indicator.textContent = '🟢';
678
+ indicator.setAttribute('data-status', 'ok');
679
+ }}
680
+ // Remove skip indicator if present
681
+ var skipIndicator = document.getElementById('skip-indicator-' + id);
682
+ if (skipIndicator) {{
683
+ skipIndicator.remove();
684
+ }}
685
+ }} else {{
686
+ statusDiv.innerHTML = '❌ ' + data.message;
687
+ statusDiv.style.color = '#dc3545';
688
+ }}
689
+ }})
690
+ .catch(error => {{
691
+ statusDiv.innerHTML = '❌ Error: ' + error.message;
692
+ statusDiv.style.color = '#dc3545';
693
+ }});
694
+ }}
633
695
  </script>
634
696
  """
635
697
  return template.format(
@@ -637,7 +699,7 @@ def hostname_form_html(
637
699
  hostname_form_content=hostname_form_content,
638
700
  button=button_html,
639
701
  stored_url=stored_url,
640
- restart_section=restart_section,
702
+ is_flaresolverr_skipped="true" if is_flaresolverr_skipped else "false",
641
703
  )
642
704
 
643
705
 
@@ -707,16 +769,366 @@ def save_hostnames(shared_state, timeout=5, first_run=True):
707
769
  else:
708
770
  optional_text = "All provided hostnames are valid.<br>"
709
771
 
710
- if not first_run:
711
- # Append restart notice for specific sites that actually changed
712
- for site in changed_sites:
713
- if site.lower() in {"al", "dd", "dl", "nx"}:
714
- optional_text += f"{site.upper()}: You must restart Quasarr and follow additional steps to start using this site.<br>"
715
-
716
772
  full_message = f"{success_msg}<br><small>{optional_text}</small>"
717
773
  return render_reconnect_success(full_message)
718
774
 
719
775
 
776
+ def check_credentials(shared_state, shorthand):
777
+ response.content_type = "application/json"
778
+ try:
779
+ data = request.json
780
+ user = data.get("user")
781
+ password = data.get("password")
782
+
783
+ config = Config(shorthand.upper())
784
+ # Store old credentials to revert if check fails
785
+ old_user = config.get("user")
786
+ old_password = config.get("password")
787
+
788
+ # Temporarily save new credentials for the check
789
+ config.save("user", user)
790
+ config.save("password", password)
791
+
792
+ success = False
793
+ message = "Session check failed"
794
+
795
+ sh_lower = shorthand.lower()
796
+
797
+ # Clear skip login if set (temporarily, will be restored if check fails?)
798
+ # Actually, if user is trying to set credentials, they probably intend to stop skipping.
799
+ # But if check fails, we might want to keep the skip status?
800
+ # For now, let's assume if they try to check credentials, they want to use them.
801
+
802
+ if sh_lower == "al":
803
+ if quasarr.providers.sessions.al.create_and_persist_session(shared_state):
804
+ success = True
805
+ message = "Session valid!"
806
+ else:
807
+ message = "Session check failed (check logs)"
808
+ elif sh_lower == "dd":
809
+ if quasarr.providers.sessions.dd.create_and_persist_session(shared_state):
810
+ success = True
811
+ message = "Session valid!"
812
+ else:
813
+ message = "Session check failed (check logs)"
814
+ elif sh_lower == "dl":
815
+ if quasarr.providers.sessions.dl.create_and_persist_session(shared_state):
816
+ success = True
817
+ message = "Session valid!"
818
+ else:
819
+ message = "Session check failed (check logs)"
820
+ elif sh_lower == "nx":
821
+ if quasarr.providers.sessions.nx.create_and_persist_session(shared_state):
822
+ success = True
823
+ message = "Session valid!"
824
+ else:
825
+ message = "Session check failed (check logs)"
826
+ else:
827
+ success = True
828
+ message = "Credentials saved"
829
+
830
+ if success:
831
+ # If successful, ensure skip login is removed
832
+ DataBase("skip_login").delete(shorthand.lower())
833
+ else:
834
+ # If failed, revert credentials
835
+ config.save("user", old_user)
836
+ config.save("password", old_password)
837
+
838
+ return {"success": success, "message": message}
839
+
840
+ except Exception as e:
841
+ return {"success": False, "message": str(e)}
842
+
843
+
844
+ def import_hostnames_from_url():
845
+ """Fetch URL and parse hostnames, return JSON for JS to populate fields."""
846
+ response.content_type = "application/json"
847
+ try:
848
+ data = request.json
849
+ url = data.get("url", "").strip()
850
+
851
+ if not url:
852
+ return {"success": False, "error": "No URL provided"}
853
+
854
+ # Validate URL
855
+ parsed = urlparse(url)
856
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
857
+ return {"success": False, "error": "Invalid URL format"}
858
+
859
+ # Fetch content
860
+ try:
861
+ resp = requests.get(url, timeout=15)
862
+ resp.raise_for_status()
863
+ content = resp.text
864
+ except requests.RequestException as e:
865
+ info(f"Failed to fetch hostnames URL: {e}")
866
+ return {
867
+ "success": False,
868
+ "error": "Failed to fetch URL. Check the console log for details.",
869
+ }
870
+
871
+ # Parse hostnames
872
+ allowed_keys = extract_allowed_keys(Config._DEFAULT_CONFIG, "Hostnames")
873
+ results = extract_kv_pairs(content, allowed_keys)
874
+
875
+ if not results:
876
+ return {
877
+ "success": False,
878
+ "error": "No hostnames found in the provided URL",
879
+ }
880
+
881
+ # Validate each hostname
882
+ valid_hostnames = {}
883
+ invalid_hostnames = {}
884
+ for shorthand, hostname in results.items():
885
+ domain_check = extract_valid_hostname(hostname, shorthand)
886
+ domain = domain_check.get("domain")
887
+ if domain:
888
+ valid_hostnames[shorthand] = domain
889
+ else:
890
+ invalid_hostnames[shorthand] = domain_check.get("message", "Invalid")
891
+
892
+ if not valid_hostnames:
893
+ return {
894
+ "success": False,
895
+ "error": "No valid hostnames found in the provided URL",
896
+ }
897
+
898
+ return {
899
+ "success": True,
900
+ "hostnames": valid_hostnames,
901
+ "errors": invalid_hostnames,
902
+ }
903
+
904
+ except Exception as e:
905
+ return {"success": False, "error": f"Error: {str(e)}"}
906
+
907
+
908
+ def get_skip_login():
909
+ """Return list of hostnames with skipped login."""
910
+ response.content_type = "application/json"
911
+ skip_db = DataBase("skip_login")
912
+ login_required_sites = ["al", "dd", "dl", "nx"]
913
+ skipped = []
914
+ for site in login_required_sites:
915
+ if skip_db.retrieve(site):
916
+ skipped.append(site)
917
+ return {"skipped": skipped}
918
+
919
+
920
+ def clear_skip_login(shorthand):
921
+ """Clear skip login preference for a hostname."""
922
+ response.content_type = "application/json"
923
+ shorthand = shorthand.lower()
924
+ login_required_sites = ["al", "dd", "dl", "nx"]
925
+ if shorthand not in login_required_sites:
926
+ return {"success": False, "error": f"Invalid shorthand: {shorthand}"}
927
+
928
+ skip_db = DataBase("skip_login")
929
+ skip_db.delete(shorthand)
930
+ info(f'Skip login preference cleared for "{shorthand.upper()}"')
931
+ return {"success": True}
932
+
933
+
934
+ def save_flaresolverr_url(shared_state, is_setup=False):
935
+ """Save FlareSolverr URL from web UI."""
936
+ url = request.forms.get("url", "").strip()
937
+ config = Config("FlareSolverr")
938
+
939
+ if not url:
940
+ # If URL is empty, treat it as skipping FlareSolverr
941
+ config.save("url", "")
942
+ DataBase("skip_flaresolverr").update_store("skipped", "true")
943
+ # Set fallback user agent
944
+ shared_state.update("user_agent", FALLBACK_USER_AGENT)
945
+ info("FlareSolverr URL cleared and setup skipped")
946
+
947
+ if is_setup:
948
+ quasarr.providers.web_server.temp_server_success = True
949
+
950
+ return render_reconnect_success("FlareSolverr URL cleared (setup skipped).")
951
+
952
+ if not url.startswith("http://") and not url.startswith("https://"):
953
+ url = "http://" + url
954
+
955
+ # Validate URL format
956
+ if not re.search(r"/v\d+$", url):
957
+ return render_fail(
958
+ "FlareSolverr URL must end with /v1 (or similar version path)."
959
+ )
960
+
961
+ try:
962
+ headers = {"Content-Type": "application/json"}
963
+ data = {
964
+ "cmd": "request.get",
965
+ "url": "http://www.google.com/",
966
+ "maxTimeout": 30000,
967
+ }
968
+ resp = requests.post(url, headers=headers, json=data, timeout=30)
969
+ if resp.status_code == 200:
970
+ json_data = resp.json()
971
+ if json_data.get("status") == "ok":
972
+ config.save("url", url)
973
+ # Clear skip preference since we now have a working URL
974
+ DataBase("skip_flaresolverr").delete("skipped")
975
+
976
+ # Update user agent from FlareSolverr response
977
+ solution = json_data.get("solution", {})
978
+ solution_ua = solution.get("userAgent")
979
+ if solution_ua:
980
+ shared_state.update("user_agent", solution_ua)
981
+
982
+ info(f'FlareSolverr URL configured: "{url}"')
983
+
984
+ if is_setup:
985
+ quasarr.providers.web_server.temp_server_success = True
986
+
987
+ return render_reconnect_success("FlareSolverr URL saved successfully!")
988
+ else:
989
+ return render_fail(
990
+ f"FlareSolverr returned unexpected status: {json_data.get('status')}"
991
+ )
992
+ except requests.RequestException:
993
+ return render_fail("Could not reach FlareSolverr!")
994
+
995
+ return render_fail("Could not reach FlareSolverr at that URL (expected HTTP 200).")
996
+
997
+
998
+ def get_flaresolverr_status_data(shared_state):
999
+ """Return FlareSolverr configuration status."""
1000
+ response.content_type = "application/json"
1001
+ skip_db = DataBase("skip_flaresolverr")
1002
+ is_skipped = bool(skip_db.retrieve("skipped"))
1003
+ current_url = Config("FlareSolverr").get("url") or ""
1004
+
1005
+ # Test connection if URL is set
1006
+ is_working = False
1007
+ if current_url and not is_skipped:
1008
+ is_working = check_flaresolverr(shared_state, current_url)
1009
+
1010
+ return {"skipped": is_skipped, "url": current_url, "working": is_working}
1011
+
1012
+
1013
+ def delete_skip_flaresolverr_preference():
1014
+ """Clear skip FlareSolverr preference."""
1015
+ response.content_type = "application/json"
1016
+ skip_db = DataBase("skip_flaresolverr")
1017
+ skip_db.delete("skipped")
1018
+ info("Skip FlareSolverr preference cleared")
1019
+ return {"success": True}
1020
+
1021
+
1022
+ def flaresolverr_form_html(shared_state, is_setup=False):
1023
+ skip_db = DataBase("skip_flaresolverr")
1024
+ is_skipped = skip_db.retrieve("skipped")
1025
+ current_url = Config("FlareSolverr").get("url") or ""
1026
+
1027
+ skip_indicator = ""
1028
+ if is_skipped and not is_setup:
1029
+ skip_indicator = """
1030
+ <div class="skip-indicator" style="margin-bottom:1rem; padding:0.75rem; background:var(--code-bg, #f8f9fa); border-radius:0.25rem; font-size:0.875rem;">
1031
+ <span style="color:#dc3545;">⚠️ FlareSolverr setup was skipped</span>
1032
+ <p style="margin:0.5rem 0 0 0; font-size:0.75rem; color:var(--secondary, #6c757d);">
1033
+ Some sites (like AL) won't work until FlareSolverr is configured.
1034
+ </p>
1035
+ </div>
1036
+ """
1037
+
1038
+ form_content = f'''
1039
+ {skip_indicator}
1040
+ <span><a href="https://github.com/FlareSolverr/FlareSolverr?tab=readme-ov-file#installation" target="_blank">FlareSolverr</a>
1041
+ must be running and reachable to Quasarr for some sites to work.</span><br><br>
1042
+ <label for="url">FlareSolverr URL</label>
1043
+ <input type="text" id="url" name="url" placeholder="http://192.168.0.1:8191/v1" value="{current_url}"><br>
1044
+ '''
1045
+
1046
+ buttons = render_button("Save", "primary", {"type": "submit", "id": "submitBtn"})
1047
+
1048
+ extra_js = ""
1049
+
1050
+ if is_setup:
1051
+ buttons += ' <button type="button" class="btn-warning" id="skipBtn" onclick="skipFlaresolverr()">Skip for now</button>'
1052
+ extra_js = """
1053
+ function skipFlaresolverr() {
1054
+ if (formSubmitted) return;
1055
+ formSubmitted = true;
1056
+ var skipBtn = document.getElementById('skipBtn');
1057
+ var submitBtn = document.getElementById('submitBtn');
1058
+ if (skipBtn) { skipBtn.disabled = true; skipBtn.textContent = 'Skipping...'; }
1059
+ if (submitBtn) { submitBtn.disabled = true; }
1060
+
1061
+ fetch('/api/flaresolverr/skip', { method: 'POST' })
1062
+ .then(response => {
1063
+ if (response.ok) {
1064
+ window.location.href = '/skip-success';
1065
+ } else {
1066
+ showModal('Error', 'Failed to skip FlareSolverr setup');
1067
+ formSubmitted = false;
1068
+ if (skipBtn) { skipBtn.disabled = false; skipBtn.textContent = 'Skip for now'; }
1069
+ if (submitBtn) { submitBtn.disabled = false; }
1070
+ }
1071
+ })
1072
+ .catch(error => {
1073
+ showModal('Error', 'Error: ' + error.message);
1074
+ formSubmitted = false;
1075
+ if (skipBtn) { skipBtn.disabled = false; skipBtn.textContent = 'Skip for now'; }
1076
+ if (submitBtn) { submitBtn.disabled = false; }
1077
+ });
1078
+ }
1079
+ """
1080
+
1081
+ form_html = f"""
1082
+ <style>
1083
+ .button-row {{
1084
+ display: flex;
1085
+ gap: 0.75rem;
1086
+ justify-content: center;
1087
+ flex-wrap: wrap;
1088
+ margin-top: 1rem;
1089
+ }}
1090
+ .btn-warning {{
1091
+ background-color: #ffc107;
1092
+ color: #212529;
1093
+ border: 1.5px solid #d39e00;
1094
+ padding: 0.5rem 1rem;
1095
+ font-size: 1rem;
1096
+ border-radius: 0.5rem;
1097
+ font-weight: 500;
1098
+ cursor: pointer;
1099
+ }}
1100
+ .btn-warning:hover {{
1101
+ background-color: #e0a800;
1102
+ border-color: #c69500;
1103
+ }}
1104
+ </style>
1105
+ <form action="/api/flaresolverr" method="post" onsubmit="return handleSubmit(this)">
1106
+ {form_content}
1107
+ <div class="button-row">
1108
+ {buttons}
1109
+ </div>
1110
+ </form>
1111
+ <script>
1112
+ var formSubmitted = false;
1113
+ function handleSubmit(form) {{
1114
+ if (formSubmitted) return false;
1115
+ formSubmitted = true;
1116
+ var btn = document.getElementById('submitBtn');
1117
+ if (btn) {{ btn.disabled = true; btn.textContent = 'Saving...'; }}
1118
+ var skipBtn = document.getElementById('skipBtn');
1119
+ if (skipBtn) {{ skipBtn.disabled = true; }}
1120
+ return true;
1121
+ }}
1122
+ {extra_js}
1123
+ </script>
1124
+ """
1125
+
1126
+ if not is_setup:
1127
+ form_html += f"""<p>{render_button("Back", "secondary", {"onclick": "location.href='/'"})}</p>"""
1128
+
1129
+ return form_html
1130
+
1131
+
720
1132
  def hostnames_config(shared_state):
721
1133
  app = Bottle()
722
1134
  add_no_cache_headers(app)
@@ -739,87 +1151,20 @@ def hostnames_config(shared_state):
739
1151
  return save_hostnames(shared_state)
740
1152
 
741
1153
  @app.post("/api/hostnames/import-url")
742
- def import_hostnames_from_url():
743
- """Fetch URL and parse hostnames, return JSON for JS to populate fields."""
744
- response.content_type = "application/json"
745
- try:
746
- data = request.json
747
- url = data.get("url", "").strip()
748
-
749
- if not url:
750
- return {"success": False, "error": "No URL provided"}
751
-
752
- # Validate URL
753
- parsed = urlparse(url)
754
- if parsed.scheme not in ("http", "https") or not parsed.netloc:
755
- return {"success": False, "error": "Invalid URL format"}
756
-
757
- # Fetch content
758
- try:
759
- resp = requests.get(url, timeout=15)
760
- resp.raise_for_status()
761
- content = resp.text
762
- except requests.RequestException as e:
763
- info(f"Failed to fetch hostnames URL: {e}")
764
- return {
765
- "success": False,
766
- "error": "Failed to fetch URL. Check the console log for details.",
767
- }
768
-
769
- # Parse hostnames
770
- allowed_keys = extract_allowed_keys(Config._DEFAULT_CONFIG, "Hostnames")
771
- results = extract_kv_pairs(content, allowed_keys)
772
-
773
- if not results:
774
- return {
775
- "success": False,
776
- "error": "No hostnames found in the provided URL",
777
- }
778
-
779
- # Validate each hostname
780
- valid_hostnames = {}
781
- invalid_hostnames = {}
782
- for shorthand, hostname in results.items():
783
- domain_check = extract_valid_hostname(hostname, shorthand)
784
- domain = domain_check.get("domain")
785
- if domain:
786
- valid_hostnames[shorthand] = domain
787
- else:
788
- invalid_hostnames[shorthand] = domain_check.get(
789
- "message", "Invalid"
790
- )
791
-
792
- if not valid_hostnames:
793
- return {
794
- "success": False,
795
- "error": "No valid hostnames found in the provided URL",
796
- }
797
-
798
- return {
799
- "success": True,
800
- "hostnames": valid_hostnames,
801
- "errors": invalid_hostnames,
802
- }
803
-
804
- except Exception as e:
805
- return {"success": False, "error": f"Error: {str(e)}"}
1154
+ def import_hostnames_route():
1155
+ return import_hostnames_from_url()
806
1156
 
807
1157
  @app.get("/api/skip-login")
808
- def get_skip_login():
809
- """Return list of hostnames with skipped login."""
810
- response.content_type = "application/json"
811
- skip_db = DataBase("skip_login")
812
- login_required_sites = ["al", "dd", "dl", "nx"]
813
- skipped = []
814
- for site in login_required_sites:
815
- if skip_db.retrieve(site):
816
- skipped.append(site)
817
- return {"skipped": skipped}
1158
+ def get_skip_login_route():
1159
+ return get_skip_login()
818
1160
 
819
1161
  @app.delete("/api/skip-login/<shorthand>")
820
- def clear_skip_login(shorthand):
821
- DataBase("skip_login").delete(shorthand)
822
- return {"success": True}
1162
+ def clear_skip_login_route(shorthand):
1163
+ return clear_skip_login(shorthand)
1164
+
1165
+ @app.post("/api/hostnames/check-credentials/<shorthand>")
1166
+ def check_credentials_route(shorthand):
1167
+ return check_credentials(shared_state, shorthand)
823
1168
 
824
1169
  info(
825
1170
  f'Hostnames not set. Starting web server for config at: "{shared_state.values["internal_address"]}".'
@@ -838,15 +1183,50 @@ def hostname_credentials_config(shared_state, shorthand, domain):
838
1183
 
839
1184
  shorthand = shorthand.upper()
840
1185
 
1186
+ @app.post("/api/flaresolverr_inline")
1187
+ def set_flaresolverr_inline():
1188
+ return save_flaresolverr_url(shared_state, is_setup=False)
1189
+
841
1190
  @app.get("/")
842
1191
  def credentials_form():
843
- form_content = f"""
1192
+ flaresolverr_url = Config("FlareSolverr").get("url")
1193
+
1194
+ is_al_missing_flaresolverr = shorthand == "AL" and not flaresolverr_url
1195
+
1196
+ flaresolverr_section = ""
1197
+
1198
+ if is_al_missing_flaresolverr:
1199
+ flaresolverr_section = """
1200
+ <div style="margin-bottom: 1.5rem; padding: 1rem; background: #fff3cd; border: 1px solid #ffeeba; border-radius: 0.5rem;">
1201
+ <h4 style="margin-top:0; font-size:1rem; color:#856404;">⚠️ FlareSolverr Required</h4>
1202
+ <p style="font-size:0.875rem; margin-bottom:0.5rem; color:#856404;">
1203
+ This site requires FlareSolverr. Please configure it below before checking credentials.
1204
+ </p>
1205
+ <form action="/api/flaresolverr_inline" method="post" onsubmit="return handleFlareSolverrSubmit(this)">
1206
+ <div style="display:flex; gap:0.5rem;">
1207
+ <input type="text" name="url" placeholder="http://192.168.0.1:8191/v1" style="flex:1; margin-bottom:0;">
1208
+ <button type="submit" class="btn-secondary" id="fsSubmitBtn" style="margin-top:0;">Save URL</button>
1209
+ </div>
1210
+ </form>
1211
+ </div>
1212
+ <script>
1213
+ function handleFlareSolverrSubmit(form) {{
1214
+ var btn = document.getElementById('fsSubmitBtn');
1215
+ if (btn) {{ btn.disabled = true; btn.textContent = 'Saving...'; }}
1216
+ return true;
1217
+ }}
1218
+ </script>
1219
+ """
1220
+
1221
+ disabled_attr = "disabled" if is_al_missing_flaresolverr else ""
1222
+
1223
+ credentials_inputs = f"""
844
1224
  <span>If required register account at: <a href="https://{domain}">{domain}</a>!</span><br><br>
845
1225
  <label for="user">Username</label>
846
- <input type="text" id="user" name="user" placeholder="User" autocorrect="off"><br>
1226
+ <input type="text" id="user" name="user" placeholder="User" autocorrect="off" {disabled_attr}><br>
847
1227
 
848
1228
  <label for="password">Password</label>
849
- <input type="password" id="password" name="password" placeholder="Password"><br>
1229
+ <input type="password" id="password" name="password" placeholder="Password" {disabled_attr}><br>
850
1230
  """
851
1231
 
852
1232
  form_html = f"""
@@ -873,8 +1253,9 @@ def hostname_credentials_config(shared_state, shorthand, domain):
873
1253
  border-color: #c69500;
874
1254
  }}
875
1255
  </style>
1256
+ {flaresolverr_section}
876
1257
  <form id="credentialsForm" action="/api/credentials/{shorthand}" method="post" onsubmit="return handleSubmit(this)">
877
- {form_content}
1258
+ {credentials_inputs}
878
1259
  <div class="button-row">
879
1260
  {render_button("Save", "primary", {"type": "submit", "id": "submitBtn"})}
880
1261
  <button type="button" class="btn-warning" id="skipBtn" onclick="skipLogin()">Skip for now</button>
@@ -885,7 +1266,14 @@ def hostname_credentials_config(shared_state, shorthand, domain):
885
1266
  </p>
886
1267
  <script>
887
1268
  var formSubmitted = false;
1269
+ var isAlMissingFlaresolverr = {"true" if is_al_missing_flaresolverr else "false"};
1270
+
888
1271
  function handleSubmit(form) {{
1272
+ if (isAlMissingFlaresolverr) {{
1273
+ showModal('FlareSolverr Required', 'You must configure FlareSolverr below or skip login for this site.');
1274
+ return false;
1275
+ }}
1276
+
889
1277
  if (formSubmitted) return false;
890
1278
  formSubmitted = true;
891
1279
  var btn = document.getElementById('submitBtn');
@@ -1023,85 +1411,9 @@ def flaresolverr_config(shared_state):
1023
1411
 
1024
1412
  @app.get("/")
1025
1413
  def url_form():
1026
- form_content = """
1027
- <span><a href="https://github.com/FlareSolverr/FlareSolverr?tab=readme-ov-file#installation">A local instance</a>
1028
- must be running and reachable to Quasarr!</span><br><br>
1029
- <label for="url">FlareSolverr URL</label>
1030
- <input type="text" id="url" name="url" placeholder="http://192.168.0.1:8191/v1"><br>
1031
- """
1032
- form_html = f"""
1033
- <style>
1034
- .button-row {{
1035
- display: flex;
1036
- gap: 0.75rem;
1037
- justify-content: center;
1038
- flex-wrap: wrap;
1039
- margin-top: 1rem;
1040
- }}
1041
- .btn-warning {{
1042
- background-color: #ffc107;
1043
- color: #212529;
1044
- border: 1.5px solid #d39e00;
1045
- padding: 0.5rem 1rem;
1046
- font-size: 1rem;
1047
- border-radius: 0.5rem;
1048
- font-weight: 500;
1049
- cursor: pointer;
1050
- }}
1051
- .btn-warning:hover {{
1052
- background-color: #e0a800;
1053
- border-color: #c69500;
1054
- }}
1055
- </style>
1056
- <form action="/api/flaresolverr" method="post" onsubmit="return handleSubmit(this)">
1057
- {form_content}
1058
- <div class="button-row">
1059
- {render_button("Save", "primary", {"type": "submit", "id": "submitBtn"})}
1060
- <button type="button" class="btn-warning" id="skipBtn" onclick="skipFlaresolverr()">Skip for now</button>
1061
- </div>
1062
- </form>
1063
- <p style="font-size:0.875rem; color:var(--secondary, #6c757d); margin-top:1rem;">
1064
- Skipping will allow Quasarr to start, but some sites (like AL) won't work without FlareSolverr.
1065
- </p>
1066
- <script>
1067
- var formSubmitted = false;
1068
- function handleSubmit(form) {{
1069
- if (formSubmitted) return false;
1070
- formSubmitted = true;
1071
- var btn = document.getElementById('submitBtn');
1072
- if (btn) {{ btn.disabled = true; btn.textContent = 'Saving...'; }}
1073
- document.getElementById('skipBtn').disabled = true;
1074
- return true;
1075
- }}
1076
- function skipFlaresolverr() {{
1077
- if (formSubmitted) return;
1078
- formSubmitted = true;
1079
- var skipBtn = document.getElementById('skipBtn');
1080
- var submitBtn = document.getElementById('submitBtn');
1081
- if (skipBtn) {{ skipBtn.disabled = true; skipBtn.textContent = 'Skipping...'; }}
1082
- if (submitBtn) {{ submitBtn.disabled = true; }}
1083
-
1084
- fetch('/api/flaresolverr/skip', {{ method: 'POST' }})
1085
- .then(response => {{
1086
- if (response.ok) {{
1087
- window.location.href = '/skip-success';
1088
- }} else {{
1089
- showModal('Error', 'Failed to skip FlareSolverr setup');
1090
- formSubmitted = false;
1091
- if (skipBtn) {{ skipBtn.disabled = false; skipBtn.textContent = 'Skip for now'; }}
1092
- if (submitBtn) {{ submitBtn.disabled = false; }}
1093
- }}
1094
- }})
1095
- .catch(error => {{
1096
- showModal('Error', 'Error: ' + error.message);
1097
- formSubmitted = false;
1098
- if (skipBtn) {{ skipBtn.disabled = false; skipBtn.textContent = 'Skip for now'; }}
1099
- if (submitBtn) {{ submitBtn.disabled = false; }}
1100
- }});
1101
- }}
1102
- </script>
1103
- """
1104
- return render_form("Set FlareSolverr URL", form_html)
1414
+ return render_form(
1415
+ "Set FlareSolverr URL", flaresolverr_form_html(shared_state, is_setup=True)
1416
+ )
1105
1417
 
1106
1418
  @app.get("/skip-success")
1107
1419
  def skip_success():
@@ -1121,38 +1433,7 @@ def flaresolverr_config(shared_state):
1121
1433
 
1122
1434
  @app.post("/api/flaresolverr")
1123
1435
  def set_flaresolverr_url():
1124
- url = request.forms.get("url").strip()
1125
- config = Config("FlareSolverr")
1126
-
1127
- if not url.startswith("http://") and not url.startswith("https://"):
1128
- url = "http://" + url
1129
-
1130
- if url:
1131
- try:
1132
- headers = {"Content-Type": "application/json"}
1133
- data = {
1134
- "cmd": "request.get",
1135
- "url": "http://www.google.com/",
1136
- "maxTimeout": 30000,
1137
- }
1138
- resp = requests.post(url, headers=headers, json=data, timeout=30)
1139
- if resp.status_code == 200:
1140
- config.save("url", url)
1141
- # Clear skip preference since we now have a working URL
1142
- DataBase("skip_flaresolverr").delete("skipped")
1143
- print(f'Using Flaresolverr URL: "{url}"')
1144
- quasarr.providers.web_server.temp_server_success = True
1145
- return render_reconnect_success(
1146
- "FlareSolverr URL saved successfully!"
1147
- )
1148
- except requests.RequestException:
1149
- pass
1150
-
1151
- # on failure, clear any existing value and notify user
1152
- config.save("url", "")
1153
- return render_fail(
1154
- "Could not reach FlareSolverr at that URL (expected HTTP 200)."
1155
- )
1436
+ return save_flaresolverr_url(shared_state, is_setup=True)
1156
1437
 
1157
1438
  info(
1158
1439
  '"flaresolverr" URL is required for some sites (like AL). '