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:
OusmBlueNinja
2025-06-03 22:56:36 -05:00
parent e252e6aeef
commit 4f87ec2b60
12 changed files with 1568 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
instance/qb.db

328
main.py Normal file
View 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
View File

@@ -0,0 +1,4 @@
flask
qbittorrent-api
flask_sqlalchemy
flask_bcrypt

94
static/style.css Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
1.0.0