#!/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)