quasarr 1.32.0__py3-none-any.whl → 2.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of quasarr might be problematic. Click here for more details.
- quasarr/api/__init__.py +324 -106
- quasarr/api/arr/__init__.py +56 -20
- quasarr/api/captcha/__init__.py +26 -1
- quasarr/api/config/__init__.py +1 -1
- quasarr/api/packages/__init__.py +435 -0
- quasarr/api/sponsors_helper/__init__.py +4 -0
- quasarr/downloads/__init__.py +96 -6
- quasarr/downloads/linkcrypters/filecrypt.py +1 -1
- quasarr/downloads/linkcrypters/hide.py +45 -6
- quasarr/providers/auth.py +250 -0
- quasarr/providers/html_templates.py +65 -10
- quasarr/providers/obfuscated.py +9 -7
- quasarr/providers/shared_state.py +24 -0
- quasarr/providers/version.py +1 -1
- quasarr/search/sources/al.py +1 -1
- quasarr/search/sources/by.py +1 -1
- quasarr/search/sources/dd.py +2 -1
- quasarr/search/sources/dj.py +2 -2
- quasarr/search/sources/dl.py +11 -4
- quasarr/search/sources/dt.py +1 -1
- quasarr/search/sources/dw.py +6 -7
- quasarr/search/sources/fx.py +4 -4
- quasarr/search/sources/he.py +1 -1
- quasarr/search/sources/mb.py +1 -1
- quasarr/search/sources/nk.py +1 -1
- quasarr/search/sources/nx.py +1 -1
- quasarr/search/sources/sf.py +4 -2
- quasarr/search/sources/sj.py +2 -2
- quasarr/search/sources/sl.py +3 -3
- quasarr/search/sources/wd.py +1 -1
- quasarr/search/sources/wx.py +4 -3
- quasarr/storage/setup.py +12 -0
- {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/METADATA +47 -24
- {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/RECORD +38 -36
- {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/WHEEL +0 -0
- {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/entry_points.txt +0 -0
- {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/licenses/LICENSE +0 -0
- {quasarr-1.32.0.dist-info → quasarr-2.1.0.dist-info}/top_level.txt +0 -0
|
@@ -12,22 +12,58 @@ from quasarr.providers.log import info, debug
|
|
|
12
12
|
from quasarr.providers.statistics import StatsHelper
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
def unhide_links(shared_state, url):
|
|
15
|
+
def unhide_links(shared_state, url, session):
|
|
16
16
|
try:
|
|
17
17
|
links = []
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
# Support both formats:
|
|
20
|
+
# - https://hide.cx/container/{id}
|
|
21
|
+
# - https://hide.cx/fc/Container/{id}.html
|
|
22
|
+
match = re.search(
|
|
23
|
+
r"/(?:fc/)?container/([a-z0-9A-Z\-]+)(?:\.html)?",
|
|
24
|
+
url,
|
|
25
|
+
re.IGNORECASE,
|
|
26
|
+
)
|
|
27
|
+
|
|
20
28
|
if not match:
|
|
21
29
|
info(f"Invalid hide.cx URL: {url}")
|
|
22
30
|
return []
|
|
23
31
|
|
|
24
32
|
container_id = match.group(1)
|
|
33
|
+
is_fc = "/fc/" in url.lower()
|
|
34
|
+
# resolve fc foreign ID to canonical container ID
|
|
35
|
+
if is_fc:
|
|
36
|
+
headers = {
|
|
37
|
+
"User-Agent": shared_state.values["user_agent"],
|
|
38
|
+
"Accept": "application/json",
|
|
39
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
info(f"Resolving hide.cx foreign container ID: {container_id}")
|
|
43
|
+
resolve_url = f"https://api.hide.cx/fc/Container/{container_id}"
|
|
44
|
+
resp = session.get(resolve_url, headers=headers, timeout=30)
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
resolved = resp.json()
|
|
48
|
+
except Exception:
|
|
49
|
+
debug(f"Failed to resolve foreign container {container_id}")
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
canonical_id = resolved.get("id")
|
|
53
|
+
if not canonical_id:
|
|
54
|
+
debug(f"No canonical container ID found for {container_id}")
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
container_id = canonical_id
|
|
58
|
+
debug(f"Resolved to canonical container ID: {container_id}")
|
|
59
|
+
|
|
60
|
+
headers = {'User-Agent': shared_state.values["user_agent"]}
|
|
25
61
|
info(f"Fetching hide.cx container with ID: {container_id}")
|
|
26
62
|
|
|
27
63
|
headers = {'User-Agent': shared_state.values["user_agent"]}
|
|
28
64
|
|
|
29
65
|
container_url = f"https://api.hide.cx/containers/{container_id}"
|
|
30
|
-
response =
|
|
66
|
+
response = session.get(container_url, headers=headers)
|
|
31
67
|
data = response.json()
|
|
32
68
|
|
|
33
69
|
link_ids = [link.get("id") for link in data.get("links", []) if link.get("id")]
|
|
@@ -39,7 +75,7 @@ def unhide_links(shared_state, url):
|
|
|
39
75
|
def fetch_link(link_id):
|
|
40
76
|
debug(f"Fetching hide.cx link with ID: {link_id}")
|
|
41
77
|
link_url = f"https://api.hide.cx/containers/{container_id}/links/{link_id}"
|
|
42
|
-
link_data =
|
|
78
|
+
link_data = session.get(link_url, headers=headers).json()
|
|
43
79
|
return link_data.get("url")
|
|
44
80
|
|
|
45
81
|
# Process links in batches of 10
|
|
@@ -103,12 +139,15 @@ def decrypt_links_if_hide(shared_state: Any, items: List[List[str]]) -> Dict[str
|
|
|
103
139
|
resp = session.get(original_url, allow_redirects=True, timeout=10)
|
|
104
140
|
|
|
105
141
|
final_url = resp.url
|
|
106
|
-
|
|
142
|
+
|
|
143
|
+
# accept hide.cx even if it did not redirect
|
|
144
|
+
if "hide.cx" in final_url or "hide.cx" in original_url:
|
|
107
145
|
debug(f"Identified hide.cx link: {final_url}")
|
|
108
146
|
hide_urls.append(final_url)
|
|
109
147
|
else:
|
|
110
148
|
debug(f"Not a hide.cx link (skipped): {final_url}")
|
|
111
149
|
|
|
150
|
+
|
|
112
151
|
except requests.RequestException as e:
|
|
113
152
|
info(f"Error resolving URL {original_url}: {e}")
|
|
114
153
|
continue
|
|
@@ -121,7 +160,7 @@ def decrypt_links_if_hide(shared_state: Any, items: List[List[str]]) -> Dict[str
|
|
|
121
160
|
decrypted_links: List[str] = []
|
|
122
161
|
for url in hide_urls:
|
|
123
162
|
try:
|
|
124
|
-
links = unhide_links(shared_state, url)
|
|
163
|
+
links = unhide_links(shared_state, url, session)
|
|
125
164
|
if not links:
|
|
126
165
|
debug(f"No links decrypted for {url}")
|
|
127
166
|
continue
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from bottle import request, response, redirect
|
|
12
|
+
|
|
13
|
+
import quasarr.providers.html_images as images
|
|
14
|
+
from quasarr.providers.version import get_version
|
|
15
|
+
|
|
16
|
+
# Auth configuration from environment
|
|
17
|
+
AUTH_USER = os.environ.get('USER', '')
|
|
18
|
+
AUTH_PASS = os.environ.get('PASS', '')
|
|
19
|
+
AUTH_TYPE = os.environ.get('AUTH', '').lower()
|
|
20
|
+
|
|
21
|
+
# Cookie settings
|
|
22
|
+
COOKIE_NAME = 'quasarr_session'
|
|
23
|
+
COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_auth_enabled():
|
|
27
|
+
"""Check if authentication is enabled (both USER and PASS set)."""
|
|
28
|
+
return bool(AUTH_USER and AUTH_PASS)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_form_auth():
|
|
32
|
+
"""Check if form-based auth is enabled."""
|
|
33
|
+
return AUTH_TYPE == 'form'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _sign_cookie(user, expiry):
|
|
37
|
+
"""Create HMAC signature for cookie."""
|
|
38
|
+
msg = f"{user}:{expiry}".encode()
|
|
39
|
+
return hmac.new(AUTH_PASS.encode(), msg, hashlib.sha256).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _create_session_cookie(user):
|
|
43
|
+
"""Create a signed session cookie value."""
|
|
44
|
+
expiry = int(time.time()) + COOKIE_MAX_AGE
|
|
45
|
+
signature = _sign_cookie(user, expiry)
|
|
46
|
+
return f"{user}:{expiry}:{signature}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _verify_session_cookie(cookie_value):
|
|
50
|
+
"""Verify a session cookie. Returns True if valid."""
|
|
51
|
+
try:
|
|
52
|
+
parts = cookie_value.split(':')
|
|
53
|
+
if len(parts) != 3:
|
|
54
|
+
return False
|
|
55
|
+
user, expiry, signature = parts
|
|
56
|
+
expiry = int(expiry)
|
|
57
|
+
if time.time() > expiry:
|
|
58
|
+
return False
|
|
59
|
+
expected_sig = _sign_cookie(user, expiry)
|
|
60
|
+
return hmac.compare_digest(signature, expected_sig)
|
|
61
|
+
except:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def check_basic_auth():
|
|
66
|
+
"""Check HTTP Basic Auth header. Returns True if valid."""
|
|
67
|
+
auth = request.headers.get('Authorization', '')
|
|
68
|
+
if not auth.startswith('Basic '):
|
|
69
|
+
return False
|
|
70
|
+
try:
|
|
71
|
+
decoded = base64.b64decode(auth[6:]).decode('utf-8')
|
|
72
|
+
user, passwd = decoded.split(':', 1)
|
|
73
|
+
return user == AUTH_USER and passwd == AUTH_PASS
|
|
74
|
+
except:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check_form_auth():
|
|
79
|
+
"""Check session cookie. Returns True if valid."""
|
|
80
|
+
cookie = request.get_cookie(COOKIE_NAME)
|
|
81
|
+
return cookie and _verify_session_cookie(cookie)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def require_basic_auth():
|
|
85
|
+
"""Send 401 response for Basic Auth."""
|
|
86
|
+
response.status = 401
|
|
87
|
+
response.set_header('WWW-Authenticate', 'Basic realm="Quasarr"')
|
|
88
|
+
return "Authentication required"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _render_login_page(error=None):
|
|
92
|
+
"""Render login form page using Quasarr styling."""
|
|
93
|
+
error_html = f'<p style="color: #dc3545; margin-bottom: 1rem;"><b>{error}</b></p>' if error else ''
|
|
94
|
+
next_url = request.query.get('next', '/')
|
|
95
|
+
|
|
96
|
+
# Inline the centered HTML to avoid circular import
|
|
97
|
+
return f'''<html>
|
|
98
|
+
<head>
|
|
99
|
+
<meta charset="utf-8">
|
|
100
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
101
|
+
<title>Quasarr - Login</title>
|
|
102
|
+
<link rel="icon" href="{images.logo}" type="image/png">
|
|
103
|
+
<style>
|
|
104
|
+
:root {{
|
|
105
|
+
--bg-color: #ffffff;
|
|
106
|
+
--fg-color: #212529;
|
|
107
|
+
--card-bg: #ffffff;
|
|
108
|
+
--card-shadow: rgba(0, 0, 0, 0.1);
|
|
109
|
+
--primary: #0d6efd;
|
|
110
|
+
--secondary: #6c757d;
|
|
111
|
+
--spacing: 1rem;
|
|
112
|
+
}}
|
|
113
|
+
@media (prefers-color-scheme: dark) {{
|
|
114
|
+
:root {{
|
|
115
|
+
--bg-color: #181a1b;
|
|
116
|
+
--fg-color: #f1f1f1;
|
|
117
|
+
--card-bg: #242526;
|
|
118
|
+
--card-shadow: rgba(0, 0, 0, 0.5);
|
|
119
|
+
}}
|
|
120
|
+
}}
|
|
121
|
+
*, *::before, *::after {{ box-sizing: border-box; }}
|
|
122
|
+
html, body {{
|
|
123
|
+
margin: 0; padding: 0; width: 100%; height: 100%;
|
|
124
|
+
background-color: var(--bg-color); color: var(--fg-color);
|
|
125
|
+
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
126
|
+
line-height: 1.6; display: flex; flex-direction: column; min-height: 100vh;
|
|
127
|
+
}}
|
|
128
|
+
.outer {{ flex: 1; display: flex; justify-content: center; align-items: center; padding: var(--spacing); }}
|
|
129
|
+
.inner {{
|
|
130
|
+
background-color: var(--card-bg); border-radius: 1rem;
|
|
131
|
+
box-shadow: 0 0.5rem 1.5rem var(--card-shadow);
|
|
132
|
+
padding: calc(var(--spacing) * 2); text-align: center; width: 100%; max-width: 400px;
|
|
133
|
+
}}
|
|
134
|
+
.logo {{ height: 64px; width: 64px; vertical-align: middle; margin-right: 0.5rem; }}
|
|
135
|
+
h1 {{ margin: 0 0 1rem 0; font-size: 1.75rem; }}
|
|
136
|
+
h2 {{ margin: 0 0 1.5rem 0; font-size: 1.25rem; font-weight: 500; }}
|
|
137
|
+
label {{ display: block; text-align: left; margin-bottom: 0.25rem; font-weight: 500; }}
|
|
138
|
+
input[type="text"], input[type="password"] {{
|
|
139
|
+
width: 100%; padding: 0.5rem; margin-bottom: 1rem;
|
|
140
|
+
border: 1px solid var(--secondary); border-radius: 0.5rem;
|
|
141
|
+
font-size: 1rem; background: var(--card-bg); color: var(--fg-color);
|
|
142
|
+
}}
|
|
143
|
+
.btn-primary {{
|
|
144
|
+
background-color: var(--primary); color: #fff; border: 1.5px solid #0856c7;
|
|
145
|
+
padding: 0.5rem 1.5rem; font-size: 1rem; border-radius: 0.5rem;
|
|
146
|
+
font-weight: 500; cursor: pointer; width: 100%;
|
|
147
|
+
}}
|
|
148
|
+
.btn-primary:hover {{ background-color: #0b5ed7; }}
|
|
149
|
+
footer {{ text-align: center; font-size: 0.75rem; color: var(--secondary); padding: 0.5rem 0; }}
|
|
150
|
+
</style>
|
|
151
|
+
</head>
|
|
152
|
+
<body>
|
|
153
|
+
<div class="outer">
|
|
154
|
+
<div class="inner">
|
|
155
|
+
<h1><img src="{images.logo}" class="logo" alt="Quasarr"/>Quasarr</h1>
|
|
156
|
+
{error_html}
|
|
157
|
+
<form method="post" action="/login">
|
|
158
|
+
<input type="hidden" name="next" value="{next_url}">
|
|
159
|
+
<label for="username">Username</label>
|
|
160
|
+
<input type="text" id="username" name="username" autocomplete="username" required>
|
|
161
|
+
<label for="password">Password</label>
|
|
162
|
+
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
|
163
|
+
<button type="submit" class="btn-primary">Login</button>
|
|
164
|
+
</form>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
<footer>Quasarr v.{get_version()}</footer>
|
|
168
|
+
</body>
|
|
169
|
+
</html>'''
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _handle_login_post():
|
|
173
|
+
"""Handle login form submission."""
|
|
174
|
+
username = request.forms.get('username', '')
|
|
175
|
+
password = request.forms.get('password', '')
|
|
176
|
+
next_url = request.forms.get('next', '/')
|
|
177
|
+
|
|
178
|
+
if username == AUTH_USER and password == AUTH_PASS:
|
|
179
|
+
cookie_value = _create_session_cookie(username)
|
|
180
|
+
response.set_cookie(COOKIE_NAME, cookie_value, max_age=COOKIE_MAX_AGE, path='/', httponly=True)
|
|
181
|
+
redirect(next_url)
|
|
182
|
+
else:
|
|
183
|
+
return _render_login_page(error="Invalid username or password")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _handle_logout():
|
|
187
|
+
"""Handle logout - clear cookie and redirect to login."""
|
|
188
|
+
response.delete_cookie(COOKIE_NAME, path='/')
|
|
189
|
+
redirect('/login')
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def show_logout_link():
|
|
193
|
+
"""Returns True if logout link should be shown (form auth enabled and authenticated)."""
|
|
194
|
+
return is_auth_enabled() and is_form_auth() and check_form_auth()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def add_auth_routes(app):
|
|
198
|
+
"""Add login/logout routes to a Bottle app (for form auth only)."""
|
|
199
|
+
if not is_auth_enabled():
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
if is_form_auth():
|
|
203
|
+
@app.get('/login')
|
|
204
|
+
def login_get():
|
|
205
|
+
if check_form_auth():
|
|
206
|
+
redirect('/')
|
|
207
|
+
return _render_login_page()
|
|
208
|
+
|
|
209
|
+
@app.post('/login')
|
|
210
|
+
def login_post():
|
|
211
|
+
return _handle_login_post()
|
|
212
|
+
|
|
213
|
+
@app.get('/logout')
|
|
214
|
+
def logout():
|
|
215
|
+
return _handle_logout()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def add_auth_hook(app, whitelist_prefixes=None):
|
|
219
|
+
"""Add authentication hook to a Bottle app.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
app: Bottle application
|
|
223
|
+
whitelist_prefixes: List of path prefixes to skip auth (e.g., ['/api/', '/sponsors_helper/'])
|
|
224
|
+
"""
|
|
225
|
+
if whitelist_prefixes is None:
|
|
226
|
+
whitelist_prefixes = []
|
|
227
|
+
|
|
228
|
+
@app.hook('before_request')
|
|
229
|
+
def auth_hook():
|
|
230
|
+
if not is_auth_enabled():
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
path = request.path
|
|
234
|
+
|
|
235
|
+
# Always allow login/logout
|
|
236
|
+
if path in ['/login', '/logout']:
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
# Check whitelist prefixes
|
|
240
|
+
for prefix in whitelist_prefixes:
|
|
241
|
+
if path.startswith(prefix):
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
# Check authentication
|
|
245
|
+
if is_form_auth():
|
|
246
|
+
if not check_form_auth():
|
|
247
|
+
redirect(f'/login?next={path}')
|
|
248
|
+
else:
|
|
249
|
+
if not check_basic_auth():
|
|
250
|
+
return require_basic_auth()
|
|
@@ -6,7 +6,7 @@ import quasarr.providers.html_images as images
|
|
|
6
6
|
from quasarr.providers.version import get_version
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
def render_centered_html(inner_content):
|
|
9
|
+
def render_centered_html(inner_content, footer_content=""):
|
|
10
10
|
head = '''
|
|
11
11
|
<head>
|
|
12
12
|
<meta charset="utf-8">
|
|
@@ -20,6 +20,7 @@ def render_centered_html(inner_content):
|
|
|
20
20
|
--fg-color: #212529;
|
|
21
21
|
--card-bg: #ffffff;
|
|
22
22
|
--card-shadow: rgba(0, 0, 0, 0.1);
|
|
23
|
+
--card-border: #dee2e6;
|
|
23
24
|
--primary: #0d6efd;
|
|
24
25
|
--secondary: #6c757d;
|
|
25
26
|
--code-bg: #f8f9fa;
|
|
@@ -27,21 +28,40 @@ def render_centered_html(inner_content):
|
|
|
27
28
|
--info-border: #2d5a2d;
|
|
28
29
|
--setup-border: var(--primary);
|
|
29
30
|
--divider-color: #dee2e6;
|
|
31
|
+
--border-color: #dee2e6;
|
|
30
32
|
--btn-subtle-bg: #e9ecef;
|
|
31
33
|
--btn-subtle-border: #ced4da;
|
|
34
|
+
--text-muted: #666;
|
|
35
|
+
--link-color: #0d6efd;
|
|
36
|
+
--success-color: #198754;
|
|
37
|
+
--success-bg: #d1e7dd;
|
|
38
|
+
--success-border: #a3cfbb;
|
|
39
|
+
--error-color: #dc3545;
|
|
40
|
+
--error-bg: #f8d7da;
|
|
41
|
+
--error-border: #f1aeb5;
|
|
32
42
|
}
|
|
33
43
|
@media (prefers-color-scheme: dark) {
|
|
34
44
|
:root {
|
|
35
45
|
--bg-color: #181a1b;
|
|
36
46
|
--fg-color: #f1f1f1;
|
|
37
|
-
--card-bg: #
|
|
47
|
+
--card-bg: #2d3748;
|
|
38
48
|
--card-shadow: rgba(0, 0, 0, 0.5);
|
|
49
|
+
--card-border: #4a5568;
|
|
39
50
|
--code-bg: #2c2f33;
|
|
40
51
|
--info-border: #4a8c4a;
|
|
41
52
|
--setup-border: var(--primary);
|
|
42
|
-
--divider-color: #
|
|
53
|
+
--divider-color: #4a5568;
|
|
54
|
+
--border-color: #4a5568;
|
|
43
55
|
--btn-subtle-bg: #444;
|
|
44
56
|
--btn-subtle-border: #666;
|
|
57
|
+
--text-muted: #a0aec0;
|
|
58
|
+
--link-color: #63b3ed;
|
|
59
|
+
--success-color: #68d391;
|
|
60
|
+
--success-bg: #1c4532;
|
|
61
|
+
--success-border: #276749;
|
|
62
|
+
--error-color: #fc8181;
|
|
63
|
+
--error-bg: #3d2d2d;
|
|
64
|
+
--error-border: #c53030;
|
|
45
65
|
}
|
|
46
66
|
}
|
|
47
67
|
/* Info box styling */
|
|
@@ -66,6 +86,27 @@ def render_centered_html(inner_content):
|
|
|
66
86
|
margin-top: 0;
|
|
67
87
|
color: var(--setup-border);
|
|
68
88
|
}
|
|
89
|
+
/* Status pill styling */
|
|
90
|
+
.status-pill {
|
|
91
|
+
display: inline-flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
gap: 6px;
|
|
94
|
+
padding: 6px 12px;
|
|
95
|
+
border-radius: 20px;
|
|
96
|
+
font-size: 0.9rem;
|
|
97
|
+
font-weight: 500;
|
|
98
|
+
margin: 8px 0;
|
|
99
|
+
}
|
|
100
|
+
.status-pill.success {
|
|
101
|
+
background: var(--success-bg);
|
|
102
|
+
color: var(--success-color);
|
|
103
|
+
border: 1px solid var(--success-border);
|
|
104
|
+
}
|
|
105
|
+
.status-pill.error {
|
|
106
|
+
background: var(--error-bg);
|
|
107
|
+
color: var(--error-color);
|
|
108
|
+
border: 1px solid var(--error-border);
|
|
109
|
+
}
|
|
69
110
|
/* Subtle button styling (ghost style) */
|
|
70
111
|
.btn-subtle {
|
|
71
112
|
background: transparent;
|
|
@@ -112,7 +153,7 @@ def render_centered_html(inner_content):
|
|
|
112
153
|
width: 100%;
|
|
113
154
|
padding: 0.5rem;
|
|
114
155
|
font-size: 1rem;
|
|
115
|
-
border: 1px solid
|
|
156
|
+
border: 1px solid var(--card-border);
|
|
116
157
|
border-radius: 0.5rem;
|
|
117
158
|
background-color: var(--card-bg);
|
|
118
159
|
color: var(--fg-color);
|
|
@@ -220,22 +261,36 @@ def render_centered_html(inner_content):
|
|
|
220
261
|
box-shadow: 0 2px 6px rgba(108, 117, 125, 0.4);
|
|
221
262
|
}
|
|
222
263
|
a {
|
|
223
|
-
color: var(--
|
|
264
|
+
color: var(--link-color);
|
|
224
265
|
text-decoration: none;
|
|
225
266
|
}
|
|
226
267
|
a:hover {
|
|
227
|
-
|
|
268
|
+
text-decoration: underline;
|
|
228
269
|
}
|
|
229
270
|
/* footer styling */
|
|
230
271
|
footer {
|
|
231
272
|
text-align: center;
|
|
232
273
|
font-size: 0.75rem;
|
|
233
|
-
color: var(--
|
|
274
|
+
color: var(--text-muted);
|
|
234
275
|
padding: 0.5rem 0;
|
|
235
276
|
}
|
|
277
|
+
footer a {
|
|
278
|
+
color: var(--text-muted);
|
|
279
|
+
margin: 0 0;
|
|
280
|
+
}
|
|
281
|
+
footer a:hover {
|
|
282
|
+
color: var(--fg-color);
|
|
283
|
+
}
|
|
236
284
|
</style>
|
|
237
285
|
</head>'''
|
|
238
286
|
|
|
287
|
+
# Build footer content
|
|
288
|
+
version_text = f"Quasarr v.{get_version()}"
|
|
289
|
+
if footer_content:
|
|
290
|
+
footer_html = f"{footer_content} · {version_text}"
|
|
291
|
+
else:
|
|
292
|
+
footer_html = version_text
|
|
293
|
+
|
|
239
294
|
body = f'''
|
|
240
295
|
{head}
|
|
241
296
|
<body>
|
|
@@ -245,7 +300,7 @@ def render_centered_html(inner_content):
|
|
|
245
300
|
</div>
|
|
246
301
|
</div>
|
|
247
302
|
<footer>
|
|
248
|
-
|
|
303
|
+
{footer_html}
|
|
249
304
|
</footer>
|
|
250
305
|
</body>
|
|
251
306
|
'''
|
|
@@ -260,14 +315,14 @@ def render_button(text, button_type="primary", attributes=None):
|
|
|
260
315
|
return f'<button class="{cls}" {attr_str}>{text}</button>'
|
|
261
316
|
|
|
262
317
|
|
|
263
|
-
def render_form(header, form="", script=""):
|
|
318
|
+
def render_form(header, form="", script="", footer_content=""):
|
|
264
319
|
content = f'''
|
|
265
320
|
<h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
|
|
266
321
|
<h2>{header}</h2>
|
|
267
322
|
{form}
|
|
268
323
|
{script}
|
|
269
324
|
'''
|
|
270
|
-
return render_centered_html(content)
|
|
325
|
+
return render_centered_html(content, footer_content)
|
|
271
326
|
|
|
272
327
|
|
|
273
328
|
def render_success(message, timeout=10, optional_text=""):
|