Initial commit: Implements core application
Sets up a Flask-based qBittorrent monitoring and management interface. This commit introduces the foundational structure, including: - User authentication with registration and login - A setup page for initial qBittorrent configuration - Real-time torrent data polling and display - Basic UI with dashboards and news feeds - Configuration settings and user management - Version checking
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
instance/qb.db
|
||||
328
main.py
Normal file
328
main.py
Normal file
@@ -0,0 +1,328 @@
|
||||
from flask import Flask, render_template, request, redirect, url_for, session, jsonify
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_bcrypt import Bcrypt
|
||||
import qbittorrentapi
|
||||
import threading
|
||||
import time
|
||||
import datetime
|
||||
from collections import Counter
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'change_this_secret'
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///qb.db'
|
||||
db = SQLAlchemy(app)
|
||||
bcrypt = Bcrypt(app)
|
||||
|
||||
# Models
|
||||
class User(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(32), unique=True)
|
||||
password = db.Column(db.String(128))
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
|
||||
class QBConfig(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
url = db.Column(db.String(128))
|
||||
username = db.Column(db.String(64))
|
||||
password = db.Column(db.String(128))
|
||||
|
||||
class AppSetting(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
allow_registration = db.Column(db.Boolean, default=True)
|
||||
|
||||
|
||||
|
||||
# Globals
|
||||
qb = None
|
||||
torrent_data, torrent_news, torrent_stats, bandwidth_history = [], [], {}, []
|
||||
auto_resume_enabled = True
|
||||
data_lock = threading.Lock()
|
||||
|
||||
import requests
|
||||
|
||||
def check_outdated(local_version, url):
|
||||
try:
|
||||
response = requests.get(url, timeout=5)
|
||||
response.raise_for_status()
|
||||
|
||||
remote_version = response.text.strip()
|
||||
|
||||
if local_version != remote_version:
|
||||
print(f"⚠️ Outdated: Local version {local_version}, Remote version {remote_version}")
|
||||
return True
|
||||
else:
|
||||
print(f"✅ Up to date. Version: {local_version}")
|
||||
return False
|
||||
|
||||
except requests.RequestException as e:
|
||||
print(f"❌ Failed to fetch version file: {e}")
|
||||
return False
|
||||
|
||||
outdated = check_outdated("1.0.0", )
|
||||
|
||||
|
||||
# qBittorrent polling
|
||||
def poll_torrents():
|
||||
global torrent_data, torrent_news, torrent_stats, bandwidth_history
|
||||
prev_states = {}
|
||||
while True:
|
||||
try:
|
||||
torrents = qb.torrents_info()
|
||||
updated, state_counter, new_events = [], Counter(), []
|
||||
total_progress = total_speed_down = total_speed_up = 0
|
||||
|
||||
for t in torrents:
|
||||
state_counter[t.state] += 1
|
||||
total_progress += t.progress
|
||||
total_speed_down += t.dlspeed
|
||||
total_speed_up += t.upspeed
|
||||
|
||||
if auto_resume_enabled and t.state == "error":
|
||||
qb.torrents_resume(t.hash)
|
||||
|
||||
if t.hash not in prev_states or prev_states[t.hash] != t.state:
|
||||
new_events.insert(0, {
|
||||
"name": t.name,
|
||||
"state": t.state,
|
||||
"time": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
prev_states[t.hash] = t.state
|
||||
|
||||
updated.append({
|
||||
"name": t.name,
|
||||
"state": t.state,
|
||||
"progress": round(t.progress * 100, 1),
|
||||
"download_speed": round(t.dlspeed / 1024, 1),
|
||||
"upload_speed": round(t.upspeed / 1024, 1),
|
||||
"eta": t.eta,
|
||||
"size": round(t.total_size / 1e9, 2),
|
||||
"ratio": round(t.ratio, 2),
|
||||
"peers": t.num_complete,
|
||||
"leechers": t.num_incomplete
|
||||
})
|
||||
|
||||
avg_progress = round((total_progress / len(torrents)) * 100, 2) if torrents else 0
|
||||
|
||||
with data_lock:
|
||||
torrent_data[:] = updated
|
||||
torrent_stats.update({
|
||||
"states": dict(state_counter),
|
||||
"average_progress": avg_progress,
|
||||
"torrent_count": len(torrents),
|
||||
"total_download_speed": round(total_speed_down / 1024, 1),
|
||||
"total_upload_speed": round(total_speed_up / 1024, 1)
|
||||
})
|
||||
bandwidth_history.append({
|
||||
"time": datetime.datetime.now().strftime('%H:%M:%S'),
|
||||
"download": round(total_speed_down / 1024, 1),
|
||||
"upload": round(total_speed_up / 1024, 1)
|
||||
})
|
||||
if len(bandwidth_history) > 120:
|
||||
bandwidth_history.pop(0)
|
||||
for e in new_events:
|
||||
torrent_news.insert(0, e)
|
||||
if len(torrent_news) > 100:
|
||||
torrent_news.pop()
|
||||
except Exception as e:
|
||||
print("Polling error:", e)
|
||||
time.sleep(1)
|
||||
|
||||
# qBittorrent connection
|
||||
def connect_to_qb():
|
||||
global qb
|
||||
config = QBConfig.query.first()
|
||||
if not config:
|
||||
return False
|
||||
try:
|
||||
qb = qbittorrentapi.Client(
|
||||
host=config.url,
|
||||
username=config.username,
|
||||
password=config.password
|
||||
)
|
||||
qb.auth_log_in()
|
||||
print("Connected to qBittorrent.")
|
||||
return True
|
||||
except Exception as e:
|
||||
print("Failed to connect:", e)
|
||||
return False
|
||||
|
||||
# Access control
|
||||
@app.before_request
|
||||
def require_login():
|
||||
if request.endpoint in ['setup', 'test_connection', 'login', 'register', 'static']:
|
||||
return
|
||||
if not QBConfig.query.first():
|
||||
return redirect(url_for('setup'))
|
||||
if 'user' not in session:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
# Setup qBittorrent
|
||||
@app.route('/setup', methods=['GET', 'POST'])
|
||||
def setup():
|
||||
if QBConfig.query.first():
|
||||
return redirect(url_for('login'))
|
||||
if request.method == 'POST':
|
||||
url = request.form['url']
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
config = QBConfig(url=url, username=username, password=password)
|
||||
db.session.add(config)
|
||||
db.session.commit()
|
||||
return redirect(url_for('register'))
|
||||
return render_template('setup.html')
|
||||
|
||||
@app.route('/test-connection', methods=['POST'])
|
||||
def test_connection():
|
||||
data = request.get_json()
|
||||
try:
|
||||
client = qbittorrentapi.Client(
|
||||
host=data['url'],
|
||||
username=data['username'],
|
||||
password=data['password']
|
||||
)
|
||||
client.auth_log_in()
|
||||
return jsonify({"success": True, "version": client.app_version()})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
|
||||
# Auth
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
user = User.query.filter_by(username=request.form['username']).first()
|
||||
if user and bcrypt.check_password_hash(user.password, request.form['password']):
|
||||
session['user'] = user.username
|
||||
if connect_to_qb():
|
||||
threading.Thread(target=poll_torrents, daemon=True).start()
|
||||
return redirect(url_for('home'))
|
||||
return render_template("login.html", error="Invalid credentials")
|
||||
return render_template("login.html")
|
||||
|
||||
@app.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
setting = AppSetting.query.first()
|
||||
if setting and not setting.allow_registration and User.query.count() > 0:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form['username'].strip()
|
||||
password = request.form['password']
|
||||
if User.query.filter_by(username=username).first():
|
||||
return render_template("register.html", error="Username already exists")
|
||||
is_admin = User.query.count() == 0
|
||||
new_user = User(
|
||||
username=username,
|
||||
password=bcrypt.generate_password_hash(password).decode('utf-8'),
|
||||
is_admin=is_admin
|
||||
)
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
session['user'] = new_user.username
|
||||
if connect_to_qb():
|
||||
threading.Thread(target=poll_torrents, daemon=True).start()
|
||||
return redirect(url_for('home'))
|
||||
|
||||
return render_template("register.html")
|
||||
|
||||
# Views
|
||||
@app.route('/')
|
||||
def home():
|
||||
return render_template("home.html", outdated = outdated)
|
||||
|
||||
@app.route('/dashboard')
|
||||
def dashboard():
|
||||
return render_template("index.html")
|
||||
|
||||
@app.route('/news')
|
||||
def news():
|
||||
return render_template("news.html")
|
||||
|
||||
@app.route('/settings', methods=['GET', 'POST'])
|
||||
def settings():
|
||||
if 'user' not in session:
|
||||
return redirect(url_for('login'))
|
||||
|
||||
current_user = User.query.filter_by(username=session['user']).first()
|
||||
if not current_user or not current_user.is_admin:
|
||||
return redirect(url_for('home'))
|
||||
|
||||
config = QBConfig.query.first()
|
||||
users = User.query.all()
|
||||
setting = AppSetting.query.first()
|
||||
if not setting:
|
||||
setting = AppSetting(allow_registration=True)
|
||||
db.session.add(setting)
|
||||
db.session.commit()
|
||||
|
||||
if request.method == 'POST':
|
||||
if 'url' in request.form:
|
||||
config.url = request.form['url']
|
||||
config.username = request.form['username']
|
||||
config.password = request.form['password']
|
||||
db.session.commit()
|
||||
elif 'toggle_register' in request.form:
|
||||
setting.allow_registration = not setting.allow_registration
|
||||
db.session.commit()
|
||||
elif 'new_user' in request.form:
|
||||
new_user = User(
|
||||
username=request.form['new_username'],
|
||||
password=bcrypt.generate_password_hash(request.form['new_password']).decode('utf-8'),
|
||||
is_admin='is_admin' in request.form
|
||||
)
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
return render_template('settings.html', config=config, users=users, setting=setting)
|
||||
|
||||
|
||||
@app.route('/delete_user/<int:user_id>', methods=['POST'])
|
||||
def delete_user(user_id):
|
||||
if 'user' not in session:
|
||||
return redirect(url_for('login'))
|
||||
current_user = User.query.filter_by(username=session['user']).first()
|
||||
if not current_user or not current_user.is_admin:
|
||||
return redirect(url_for('home'))
|
||||
|
||||
if current_user.id == user_id:
|
||||
return "You can't delete yourself.", 400
|
||||
|
||||
user = User.query.get(user_id)
|
||||
if user:
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
return redirect(url_for('settings'))
|
||||
|
||||
|
||||
# API
|
||||
@app.route('/api/torrents')
|
||||
def api_torrents():
|
||||
with data_lock:
|
||||
return jsonify(torrent_data)
|
||||
|
||||
@app.route('/api/news')
|
||||
def api_news():
|
||||
with data_lock:
|
||||
return jsonify(torrent_news)
|
||||
|
||||
@app.route('/api/stats')
|
||||
def api_stats():
|
||||
with data_lock:
|
||||
return jsonify(torrent_stats)
|
||||
|
||||
@app.route('/api/bandwidth')
|
||||
def api_bandwidth():
|
||||
with data_lock:
|
||||
return jsonify(bandwidth_history)
|
||||
|
||||
@app.route('/api/auto_resume', methods=['GET', 'POST'])
|
||||
def api_auto_resume():
|
||||
global auto_resume_enabled
|
||||
if request.method == 'POST':
|
||||
auto_resume_enabled = request.json.get('enabled', True)
|
||||
return jsonify({"enabled": auto_resume_enabled})
|
||||
|
||||
# Entry
|
||||
if __name__ == '__main__':
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
flask
|
||||
qbittorrent-api
|
||||
flask_sqlalchemy
|
||||
flask_bcrypt
|
||||
94
static/style.css
Normal file
94
static/style.css
Normal file
@@ -0,0 +1,94 @@
|
||||
body {
|
||||
background: #121212;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
color: #eee;
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: 500px;
|
||||
margin: auto;
|
||||
background: #1e1e1e;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.3rem;
|
||||
background: #2a2a2a;
|
||||
color: #eee;
|
||||
border: 1px solid #333;
|
||||
border-radius: 5px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #00bcd4;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: none;
|
||||
background: #00bcd4;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0097a7;
|
||||
}
|
||||
|
||||
nav {
|
||||
background: #1c1c1c;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: #80dfff;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Scrollbars for overflowed tables or logs */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #555;
|
||||
border-radius: 10px;
|
||||
}
|
||||
268
templates/home.html
Normal file
268
templates/home.html
Normal file
@@ -0,0 +1,268 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>qBittorrent Overview</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
body {
|
||||
background: #121212;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: #80dfff;
|
||||
margin-right: 1.5rem;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
color: #4bc0c0;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0.2rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.chart-container, .news-feed {
|
||||
background: #1e1e1e;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
flex: 1;
|
||||
min-width: 380px;
|
||||
}
|
||||
|
||||
.news-feed {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.event {
|
||||
margin-bottom: 0.75rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 4px solid #444;
|
||||
}
|
||||
|
||||
.event .time {
|
||||
font-size: 0.9rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.event .state {
|
||||
font-weight: bold;
|
||||
color: #00e676;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
background: #222;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.toggle label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/settings">Settings</a>
|
||||
{% if outdated %}
|
||||
<a>⚠️ You're running an outdated version!</div>
|
||||
{% endif %}
|
||||
|
||||
</nav>
|
||||
|
||||
<h1>Overview</h1>
|
||||
|
||||
<div class="toggle">
|
||||
<label>
|
||||
<input type="checkbox" id="resume-toggle" />
|
||||
Auto-resume errored torrents
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="metrics"></div>
|
||||
|
||||
<div class="flex">
|
||||
<div class="chart-container">
|
||||
<h2>Torrent State Distribution</h2>
|
||||
<canvas id="stateChart"></canvas>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<h2>Bandwidth Usage</h2>
|
||||
<canvas id="bandwidthChart"></canvas>
|
||||
</div>
|
||||
<div class="news-feed" id="news-feed">
|
||||
<h2>Status Feed</h2>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let stateChart, bandwidthChart;
|
||||
|
||||
async function loadStats() {
|
||||
const res = await fetch('/api/stats');
|
||||
const stats = await res.json();
|
||||
const grid = document.getElementById('metrics');
|
||||
grid.innerHTML = '';
|
||||
|
||||
const stateCounts = stats.states || {};
|
||||
const avg = stats.average_progress || 0;
|
||||
|
||||
for (const [state, count] of Object.entries(stateCounts)) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'card';
|
||||
div.innerHTML = `<h3>${count}</h3><p>${state}</p>`;
|
||||
grid.appendChild(div);
|
||||
}
|
||||
|
||||
const avgCard = document.createElement('div');
|
||||
avgCard.className = 'card';
|
||||
avgCard.innerHTML = `<h3>${avg}%</h3><p>Overall Progress</p>`;
|
||||
grid.appendChild(avgCard);
|
||||
|
||||
updateStateChart(stateCounts);
|
||||
}
|
||||
|
||||
function updateStateChart(stateData) {
|
||||
const labels = Object.keys(stateData);
|
||||
const values = Object.values(stateData);
|
||||
const colors = ['#4bc0c0', '#36a2eb', '#9966ff', '#ff6384', '#ffcd56', '#7fdb7f'];
|
||||
|
||||
if (!stateChart) {
|
||||
stateChart = new Chart(document.getElementById('stateChart'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{ data: values, backgroundColor: colors }]
|
||||
},
|
||||
options: { plugins: { legend: { labels: { color: '#eee' } } } }
|
||||
});
|
||||
} else {
|
||||
stateChart.data.labels = labels;
|
||||
stateChart.data.datasets[0].data = values;
|
||||
stateChart.update();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBandwidth() {
|
||||
const res = await fetch('/api/bandwidth');
|
||||
const history = await res.json();
|
||||
const labels = history.map(h => h.time);
|
||||
const down = history.map(h => h.download);
|
||||
const up = history.map(h => h.upload);
|
||||
|
||||
if (!bandwidthChart) {
|
||||
bandwidthChart = new Chart(document.getElementById('bandwidthChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'Download KB/s', data: down, borderColor: '#4bc0c0', tension: 0.3 },
|
||||
{ label: 'Upload KB/s', data: up, borderColor: '#ff6384', tension: 0.3 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
x: { ticks: { color: '#aaa' } },
|
||||
y: { ticks: { color: '#aaa' } }
|
||||
},
|
||||
plugins: { legend: { labels: { color: '#eee' } } }
|
||||
}
|
||||
});
|
||||
} else {
|
||||
bandwidthChart.data.labels = labels;
|
||||
bandwidthChart.data.datasets[0].data = down;
|
||||
bandwidthChart.data.datasets[1].data = up;
|
||||
bandwidthChart.update();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNews() {
|
||||
const res = await fetch('/api/news');
|
||||
const news = await res.json();
|
||||
const feed = document.getElementById('news-feed');
|
||||
feed.innerHTML = '<h2>Status Feed</h2>';
|
||||
for (const event of news.slice(0, 15)) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'event';
|
||||
div.innerHTML = `<div><span class="state">${event.state}</span> — <strong>${event.name}</strong></div><div class="time">${event.time}</div>`;
|
||||
feed.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResumeSetting() {
|
||||
const res = await fetch('/api/auto_resume');
|
||||
const json = await res.json();
|
||||
document.getElementById('resume-toggle').checked = json.enabled;
|
||||
}
|
||||
|
||||
async function setResumeSetting(value) {
|
||||
await fetch('/api/auto_resume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: value })
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('resume-toggle').addEventListener('change', e => {
|
||||
setResumeSetting(e.target.checked);
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([loadStats(), loadBandwidth(), loadNews()]);
|
||||
}
|
||||
|
||||
setInterval(refresh, 1000);
|
||||
loadResumeSetting();
|
||||
refresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
237
templates/index.html
Normal file
237
templates/index.html
Normal file
@@ -0,0 +1,237 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>qBittorrent Dashboard</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
background: #121212;
|
||||
color: #f0f0f0;
|
||||
padding: 2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
nav {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
nav a {
|
||||
margin-right: 1rem;
|
||||
color: #4db8ff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
input, select {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
color: #eee;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 5px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.6rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #181818;
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
th.sort-asc::after {
|
||||
content: ' ▲';
|
||||
color: #666;
|
||||
}
|
||||
|
||||
th.sort-desc::after {
|
||||
content: ' ▼';
|
||||
color: #666;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background: #191919;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
.cell-centered {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/settings">Settings</a>
|
||||
|
||||
</nav>
|
||||
|
||||
<h1>Torrents</h1>
|
||||
|
||||
<div class="toolbar">
|
||||
<input type="text" id="searchInput" placeholder="Search torrents..." />
|
||||
<select id="filterStatus">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="downloading">Downloading</option>
|
||||
<option value="stalledDL">Stalled</option>
|
||||
<option value="uploading">Uploading</option>
|
||||
<option value="pausedDL">Paused</option>
|
||||
<option value="checkingDL">Checking</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="name">Name</th>
|
||||
<th data-sort="size" class="cell-centered">Size</th>
|
||||
<th class="cell-centered">Progress</th>
|
||||
<th data-sort="progress" class="cell-centered">%</th>
|
||||
<th data-sort="download_speed" class="cell-centered">↓ KB/s</th>
|
||||
<th data-sort="upload_speed" class="cell-centered">↑ KB/s</th>
|
||||
<th class="cell-centered">ETA</th>
|
||||
<th data-sort="ratio" class="cell-centered">Ratio</th>
|
||||
<th data-sort="peers" class="cell-centered">Peers</th>
|
||||
<th data-sort="leechers" class="cell-centered">Leechers</th>
|
||||
<th data-sort="state">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="torrent-body"></tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
let torrents = [];
|
||||
let sortColumn = 'name';
|
||||
let sortDir = 'asc';
|
||||
|
||||
document.querySelectorAll('th[data-sort]').forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const col = th.dataset.sort;
|
||||
sortDir = sortColumn === col && sortDir === 'asc' ? 'desc' : 'asc';
|
||||
sortColumn = col;
|
||||
renderTable();
|
||||
updateSortIcons();
|
||||
});
|
||||
});
|
||||
|
||||
function updateSortIcons() {
|
||||
document.querySelectorAll('th[data-sort]').forEach(th => {
|
||||
th.classList.remove('sort-asc', 'sort-desc');
|
||||
if (th.dataset.sort === sortColumn) {
|
||||
th.classList.add(sortDir === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sortTorrents(data) {
|
||||
return data.sort((a, b) => {
|
||||
const valA = a[sortColumn];
|
||||
const valB = b[sortColumn];
|
||||
if (typeof valA === 'string') {
|
||||
return sortDir === 'asc'
|
||||
? valA.localeCompare(valB)
|
||||
: valB.localeCompare(valA);
|
||||
}
|
||||
return sortDir === 'asc' ? valA - valB : valB - valA;
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('torrent-body');
|
||||
const search = document.getElementById('searchInput').value.toLowerCase();
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
tbody.innerHTML = '';
|
||||
|
||||
const filtered = torrents.filter(t =>
|
||||
(!status || t.state === status) &&
|
||||
(!search || t.name.toLowerCase().includes(search))
|
||||
);
|
||||
|
||||
const sorted = sortTorrents(filtered);
|
||||
|
||||
for (const t of sorted) {
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${t.name}</td>
|
||||
<td class="cell-centered">${t.size} GB</td>
|
||||
<td>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${t.progress}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="cell-centered">${t.progress}%</td>
|
||||
<td class="cell-centered">${t.download_speed}</td>
|
||||
<td class="cell-centered">${t.upload_speed}</td>
|
||||
<td class="cell-centered">${t.eta === 8640000 ? '∞' : new Date(t.eta * 1000).toISOString().substr(11, 8)}</td>
|
||||
<td class="cell-centered">${t.ratio}</td>
|
||||
<td class="cell-centered">${t.peers}</td>
|
||||
<td class="cell-centered">${t.leechers}</td>
|
||||
<td>${t.state}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTorrents() {
|
||||
const res = await fetch('/api/torrents');
|
||||
torrents = await res.json();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
document.getElementById('searchInput').addEventListener('input', renderTable);
|
||||
document.getElementById('filterStatus').addEventListener('change', renderTable);
|
||||
|
||||
setInterval(refreshTorrents, 3000);
|
||||
refreshTorrents();
|
||||
updateSortIcons();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
103
templates/login.html
Normal file
103
templates/login.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Login</title>
|
||||
<style>
|
||||
body {
|
||||
background: #121212;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
color: #eee;
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1e1e1e;
|
||||
padding: 2rem 2.5rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 25px rgba(0, 0, 0, 0.6);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-top: 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.55rem;
|
||||
margin-top: 0.3rem;
|
||||
background: #2c2c2c;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
border-radius: 5px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #00bcd4;
|
||||
background: #333;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
margin-top: 1.5rem;
|
||||
border: none;
|
||||
background: #00bcd4;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0097a7;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 1rem;
|
||||
color: #ff5252;
|
||||
background: #2a1a1a;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Login</h2>
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="POST">
|
||||
<label>Username:</label>
|
||||
<input name="username" required autofocus>
|
||||
|
||||
<label>Password:</label>
|
||||
<input name="password" type="password" required>
|
||||
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
52
templates/news.html
Normal file
52
templates/news.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Status Feed</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; background: #121212; color: white; padding: 2rem; }
|
||||
nav a { margin-right: 1rem; color: #80dfff; text-decoration: none; font-weight: bold; }
|
||||
h1 { margin-bottom: 1rem; }
|
||||
.event {
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: #1e1e1e;
|
||||
border-left: 5px solid #444;
|
||||
}
|
||||
.event .time { font-size: 0.9em; color: #aaa; }
|
||||
.event .state { font-weight: bold; text-transform: capitalize; color: #00e676; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
<a href="/news">News</a>
|
||||
<a href="/settings">Settings</a>
|
||||
|
||||
</nav>
|
||||
|
||||
<h1>Status Feed</h1>
|
||||
<div id="news-feed"></div>
|
||||
|
||||
<script>
|
||||
async function refreshNews() {
|
||||
const res = await fetch('/api/news');
|
||||
const news = await res.json();
|
||||
const feed = document.getElementById('news-feed');
|
||||
feed.innerHTML = '';
|
||||
for (const event of news.slice(0, 100)) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'event';
|
||||
el.innerHTML = `
|
||||
<div><span class="state">${event.state}</span> — <strong>${event.name}</strong></div>
|
||||
<div class="time">${event.time}</div>
|
||||
`;
|
||||
feed.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(refreshNews, 5000);
|
||||
refreshNews();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
97
templates/register.html
Normal file
97
templates/register.html
Normal file
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Register</title>
|
||||
<style>
|
||||
body {
|
||||
background: #121212;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
color: #eee;
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1e1e1e;
|
||||
padding: 2rem 2.5rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 25px rgba(0, 0, 0, 0.6);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-top: 0;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-top: 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.55rem;
|
||||
margin-top: 0.3rem;
|
||||
background: #2c2c2c;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
border-radius: 5px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
margin-top: 1.5rem;
|
||||
border: none;
|
||||
background: #00bcd4;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0097a7;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 1rem;
|
||||
color: #ff5252;
|
||||
background: #2a1a1a;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Register</h2>
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="POST">
|
||||
<label>Username:</label>
|
||||
<input name="username" required>
|
||||
|
||||
<label>Password:</label>
|
||||
<input name="password" type="password" required>
|
||||
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
174
templates/settings.html
Normal file
174
templates/settings.html
Normal file
@@ -0,0 +1,174 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Settings</title>
|
||||
<style>
|
||||
body {
|
||||
background: #121212;
|
||||
color: #eee;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
background: #1f1f1f;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: #80dfff;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
color: #00bcd4;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #1e1e1e;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-top: 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.2rem;
|
||||
background: #2a2a2a;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
padding: 0.6rem 1.2rem;
|
||||
background: #00bcd4;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0097a7;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
ul li {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #333;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
form.inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
color: #ccc;
|
||||
font-size: 0.85rem;
|
||||
margin-left: 5px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/">Back to Home</a>
|
||||
<span>Settings</span>
|
||||
</nav>
|
||||
|
||||
<h1>Settings</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>qBittorrent Settings</h2>
|
||||
<form method="POST">
|
||||
<label>Web UI URL</label>
|
||||
<input name="url" type="text" value="{{ config.url }}" required>
|
||||
|
||||
<label>Username</label>
|
||||
<input name="username" type="text" value="{{ config.username }}" required>
|
||||
|
||||
<label>Password</label>
|
||||
<input name="password" type="password" value="{{ config.password }}" required>
|
||||
|
||||
<button type="submit">Update qBittorrent Config</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Toggle Registration</h2>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="toggle_register" value="1">
|
||||
<button type="submit">
|
||||
{{ 'Disable' if setting.allow_registration else 'Enable' }} Registration
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Create New User</h2>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="new_user" value="1">
|
||||
|
||||
<label>Username</label>
|
||||
<input name="new_username" type="text" required>
|
||||
|
||||
<label>Password</label>
|
||||
<input name="new_password" type="password" required>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="is_admin">
|
||||
Admin
|
||||
</label>
|
||||
|
||||
<button type="submit">Create User</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Users</h2>
|
||||
<ul>
|
||||
{% for user in users %}
|
||||
<li>
|
||||
<span>{{ user.username }}
|
||||
{% if user.is_admin %}
|
||||
<span class="admin-badge">(Admin)</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if user.username != session['user'] %}
|
||||
<form method="POST" action="/delete_user/{{ user.id }}" class="inline">
|
||||
<button type="submit">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
208
templates/setup.html
Normal file
208
templates/setup.html
Normal file
@@ -0,0 +1,208 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>qBittorrent Setup</title>
|
||||
<style>
|
||||
body {
|
||||
background: #121212;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
color: #eee;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1e1e1e;
|
||||
padding: 2rem 2.5rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 25px rgba(0, 0, 0, 0.6);
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-top: 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.55rem;
|
||||
margin-top: 0.3rem;
|
||||
background: #2c2c2c;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
border-radius: 5px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: none;
|
||||
background: #00bcd4;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
transition: background 0.2s ease;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0097a7;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 3px solid #eee;
|
||||
border-top: 3px solid transparent;
|
||||
border-radius: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
animation: spin 0.8s linear infinite;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.result-box {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #252525;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.success .checkmark {
|
||||
color: #00e676;
|
||||
font-size: 1.6rem;
|
||||
margin-right: 10px;
|
||||
transform: scale(0);
|
||||
animation: pop 0.4s forwards;
|
||||
}
|
||||
|
||||
.error .checkmark {
|
||||
color: #ff1744;
|
||||
font-size: 1.6rem;
|
||||
margin-right: 10px;
|
||||
transform: scale(0);
|
||||
animation: pop 0.4s forwards;
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
to { transform: scale(1); }
|
||||
}
|
||||
|
||||
.info {
|
||||
margin-top: 0.4rem;
|
||||
color: #ccc;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>qBittorrent Setup</h2>
|
||||
<form method="POST">
|
||||
<label>Web UI URL:</label>
|
||||
<input name="url" required value="http://192.168.0.236:8080/">
|
||||
|
||||
<label>Username:</label>
|
||||
<input name="username" required value="admin">
|
||||
|
||||
<label>Password:</label>
|
||||
<input name="password" type="password" required value="123456">
|
||||
|
||||
<div class="button-row">
|
||||
<button type="submit">Save & Start</button>
|
||||
<button id="testBtn" type="button" onclick="testConnection()">Test</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="result-box" id="test-result-box">
|
||||
<span class="checkmark">✔</span>
|
||||
<span id="test-message"></span>
|
||||
<div class="info" id="test-info"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function testConnection() {
|
||||
const url = document.querySelector('input[name="url"]').value;
|
||||
const username = document.querySelector('input[name="username"]').value;
|
||||
const password = document.querySelector('input[name="password"]').value;
|
||||
|
||||
const box = document.getElementById('test-result-box');
|
||||
const msg = document.getElementById('test-message');
|
||||
const info = document.getElementById('test-info');
|
||||
const checkmark = document.querySelector('.checkmark');
|
||||
const testBtn = document.getElementById('testBtn');
|
||||
|
||||
testBtn.disabled = true;
|
||||
testBtn.innerHTML = '<span class="spinner"></span>';
|
||||
|
||||
box.style.display = 'block';
|
||||
box.className = 'result-box';
|
||||
msg.innerText = '';
|
||||
checkmark.textContent = '';
|
||||
info.innerText = '';
|
||||
|
||||
fetch('/test-connection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, username, password })
|
||||
}).then(res => res.json()).then(data => {
|
||||
testBtn.disabled = false;
|
||||
testBtn.innerText = 'Test';
|
||||
|
||||
if (data.success) {
|
||||
box.className = 'result-box success';
|
||||
checkmark.textContent = '✔';
|
||||
msg.innerText = 'Connected successfully!';
|
||||
info.innerHTML = `Version: <b>${data.version}</b><br>Port: <b>${new URL(url).port || '8080'}</b>`;
|
||||
} else {
|
||||
box.className = 'result-box error';
|
||||
checkmark.textContent = '✖';
|
||||
msg.innerText = 'Connection failed.';
|
||||
info.innerText = `Error: ${data.error}`;
|
||||
}
|
||||
}).catch(err => {
|
||||
testBtn.disabled = false;
|
||||
testBtn.innerText = 'Test';
|
||||
box.className = 'result-box error';
|
||||
checkmark.textContent = '✖';
|
||||
msg.innerText = 'Connection failed.';
|
||||
info.innerText = `Error: ${err}`;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
version.txt
Normal file
1
version.txt
Normal file
@@ -0,0 +1 @@
|
||||
1.0.0
|
||||
Reference in New Issue
Block a user