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.
- quasarr/__init__.py +316 -42
- quasarr/api/__init__.py +187 -0
- quasarr/api/arr/__init__.py +387 -0
- quasarr/api/captcha/__init__.py +1189 -0
- quasarr/api/config/__init__.py +23 -0
- quasarr/api/sponsors_helper/__init__.py +166 -0
- quasarr/api/statistics/__init__.py +196 -0
- quasarr/downloads/__init__.py +319 -256
- quasarr/downloads/linkcrypters/__init__.py +0 -0
- quasarr/downloads/linkcrypters/al.py +237 -0
- quasarr/downloads/linkcrypters/filecrypt.py +444 -0
- quasarr/downloads/linkcrypters/hide.py +123 -0
- quasarr/downloads/packages/__init__.py +476 -0
- quasarr/downloads/sources/al.py +697 -0
- quasarr/downloads/sources/by.py +106 -0
- quasarr/downloads/sources/dd.py +76 -0
- quasarr/downloads/sources/dj.py +7 -0
- quasarr/downloads/sources/dl.py +199 -0
- quasarr/downloads/sources/dt.py +66 -0
- quasarr/downloads/sources/dw.py +14 -7
- quasarr/downloads/sources/he.py +112 -0
- quasarr/downloads/sources/mb.py +47 -0
- quasarr/downloads/sources/nk.py +54 -0
- quasarr/downloads/sources/nx.py +42 -83
- quasarr/downloads/sources/sf.py +159 -0
- quasarr/downloads/sources/sj.py +7 -0
- quasarr/downloads/sources/sl.py +90 -0
- quasarr/downloads/sources/wd.py +110 -0
- quasarr/downloads/sources/wx.py +127 -0
- quasarr/providers/cloudflare.py +204 -0
- quasarr/providers/html_images.py +22 -0
- quasarr/providers/html_templates.py +211 -104
- quasarr/providers/imdb_metadata.py +108 -3
- quasarr/providers/log.py +19 -0
- quasarr/providers/myjd_api.py +201 -40
- quasarr/providers/notifications.py +99 -11
- quasarr/providers/obfuscated.py +65 -0
- quasarr/providers/sessions/__init__.py +0 -0
- quasarr/providers/sessions/al.py +286 -0
- quasarr/providers/sessions/dd.py +78 -0
- quasarr/providers/sessions/dl.py +175 -0
- quasarr/providers/sessions/nx.py +76 -0
- quasarr/providers/shared_state.py +656 -79
- quasarr/providers/statistics.py +154 -0
- quasarr/providers/version.py +60 -1
- quasarr/providers/web_server.py +1 -1
- quasarr/search/__init__.py +144 -15
- quasarr/search/sources/al.py +448 -0
- quasarr/search/sources/by.py +204 -0
- quasarr/search/sources/dd.py +135 -0
- quasarr/search/sources/dj.py +213 -0
- quasarr/search/sources/dl.py +354 -0
- quasarr/search/sources/dt.py +265 -0
- quasarr/search/sources/dw.py +94 -67
- quasarr/search/sources/fx.py +89 -33
- quasarr/search/sources/he.py +196 -0
- quasarr/search/sources/mb.py +195 -0
- quasarr/search/sources/nk.py +188 -0
- quasarr/search/sources/nx.py +75 -21
- quasarr/search/sources/sf.py +374 -0
- quasarr/search/sources/sj.py +213 -0
- quasarr/search/sources/sl.py +246 -0
- quasarr/search/sources/wd.py +208 -0
- quasarr/search/sources/wx.py +337 -0
- quasarr/storage/config.py +39 -10
- quasarr/storage/setup.py +269 -97
- quasarr/storage/sqlite_database.py +6 -1
- quasarr-1.23.0.dist-info/METADATA +306 -0
- quasarr-1.23.0.dist-info/RECORD +77 -0
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/WHEEL +1 -1
- quasarr/arr/__init__.py +0 -423
- quasarr/captcha_solver/__init__.py +0 -284
- quasarr-0.1.6.dist-info/METADATA +0 -81
- quasarr-0.1.6.dist-info/RECORD +0 -31
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/entry_points.txt +0 -0
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info/licenses}/LICENSE +0 -0
- {quasarr-0.1.6.dist-info → quasarr-1.23.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
from quasarr.providers.html_templates import render_form
|
|
6
|
+
from quasarr.providers.html_templates import render_button
|
|
7
|
+
from quasarr.storage.setup import hostname_form_html, save_hostnames
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def setup_config(app, shared_state):
|
|
11
|
+
@app.get('/hostnames')
|
|
12
|
+
def hostnames_ui():
|
|
13
|
+
message = """<p>
|
|
14
|
+
At least one hostname must be kept.
|
|
15
|
+
</p>"""
|
|
16
|
+
back_button = f'''<p>
|
|
17
|
+
{render_button("Back", "secondary", {"onclick": "location.href='/'"})}
|
|
18
|
+
</p>'''
|
|
19
|
+
return render_form("Hostnames", hostname_form_html(shared_state, message) + back_button)
|
|
20
|
+
|
|
21
|
+
@app.post("/api/hostnames")
|
|
22
|
+
def hostnames_api():
|
|
23
|
+
return save_hostnames(shared_state, timeout=1, first_run=False)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from bottle import request, abort
|
|
8
|
+
|
|
9
|
+
from quasarr.downloads import fail
|
|
10
|
+
from quasarr.providers import shared_state
|
|
11
|
+
from quasarr.providers.log import info
|
|
12
|
+
from quasarr.providers.notifications import send_discord_message
|
|
13
|
+
from quasarr.providers.statistics import StatsHelper
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def setup_sponsors_helper_routes(app):
|
|
17
|
+
@app.get("/sponsors_helper/api/to_decrypt/")
|
|
18
|
+
def to_decrypt_api():
|
|
19
|
+
try:
|
|
20
|
+
if not shared_state.values["helper_active"]:
|
|
21
|
+
shared_state.update("helper_active", True)
|
|
22
|
+
info(f"Sponsor status activated successfully")
|
|
23
|
+
|
|
24
|
+
protected = shared_state.get_db("protected").retrieve_all_titles()
|
|
25
|
+
if not protected:
|
|
26
|
+
return abort(404, "No encrypted packages found")
|
|
27
|
+
|
|
28
|
+
# Find the first package without a "session" key
|
|
29
|
+
selected_package = None
|
|
30
|
+
for package in protected:
|
|
31
|
+
data = json.loads(package[1])
|
|
32
|
+
if "session" not in data:
|
|
33
|
+
selected_package = (package[0], data)
|
|
34
|
+
break
|
|
35
|
+
|
|
36
|
+
if not selected_package:
|
|
37
|
+
return abort(404, "No valid packages without session found")
|
|
38
|
+
|
|
39
|
+
package_id, data = selected_package
|
|
40
|
+
title = data["title"]
|
|
41
|
+
links = data["links"]
|
|
42
|
+
mirror = None if (mirror := data.get('mirror')) == "None" else mirror
|
|
43
|
+
password = data["password"]
|
|
44
|
+
|
|
45
|
+
rapid = [ln for ln in links if "rapidgator" in ln[1].lower()]
|
|
46
|
+
others = [ln for ln in links if "rapidgator" not in ln[1].lower()]
|
|
47
|
+
prioritized_links = rapid + others
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
"to_decrypt": {
|
|
51
|
+
"name": title,
|
|
52
|
+
"id": package_id,
|
|
53
|
+
"url": prioritized_links,
|
|
54
|
+
"mirror": mirror,
|
|
55
|
+
"password": password,
|
|
56
|
+
"max_attempts": 3
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
except Exception as e:
|
|
60
|
+
return abort(500, str(e))
|
|
61
|
+
|
|
62
|
+
@app.post("/sponsors_helper/api/to_download/")
|
|
63
|
+
def to_download_api():
|
|
64
|
+
try:
|
|
65
|
+
data = request.json
|
|
66
|
+
title = data.get('name')
|
|
67
|
+
package_id = data.get('package_id')
|
|
68
|
+
download_links = data.get('urls')
|
|
69
|
+
password = data.get('password')
|
|
70
|
+
|
|
71
|
+
info(f"Received {len(download_links)} download links for {title}")
|
|
72
|
+
|
|
73
|
+
if download_links:
|
|
74
|
+
downloaded = shared_state.download_package(download_links, title, password, package_id)
|
|
75
|
+
if downloaded:
|
|
76
|
+
StatsHelper(shared_state).increment_package_with_links(download_links)
|
|
77
|
+
StatsHelper(shared_state).increment_captcha_decryptions_automatic()
|
|
78
|
+
shared_state.get_db("protected").delete(package_id)
|
|
79
|
+
send_discord_message(shared_state, title=title, case="solved")
|
|
80
|
+
info(f"Download successfully started for {title}")
|
|
81
|
+
return f"Downloaded {len(download_links)} download links for {title}"
|
|
82
|
+
else:
|
|
83
|
+
info(f"Download failed for {title}")
|
|
84
|
+
|
|
85
|
+
except Exception as e:
|
|
86
|
+
info(f"Error decrypting: {e}")
|
|
87
|
+
|
|
88
|
+
StatsHelper(shared_state).increment_failed_decryptions_automatic()
|
|
89
|
+
return abort(500, "Failed") #
|
|
90
|
+
|
|
91
|
+
@app.post("/sponsors_helper/api/to_replace/")
|
|
92
|
+
def to_replace_api():
|
|
93
|
+
try:
|
|
94
|
+
data = request.json
|
|
95
|
+
name = data.get('name')
|
|
96
|
+
package_id = data.get('package_id')
|
|
97
|
+
password = data.get('password')
|
|
98
|
+
replace_url = data.get('replace_url')
|
|
99
|
+
mirror = data.get('mirror')
|
|
100
|
+
session = data.get('session')
|
|
101
|
+
|
|
102
|
+
if not all([name, package_id, replace_url, mirror, session]):
|
|
103
|
+
info("Missing required replacement data")
|
|
104
|
+
return {"error": "Missing required replacement data"}, 400
|
|
105
|
+
|
|
106
|
+
if password is None:
|
|
107
|
+
password = ""
|
|
108
|
+
|
|
109
|
+
blob = json.dumps(
|
|
110
|
+
{
|
|
111
|
+
"title": name,
|
|
112
|
+
"links": [replace_url, mirror],
|
|
113
|
+
"size_mb": 0,
|
|
114
|
+
"password": password,
|
|
115
|
+
"mirror": mirror,
|
|
116
|
+
"session": session
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
shared_state.get_db("protected").update_store(package_id, blob)
|
|
120
|
+
|
|
121
|
+
info(f"Another CAPTCHA solution is required for {mirror} link: {replace_url}")
|
|
122
|
+
|
|
123
|
+
StatsHelper(shared_state).increment_captcha_decryptions_automatic()
|
|
124
|
+
|
|
125
|
+
return f"Replacement link stored for {name}"
|
|
126
|
+
|
|
127
|
+
except Exception as e:
|
|
128
|
+
StatsHelper(shared_state).increment_failed_decryptions_automatic()
|
|
129
|
+
info(f"Error handling replacement: {e}")
|
|
130
|
+
return {"error": str(e)}, 500
|
|
131
|
+
|
|
132
|
+
@app.delete("/sponsors_helper/api/to_failed/")
|
|
133
|
+
def move_to_failed_api():
|
|
134
|
+
try:
|
|
135
|
+
StatsHelper(shared_state).increment_failed_decryptions_automatic()
|
|
136
|
+
|
|
137
|
+
data = request.json
|
|
138
|
+
package_id = data.get('package_id')
|
|
139
|
+
|
|
140
|
+
data = json.loads(shared_state.get_db("protected").retrieve(package_id))
|
|
141
|
+
title = data.get('title')
|
|
142
|
+
|
|
143
|
+
if package_id:
|
|
144
|
+
info(f'Marking package "{title}" with ID "{package_id}" as failed')
|
|
145
|
+
failed = fail(title, package_id, shared_state, reason="Too many failed attempts by SponsorsHelper")
|
|
146
|
+
if failed:
|
|
147
|
+
shared_state.get_db("protected").delete(package_id)
|
|
148
|
+
send_discord_message(shared_state, title=title, case="failed")
|
|
149
|
+
return f'Package "{title}" with ID "{package_id} marked as failed!"'
|
|
150
|
+
except Exception as e:
|
|
151
|
+
info(f"Error moving to failed: {e}")
|
|
152
|
+
|
|
153
|
+
return abort(500, "Failed")
|
|
154
|
+
|
|
155
|
+
@app.put("/sponsors_helper/api/set_sponsor_status/")
|
|
156
|
+
def activate_sponsor_status():
|
|
157
|
+
try:
|
|
158
|
+
data = request.body.read().decode("utf-8")
|
|
159
|
+
payload = json.loads(data)
|
|
160
|
+
if payload["activate"]:
|
|
161
|
+
shared_state.update("helper_active", True)
|
|
162
|
+
info(f"Sponsor status activated successfully")
|
|
163
|
+
return "Sponsor status activated successfully!"
|
|
164
|
+
except:
|
|
165
|
+
pass
|
|
166
|
+
return abort(500, "Failed")
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Quasarr
|
|
3
|
+
# Project by https://github.com/rix1337
|
|
4
|
+
|
|
5
|
+
import quasarr.providers.html_images as images
|
|
6
|
+
from quasarr.providers.html_templates import render_button, render_centered_html
|
|
7
|
+
from quasarr.providers.statistics import StatsHelper
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def setup_statistics(app, shared_state):
|
|
11
|
+
@app.get('/statistics')
|
|
12
|
+
def statistics():
|
|
13
|
+
stats_helper = StatsHelper(shared_state)
|
|
14
|
+
stats = stats_helper.get_stats()
|
|
15
|
+
|
|
16
|
+
stats_html = f"""
|
|
17
|
+
<h1><img src="{images.logo}" type="image/png" alt="Quasarr logo" class="logo"/>Quasarr</h1>
|
|
18
|
+
<h2>Statistics</h2>
|
|
19
|
+
<div class="stats-container">
|
|
20
|
+
<h3>📊 Overview</h3>
|
|
21
|
+
<div class="stats-grid compact">
|
|
22
|
+
<div class="stat-card highlight">
|
|
23
|
+
<h3>📦 Total Download Attempts</h3>
|
|
24
|
+
<div class="stat-value">{stats['total_download_attempts']}</div>
|
|
25
|
+
<div class="stat-subtitle">Success Rate: {stats['download_success_rate']:.1f}%</div>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="stat-card highlight">
|
|
28
|
+
<h3>🔐 Total CAPTCHA Decryptions</h3>
|
|
29
|
+
<div class="stat-value">{stats['total_captcha_decryptions']}</div>
|
|
30
|
+
<div class="stat-subtitle">Success Rate: {stats['decryption_success_rate']:.1f}%</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<h3>📥 Downloads</h3>
|
|
35
|
+
<div class="stats-grid compact">
|
|
36
|
+
<div class="stat-card">
|
|
37
|
+
<h3>✅ Packages Downloaded</h3>
|
|
38
|
+
<div class="stat-value">{stats['packages_downloaded']}</div>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="stat-card">
|
|
41
|
+
<h3>⚙️ Links Processed</h3>
|
|
42
|
+
<div class="stat-value">{stats['links_processed']}</div>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="stat-card">
|
|
45
|
+
<h3>❌ Failed Downloads</h3>
|
|
46
|
+
<div class="stat-value">{stats['failed_downloads']}</div>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="stat-card">
|
|
49
|
+
<h3>🔗 Average Links per Package</h3>
|
|
50
|
+
<div class="stat-value">{stats['average_links_per_package']:.1f}</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<h3>🧩 CAPTCHAs</h3>
|
|
55
|
+
<div class="stats-grid compact">
|
|
56
|
+
<div class="stat-card">
|
|
57
|
+
<h3>🤖 Automatic Decryptions</h3>
|
|
58
|
+
<div class="stat-value">{stats['captcha_decryptions_automatic']}</div>
|
|
59
|
+
<div class="stat-subtitle">Success Rate: {stats['automatic_decryption_success_rate']:.1f}%</div>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="stat-card">
|
|
62
|
+
<h3>👤 Manual Decryptions</h3>
|
|
63
|
+
<div class="stat-value">{stats['captcha_decryptions_manual']}</div>
|
|
64
|
+
<div class="stat-subtitle">Success Rate: {stats['manual_decryption_success_rate']:.1f}%</div>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="stat-card">
|
|
67
|
+
<h3>⛔ Failed Auto Decryptions</h3>
|
|
68
|
+
<div class="stat-value">{stats['failed_decryptions_automatic']}</div>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="stat-card">
|
|
71
|
+
<h3>🚫 Failed Manual Decryptions</h3>
|
|
72
|
+
<div class="stat-value">{stats['failed_decryptions_manual']}</div>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<p>
|
|
78
|
+
{render_button("Back", "secondary", {"onclick": "location.href='/'"})}
|
|
79
|
+
</p>
|
|
80
|
+
|
|
81
|
+
<style>
|
|
82
|
+
.stats-container {{
|
|
83
|
+
max-width: 1000px;
|
|
84
|
+
margin: 0 auto;
|
|
85
|
+
}}
|
|
86
|
+
|
|
87
|
+
.stats-grid {{
|
|
88
|
+
display: grid;
|
|
89
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
90
|
+
gap: 15px;
|
|
91
|
+
margin: 15px 0;
|
|
92
|
+
}}
|
|
93
|
+
|
|
94
|
+
.stats-grid.compact {{
|
|
95
|
+
gap: 12px;
|
|
96
|
+
margin: 12px 0;
|
|
97
|
+
}}
|
|
98
|
+
|
|
99
|
+
.stat-card {{
|
|
100
|
+
background: var(--card-bg, #f8f9fa);
|
|
101
|
+
border: 1px solid var(--card-border, #dee2e6);
|
|
102
|
+
border-radius: 8px;
|
|
103
|
+
padding: 15px;
|
|
104
|
+
text-align: center;
|
|
105
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
106
|
+
}}
|
|
107
|
+
|
|
108
|
+
.stat-card:hover {{
|
|
109
|
+
transform: translateY(-2px);
|
|
110
|
+
box-shadow: 0 4px 12px var(--card-shadow, rgba(0,0,0,0.1));
|
|
111
|
+
}}
|
|
112
|
+
|
|
113
|
+
.stat-card.highlight {{
|
|
114
|
+
background: var(--highlight-bg, #e3f2fd);
|
|
115
|
+
border-color: var(--highlight-border, #2196f3);
|
|
116
|
+
}}
|
|
117
|
+
|
|
118
|
+
.stat-card h3 {{
|
|
119
|
+
margin: 0 0 8px 0;
|
|
120
|
+
font-size: 13px;
|
|
121
|
+
color: var(--text-muted, #666);
|
|
122
|
+
text-transform: uppercase;
|
|
123
|
+
letter-spacing: 0.5px;
|
|
124
|
+
}}
|
|
125
|
+
|
|
126
|
+
.stat-value {{
|
|
127
|
+
font-size: 24px;
|
|
128
|
+
font-weight: bold;
|
|
129
|
+
color: var(--text-primary, #333);
|
|
130
|
+
margin: 8px 0;
|
|
131
|
+
}}
|
|
132
|
+
|
|
133
|
+
.stat-subtitle {{
|
|
134
|
+
font-size: 11px;
|
|
135
|
+
color: var(--text-secondary, #888);
|
|
136
|
+
margin-top: 4px;
|
|
137
|
+
}}
|
|
138
|
+
|
|
139
|
+
h3 {{
|
|
140
|
+
color: var(--heading-color, #444);
|
|
141
|
+
padding-bottom: 8px;
|
|
142
|
+
margin-top: 25px;
|
|
143
|
+
margin-bottom: 15px;
|
|
144
|
+
}}
|
|
145
|
+
|
|
146
|
+
/* Dark mode styles */
|
|
147
|
+
@media (prefers-color-scheme: dark) {{
|
|
148
|
+
:root {{
|
|
149
|
+
--card-border: #4a5568;
|
|
150
|
+
--card-shadow: rgba(0,0,0,0.3);
|
|
151
|
+
--highlight-bg: #1a365d;
|
|
152
|
+
--highlight-border: #3182ce;
|
|
153
|
+
--text-muted: #a0aec0;
|
|
154
|
+
--text-primary: #f7fafc;
|
|
155
|
+
--text-secondary: #cbd5e0;
|
|
156
|
+
--heading-color: #e2e8f0;
|
|
157
|
+
--border-color: #4a5568;
|
|
158
|
+
}}
|
|
159
|
+
}}
|
|
160
|
+
|
|
161
|
+
/* Force dark mode styles for applications that don't support prefers-color-scheme */
|
|
162
|
+
body.dark-mode .stat-card {{
|
|
163
|
+
background: #2d3748;
|
|
164
|
+
border-color: #4a5568;
|
|
165
|
+
color: #f7fafc;
|
|
166
|
+
}}
|
|
167
|
+
|
|
168
|
+
body.dark-mode .stat-card:hover {{
|
|
169
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
170
|
+
}}
|
|
171
|
+
|
|
172
|
+
body.dark-mode .stat-card.highlight {{
|
|
173
|
+
background: #1a365d;
|
|
174
|
+
border-color: #3182ce;
|
|
175
|
+
}}
|
|
176
|
+
|
|
177
|
+
body.dark-mode .stat-card h3 {{
|
|
178
|
+
color: #a0aec0;
|
|
179
|
+
}}
|
|
180
|
+
|
|
181
|
+
body.dark-mode .stat-value {{
|
|
182
|
+
color: #f7fafc;
|
|
183
|
+
}}
|
|
184
|
+
|
|
185
|
+
body.dark-mode .stat-subtitle {{
|
|
186
|
+
color: #cbd5e0;
|
|
187
|
+
}}
|
|
188
|
+
|
|
189
|
+
body.dark-mode h2 {{
|
|
190
|
+
color: #e2e8f0;
|
|
191
|
+
border-bottom-color: #4a5568;
|
|
192
|
+
}}
|
|
193
|
+
</style>
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
return render_centered_html(stats_html)
|