update
This commit is contained in:
168
app.py
168
app.py
@@ -1,12 +1,7 @@
|
||||
# app.py
|
||||
|
||||
|
||||
local_version = ""
|
||||
with open("./version.txt", "r") as f:
|
||||
local_version = f.readline()
|
||||
|
||||
|
||||
|
||||
from flask import Flask, render_template, request, redirect, url_for, jsonify
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_bcrypt import Bcrypt
|
||||
@@ -19,7 +14,6 @@ from collections import Counter
|
||||
import requests
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'change_this_secret'
|
||||
@@ -27,77 +21,36 @@ app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///qb.db'
|
||||
db = SQLAlchemy(app)
|
||||
bcrypt = Bcrypt(app)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class Logger:
|
||||
"""
|
||||
A file-based logger that:
|
||||
- On initialization, moves any existing `latest.log` into an `archive/` folder
|
||||
(timestamped). If the file is locked (Windows), it skips archiving without crashing.
|
||||
- Creates a fresh `latest.log` for new entries.
|
||||
- Supports five log levels: ERROR, WARNING, INFO, DEBUG, VERBOSE.
|
||||
"""
|
||||
|
||||
LEVELS = {
|
||||
"ERROR": 40,
|
||||
"WARNING": 30,
|
||||
"INFO": 20,
|
||||
"DEBUG": 10,
|
||||
"VERBOSE": 5,
|
||||
"VERBOSE": 5,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
log_dir: str = ".",
|
||||
latest_name: str = "latest.log",
|
||||
archive_dir: str = "archive",
|
||||
level: str = "DEBUG"
|
||||
):
|
||||
"""
|
||||
:param log_dir: Directory where logs (latest.log + archives) live.
|
||||
:param latest_name: Filename for the “latest” log.
|
||||
:param archive_dir: Subdirectory under log_dir where old logs are stored.
|
||||
:param level: Minimum log level to record (ERROR, WARNING, INFO, DEBUG, VERBOSE).
|
||||
"""
|
||||
# Initialize the lock first so __del__ never fails referencing it.
|
||||
def __init__(self, log_dir=".", latest_name="latest.log", archive_dir="archive", level="DEBUG"):
|
||||
self._file_lock = threading.Lock()
|
||||
|
||||
# Normalize log directory paths
|
||||
self.log_dir = os.path.abspath(log_dir)
|
||||
self.latest_name = latest_name
|
||||
self.archive_dir = os.path.join(self.log_dir, archive_dir)
|
||||
self.latest_path = os.path.join(self.log_dir, self.latest_name)
|
||||
|
||||
# Validate and normalize level
|
||||
self.level = level.upper()
|
||||
if self.level not in Logger.LEVELS:
|
||||
raise ValueError(f"Unknown log level: {self.level}")
|
||||
|
||||
# Ensure directories exist
|
||||
os.makedirs(self.log_dir, exist_ok=True)
|
||||
os.makedirs(self.archive_dir, exist_ok=True)
|
||||
|
||||
# Archive any existing latest.log (if possible)
|
||||
self._archive_latest()
|
||||
self.logs = []
|
||||
|
||||
# Open (or create) a fresh latest.log for appending
|
||||
try:
|
||||
self._log_file = open(self.latest_path, "a", encoding="utf-8")
|
||||
except Exception:
|
||||
# In the rare case append-mode fails, fall back to write-mode
|
||||
self._log_file = open(self.latest_path, "w", encoding="utf-8")
|
||||
|
||||
def _archive_latest(self):
|
||||
"""
|
||||
If latest.log exists and is non-empty, attempt to move it into the archive folder
|
||||
with a timestamped filename. On Windows, if the file is locked, skip archiving.
|
||||
"""
|
||||
if not os.path.exists(self.latest_path):
|
||||
return
|
||||
|
||||
try:
|
||||
if os.path.getsize(self.latest_path) > 0:
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
@@ -106,79 +59,43 @@ class Logger:
|
||||
try:
|
||||
shutil.move(self.latest_path, archived_path)
|
||||
except PermissionError:
|
||||
# File is locked by another process (e.g. Flask reloader). Skip archiving.
|
||||
return
|
||||
else:
|
||||
# If it exists but is empty, just remove it
|
||||
os.remove(self.latest_path)
|
||||
except Exception:
|
||||
# Any unexpected error while archiving—we simply skip archiving to keep running.
|
||||
return
|
||||
|
||||
def _log(self, level_name: str, message: str):
|
||||
"""
|
||||
Internal helper to format and write a log line if the level is high enough.
|
||||
"""
|
||||
def _log(self, level_name, message):
|
||||
if Logger.LEVELS[level_name] < Logger.LEVELS[self.level]:
|
||||
return
|
||||
|
||||
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
line = f"{now_str} [{level_name}] {message}"
|
||||
|
||||
self.logs.append(line)
|
||||
line+="\n"
|
||||
|
||||
line += "\n"
|
||||
with self._file_lock:
|
||||
try:
|
||||
self._log_file.write(line)
|
||||
self._log_file.flush()
|
||||
except Exception:
|
||||
# If writing fails, skip to avoid crashing the entire app
|
||||
pass
|
||||
|
||||
def error(self, message: str):
|
||||
"""Log an ERROR-level message."""
|
||||
self._log("ERROR", message)
|
||||
|
||||
def warning(self, message: str):
|
||||
"""Log a WARNING-level message."""
|
||||
self._log("WARNING", message)
|
||||
|
||||
def info(self, message: str):
|
||||
"""Log an INFO-level message."""
|
||||
self._log("INFO", message)
|
||||
|
||||
def debug(self, message: str):
|
||||
"""Log a DEBUG-level message."""
|
||||
self._log("DEBUG", message)
|
||||
|
||||
def verbose(self, message: str):
|
||||
"""Log a VERBOSE-level message."""
|
||||
self._log("VERBOSE", message)
|
||||
|
||||
def error(self, message): self._log("ERROR", message)
|
||||
def warning(self, message): self._log("WARNING", message)
|
||||
def info(self, message): self._log("INFO", message)
|
||||
def debug(self, message): self._log("DEBUG", message)
|
||||
def verbose(self, message): self._log("VERBOSE", message)
|
||||
def close(self):
|
||||
"""Close the underlying file handle cleanly."""
|
||||
with self._file_lock:
|
||||
try:
|
||||
if hasattr(self, "_log_file") and not self._log_file.closed:
|
||||
self._log_file.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __del__(self):
|
||||
# Ensure close() is called if the Logger is garbage-collected
|
||||
try:
|
||||
self.close()
|
||||
except Exception:
|
||||
pass
|
||||
try: self.close()
|
||||
except: pass
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ─── FLASK-LOGIN SETUP ───────────────────────────────────────────────────────────
|
||||
# ─── SETUP ─────────────────────────────────────
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = 'login'
|
||||
login_manager.init_app(app)
|
||||
@@ -186,17 +103,13 @@ logger = Logger(log_dir="instance/logs/", level="VERBOSE")
|
||||
qb_connected = False
|
||||
qb_version = ""
|
||||
|
||||
# ─── MODELS ────────────────────────────────────────────────────────────────────
|
||||
# ─── MODELS ─────────────────────────────────────
|
||||
class User(db.Model, UserMixin):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(32), unique=True, nullable=False)
|
||||
password = db.Column(db.String(128), nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=False)
|
||||
|
||||
# Flask-Login requires this method; inheriting UserMixin provides it automatically:
|
||||
# def get_id(self): ...
|
||||
# but we still need a user_loader below.
|
||||
|
||||
class QBConfig(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
url = db.Column(db.String(128), nullable=False)
|
||||
@@ -207,13 +120,10 @@ class AppSetting(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
allow_registration = db.Column(db.Boolean, default=True)
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
|
||||
# ─── GLOBALS & VERSION CHECK ────────────────────────────────────────────────────
|
||||
qb = None
|
||||
torrent_data, torrent_news, torrent_stats, bandwidth_history = [], [], {}, []
|
||||
auto_resume_enabled = True
|
||||
@@ -234,32 +144,28 @@ def check_outdated():
|
||||
return False
|
||||
|
||||
outdated = check_outdated()
|
||||
|
||||
def version_check_loop():
|
||||
while True:
|
||||
check_outdated()
|
||||
time.sleep(60)
|
||||
time.sleep(60 * 60)
|
||||
|
||||
|
||||
# ─── QBITTORRENT POLLING ─────────────────────────────────────────────────────────
|
||||
def poll_torrents():
|
||||
global torrent_data, torrent_news, torrent_stats, bandwidth_history, qb_connected
|
||||
prev_states = {}
|
||||
while True:
|
||||
try:
|
||||
qb_connected=True
|
||||
qb_connected = True
|
||||
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,
|
||||
@@ -267,7 +173,6 @@ def poll_torrents():
|
||||
"time": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
prev_states[t.hash] = t.state
|
||||
|
||||
updated.append({
|
||||
"hash": t.hash,
|
||||
"name": t.name,
|
||||
@@ -281,9 +186,7 @@ def poll_torrents():
|
||||
"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.clear()
|
||||
@@ -307,19 +210,15 @@ def poll_torrents():
|
||||
torrent_news.pop()
|
||||
except Exception as e:
|
||||
qb_connected = False
|
||||
logger.error("qBittorrent Error:", e)
|
||||
|
||||
logger.error("qBittorrent Error: " + str(e))
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
# ─── QBITTORRENT CONNECTION ───────────────────────────────────────────────────────
|
||||
def connect_to_qb():
|
||||
global qb, qb_connected, qb_version
|
||||
config = QBConfig.query.first()
|
||||
if not config:
|
||||
logger.error("QBConfig not found; cannot connect to qBittorrent.")
|
||||
return False
|
||||
|
||||
try:
|
||||
qb = qbittorrentapi.Client(
|
||||
host=config.url,
|
||||
@@ -329,24 +228,31 @@ def connect_to_qb():
|
||||
qb.auth_log_in()
|
||||
qb_version = qb.app_version()
|
||||
logger.info("Connected to qBittorrent.")
|
||||
qb_connected = True
|
||||
qb_connected = True
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to qBittorrent: {e}")
|
||||
qb_connected = False
|
||||
return False
|
||||
|
||||
|
||||
@app.before_request
|
||||
def before_request():
|
||||
# Allow the setup, login, register, and static routes to be accessed freely
|
||||
exempt_endpoints = {'setup', 'register', 'static', 'test_connection'}
|
||||
if request.endpoint in exempt_endpoints or qb_connected:
|
||||
exempt_endpoints = {'setup', 'register', 'login', 'logout', 'static', 'test_connection'}
|
||||
if request.endpoint in exempt_endpoints:
|
||||
return
|
||||
|
||||
if QBConfig.query.first():
|
||||
if not QBConfig.query.first():
|
||||
return redirect(url_for('setup'))
|
||||
|
||||
@app.route('/')
|
||||
@login_required
|
||||
def home():
|
||||
return render_template(
|
||||
'home.html',
|
||||
outdated=outdated,
|
||||
current_version=local_version,
|
||||
latest_version=remote_version,
|
||||
qb_error=not qb_connected
|
||||
)
|
||||
|
||||
# ─── ROUTES ───────────────────────────────────────────────────────────────────────
|
||||
@app.route('/setup', methods=['GET', 'POST'])
|
||||
@@ -457,15 +363,6 @@ def logout():
|
||||
|
||||
|
||||
# ─── PROTECTED VIEWS ─────────────────────────────────────────────────────────────
|
||||
@app.route('/')
|
||||
@login_required
|
||||
def home():
|
||||
return render_template(
|
||||
'home.html',
|
||||
outdated=outdated,
|
||||
current_version=local_version,
|
||||
latest_version=remote_version
|
||||
)
|
||||
|
||||
|
||||
@app.route('/dashboard')
|
||||
@@ -645,12 +542,11 @@ def api_delete(hash_id):
|
||||
return jsonify({"success": False, "error": str(e)})
|
||||
|
||||
|
||||
# ─── ENTRY POINT ─────────────────────────────────────────────────────────────────
|
||||
# Ensure everything starts properly
|
||||
if __name__ == '__main__':
|
||||
logger.info("Starting...")
|
||||
with app.app_context():
|
||||
logger.info("Creating DB")
|
||||
|
||||
db.create_all()
|
||||
logger.warning("Note: If your app freezes here, delete qb.db and restart.")
|
||||
logger.info("Connecting to qBit")
|
||||
|
||||
1924
templates/home.html
1924
templates/home.html
File diff suppressed because it is too large
Load Diff
37
update.sh
37
update.sh
@@ -2,16 +2,8 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# build_and_push.sh
|
||||
#
|
||||
# Builds the BitManager Docker image, tags it by version (and 'latest'),
|
||||
# then pushes both tags to Docker Hub so that older versions remain available.
|
||||
#
|
||||
# Requirements:
|
||||
# - A file named "version.txt" in the project root containing your version
|
||||
# string (e.g. "1.0.5").
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x build_and_push.sh
|
||||
# ./build_and_push.sh
|
||||
# Always recreates version.txt with dev-<timestamp>, builds Docker image,
|
||||
# tags it with that version and 'latest', and pushes to Docker Hub.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
set -euo pipefail
|
||||
@@ -19,39 +11,36 @@ set -euo pipefail
|
||||
# ─── CONFIGURATION ─────────────────────────────────────────────────────────────
|
||||
|
||||
WORKDIR="./"
|
||||
|
||||
# 2) Docker Hub credentials / repo info
|
||||
DOCKERHUB_USER="thezone123"
|
||||
IMAGE_NAME="bitmanager"
|
||||
REPO="${DOCKERHUB_USER}/${IMAGE_NAME}"
|
||||
VERSION_FILE="${WORKDIR}/version.txt"
|
||||
|
||||
if [[ -f "${WORKDIR}/version.txt" ]]; then
|
||||
VERSION=$(<"${WORKDIR}/version.txt")
|
||||
else
|
||||
echo "⚠️ version.txt not found; defaulting to 'dev-$(date +%Y%m%d%H%M)'."
|
||||
VERSION="dev-$(date +%Y%m%d%H%M)"
|
||||
fi
|
||||
# ─── GENERATE NEW VERSION ──────────────────────────────────────────────────────
|
||||
|
||||
# Always tag a 'latest' as well
|
||||
VERSION="dev-$(date +%Y%m%d%H%M)"
|
||||
echo "$VERSION" > "$VERSION_FILE"
|
||||
echo "📝 Recreated version.txt with version: $VERSION"
|
||||
|
||||
TAG_VERSION="$VERSION"
|
||||
TAG_LATEST="latest"
|
||||
TAG_VERSION="${VERSION}"
|
||||
|
||||
# ─── BUILD & PUSH ──────────────────────────────────────────────────────────────
|
||||
|
||||
echo "→ Changing directory to: ${WORKDIR}"
|
||||
cd "${WORKDIR}"
|
||||
|
||||
echo "→ Building Docker image '${IMAGE_NAME}'..."
|
||||
echo "🐳 Building Docker image '${IMAGE_NAME}'..."
|
||||
docker build -t "${IMAGE_NAME}" .
|
||||
|
||||
echo "→ Tagging image as '${REPO}:${TAG_LATEST}' and '${REPO}:${TAG_VERSION}'..."
|
||||
echo "🏷️ Tagging image as '${REPO}:${TAG_LATEST}' and '${REPO}:${TAG_VERSION}'..."
|
||||
docker tag "${IMAGE_NAME}" "${REPO}:${TAG_LATEST}"
|
||||
docker tag "${IMAGE_NAME}" "${REPO}:${TAG_VERSION}"
|
||||
|
||||
echo "→ Pushing '${REPO}:${TAG_VERSION}' to Docker Hub..."
|
||||
echo "📤 Pushing '${REPO}:${TAG_VERSION}' to Docker Hub..."
|
||||
docker push "${REPO}:${TAG_VERSION}"
|
||||
|
||||
echo "→ Pushing '${REPO}:${TAG_LATEST}' to Docker Hub..."
|
||||
echo "📤 Pushing '${REPO}:${TAG_LATEST}' to Docker Hub..."
|
||||
docker push "${REPO}:${TAG_LATEST}"
|
||||
|
||||
echo "✅ Build and push complete!"
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.0.29
|
||||
dev-202506260936
|
||||
|
||||
Reference in New Issue
Block a user