316 lines
10 KiB
Python
316 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Worker API — runs alongside the dashboard on Windows by default (port 5001).
|
|
Exposes the HEVCWorker over HTTP so the dashboard can control scan/process jobs.
|
|
"""
|
|
import json
|
|
import time
|
|
from collections import deque
|
|
|
|
from flask import Flask, jsonify, request, Response, stream_with_context
|
|
from flask_cors import CORS
|
|
|
|
from worker import worker
|
|
|
|
app = Flask(__name__)
|
|
CORS(app) # Allow cross-origin requests from the dashboard
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Status & control
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@app.route("/api/status")
|
|
def api_status():
|
|
return jsonify(worker.get_status())
|
|
|
|
|
|
def get_hardware_info():
|
|
import os
|
|
import subprocess
|
|
import platform
|
|
|
|
cpu_name = "Unknown CPU"
|
|
gpu_name = "Unknown GPU"
|
|
|
|
# CPU Detection
|
|
if os.name == "nt":
|
|
try:
|
|
import winreg
|
|
try:
|
|
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System\CentralProcessor\0")
|
|
cpu_name = winreg.QueryValueEx(key, "ProcessorNameString")[0].strip()
|
|
except Exception:
|
|
out = subprocess.check_output("wmic cpu get name", shell=True, text=True)
|
|
lines = [l.strip() for l in out.splitlines() if l.strip()]
|
|
if len(lines) > 1:
|
|
cpu_name = lines[1]
|
|
except Exception:
|
|
pass
|
|
else:
|
|
# Linux
|
|
try:
|
|
if os.path.exists("/proc/cpuinfo"):
|
|
with open("/proc/cpuinfo", "r") as f:
|
|
for line in f:
|
|
if "model name" in line.casefold() or "hardware" in line.casefold():
|
|
cpu_name = line.split(":", 1)[1].strip()
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
if not cpu_name or cpu_name == "Unknown CPU":
|
|
try:
|
|
out = subprocess.check_output("lscpu", shell=True, text=True)
|
|
for line in out.splitlines():
|
|
if "Model name:" in line:
|
|
cpu_name = line.split(":", 1)[1].strip()
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
if not cpu_name or cpu_name == "Unknown CPU":
|
|
try:
|
|
cpu_name = platform.processor() or "Unknown CPU"
|
|
except Exception:
|
|
pass
|
|
|
|
# GPU Detection
|
|
try:
|
|
if os.name == "nt":
|
|
# Try PowerShell first since wmic is deprecated/removed in newer Windows 11
|
|
try:
|
|
out = subprocess.check_output(
|
|
["powershell", "-NoProfile", "-Command", "Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name"],
|
|
text=True,
|
|
stderr=subprocess.DEVNULL
|
|
)
|
|
gpus = [l.strip() for l in out.splitlines() if l.strip()]
|
|
if gpus:
|
|
gpu_name = ", ".join(gpus)
|
|
except Exception:
|
|
# Fallback to wmic
|
|
try:
|
|
out = subprocess.check_output("wmic path win32_VideoController get name", shell=True, text=True, stderr=subprocess.DEVNULL)
|
|
lines = [l.strip() for l in out.splitlines() if l.strip()]
|
|
if len(lines) > 1:
|
|
gpus = [l for l in lines[1:] if l and l.lower() != "name"]
|
|
if gpus:
|
|
gpu_name = ", ".join(gpus)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
# Linux
|
|
gpus = []
|
|
try:
|
|
out = subprocess.check_output("lspci", shell=True, text=True, stderr=subprocess.DEVNULL)
|
|
for line in out.splitlines():
|
|
if "VGA compatible controller" in line or "3D controller" in line:
|
|
parts = line.split(":", 2)
|
|
if len(parts) > 2:
|
|
gpus.append(parts[2].strip())
|
|
except Exception:
|
|
pass
|
|
|
|
if not gpus:
|
|
try:
|
|
out = subprocess.check_output("nvidia-smi --query-gpu=name --format=csv,noheader", shell=True, text=True, stderr=subprocess.DEVNULL)
|
|
for line in out.splitlines():
|
|
if line.strip():
|
|
gpus.append(line.strip())
|
|
except Exception:
|
|
pass
|
|
|
|
if not gpus:
|
|
try:
|
|
if os.path.isdir("/sys/class/drm"):
|
|
for card in os.listdir("/sys/class/drm"):
|
|
if card.startswith("card") and "-" not in card:
|
|
uevent_path = f"/sys/class/drm/{card}/device/uevent"
|
|
if os.path.exists(uevent_path):
|
|
with open(uevent_path, "r") as f:
|
|
for line in f:
|
|
if line.startswith("DRIVER="):
|
|
driver = line.split("=")[1].strip()
|
|
gpus.append(f"{driver.capitalize()} (DRM)")
|
|
except Exception:
|
|
pass
|
|
|
|
if gpus:
|
|
gpus = list(dict.fromkeys(gpus))
|
|
gpu_name = ", ".join(gpus)
|
|
except Exception:
|
|
pass
|
|
|
|
return {"cpu": cpu_name, "gpu": gpu_name}
|
|
|
|
|
|
@app.route("/api/sysinfo")
|
|
def api_sysinfo():
|
|
import os
|
|
info = get_hardware_info()
|
|
return jsonify({
|
|
"cpu_count": os.cpu_count(),
|
|
"recommended_threads": worker._recommended_workers(),
|
|
"cpu_name": info["cpu"],
|
|
"gpu_name": info["gpu"]
|
|
})
|
|
|
|
|
|
@app.route("/api/test-webhook", methods=["POST"])
|
|
def api_test_webhook():
|
|
config = request.get_json() or {}
|
|
webhook_url = str(config.get("discord_webhook") or "").strip()
|
|
if not webhook_url:
|
|
return jsonify({"error": "No webhook URL provided"}), 400
|
|
|
|
from worker import _send_discord_webhook
|
|
try:
|
|
_send_discord_webhook(webhook_url, "test_file_hello_world.mkv", 1024 * 1024 * 1024)
|
|
return jsonify({"ok": True})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/scan", methods=["POST"])
|
|
def api_scan():
|
|
config = request.get_json() or {}
|
|
return jsonify(worker.start_scan(config))
|
|
|
|
@app.route("/api/process", methods=["POST"])
|
|
def api_process():
|
|
config = request.get_json() or {}
|
|
return jsonify(worker.start_process(config))
|
|
|
|
@app.route("/api/database/clear", methods=["POST"])
|
|
def api_database_clear():
|
|
worker.clear_database()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
|
|
@app.route("/api/pause", methods=["POST"])
|
|
def api_pause():
|
|
worker.pause()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/resume", methods=["POST"])
|
|
def api_resume():
|
|
worker.resume()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.route("/api/stop", methods=["POST"])
|
|
def api_stop():
|
|
worker.stop()
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Log endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@app.route("/api/browse")
|
|
def api_browse():
|
|
"""
|
|
List subdirectories of a given path on the server filesystem.
|
|
Query param: ?path=/mnt (defaults to /)
|
|
Returns: { path, parent, dirs: [{name, full_path}] }
|
|
"""
|
|
import os
|
|
req_path = request.args.get("path", "/").strip()
|
|
req_path = os.path.normpath(req_path)
|
|
|
|
if not os.path.isdir(req_path):
|
|
return jsonify({"error": f"Not a directory: {req_path}"}), 400
|
|
|
|
try:
|
|
entries = []
|
|
for name in sorted(os.listdir(req_path)):
|
|
full = os.path.join(req_path, name)
|
|
if os.path.isdir(full) and not name.startswith("."):
|
|
entries.append({"name": name, "full_path": full})
|
|
except PermissionError:
|
|
return jsonify({"error": "Permission denied"}), 403
|
|
|
|
parent = str(os.path.dirname(req_path)) if req_path != "/" else None
|
|
return jsonify({"path": req_path, "parent": parent, "dirs": entries})
|
|
|
|
|
|
@app.route("/api/drives")
|
|
def api_drives():
|
|
"""Return all active drive letters on Windows, or root on Linux/macOS."""
|
|
import os
|
|
if os.name != "nt":
|
|
return jsonify(["/"])
|
|
import string
|
|
drives = []
|
|
for letter in string.ascii_uppercase:
|
|
drive = f"{letter}:\\"
|
|
if os.path.exists(drive):
|
|
drives.append(drive.replace("\\", "/"))
|
|
return jsonify(drives)
|
|
|
|
|
|
@app.route("/api/logs")
|
|
def api_logs():
|
|
"""Return all buffered log entries as JSON."""
|
|
return jsonify(worker.get_logs())
|
|
|
|
|
|
@app.route("/api/logs/stream")
|
|
def api_logs_stream():
|
|
"""
|
|
Server-Sent Events endpoint.
|
|
Sends all existing buffered logs first, then streams new ones live.
|
|
"""
|
|
subscriber_q: deque = deque(maxlen=500)
|
|
worker.subscribe_logs(subscriber_q)
|
|
|
|
# Pre-fill with existing buffer so client sees history on connect
|
|
for entry in worker.get_logs():
|
|
subscriber_q.append(entry)
|
|
|
|
def generate():
|
|
last_keepalive = time.time()
|
|
try:
|
|
while True:
|
|
if subscriber_q:
|
|
entry = subscriber_q.popleft()
|
|
yield f"data: {json.dumps(entry)}\n\n"
|
|
last_keepalive = time.time()
|
|
else:
|
|
time.sleep(0.1)
|
|
if time.time() - last_keepalive > 15:
|
|
yield ": keepalive\n\n"
|
|
last_keepalive = time.time()
|
|
except GeneratorExit:
|
|
pass
|
|
finally:
|
|
worker.unsubscribe_logs(subscriber_q)
|
|
|
|
return Response(
|
|
stream_with_context(generate()),
|
|
mimetype="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"X-Accel-Buffering": "no",
|
|
"Connection": "keep-alive",
|
|
},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
if __name__ == "__main__":
|
|
import argparse, os as _os
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--port", type=int,
|
|
default=int(_os.environ.get("WORKER_PORT", 5001)))
|
|
args = parser.parse_args()
|
|
print(f"HEVC Worker API starting on 0.0.0.0:{args.port}")
|
|
app.run(host="0.0.0.0", port=args.port, debug=False, threaded=True)
|