This commit is contained in:
OusmBlueNinja
2025-06-26 09:36:42 -05:00
parent 9033b9fa9c
commit b55603109d
4 changed files with 1044 additions and 1087 deletions

168
app.py
View File

@@ -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")

File diff suppressed because it is too large Load Diff

View File

@@ -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!"

View File

@@ -1 +1 @@
1.0.29
dev-202506260936