Files
2026-06-16 22:25:33 +01:00

240 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""
HEVC Dashboard — runs on the Windows PC (port 5000).
Features:
- Runs alongside the local worker API by default
- Browses local mounted paths (e.g., Z:/TV)
- Proxies all worker API calls (status, scan, process, pause, resume, stop)
- SSE log stream proxy
"""
import os
import requests
from flask import Flask, Response, jsonify, render_template, request, stream_with_context
app = Flask(__name__)
# ---------------------------------------------------------------------------
# Global config (editable at runtime via /config POST)
# ---------------------------------------------------------------------------
_config: dict = {
"worker_url": os.environ.get("WORKER_URL", "http://127.0.0.1:5001").rstrip("/"),
"media_root": os.environ.get("MEDIA_ROOT", "").replace("\\", "/"),
"temp_dir": os.environ.get("TEMP_DIR", "").replace("\\", "/"),
"discord_webhook": os.environ.get("DISCORD_WEBHOOK", ""),
"custom_ffmpeg": os.environ.get("CUSTOM_FFMPEG", ""),
"custom_ffprobe": os.environ.get("CUSTOM_FFPROBE", ""),
"encoder": os.environ.get("ENCODER", "libx265"),
"audio_codec": os.environ.get("AUDIO_CODEC", "auto"),
"rename_output": os.environ.get("RENAME_OUTPUT", "false").strip().lower() in {"1", "true", "yes", "on"},
"force_reencode": os.environ.get("FORCE_REENCODE", "false").strip().lower() in {"1", "true", "yes", "on"},
}
# ---------------------------------------------------------------------------
# Dashboard UI
# ---------------------------------------------------------------------------
@app.route("/")
def index():
return render_template(
"index.html",
worker_url=_config["worker_url"],
media_root=_config["media_root"],
custom_ffmpeg=_config.get("custom_ffmpeg", ""),
custom_ffprobe=_config.get("custom_ffprobe", ""),
temp_dir=_config["temp_dir"],
discord_webhook=_config.get("discord_webhook", ""),
encoder=_config.get("encoder", "libx265"),
audio_codec=_config.get("audio_codec", "auto"),
rename_output=_config.get("rename_output", False),
force_reencode=_config.get("force_reencode", False),
)
@app.route("/config", methods=["GET", "POST"])
def config():
if request.method == "POST":
data = request.get_json(force=True, silent=True) or {}
if "worker_url" in data:
_config["worker_url"] = str(data["worker_url"]).rstrip("/")
if "media_root" in data:
_config["media_root"] = str(data["media_root"]).replace("\\", "/")
if "temp_dir" in data:
_config["temp_dir"] = str(data["temp_dir"]).replace("\\", "/")
if "discord_webhook" in data:
_config["discord_webhook"] = str(data["discord_webhook"]).strip()
if "custom_ffmpeg" in data:
_config["custom_ffmpeg"] = str(data["custom_ffmpeg"]).strip()
if "custom_ffprobe" in data:
_config["custom_ffprobe"] = str(data["custom_ffprobe"]).strip()
if "encoder" in data:
_config["encoder"] = str(data["encoder"]).strip()
if "audio_codec" in data:
_config["audio_codec"] = str(data["audio_codec"]).strip()
if "rename_output" in data:
_config["rename_output"] = bool(data["rename_output"])
if "force_reencode" in data:
_config["force_reencode"] = bool(data["force_reencode"])
return jsonify({"ok": True, "config": dict(_config)})
return jsonify(dict(_config))
# ---------------------------------------------------------------------------
# Local directory browser
# ---------------------------------------------------------------------------
def _normalize_local_path(raw_path: str) -> str:
path = (raw_path or "").strip()
# On Linux "/" is a real path — only fall back to media_root if truly empty
if path == "":
path = _config.get("media_root", "")
if not path:
return "/" if os.name != "nt" else ""
return os.path.normpath(path)
@app.route("/proxy/browse")
def proxy_browse():
path = _normalize_local_path(request.args.get("path", ""))
if not os.path.isdir(path):
return jsonify({"error": f"Path not found: {path}"}), 404
try:
items = sorted(os.listdir(path))
except PermissionError:
return jsonify({"error": f"Permission denied: {path}"}), 403
dirs = []
for name in items:
full = os.path.join(path, name)
if os.path.isdir(full) and not name.startswith("."):
dirs.append({"name": name, "full_path": full.replace("\\", "/")})
parent = os.path.dirname(path)
if not parent or parent == path:
parent = None
return jsonify(
{
"path": path.replace("\\", "/"),
"parent": parent.replace("\\", "/") if parent else None,
"dirs": dirs,
}
)
# ---------------------------------------------------------------------------
# Worker API proxy
# ---------------------------------------------------------------------------
def worker_url() -> str:
return _config["worker_url"]
def _proxy_get(path: str):
try:
r = requests.get(f"{worker_url()}{path}", timeout=8)
return Response(
r.content,
status=r.status_code,
content_type=r.headers.get("Content-Type", "application/json"),
)
except requests.exceptions.ConnectionError:
return jsonify({"error": "Cannot reach worker", "state": "unreachable"}), 503
except Exception as exc:
return jsonify({"error": str(exc)}), 503
def _proxy_post(path: str, body=None):
try:
r = requests.post(f"{worker_url()}{path}", json=body or {}, timeout=8)
return Response(r.content, status=r.status_code, content_type="application/json")
except requests.exceptions.ConnectionError:
return jsonify({"error": "Cannot reach worker"}), 503
except Exception as exc:
return jsonify({"error": str(exc)}), 503
@app.route("/proxy/status")
def proxy_status():
return _proxy_get("/api/status")
@app.route("/proxy/drives")
def proxy_drives():
return _proxy_get("/api/drives")
@app.route("/proxy/sysinfo")
def proxy_sysinfo():
return _proxy_get("/api/sysinfo")
@app.route("/proxy/test-webhook", methods=["POST"])
def proxy_test_webhook():
body = request.get_json(force=True, silent=True) or {}
return _proxy_post("/api/test-webhook", body)
@app.route("/proxy/scan", methods=["POST"])
def proxy_scan():
body = request.get_json(force=True, silent=True) or {}
return _proxy_post("/api/scan", body)
@app.route("/proxy/process", methods=["POST"])
def proxy_process():
body = request.get_json(force=True, silent=True) or {}
return _proxy_post("/api/process", body)
@app.route("/proxy/database/clear", methods=["POST"])
def proxy_database_clear():
return _proxy_post("/api/database/clear")
@app.route("/proxy/pause", methods=["POST"])
def proxy_pause():
return _proxy_post("/api/pause")
@app.route("/proxy/resume", methods=["POST"])
def proxy_resume():
return _proxy_post("/api/resume")
@app.route("/proxy/stop", methods=["POST"])
def proxy_stop():
return _proxy_post("/api/stop")
@app.route("/proxy/logs/stream")
def proxy_logs_stream():
def generate():
try:
r = requests.get(f"{worker_url()}/api/logs/stream", stream=True, timeout=120)
for line in r.iter_lines():
if line:
yield f"{line.decode()}\n\n"
except Exception:
pass
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
print("HEVC Dashboard -> http://localhost:5000")
print(f"Worker URL -> {worker_url()}")
print(f"Media root -> {_config['media_root']}")
app.run(host="0.0.0.0", port=5000, debug=False, threaded=True)