From 4b8c7a0220dbc303969d4f3c55e15100d15d2638 Mon Sep 17 00:00:00 2001
From: AshiePleb <38188828+AshiePleb@users.noreply.github.com>
Date: Tue, 16 Jun 2026 22:25:33 +0100
Subject: [PATCH] First Public Release!
---
README.md | 83 ++-
dashboard/dashboard.py | 239 ++++++
dashboard/requirements.txt | 2 +
dashboard/static/app.js | 629 ++++++++++++++++
dashboard/static/style.css | 346 +++++++++
dashboard/templates/index.html | 361 ++++++++++
server/requirements.txt | 2 +
server/worker.py | 1237 ++++++++++++++++++++++++++++++++
server/worker_api.py | 315 ++++++++
start_dashboard.bat | 203 ++++++
start_dashboard.sh | 138 ++++
11 files changed, 3554 insertions(+), 1 deletion(-)
create mode 100644 dashboard/dashboard.py
create mode 100644 dashboard/requirements.txt
create mode 100644 dashboard/static/app.js
create mode 100644 dashboard/static/style.css
create mode 100644 dashboard/templates/index.html
create mode 100644 server/requirements.txt
create mode 100644 server/worker.py
create mode 100644 server/worker_api.py
create mode 100644 start_dashboard.bat
create mode 100644 start_dashboard.sh
diff --git a/README.md b/README.md
index d09e136..133326f 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,83 @@
-# hevc-encoder
+# HEVC Re-Encode Dashboard & Worker
+A lightweight, self-hosted web dashboard and queue worker for scanning media libraries and transcoding video files to **HEVC MKV** with selectable audio handling.
+
+It includes a modern dark dashboard, server-side CPU/GPU detection, folder browsers, live logs, safe rename and remux handling, and an optional force re-encode mode for normalizing an entire library.
+
+---
+
+## Features
+
+- ๐ฅ๏ธ **Cross-Platform Support**: Runs on Windows and Linux hosts.
+- โ๏ธ **Zero Hardcoded Paths**: Configure Media and Temporary directories directly inside the dashboard.
+- ๐งฌ **Hardware Detection**: Automatically queries and displays the host CPU and GPU names.
+- ๐ฅ **Video Encoders**: Support for CPU `libx265`, NVIDIA NVENC, AMD AMF, and Intel QSV.
+- ๐ต **Audio Handling**: Copy original audio, keep source-aware audio behavior, or transcode to AAC/AC3.
+- ๐ **Force Re-encode Mode**: Re-encode files to HEVC plus the selected audio codec even when the output is not smaller.
+- ๐ท๏ธ **Optional Safe Renaming**: Rename outputs to Plex/Radarr/Sonarr-friendly names while preserving the library structure.
+- ๐งฑ **Remux Support**: Remux compatible files to MKV without re-encoding when possible.
+- ๐ง **Custom Binaries**: Set custom FFmpeg and FFprobe paths from the UI.
+- โก **Auto-detected Worker Threads**: Recommends a thread count based on CPU cores, with manual override.
+- ๐ **Discord Webhooks**: Sends structured completion embeds with a built-in test button.
+- ๐ **Filesystem Browser Modal**: Browse drives and directories to choose media and temp folders.
+- ๐ **Real-Time Progress & Logs**: Shows FPS, speed, ETA, progress bars, and live worker logs.
+- ๐งน **Safe Move and Temp Cleanup**: Handles cross-filesystem moves and cleans up failed temp outputs.
+
+---
+
+## Installation & Setup
+
+Before running, ensure **Python 3, FFmpeg, and FFprobe** are installed on the host machine.
+If you use the `start_dashboard.sh` script on Linux, it will attempt to **automatically detect your OS and install missing dependencies**!
+
+- **Windows**: Download from [gyan.dev](https://www.gyan.dev/ffmpeg/builds/) and add its `bin` folder to your system environment variables (`PATH`), or set custom paths in the Dashboard UI.
+- **Linux (CachyOS / Arch)**:
+ ```bash
+ sudo pacman -Sy python ffmpeg
+ ```
+- **Linux (Debian / Ubuntu)**:
+ ```bash
+ sudo apt update && sudo apt install -y python3 python3-venv ffmpeg
+ ```
+
+---
+
+## How to Run
+
+### Windows
+
+1. Double-click the `start_dashboard.bat` script in this repository.
+ - This automatically creates virtual environments, installs lightweight dependencies (`Flask`, `Flask-CORS`, `requests`), and launches the worker API (port 5001) and web dashboard (port 5000).
+2. Open your browser and go to **http://localhost:5000**.
+
+### Linux
+
+1. Make the startup script executable and run it:
+ ```bash
+ chmod +x start_dashboard.sh
+ ./start_dashboard.sh
+ ```
+
+ - This launches the worker API in the background (logging output to `server/worker.log`), runs the dashboard in the foreground, and automatically tears down the background processes when stopped (Ctrl+C).
+2. Open your browser and go to **http://localhost:5000**.
+
+---
+
+## Usage Guide
+
+1. **Media Directory**: Click the **Browse** button, choose an active drive/root volume, navigate to your media folder, and click **Select Folder** (or type it in manually).
+2. **Temporary Directory**: Set this to a fast local SSD drive to prevent network performance bottlenecks during active transcoding.
+3. **Hardware / Codec Limits**: Set your video encoder, audio format, and custom FFmpeg paths if required.
+4. **Rename Output Files**: Enable this if you want safer library-friendly names during scan and processing.
+5. **Force Re-encode**: Enable this if you want every queued file normalized to HEVC plus the selected audio codec, even when the result is larger.
+6. **Threads**: Defaults to the server's auto-detected recommended thread count, or select a manual override.
+7. **Discord Webhook**: (Optional) Paste your webhook URL. Click the **Test** button to confirm a success notification embed is received.
+8. **Scan Media Library**: Scans files recursively. Files smaller than the _Min Size_ or already matched by the current target format are skipped or marked done.
+9. **Run Processing**: Starts the transcode queue.
+
+
+## Open Source Notes
+
+- The project is designed to be self-hosted and does not require cloud services.
+- Keep any test media out of the repository unless it is explicitly licensed for redistribution.
+- If you publish on GitHub, include a short note that the worker depends on FFmpeg/FFprobe being installed on the host.
diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py
new file mode 100644
index 0000000..321088b
--- /dev/null
+++ b/dashboard/dashboard.py
@@ -0,0 +1,239 @@
+#!/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)
diff --git a/dashboard/requirements.txt b/dashboard/requirements.txt
new file mode 100644
index 0000000..a0d407c
--- /dev/null
+++ b/dashboard/requirements.txt
@@ -0,0 +1,2 @@
+flask>=3.0
+requests>=2.31
diff --git a/dashboard/static/app.js b/dashboard/static/app.js
new file mode 100644
index 0000000..b313536
--- /dev/null
+++ b/dashboard/static/app.js
@@ -0,0 +1,629 @@
+// ---------------------------------------------------------------------------
+// State & DOM
+// ---------------------------------------------------------------------------
+const $ = (id) => document.getElementById(id);
+
+let workerUrl = $('worker-url-display').textContent.trim();
+let evtSource = null;
+let isPolling = false;
+let excludedFolders = new Set();
+let recommendedThreads = 3;
+
+const elStatus = $('status-indicator');
+const elStatusText = $('status-text');
+
+const elDone = $('stat-done');
+const elSkipped = $('stat-skipped');
+const elSaved = $('stat-saved');
+const elQueued = $('stat-queued');
+
+const elActiveList = $('active-encodes-container');
+const elRecentList = $('recent-finished-container');
+const elLogCount = $('log-count');
+const elWorkerUrl = $('worker-url-display');
+const elUrlEditor = $('url-editor');
+const elUrlInput = $('worker-url-input');
+const elModalBdrop = $('modal-backdrop');
+const elModalBody = $('modal-body');
+const elModalCrumb = $('modal-breadcrumb');
+const elModalPath = $('modal-current-path');
+
+// ---------------------------------------------------------------------------
+// Worker URL
+// ---------------------------------------------------------------------------
+function updateWorkerDisplay() {
+ elWorkerUrl.textContent = workerUrl;
+ elUrlInput.value = workerUrl;
+}
+
+// ---------------------------------------------------------------------------
+// API Helpers
+// ---------------------------------------------------------------------------
+async function POST(path, body = {}) {
+ const res = await fetch(path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ });
+ return res.json();
+}
+
+// ---------------------------------------------------------------------------
+// GET Helpers
+// ---------------------------------------------------------------------------
+async function GET(path) {
+ const res = await fetch(path);
+ return res.json();
+}
+
+// ---------------------------------------------------------------------------
+// Status Polling
+// ---------------------------------------------------------------------------
+function applyStatus(data) {
+ const state = data.state || 'unreachable';
+ elStatus.dataset.state = state;
+ elStatusText.textContent = state.toUpperCase();
+
+ // Color dynamic header status indicator
+ elStatus.className = "status-badge px-4 py-2 border rounded-xl text-xs font-bold tracking-wider flex items-center gap-2 transition-all ";
+ let dotColorClass = "bg-zinc-500";
+ if (state === 'idle') {
+ elStatus.classList.add("border-zinc-800", "text-zinc-400", "bg-zinc-950/80");
+ dotColorClass = "bg-zinc-500";
+ } else if (state === 'scanning' || state === 'paused') {
+ elStatus.classList.add("border-amber-500/50", "text-amber-500", "bg-amber-950/10");
+ dotColorClass = "bg-amber-500";
+ } else if (state === 'running' || state === 'done') {
+ elStatus.classList.add("border-emerald-500/50", "text-emerald-500", "bg-emerald-950/10");
+ dotColorClass = "bg-emerald-500";
+ } else {
+ elStatus.classList.add("border-rose-500/50", "text-rose-500", "bg-rose-950/10");
+ dotColorClass = "bg-rose-500";
+ }
+ const statusDot = $('status-dot');
+ if (statusDot) {
+ statusDot.className = `w-2 h-2 rounded-full ${dotColorClass}`;
+ }
+
+ elDone.textContent = data.done_count ?? 0;
+ elSkipped.textContent = data.skipped_count ?? 0;
+ elSaved.textContent = data.total_saved_human ?? '0 B';
+ elQueued.textContent = data.queued_count ?? 0;
+
+ // Render extra stats
+ const elTotal = $('stat-total');
+ const elRatio = $('stat-ratio');
+ if (elTotal) elTotal.textContent = data.total_files ?? 0;
+ if (elRatio) elRatio.textContent = (data.compression_ratio ?? 0.0) + '%';
+
+ // Auto-set threads to recommended value if returned and not overridden by user
+ const selectThreads = $('threads');
+ if (selectThreads && data.recommended_threads && !selectThreads.dataset.userModified) {
+ selectThreads.value = String(data.recommended_threads);
+ }
+
+ renderActiveEncodes(data.active_encodes || []);
+ renderRecentDone(data.recent_done || []);
+ updateButtons(state);
+}
+
+async function pollStatus() {
+ try {
+ const data = await GET('/proxy/status');
+ if (data && data.state) applyStatus(data);
+ else throw new Error('Invalid status');
+ } catch (_) {
+ elStatus.dataset.state = 'unreachable';
+ elStatusText.textContent = 'UNREACHABLE';
+
+ elStatus.className = "status-badge px-4 py-2 border border-rose-500/50 text-rose-500 bg-rose-950/10 rounded-xl text-xs font-bold tracking-wider flex items-center gap-2";
+ const statusDot = $('status-dot');
+ if (statusDot) statusDot.className = "w-2 h-2 rounded-full bg-rose-500";
+
+ updateButtons('unreachable');
+ }
+}
+
+function startPolling() {
+ if (isPolling) return;
+ isPolling = true;
+ pollStatus();
+ setInterval(pollStatus, 2000);
+}
+
+// ---------------------------------------------------------------------------
+// Render UI
+// ---------------------------------------------------------------------------
+function renderActiveEncodes(list) {
+ if (!list || list.length === 0) {
+ elActiveList.innerHTML = '
No active processes
';
+ return;
+ }
+ let html = '';
+ list.forEach(enc => {
+ html += `
+
+
+
${escAttr(enc.file)}
+
${enc.progress}%
+
+
+
+
+
+ ${enc.eta}
+
+
+
+ ${enc.fps} fps
+
+
ยท
+
${enc.speed}
+
ยท
+
${enc.bitrate}
+
+
+ `;
+ });
+ elActiveList.innerHTML = html;
+}
+
+function renderRecentDone(list) {
+ if (!list || list.length === 0) {
+ elRecentList.innerHTML = 'Nothing finished yet
';
+ return;
+ }
+ let html = '';
+ list.forEach(item => {
+ html += `
+
+
${escAttr(item.file)}
+
Saved ${item.saved_human}
+
+ `;
+ });
+ elRecentList.innerHTML = html;
+}
+
+// ---------------------------------------------------------------------------
+// Button state machine
+// ---------------------------------------------------------------------------
+function updateButtons(state) {
+ const scan = $('btn-scan');
+ const proc = $('btn-process');
+ const p = $('btn-pause');
+ const r = $('btn-resume');
+ const x = $('btn-stop');
+ const cl = $('btn-clear-db');
+
+ // Reset
+ [scan, proc, p, r, x, cl].forEach(b => { if(b) b.disabled = true; });
+
+ if (state === 'idle' || state === 'done' || state === 'unreachable') {
+ if (scan) scan.disabled = false;
+ if (proc) proc.disabled = false;
+ if (cl) cl.disabled = false;
+ setHidden(p, false);
+ setHidden(r, true);
+ } else if (state === 'scanning' || state === 'running') {
+ if (p) p.disabled = false;
+ if (x) x.disabled = false;
+ setHidden(p, false);
+ setHidden(r, true);
+ } else if (state === 'paused') {
+ if (r) r.disabled = false;
+ if (x) x.disabled = false;
+ setHidden(p, true);
+ setHidden(r, false);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Server-Sent Events (Logs)
+// ---------------------------------------------------------------------------
+function connectSSE() {
+ if (evtSource) {
+ evtSource.close();
+ }
+ evtSource = new EventSource('/proxy/logs/stream');
+ const logContainer = $('logs-container');
+ let lineCount = 0;
+
+ evtSource.onmessage = (e) => {
+ if (e.data.includes('keepalive')) return;
+ try {
+ const data = JSON.parse(e.data);
+ const div = document.createElement('div');
+ div.className = 'py-0.5 border-b border-zinc-950 font-mono text-[11px] flex gap-3 text-zinc-300';
+
+ let levelColor = 'text-zinc-500';
+ if (data.level === 'SUCCESS') levelColor = 'text-emerald-400 font-semibold';
+ else if (data.level === 'WARN') levelColor = 'text-amber-400 font-semibold';
+ else if (data.level === 'ERROR') levelColor = 'text-rose-400 font-bold';
+ else if (data.level === 'INFO') levelColor = 'text-zinc-400';
+
+ div.innerHTML = `
+ ${data.time.split(' ')[1]}
+ ${data.level}
+ ${escAttr(data.message)}
+ `;
+ logContainer.appendChild(div);
+ lineCount++;
+ elLogCount.textContent = lineCount + ' lines';
+
+ // Auto-scroll logic
+ if (logContainer.scrollHeight - logContainer.scrollTop < logContainer.clientHeight + 100) {
+ logContainer.scrollTop = logContainer.scrollHeight;
+ }
+ } catch (_) {}
+ };
+
+ evtSource.onerror = () => {
+ evtSource.close();
+ setTimeout(connectSSE, 5000); // Reconnect
+ };
+}
+
+$('btn-clear-logs')?.addEventListener('click', () => {
+ $('logs-container').innerHTML = '';
+ elLogCount.textContent = '0 lines';
+});
+
+// ---------------------------------------------------------------------------
+// Config Builders
+// ---------------------------------------------------------------------------
+function escAttr(str) {
+ return String(str).replace(/"/g,'"');
+}
+
+function setHidden(element, hidden) {
+ if (!element) return;
+ element.classList.toggle('is-hidden', hidden);
+}
+
+function calcRecommendedThreads(logicalCores) {
+ const cores = Math.max(1, logicalCores || 4);
+ if (cores >= 12) return Math.floor(cores / 2);
+ if (cores >= 6) return cores - 2;
+ return Math.max(1, cores - 1);
+}
+
+async function setupThreadOptions() {
+ const select = $('threads');
+ if (!select) return;
+
+ let logicalCores = 8;
+ try {
+ const sysinfo = await GET('/proxy/sysinfo');
+ logicalCores = sysinfo.cpu_count || 8;
+ recommendedThreads = sysinfo.recommended_threads || 3;
+ if ($('spec-cpu')) $('spec-cpu').textContent = sysinfo.cpu_name || 'Unknown CPU';
+ if ($('spec-gpu')) $('spec-gpu').textContent = sysinfo.gpu_name || 'Unknown GPU';
+ } catch (err) {
+ console.error('Error fetching sysinfo from server, falling back to local hardware:', err);
+ logicalCores = Number(navigator.hardwareConcurrency) || 8;
+ recommendedThreads = Math.max(1, Math.min(logicalCores, calcRecommendedThreads(logicalCores)));
+ if ($('spec-cpu')) $('spec-cpu').textContent = 'Unknown CPU';
+ if ($('spec-gpu')) $('spec-gpu').textContent = 'Unknown GPU';
+ }
+
+ const maxThreads = Math.max(4, Math.min(12, logicalCores));
+ recommendedThreads = Math.max(1, Math.min(maxThreads, recommendedThreads));
+
+ select.innerHTML = '';
+ for (let t = 1; t <= maxThreads; t++) {
+ const opt = document.createElement('option');
+ opt.value = String(t);
+ opt.textContent = `${t} thread${t === 1 ? '' : 's'}${t === recommendedThreads ? ' (Recommended)' : ''}`;
+ if (t === recommendedThreads) {
+ opt.selected = true;
+ }
+ select.appendChild(opt);
+ }
+}
+
+function buildStartConfig() {
+ const minSizeMB = parseInt($('min-size').value, 10) || 0;
+ return {
+ media_dir: $('media-dir').value.trim(),
+ temp_dir: $('temp-dir').value.trim(),
+ min_size_bytes: minSizeMB * 1024 * 1024,
+ excluded_folders: [...excludedFolders],
+ threads: parseInt($('threads').value, 10) || recommendedThreads,
+ preset: $('preset').value || 'medium',
+ crf: parseInt($('crf').value, 10) || 22,
+ discord_webhook: $('discord-webhook') ? $('discord-webhook').value.trim() : '',
+ custom_ffmpeg: $('custom-ffmpeg') ? $('custom-ffmpeg').value.trim() : '',
+ custom_ffprobe: $('custom-ffprobe') ? $('custom-ffprobe').value.trim() : '',
+ encoder: $('encoder') ? $('encoder').value : 'libx265',
+ audio_codec: $('audio-codec') ? $('audio-codec').value : 'auto',
+ rename_output: $('rename-output') ? $('rename-output').checked : false,
+ force_reencode: $('force-reencode') ? $('force-reencode').checked : false,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Initialization
+// ---------------------------------------------------------------------------
+window.addEventListener('DOMContentLoaded', () => {
+ updateWorkerDisplay();
+ setupThreadOptions();
+ startPolling();
+ connectSSE();
+
+ // Mark threads as user-modified if manually changed
+ $('threads')?.addEventListener('change', () => {
+ $('threads').dataset.userModified = 'true';
+ });
+
+ // Auto-save typed inputs on blur
+ $('media-dir')?.addEventListener('blur', () => {
+ POST('/config', { media_root: $('media-dir').value.trim() });
+ });
+ $('temp-dir')?.addEventListener('blur', () => {
+ POST('/config', { temp_dir: $('temp-dir').value.trim() });
+ });
+ $('discord-webhook')?.addEventListener('blur', () => {
+ POST('/config', { discord_webhook: $('discord-webhook').value.trim() });
+ });
+ $('custom-ffmpeg')?.addEventListener('blur', () => {
+ POST('/config', { custom_ffmpeg: $('custom-ffmpeg').value.trim() });
+ });
+ $('custom-ffprobe')?.addEventListener('blur', () => {
+ POST('/config', { custom_ffprobe: $('custom-ffprobe').value.trim() });
+ });
+ $('encoder')?.addEventListener('change', () => {
+ POST('/config', { encoder: $('encoder').value });
+ });
+ $('audio-codec')?.addEventListener('change', () => {
+ POST('/config', { audio_codec: $('audio-codec').value });
+ });
+ $('rename-output')?.addEventListener('change', () => {
+ POST('/config', { rename_output: $('rename-output').checked });
+ });
+ $('force-reencode')?.addEventListener('change', () => {
+ POST('/config', { force_reencode: $('force-reencode').checked });
+ });
+
+ // Test Discord Webhook
+ $('btn-test-webhook')?.addEventListener('click', async () => {
+ const url = $('discord-webhook').value.trim();
+ if (!url) {
+ alert('Please enter a Discord Webhook URL first.');
+ return;
+ }
+ const btn = $('btn-test-webhook');
+ const originalText = btn.textContent;
+ btn.textContent = 'Testing...';
+ btn.disabled = true;
+ try {
+ const res = await POST('/proxy/test-webhook', { discord_webhook: url });
+ if (res.ok) {
+ alert('Test notification sent successfully!');
+ } else {
+ alert('Error: ' + (res.error || 'Failed to send notification.'));
+ }
+ } catch (err) {
+ alert('Network error testing webhook.');
+ } finally {
+ btn.textContent = originalText;
+ btn.disabled = false;
+ }
+ });
+
+ // --- Control buttons ---
+ $('btn-scan')?.addEventListener('click', async () => {
+ await POST('/proxy/scan', buildStartConfig());
+ pollStatus();
+ });
+ $('btn-process')?.addEventListener('click', async () => {
+ await POST('/proxy/process', buildStartConfig());
+ pollStatus();
+ });
+ $('btn-clear-db')?.addEventListener('click', async () => {
+ if (!confirm('Clear the entire media tracking database?')) return;
+ await POST('/proxy/database/clear');
+ pollStatus();
+ });
+ $('btn-pause')?.addEventListener('click', async () => {
+ await POST('/proxy/pause');
+ pollStatus();
+ });
+ $('btn-resume')?.addEventListener('click', async () => {
+ await POST('/proxy/resume');
+ pollStatus();
+ });
+ $('btn-stop')?.addEventListener('click', async () => {
+ await POST('/proxy/stop');
+ pollStatus();
+ });
+
+ // --- Worker URL Editor ---
+ $('btn-edit-url')?.addEventListener('click', (e) => {
+ e.preventDefault();
+ elUrlEditor.classList.remove('is-hidden');
+ });
+ $('btn-save-url')?.addEventListener('click', async () => {
+ const newVal = elUrlInput.value.trim();
+ if (newVal) {
+ await POST('/config', { worker_url: newVal });
+ workerUrl = newVal;
+ updateWorkerDisplay();
+ elUrlEditor.classList.add('is-hidden');
+ connectSSE();
+ pollStatus();
+ }
+ });
+ $('btn-cancel-url')?.addEventListener('click', () => {
+ elUrlEditor.classList.add('is-hidden');
+ });
+
+ // --- Tag input ---
+ const tagInput = $('excl-input');
+ tagInput?.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ const val = tagInput.value.trim();
+ if (val && !excludedFolders.has(val)) {
+ excludedFolders.add(val);
+ renderTags();
+ }
+ tagInput.value = '';
+ }
+ });
+
+ function renderTags() {
+ const container = $('excl-tags');
+ // keep only the input
+ Array.from(container.querySelectorAll('.tag')).forEach(el => el.remove());
+ excludedFolders.forEach(tag => {
+ const el = document.createElement('div');
+ el.className = 'tag bg-zinc-800 text-zinc-300 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded-lg flex items-center gap-1.5 border border-zinc-700/60 select-none';
+ el.innerHTML = `${escAttr(tag)} ×`;
+ el.querySelector('.tag-remove').onclick = () => {
+ excludedFolders.delete(tag);
+ renderTags();
+ };
+ container.insertBefore(el, tagInput);
+ });
+ }
+
+ // --- Local File Browser ---
+ let modalTarget = 'media-dir';
+
+ async function loadDrives() {
+ const container = $('modal-drive-switcher');
+ if (!container) return [];
+ try {
+ const drives = await GET('/proxy/drives');
+ const label = container.querySelector('span');
+ container.innerHTML = '';
+ if (label) container.appendChild(label);
+ if (drives && Array.isArray(drives)) {
+ drives.forEach(drive => {
+ const btn = document.createElement('button');
+ btn.className = 'px-3 py-1 bg-zinc-900 border border-zinc-800 hover:bg-zinc-800 text-zinc-300 rounded-lg text-xs font-semibold transition-colors';
+ btn.textContent = drive;
+ btn.onclick = () => loadDir(drive);
+ container.appendChild(btn);
+ });
+ return drives;
+ }
+ } catch (err) {
+ console.error('Error loading drives:', err);
+ }
+ return [];
+ }
+
+ $('btn-browse')?.addEventListener('click', async () => {
+ modalTarget = 'media-dir';
+ elModalBdrop.classList.remove('is-hidden');
+ const drives = await loadDrives();
+ const val = $('media-dir').value.trim();
+ if (val) {
+ loadDir(val);
+ } else if (drives && drives.length > 0) {
+ loadDir(drives[0]);
+ } else {
+ elModalBody.innerHTML = 'Please select a drive.
';
+ }
+ });
+
+ $('btn-browse-temp')?.addEventListener('click', async () => {
+ modalTarget = 'temp-dir';
+ elModalBdrop.classList.remove('is-hidden');
+ const drives = await loadDrives();
+ const val = $('temp-dir').value.trim();
+ if (val) {
+ loadDir(val);
+ } else if (drives && drives.length > 0) {
+ loadDir(drives[0]);
+ } else {
+ elModalBody.innerHTML = 'Please select a drive.
';
+ }
+ });
+
+ $('btn-browse-excl')?.addEventListener('click', async () => {
+ modalTarget = 'exclude';
+ elModalBdrop.classList.remove('is-hidden');
+ const drives = await loadDrives();
+ const val = $('media-dir').value.trim();
+ if (val) {
+ loadDir(val);
+ } else if (drives && drives.length > 0) {
+ loadDir(drives[0]);
+ } else {
+ elModalBody.innerHTML = 'Please select a drive.
';
+ }
+ });
+
+ $('btn-modal-cancel')?.addEventListener('click', () => {
+ elModalBdrop.classList.add('is-hidden');
+ });
+ $('btn-modal-select')?.addEventListener('click', () => {
+ if (modalTarget === 'media-dir') {
+ $('media-dir').value = elModalPath.value;
+ POST('/config', { media_root: elModalPath.value });
+ } else if (modalTarget === 'temp-dir') {
+ $('temp-dir').value = elModalPath.value;
+ POST('/config', { temp_dir: elModalPath.value });
+ } else if (modalTarget === 'exclude') {
+ // Use the last part of the path as the folder name
+ const folderName = elModalPath.value.split('/').pop() || elModalPath.value;
+ if (folderName && !excludedFolders.has(folderName)) {
+ excludedFolders.add(folderName);
+ renderTags();
+ }
+ }
+ elModalBdrop.classList.add('is-hidden');
+ });
+
+ async function loadDir(path) {
+ elModalBody.innerHTML = 'Loading...
';
+ try {
+ const res = await GET(`/proxy/browse?path=${encodeURIComponent(path)}`);
+ if (res.error) {
+ elModalBody.innerHTML = `${escAttr(res.error)}
`;
+ return;
+ }
+
+ elModalPath.value = res.path;
+ renderBreadcrumb(res.path, res.parent);
+
+ if (!res.dirs || res.dirs.length === 0) {
+ elModalBody.innerHTML = 'No subdirectories found.
';
+ return;
+ }
+
+ let html = '';
+ res.dirs.forEach(d => {
+ html += `
+
+ `;
+ });
+ elModalBody.innerHTML = html;
+
+ elModalBody.querySelectorAll('.dir-item').forEach(el => {
+ el.addEventListener('click', () => loadDir(el.dataset.path));
+ });
+ } catch (exc) {
+ elModalBody.innerHTML = `Error loading directory
`;
+ }
+ }
+
+ function renderBreadcrumb(path, parent) {
+ let html = '';
+ if (parent) {
+ html += `โ Up to ${escAttr(parent)}
`;
+ }
+ html += `${escAttr(path)}
`;
+ elModalCrumb.innerHTML = html;
+
+ $('crumb-up')?.addEventListener('click', () => loadDir(parent));
+ }
+});
diff --git a/dashboard/static/style.css b/dashboard/static/style.css
new file mode 100644
index 0000000..7637fa8
--- /dev/null
+++ b/dashboard/static/style.css
@@ -0,0 +1,346 @@
+/* HEVC Dashboard styling overrides */
+:root {
+ --bg: #000000;
+ --panel: rgba(17, 17, 17, 0.30);
+ --panel-soft: rgba(10, 10, 10, 0.78);
+ --border: rgba(39, 39, 42, 0.95);
+ --border-strong: rgba(63, 63, 70, 0.95);
+ --text: #f4f4f5;
+ --muted: #71717a;
+ --accent: #10b981;
+ --danger: #fb7185;
+ --shadow: none;
+}
+
+html {
+ color-scheme: dark;
+ background: #000000;
+}
+
+body {
+ margin: 0;
+ padding: 0;
+ min-height: 100vh;
+ color: var(--text);
+ font-family: 'Inter', system-ui, sans-serif;
+ background: #000000;
+}
+
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ background: transparent;
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #2b2b31;
+ border-radius: 999px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #40404a;
+}
+
+.dashboard-shell {
+ width: min(100%, 1640px);
+ padding-left: clamp(12px, 1.6vw, 20px);
+ padding-right: clamp(12px, 1.6vw, 20px);
+ padding-top: clamp(10px, 1.2vw, 16px);
+ padding-bottom: clamp(10px, 1.2vw, 16px);
+}
+
+.surface {
+ background: rgba(17, 17, 17, 0.30);
+ border: 1px solid rgba(39, 39, 42, 0.80);
+ box-shadow: none;
+ backdrop-filter: blur(8px);
+}
+
+.surface-soft {
+ background: rgba(12, 12, 12, 0.86);
+ border-color: rgba(39, 39, 42, 0.75);
+}
+
+.card-title {
+ font-family: 'Inter', system-ui, sans-serif;
+}
+
+.stat-card {
+ min-height: 92px;
+}
+
+.dashboard-panel,
+.dashboard-config {
+ min-width: 0;
+}
+
+.dashboard-panel-head,
+.panel-head {
+ background: transparent;
+}
+
+.dashboard-config-grid > * {
+ min-width: 0;
+}
+
+.dashboard-chip,
+.dashboard-status {
+ box-shadow: none;
+}
+
+.dashboard-shell input[type='text'],
+.dashboard-shell input[type='number'],
+.dashboard-shell select {
+ width: 100%;
+ min-width: 0;
+ background: #050505;
+ border: 1px solid rgba(63, 63, 70, 0.95);
+ color: #e5e7eb;
+ border-radius: 12px;
+ padding: 0.56rem 0.72rem;
+ font-size: 0.75rem;
+ line-height: 1.1rem;
+ outline: none;
+ transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
+}
+
+.exclude-shell {
+ width: 100%;
+ min-width: 0;
+ background: #050505;
+ border: 1px solid rgba(63, 63, 70, 0.95);
+ color: #e5e7eb;
+ border-radius: 12px;
+ padding: 0.35rem 0.5rem;
+ min-height: 36px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ align-items: center;
+ transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
+}
+
+.exclude-shell:focus-within {
+ border-color: rgba(163, 163, 163, 0.95);
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06);
+}
+
+.exclude-shell-input {
+ min-height: 20px;
+ padding: 0.15rem 0.1rem;
+}
+
+.panel-button {
+ width: 100%;
+ min-height: 2.15rem;
+ padding: 0.45rem 0.75rem;
+ border-radius: 0.9rem;
+ font-size: 0.65rem;
+ line-height: 1rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ border: 1px solid rgba(63, 63, 70, 0.95);
+ background: #050505;
+ color: #e5e7eb;
+}
+
+.panel-button:hover:not(:disabled) {
+ transform: translateY(-1px);
+}
+
+.panel-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.panel-button-small {
+ width: auto;
+ min-width: 3rem;
+ padding-left: 0.7rem;
+ padding-right: 0.7rem;
+}
+
+.panel-button-primary {
+ background: #f4f4f5;
+ color: #000;
+ border-color: #e4e4e7;
+}
+
+.panel-button-success {
+ background: #10b981;
+ color: #fff;
+ border-color: rgba(16, 185, 129, 0.75);
+ box-shadow: 0 2px 8px rgba(16, 185, 129, 0.18);
+}
+
+.panel-button-neutral {
+ background: #262626;
+ color: #e5e7eb;
+ border-color: rgba(63, 63, 70, 0.95);
+}
+
+.panel-button-danger {
+ background: rgba(127, 29, 29, 0.18);
+ color: #fca5a5;
+ border-color: rgba(127, 29, 29, 0.6);
+}
+
+.panel-button-muted {
+ background: #090909;
+ color: #71717a;
+ border-color: rgba(63, 63, 70, 0.9);
+}
+
+.dashboard-shell input[type='text']::placeholder,
+.dashboard-shell input[type='number']::placeholder {
+ color: rgba(148, 163, 184, 0.45);
+}
+
+.dashboard-shell input[type='text']:focus,
+.dashboard-shell input[type='number']:focus,
+.dashboard-shell select:focus {
+ border-color: rgba(163, 163, 163, 0.95);
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06);
+}
+
+.dashboard-shell select {
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ background-image: none;
+ padding-right: 2.4rem;
+ cursor: pointer;
+}
+
+.select-shell {
+ position: relative;
+}
+
+.select-shell::after {
+ content: '';
+ position: absolute;
+ inset: 0.5rem 0.78rem 0.5rem auto;
+ width: 1.3rem;
+ pointer-events: none;
+ background: linear-gradient(180deg, rgba(212, 212, 216, 0.84), rgba(212, 212, 216, 0.50));
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") center / 100% 100% no-repeat;
+ opacity: 0.7;
+}
+
+.select-shell select::-ms-expand {
+ display: none;
+}
+
+.select-chevron {
+ display: none;
+}
+
+.toggle-card {
+ min-height: 72px;
+}
+
+.toggle-copy {
+ max-width: calc(100% - 3.1rem);
+}
+
+.toggle-switch {
+ width: 2.55rem;
+ height: 1.28rem;
+ flex-shrink: 0;
+}
+
+.toggle-switch::after {
+ width: 0.95rem;
+ height: 0.95rem;
+}
+
+.dashboard-shell input[type='number'] {
+ appearance: textfield;
+}
+
+.dashboard-shell input[type='number']::-webkit-inner-spin-button,
+.dashboard-shell input[type='number']::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.dashboard-shell button {
+ transition: transform 160ms ease, border-color 160ms ease, background 160ms ease, color 160ms ease, opacity 160ms ease;
+}
+
+.dashboard-shell button:hover:not(:disabled) {
+ transform: translateY(-1px);
+}
+
+.dashboard-shell button:disabled {
+ cursor: not-allowed;
+}
+
+.is-hidden {
+ display: none !important;
+}
+
+.recent-pane,
+.logs-pane {
+ scrollbar-color: #2f2f38 transparent;
+}
+
+.config-panel {
+ align-self: start;
+}
+
+@media (min-width: 1280px) {
+ .config-panel {
+ max-height: calc(100vh - 17rem);
+ overflow: hidden;
+ }
+
+ .logs-pane {
+ height: 148px;
+ }
+}
+
+@media (max-width: 1279px) {
+ .dashboard-shell {
+ width: min(100%, 100%);
+ }
+}
+
+@media (max-width: 639px) {
+ .dashboard-shell {
+ padding-left: 10px;
+ padding-right: 10px;
+ }
+
+ .dashboard-shell input[type='text'],
+ .dashboard-shell input[type='number'],
+ .dashboard-shell select {
+ border-radius: 12px;
+ padding: 0.52rem 0.7rem;
+ }
+
+ .stat-card {
+ min-height: 80px;
+ }
+
+ .toggle-card {
+ min-height: 72px;
+ }
+}
diff --git a/dashboard/templates/index.html b/dashboard/templates/index.html
new file mode 100644
index 0000000..14fa3a3
--- /dev/null
+++ b/dashboard/templates/index.html
@@ -0,0 +1,361 @@
+
+
+
+
+ HEVC Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ Scanned Files
+
+
+
+ 0
+ Processed
+
+
+
+ 0
+ Ignored (Small)
+
+
+
+ 0
+ Pending Process
+
+
+
+ 0 B
+ Storage Saved
+
+
+
+ 0.0%
+ Avg. Compression
+
+
+
+
+
+
+
+
+
+
+
+
+ Active Processing
+
+
+
+
+
+
+
+ Recent Finished
+
+
+
+
+
+
+
+
Live Logs
+
+ 0 lines
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Auto keeps AAC as AAC, copies other audio, and uses no audio if the source has none.
+
+
+
+
+
+
Uses a Plex/Radarr/Sonarr-friendly name with title, year or episode, quality, and codec tags.
+
+
+
+
+
+
+
+
Always re-encode to HEVC and chosen audio, even if already HEVC/AAC.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Browse Directory
+
+
+ Drives:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/requirements.txt b/server/requirements.txt
new file mode 100644
index 0000000..3535b45
--- /dev/null
+++ b/server/requirements.txt
@@ -0,0 +1,2 @@
+flask>=3.0
+flask-cors>=4.0
diff --git a/server/worker.py b/server/worker.py
new file mode 100644
index 0000000..c2c58e6
--- /dev/null
+++ b/server/worker.py
@@ -0,0 +1,1237 @@
+#!/usr/bin/env python3
+"""
+HEVC Re-Encode Worker (SQLite version)
+======================================
+Architecture:
+ - SQLite database (media.db) tracks files and status.
+ - Phase 1: SCAN โ Walk local/mounted directories and probe codecs.
+ - Phase 2: PROCESS โ Encode/remux locally to HEVC MKV.
+"""
+
+import json
+import os
+import errno
+import re
+import shutil
+import sqlite3
+import subprocess
+import threading
+import time
+import urllib.request
+from collections import deque
+from concurrent.futures import Future, ThreadPoolExecutor
+from enum import Enum
+from typing import Optional
+
+DB_FILE = os.path.join(os.path.dirname(__file__), "media.db")
+
+
+class WorkerState(Enum):
+ IDLE = "idle"
+ SCANNING = "scanning"
+ RUNNING = "running"
+ PAUSED = "paused"
+ DONE = "done"
+
+
+class HEVCWorker:
+ def __init__(self):
+ self.state: WorkerState = WorkerState.IDLE
+
+ self.log_buffer: deque = deque(maxlen=500)
+ self._log_subscribers: list = []
+ self._log_lock = threading.Lock()
+
+ self.active_encodes: dict = {}
+ self._last_config: dict = {}
+
+ self._pause_event = threading.Event()
+ self._stop_event = threading.Event()
+ self._pause_event.set()
+
+ self._lock = threading.Lock()
+ self._worker_thread: Optional[threading.Thread] = None
+ self._ffprobe_cmd: Optional[str] = None
+ self._ffmpeg_cmd: Optional[str] = None
+ self._init_db()
+
+ def _init_db(self):
+ with sqlite3.connect(DB_FILE) as conn:
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS media (
+ filepath TEXT PRIMARY KEY,
+ basename TEXT,
+ original_size INTEGER,
+ codec TEXT,
+ container TEXT,
+ status TEXT,
+ saved_bytes INTEGER DEFAULT 0
+ )
+ """
+ )
+ conn.execute("UPDATE media SET status='pending' WHERE status IN ('encoding', 'remuxing', 'scanning')")
+ conn.execute(
+ "UPDATE media SET status='error' WHERE status='pending' AND codec='unknown' AND container='unknown'"
+ )
+
+ def _db_execute(self, query: str, params: tuple = ()):
+ with sqlite3.connect(DB_FILE) as conn:
+ conn.execute("PRAGMA journal_mode=WAL")
+ return conn.execute(query, params)
+
+ def _db_fetchone(self, query: str, params: tuple = ()):
+ with sqlite3.connect(DB_FILE) as conn:
+ return conn.execute(query, params).fetchone()
+
+ def _db_fetchall(self, query: str, params: tuple = ()):
+ with sqlite3.connect(DB_FILE) as conn:
+ return conn.execute(query, params).fetchall()
+
+ def _log(self, level: str, message: str):
+ entry = {
+ "time": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "level": level,
+ "message": message,
+ }
+ with self._log_lock:
+ self.log_buffer.append(entry)
+ for q in self._log_subscribers:
+ q.append(entry)
+ print(f"[{entry['time']}] [{level}] {message}", flush=True)
+
+ def subscribe_logs(self, q: deque):
+ with self._log_lock:
+ self._log_subscribers.append(q)
+
+ def unsubscribe_logs(self, q: deque):
+ with self._log_lock:
+ if q in self._log_subscribers:
+ self._log_subscribers.remove(q)
+
+ def _find_binary(self, base_name: str) -> Optional[str]:
+ candidates = [base_name]
+ if os.name == "nt":
+ candidates = [f"{base_name}.exe", base_name]
+ for candidate in candidates:
+ resolved = shutil.which(candidate)
+ if resolved:
+ return resolved
+ if os.name == "nt":
+ common_dirs = [
+ r"C:\ffmpeg\bin",
+ r"C:\Program Files\ffmpeg\bin",
+ r"C:\Program Files (x86)\ffmpeg\bin",
+ ]
+ for folder in common_dirs:
+ for candidate in candidates:
+ full = os.path.join(folder, candidate)
+ if os.path.isfile(full):
+ return full
+ return None
+
+ def _safe_move(self, src: str, dst: str) -> None:
+ """Move a file across filesystems safely without copying metadata.
+
+ Tries an atomic rename/replace first; on EXDEV (cross-device) falls back
+ to a copy using shutil.copyfile (not copy2) and removing the source.
+ """
+ if os.path.normpath(src) == os.path.normpath(dst):
+ return
+
+ last_error = None
+ for attempt in range(1, 4):
+ try:
+ # Prefer atomic replace when possible
+ os.replace(src, dst)
+ return
+ except OSError as e:
+ last_error = e
+
+ # If the destination is already there, try removing it first.
+ if os.path.exists(dst):
+ try:
+ os.remove(dst)
+ except Exception:
+ pass
+
+ # If it's not cross-device, try a simple rename as a fallback.
+ if getattr(e, "errno", None) != errno.EXDEV:
+ try:
+ os.rename(src, dst)
+ return
+ except Exception as rename_error:
+ last_error = rename_error
+
+ # For transient permission/lock issues, retry a few times.
+ if attempt < 3:
+ time.sleep(0.25 * attempt)
+
+ # Fallback: copy file contents only (no metadata) then remove source.
+ try:
+ shutil.copyfile(src, dst)
+ os.remove(src)
+ except Exception:
+ if last_error is not None:
+ raise last_error
+ raise
+
+ def _encoder_available(self, encoder_name: str) -> bool:
+ """Check whether ffmpeg supports the requested encoder (simple string match)."""
+ try:
+ ffmpeg_cmd = self._ffmpeg_cmd or "ffmpeg"
+ res = subprocess.run([ffmpeg_cmd, "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=10)
+ if res.returncode != 0:
+ return False
+ return encoder_name in res.stdout
+ except Exception:
+ return False
+
+ def _nvenc_preset(self, preset: str) -> str:
+ """Map generic preset names to NVENC preset names."""
+ preset = (preset or "medium").strip().lower()
+ mapping = {
+ "ultrafast": "p1",
+ "superfast": "p2",
+ "veryfast": "p3",
+ "faster": "p4",
+ "fast": "p5",
+ "medium": "p5",
+ "slow": "p6",
+ "slower": "p7",
+ "veryslow": "p7",
+ }
+ if preset.startswith("p") and preset[1:].isdigit():
+ return preset
+ return mapping.get(preset, "p5")
+
+ def _normalize_name_piece(self, text: str) -> str:
+ text = re.sub(r"[._]+", " ", text or "")
+ text = re.sub(r"\s+", " ", text)
+ return text.strip(" -._")
+
+ def _normalize_title_piece(self, text: str) -> str:
+ text = re.sub(r"\b(\d+(?:\.\d+){1,})\b", lambda match: match.group(1).replace(".", "-"), text or "")
+ text = re.sub(r"[._]+", " ", text or "")
+ text = re.sub(r"\s+", " ", text)
+ return text.strip(" -._")
+
+ def _canonical_audio_label(self, codec_name: str) -> str:
+ codec = (codec_name or "").strip().lower()
+ mapping = {
+ "aac": "AAC",
+ "ac3": "AC3",
+ "eac3": "EAC3",
+ "dts": "DTS",
+ "truehd": "TrueHD",
+ "mp3": "MP3",
+ "flac": "FLAC",
+ "opus": "Opus",
+ "vorbis": "Vorbis",
+ "pcm_s16le": "PCM",
+ "pcm_s24le": "PCM",
+ }
+ if codec in mapping:
+ return mapping[codec]
+ return codec.upper() if codec else ""
+
+ def _sanitize_release_tokens(self, text: str) -> list[str]:
+ raw = self._normalize_name_piece(text)
+ tokens: list[str] = []
+ patterns = [
+ (r"\b(2160p|1080p|720p|576p|480p|4k)\b", lambda m: m.group(1).lower()),
+ (r"\b(BluRay|Blu-Ray|WEB-DL|WEBRip|WEB Rip|BRRip|HDRip|HDTV|AMZN|NF|DSNP|ATVP|iTunes|REMUX|TELESYNC|CAM|DVD|DVDRip|UHD)\b", lambda m: m.group(1).replace(" ", "-")),
+ ]
+ for pattern, formatter in patterns:
+ for match in re.finditer(pattern, raw, flags=re.IGNORECASE):
+ token = formatter(match)
+ if token and token not in tokens:
+ tokens.append(token)
+ return tokens
+
+ def _build_output_stem(
+ self,
+ source_path: str,
+ source_video_codec: str,
+ source_audio_codec: str,
+ output_audio_codec: str,
+ rename_output: bool,
+ ) -> str:
+ base = os.path.splitext(os.path.basename(source_path))[0]
+ if not rename_output:
+ return base
+
+ raw = self._normalize_name_piece(base)
+
+ # TV shows: keep title + SxxEyy, and only add codec tags when the file is already final HEVC/AAC MKV.
+ tv_match = re.search(r"(?i)\bS(?P\d{1,2})E(?P\d{1,2})\b", raw)
+ alt_tv_match = re.search(r"(?i)\b(?P\d{1,2})x(?P\d{2})\b", raw)
+ if tv_match or alt_tv_match:
+ match = tv_match or alt_tv_match
+ season = int(match.group("season"))
+ episode = int(match.group("episode"))
+ title_part = re.sub(r"[\[\]\(\)]", " ", base[: match.start()])
+ title_part = self._normalize_title_piece(title_part)
+
+ out_parts = [title_part, f"S{season:02d}E{episode:02d}"]
+
+ source_video = (source_video_codec or "").strip().lower()
+ source_audio = (source_audio_codec or "").strip().lower()
+ if source_video == "hevc" and source_audio == "aac":
+ out_parts.append("HEVC")
+ out_parts.append("AAC")
+
+ return self._normalize_name_piece(" ".join([p for p in out_parts if p]))
+
+ # Movies: keep title + year, and only add codec tags when the file is already final HEVC/AAC MKV.
+ year_match = re.search(r"\b((?:19|20)\d{2})\b", raw)
+ title_part = raw
+ year_part = ""
+ if year_match:
+ year_part = year_match.group(1)
+ title_part = re.sub(r"[\[\]\(\)]", " ", base[: year_match.start()])
+ title_part = self._normalize_title_piece(title_part)
+ else:
+ # Strip bracket noise if there is no explicit year.
+ title_part = re.sub(r"\s*\[.*?\]\s*", " ", title_part)
+ title_part = re.sub(r"\s*\(.*?\)\s*", " ", title_part)
+ title_part = self._normalize_title_piece(title_part)
+
+ out_parts = [title_part]
+ if year_part:
+ out_parts.append(year_part)
+
+ source_video = (source_video_codec or "").strip().lower()
+ source_audio = (source_audio_codec or "").strip().lower()
+ if source_video == "hevc" and source_audio == "aac":
+ out_parts.append("HEVC")
+ out_parts.append("AAC")
+
+ return self._normalize_name_piece(" ".join([p for p in out_parts if p]))
+
+ def _build_output_path(
+ self,
+ source_path: str,
+ source_video_codec: str,
+ source_audio_codec: str,
+ output_audio_codec: str,
+ rename_output: bool,
+ ) -> str:
+ directory = os.path.dirname(source_path)
+ stem = self._build_output_stem(source_path, source_video_codec, source_audio_codec, output_audio_codec, rename_output)
+ candidate = os.path.join(directory, f"{stem}.mkv")
+ if os.path.normpath(candidate) == os.path.normpath(source_path):
+ return candidate
+
+ counter = 2
+ while os.path.exists(candidate):
+ candidate = os.path.join(directory, f"{stem} ({counter}).mkv")
+ counter += 1
+ if counter > 99:
+ break
+ return candidate
+
+ def _source_has_audio(self, audio_codec: str) -> bool:
+ return (audio_codec or "").strip().lower() not in {"", "unknown", "none"}
+
+ def _resolve_audio_codec(self, requested_codec: str, source_audio_codec: str) -> str:
+ requested = (requested_codec or "auto").strip().lower()
+ source = (source_audio_codec or "").strip().lower()
+
+ if not self._source_has_audio(source):
+ return "an"
+
+ if requested == "auto":
+ return "aac" if source == "aac" else "copy"
+
+ if requested in {"copy", "aac", "ac3"}:
+ return requested
+
+ return "copy"
+
+ def _get_subtitle_codecs(self, filepath: str) -> list:
+ """Return a list of subtitle codec names found in the file (lowercased)."""
+ try:
+ ffprobe_cmd = self._ffprobe_cmd or "ffprobe"
+ result = subprocess.run(
+ [
+ ffprobe_cmd,
+ "-v",
+ "quiet",
+ "-show_entries",
+ "stream=codec_type,codec_name",
+ "-of",
+ "json",
+ filepath,
+ ],
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if result.returncode != 0:
+ return []
+ data = json.loads(result.stdout or "{}")
+ codecs = []
+ for s in data.get("streams", []):
+ if s.get("codec_type") == "subtitle":
+ codecs.append((s.get("codec_name") or "").lower())
+ return codecs
+ except Exception:
+ return []
+
+ def _ensure_required_tools(self, config: dict = None, require_ffprobe: bool = False, require_ffmpeg: bool = False) -> bool:
+ config = config or {}
+ custom_ffprobe = config.get("custom_ffprobe", "").strip()
+ custom_ffmpeg = config.get("custom_ffmpeg", "").strip()
+
+ if require_ffprobe:
+ if custom_ffprobe:
+ self._ffprobe_cmd = custom_ffprobe
+ elif not self._ffprobe_cmd:
+ self._ffprobe_cmd = self._find_binary("ffprobe")
+
+ if not self._ffprobe_cmd or (custom_ffprobe and not os.path.isfile(self._ffprobe_cmd)):
+ ext = ".exe" if os.name == "nt" else ""
+ self._log(
+ "ERROR",
+ f"ffprobe not found or invalid path. Install FFmpeg and ensure ffprobe{ext} is available.",
+ )
+ return False
+
+ if require_ffmpeg:
+ if custom_ffmpeg:
+ self._ffmpeg_cmd = custom_ffmpeg
+ elif not self._ffmpeg_cmd:
+ self._ffmpeg_cmd = self._find_binary("ffmpeg")
+
+ if not self._ffmpeg_cmd or (custom_ffmpeg and not os.path.isfile(self._ffmpeg_cmd)):
+ ext = ".exe" if os.name == "nt" else ""
+ self._log(
+ "ERROR",
+ f"ffmpeg not found or invalid path. Install FFmpeg and ensure ffmpeg{ext} is available.",
+ )
+ return False
+
+ return True
+
+ def _parse_ffprobe_output(self, raw_json: str) -> tuple[str, str, str]:
+ data = json.loads(raw_json or "{}")
+ fmt = data.get("format", {}).get("format_name", "unknown").lower()
+ container = "mkv" if ("matroska" in fmt or "webm" in fmt) else (fmt.split(",")[0] if fmt else "unknown")
+ streams = data.get("streams", [])
+ v_codec = "unknown"
+ a_codec = "unknown"
+ for s in streams:
+ if s.get("codec_type") == "video" and v_codec == "unknown":
+ v_codec = s.get("codec_name", "unknown").lower()
+ elif s.get("codec_type") == "audio" and a_codec == "unknown":
+ a_codec = s.get("codec_name", "unknown").lower()
+ return v_codec, container, a_codec
+
+ def _get_video_info(self, filepath: str) -> tuple[str, str, str]:
+ try:
+ ffprobe_cmd = self._ffprobe_cmd or "ffprobe"
+ result = subprocess.run(
+ [
+ ffprobe_cmd,
+ "-v",
+ "quiet",
+ "-show_entries",
+ "stream=codec_type,codec_name:format=format_name",
+ "-of",
+ "json",
+ filepath,
+ ],
+ capture_output=True,
+ text=True,
+ timeout=90,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(result.stderr.strip() or "ffprobe failed")
+ return self._parse_ffprobe_output(result.stdout)
+ except Exception as exc:
+ self._log("ERROR", f"ffprobe check failed for {os.path.basename(filepath)}: {exc}")
+ return "unknown", "unknown", "unknown"
+
+ def _get_duration(self, filepath: str) -> Optional[float]:
+ try:
+ ffprobe_cmd = self._ffprobe_cmd or "ffprobe"
+ result = subprocess.run(
+ [
+ ffprobe_cmd,
+ "-v",
+ "quiet",
+ "-show_entries",
+ "format=duration",
+ "-of",
+ "json",
+ filepath,
+ ],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ if result.returncode != 0:
+ return None
+ data = json.loads(result.stdout)
+ return float(data["format"]["duration"])
+ except Exception:
+ return None
+
+ def _recommended_workers(self) -> int:
+ cpu_count = max(1, os.cpu_count() or 4)
+ if cpu_count >= 12:
+ rec = cpu_count // 2
+ elif cpu_count >= 6:
+ rec = cpu_count - 2
+ else:
+ rec = max(1, cpu_count - 1)
+ return max(1, min(12, rec))
+
+ def _run_scan(self, config: dict):
+ media_dir = str(config.get("media_dir") or "").strip()
+ if not media_dir:
+ self.state = WorkerState.IDLE
+ self._log("ERROR", "No media directory configured. Set one in the dashboard first.")
+ return
+ min_size_bytes = int(config.get("min_size_bytes", 0))
+ excluded_folders = config.get("excluded_folders", [])
+ configured_audio_codec = config.get("audio_codec", "auto").lower()
+ rename_output = bool(config.get("rename_output", False))
+
+ self.state = WorkerState.SCANNING
+ self._stop_event.clear()
+ self._pause_event.set()
+ self._log("INFO", f"Scan starting: {media_dir}")
+
+ if not os.path.isdir(media_dir):
+ self.state = WorkerState.IDLE
+ self._log("ERROR", f"Media directory not found: {media_dir}")
+ return
+
+ if not self._ensure_required_tools(config=config, require_ffprobe=True):
+ self.state = WorkerState.IDLE
+ return
+
+ video_extensions = {".mkv", ".mp4", ".avi", ".ts", ".m4v", ".mov", ".wmv"}
+ all_files: list[tuple[str, int]] = []
+
+ for root, _dirs, files in os.walk(media_dir):
+ rel_root = os.path.relpath(root, media_dir)
+ rel_parts = [] if rel_root == "." else [p.lower() for p in rel_root.split(os.sep)]
+ if any(excl.strip().lower() in rel_parts for excl in excluded_folders if excl and excl.strip()):
+ continue
+ for fname in files:
+ name_lower = fname.lower()
+ if ".hevc_tmp." in name_lower or ".remux_tmp." in name_lower:
+ continue
+ if os.path.splitext(fname)[1].lower() in video_extensions:
+ full = os.path.join(root, fname)
+ try:
+ all_files.append((full, os.path.getsize(full)))
+ except OSError:
+ continue
+
+ all_files.sort(key=lambda x: x[0].lower())
+ self._log("INFO", f"Found {len(all_files)} potential media files. Checking database...")
+
+ # Sync database with files on disk (remove obsolete entries)
+ seen_files = set(f[0] for f in all_files)
+ try:
+ db_entries = self._db_fetchall("SELECT filepath FROM media")
+ for row in db_entries:
+ if not row:
+ continue
+ db_path = row[0]
+ if db_path not in seen_files:
+ self._db_execute("DELETE FROM media WHERE filepath = ?", (db_path,))
+ except Exception as e:
+ self._log("WARN", f"Could not sync database entries with disk: {e}")
+
+ new_count = 0
+ ignored_count = 0
+ probe_error_count = 0
+
+ for i, entry in enumerate(all_files, 1):
+ try:
+ filepath, file_size = entry
+ except Exception:
+ # Skip malformed entries
+ continue
+ if self._stop_event.is_set():
+ break
+
+ basename = os.path.basename(filepath)
+ row = self._db_fetchone("SELECT status, codec, container FROM media WHERE filepath = ?", (filepath,))
+
+ if row:
+ status, codec, container = row
+ # Re-probe if previous attempt was failed / unknown
+ if codec == "unknown" or container == "unknown":
+ self._log("INFO", f"[{i}/{len(all_files)}] Re-ffprobe: {basename}")
+ codec, container, a_codec = self._get_video_info(filepath)
+ if codec == "unknown" and container == "unknown":
+ probe_error_count += 1
+ self._db_execute(
+ "UPDATE media SET status = 'error', codec = 'unknown', container = 'unknown' WHERE filepath = ?",
+ (filepath,)
+ )
+ continue
+ else:
+ self._db_execute(
+ "UPDATE media SET codec = ?, container = ? WHERE filepath = ?",
+ (codec, container, filepath)
+ )
+
+ # Pull the audio codec for re-evaluation and rename decisions.
+ _, _, a_codec = self._get_video_info(filepath)
+ resolved_audio_codec = self._resolve_audio_codec(configured_audio_codec, a_codec)
+ if resolved_audio_codec == "an":
+ audio_ok = not self._source_has_audio(a_codec)
+ elif resolved_audio_codec == "copy":
+ audio_ok = self._source_has_audio(a_codec)
+ else:
+ audio_ok = (a_codec == resolved_audio_codec)
+ is_hevc_mkv = (codec == "hevc" and container == "mkv")
+ expected_stem = self._build_output_stem(filepath, codec, a_codec, resolved_audio_codec, rename_output)
+ current_stem = os.path.splitext(os.path.basename(filepath))[0]
+ needs_rename = rename_output and expected_stem.lower() != current_stem.lower()
+
+ if rename_output and needs_rename:
+ final_path = self._build_output_path(filepath, codec, a_codec, resolved_audio_codec, rename_output)
+ try:
+ self._safe_move(filepath, final_path)
+ self._db_execute(
+ "UPDATE media SET filepath = ?, basename = ? WHERE filepath = ?",
+ (final_path, os.path.basename(final_path), filepath),
+ )
+ self._log("SUCCESS", f"Renamed {basename} -> {os.path.basename(final_path)}")
+ filepath = final_path
+ basename = os.path.basename(final_path)
+ current_stem = os.path.splitext(basename)[0]
+ needs_rename = False
+ except Exception as e:
+ self._log("ERROR", f"Failed to rename {filepath} during scan: {e}")
+ self._db_execute("UPDATE media SET status = 'pending' WHERE filepath = ?", (filepath,))
+
+ # Re-evaluate status based on config for pending/ignored/done files.
+ if status in ("pending", "ignored", "done"):
+ if is_hevc_mkv and audio_ok and not needs_rename:
+ new_status = "done"
+ else:
+ new_status = "pending"
+
+ if status != new_status:
+ self._db_execute("UPDATE media SET status = ? WHERE filepath = ?", (new_status, filepath))
+ status = new_status
+
+ # Count based on final status
+ if status == "pending":
+ new_count += 1
+ elif status == "ignored":
+ ignored_count += 1
+ elif status == "error":
+ probe_error_count += 1
+ else:
+ self._log("INFO", f"[{i}/{len(all_files)}] ffprobe: {basename}")
+ codec, container, a_codec = self._get_video_info(filepath)
+ if codec == "unknown" and container == "unknown":
+ probe_error_count += 1
+ self._db_execute(
+ "INSERT INTO media (filepath, basename, original_size, codec, container, status) VALUES (?, ?, ?, ?, ?, ?)",
+ (filepath, basename, int(file_size), codec, container, "error"),
+ )
+ continue
+
+ is_hevc_mkv = (codec == "hevc" and container == "mkv")
+ resolved_audio_codec = self._resolve_audio_codec(configured_audio_codec, a_codec)
+ if resolved_audio_codec == "an":
+ audio_ok = not self._source_has_audio(a_codec)
+ elif resolved_audio_codec == "copy":
+ audio_ok = self._source_has_audio(a_codec)
+ else:
+ audio_ok = (a_codec == resolved_audio_codec)
+
+ expected_stem = self._build_output_stem(filepath, codec, a_codec, resolved_audio_codec, rename_output)
+ current_stem = os.path.splitext(os.path.basename(filepath))[0]
+ needs_rename = rename_output and expected_stem.lower() != current_stem.lower()
+
+ if rename_output and needs_rename:
+ final_path = self._build_output_path(filepath, codec, a_codec, resolved_audio_codec, rename_output)
+ try:
+ self._safe_move(filepath, final_path)
+ filepath = final_path
+ basename = os.path.basename(final_path)
+ current_stem = os.path.splitext(basename)[0]
+ needs_rename = False
+ self._log("SUCCESS", f"Renamed {entry[0].split(os.sep)[-1]} -> {basename}")
+ except Exception as e:
+ self._log("ERROR", f"Failed to rename {filepath} during scan: {e}")
+ needs_rename = False
+
+ if is_hevc_mkv and audio_ok and not needs_rename:
+ status = "done"
+ else:
+ status = "pending"
+ new_count += 1
+
+ self._db_execute(
+ "INSERT INTO media (filepath, basename, original_size, codec, container, status) VALUES (?, ?, ?, ?, ?, ?)",
+ (filepath, basename, int(file_size), codec, container, status),
+ )
+
+ self.state = WorkerState.IDLE if self._stop_event.is_set() else WorkerState.DONE
+ self._log(
+ "SUCCESS",
+ f"Scan finished. Added {new_count} files to process, ignored {ignored_count} small files, probe errors {probe_error_count}.",
+ )
+
+ def _run_process(self, config: dict):
+ if not self._ensure_required_tools(config=config, require_ffmpeg=True):
+ self.state = WorkerState.IDLE
+ return
+
+ requested_threads = config.get("threads")
+ max_workers = int(requested_threads) if requested_threads else self._recommended_workers()
+ max_workers = max(1, min(max_workers, max(1, os.cpu_count() or max_workers)))
+ preset = config.get("preset", "medium")
+ crf = int(config.get("crf", 22))
+ discord_webhook = str(config.get("discord_webhook") or "").strip()
+ temp_dir = str(config.get("temp_dir") or "").strip()
+ encoder = config.get("encoder", "libx265")
+ audio_codec = config.get("audio_codec", "aac")
+ rename_output = bool(config.get("rename_output", False))
+ force_reencode = bool(config.get("force_reencode", False))
+ if not temp_dir:
+ self.state = WorkerState.IDLE
+ self._log("ERROR", "No temporary directory configured. Set one in the dashboard first.")
+ return
+
+ self.state = WorkerState.RUNNING
+ self._stop_event.clear()
+ self._pause_event.set()
+ self._last_config = dict(config)
+ self._discord_webhook = discord_webhook # expose to worker threads
+
+ queue = self._db_fetchall("SELECT filepath, original_size, codec, container FROM media WHERE status = 'pending'")
+ if not queue:
+ self.state = WorkerState.IDLE
+ self._log("INFO", "Nothing to process โ no pending files in database.")
+ return
+
+ self._log("INFO", f"Processing {len(queue)} pending files | threads={max_workers} | preset={preset} | crf={crf}")
+ min_size_bytes = int(config.get("min_size_bytes", 0))
+
+ with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="encode") as executor:
+ futures: dict[Future, str] = {}
+
+ for row in queue:
+ if not row or len(row) < 4:
+ self._log("WARN", f"Skipping malformed DB row in queue: {row}")
+ continue
+ filepath, size, codec, container = row
+ if self._stop_event.is_set():
+ break
+ while not self._pause_event.is_set():
+ if self._stop_event.is_set():
+ break
+ time.sleep(0.5)
+ if self._stop_event.is_set():
+ break
+
+ future = executor.submit(
+ self._process_file,
+ filepath,
+ size,
+ codec,
+ container,
+ preset,
+ crf,
+ min_size_bytes,
+ temp_dir,
+ encoder,
+ audio_codec,
+ rename_output,
+ force_reencode,
+ )
+ futures[future] = filepath
+
+ for future in futures:
+ filepath = futures[future]
+ try:
+ success = future.result()
+ if not success and not self._stop_event.is_set():
+ self._db_execute("UPDATE media SET status = 'error' WHERE filepath = ?", (filepath,))
+ except Exception as exc:
+ self._log("ERROR", f"Thread raised exception for {os.path.basename(filepath)}: {exc}")
+ self._db_execute("UPDATE media SET status = 'error' WHERE filepath = ?", (filepath,))
+
+ if self._stop_event.is_set():
+ self.state = WorkerState.IDLE
+ self._log("INFO", "Worker stopped.")
+ self._db_execute("UPDATE media SET status = 'pending' WHERE status = 'encoding'")
+ else:
+ self.state = WorkerState.DONE
+ self._log("SUCCESS", "Processing queue finished!")
+
+ def _process_file(
+ self,
+ filepath: str,
+ original_size: int,
+ codec: str,
+ container: str,
+ preset: str,
+ crf: int,
+ min_size_bytes: int,
+ temp_dir: str,
+ encoder: str = "libx265",
+ audio_codec: str = "auto",
+ rename_output: bool = False,
+ force_reencode: bool = False,
+ ) -> bool:
+ self._db_execute("UPDATE media SET status = 'encoding' WHERE filepath = ?", (filepath,))
+ basename = os.path.basename(filepath)
+ ffmpeg_cmd = self._ffmpeg_cmd or "ffmpeg"
+
+ # Determine temp directory and check if writeable
+ use_custom_temp = False
+ if temp_dir:
+ try:
+ os.makedirs(temp_dir, exist_ok=True)
+ test_file = os.path.join(temp_dir, f".write_test_{os.getpid()}")
+ with open(test_file, "w") as f:
+ f.write("test")
+ os.remove(test_file)
+ use_custom_temp = True
+ except Exception as e:
+ self._log("WARN", f"Custom temporary directory '{temp_dir}' is not writeable, using source directory. Error: {e}")
+
+ # Unique file ID to prevent collisions
+ import uuid
+ unique_id = uuid.uuid4().hex
+
+ _v_codec, _c_container, a_codec = self._get_video_info(filepath)
+
+ # Resolve audio mode based on the source stream and user selection.
+ resolved_audio_codec = self._resolve_audio_codec(audio_codec, a_codec)
+ if resolved_audio_codec == "an":
+ audio_ok = not self._source_has_audio(a_codec)
+ elif resolved_audio_codec == "copy":
+ audio_ok = self._source_has_audio(a_codec)
+ else:
+ audio_ok = (a_codec == resolved_audio_codec)
+ is_hevc_video = (codec == "hevc")
+
+ final_path = self._build_output_path(filepath, codec, a_codec, resolved_audio_codec, rename_output)
+ needs_rename_only = rename_output and os.path.normpath(final_path) != os.path.normpath(filepath) and is_hevc_video and container == "mkv" and audio_ok
+ needs_remux_only = container != "mkv" and is_hevc_video and audio_ok
+
+ if needs_rename_only:
+ self._log("INFO", f"Renaming file: {basename}")
+ try:
+ self._safe_move(filepath, final_path)
+ except Exception as e:
+ self._log("ERROR", f"Failed to rename {filepath} to {final_path}: {e}")
+ return False
+
+ self._db_execute(
+ "UPDATE media SET status = 'done', filepath = ?, basename = ? WHERE filepath = ?",
+ (final_path, os.path.basename(final_path), filepath),
+ )
+ self._log("SUCCESS", f"Renamed {basename} -> {os.path.basename(final_path)}")
+ return True
+
+ if needs_remux_only:
+ self._log("INFO", f"Remuxing file to MKV: {basename}")
+ if use_custom_temp:
+ temp_path = os.path.join(temp_dir, f"{unique_id}.mkv")
+ else:
+ temp_path = os.path.splitext(filepath)[0] + ".remux_tmp.mkv"
+
+ with self._lock:
+ self.active_encodes[basename] = {
+ "file": basename,
+ "fps": 0,
+ "speed": "N/A",
+ "bitrate": "N/A",
+ "progress": 0.0,
+ "eta": "remuxing...",
+ "original_size": original_size,
+ "original_size_human": _human_size(original_size),
+ "duration": 0,
+ }
+
+ ret = subprocess.run(
+ [ffmpeg_cmd, "-y", "-i", filepath, "-c", "copy", temp_path],
+ capture_output=True,
+ text=True,
+ )
+
+ with self._lock:
+ self.active_encodes.pop(basename, None)
+
+ if ret.returncode == 0 and os.path.exists(temp_path) and os.path.getsize(temp_path) > 0:
+ # Probe to confirm it's mkv
+ probe_codec, probe_container, probe_acodec = self._get_video_info(temp_path)
+ if probe_container == "mkv":
+ try:
+ if os.path.exists(filepath) and filepath != final_path:
+ os.remove(filepath)
+ except Exception as e:
+ self._log("ERROR", f"Failed to remove original file {filepath}: {e}")
+ try:
+ # Use safe move to handle cross-filesystem moves without metadata copy issues
+ self._safe_move(temp_path, final_path)
+ except Exception as e:
+ self._log("ERROR", f"CRITICAL: Failed to move temp file {temp_path} to final path {final_path}: {e}")
+ self._log("ERROR", f"Media file remains saved at: {temp_path}")
+ return False
+
+ new_size = os.path.getsize(final_path)
+ saved = original_size - new_size
+ self._db_execute(
+ "UPDATE media SET status = 'done', container = 'mkv', saved_bytes = ?, filepath = ?, basename = ? WHERE filepath = ?",
+ (saved, final_path, os.path.basename(final_path), filepath),
+ )
+ self._log("SUCCESS", f"Remuxed {basename} to MKV")
+ return True
+
+ self._log("ERROR", f"Remux failed for {basename}")
+ if os.path.exists(temp_path):
+ try:
+ os.remove(temp_path)
+ except OSError:
+ pass
+ return False
+
+ # If already HEVC MKV with acceptable audio, skip unless force_reencode requested
+ if is_hevc_video and container == "mkv" and audio_ok and not force_reencode:
+ self._db_execute("UPDATE media SET status = 'done' WHERE filepath = ?", (filepath,))
+ return True
+
+ if use_custom_temp:
+ temp_path = os.path.join(temp_dir, f"{unique_id}.mkv")
+ else:
+ temp_path = os.path.splitext(filepath)[0] + ".hevc_tmp.mkv"
+ duration = self._get_duration(filepath)
+
+ with self._lock:
+ self.active_encodes[basename] = {
+ "file": basename,
+ "fps": 0.0,
+ "speed": "0x",
+ "bitrate": "0kbits/s",
+ "progress": 0.0,
+ "eta": "calculating...",
+ "original_size": original_size,
+ "original_size_human": _human_size(original_size),
+ "duration": duration or 0,
+ }
+
+ # If requested hardware encoder is not available, fall back to libx265
+ if encoder and encoder != "libx265":
+ if not self._encoder_available(encoder):
+ self._log("WARN", f"Requested encoder '{encoder}' not available on this system. Falling back to libx265.")
+ encoder = "libx265"
+
+ v_opts = ["-c:v", encoder]
+ if encoder == "hevc_nvenc":
+ nvenc_preset = self._nvenc_preset(preset)
+ v_opts.extend([
+ "-preset", nvenc_preset,
+ "-rc", "vbr_hq",
+ "-cq", str(crf),
+ "-b:v", "0",
+ "-rc-lookahead", "32",
+ "-multipass", "fullres",
+ ])
+ elif encoder == "hevc_amf":
+ v_opts.extend(["-rc", "0", "-qv", str(crf), "-quality", preset])
+ elif encoder == "hevc_qsv":
+ v_opts.extend(["-global_quality", str(crf), "-preset", preset])
+ else:
+ v_opts.extend(["-crf", str(crf), "-preset", preset])
+
+ cmd = [
+ ffmpeg_cmd,
+ "-y",
+ "-i",
+ filepath,
+ ] + v_opts + [
+ ]
+
+ if resolved_audio_codec == "an":
+ cmd += ["-an"]
+ else:
+ cmd += ["-c:a", resolved_audio_codec]
+
+ # Detect problematic subtitle codecs (e.g., mov_text) and drop subtitles if present
+ subtitle_codecs = self._get_subtitle_codecs(filepath)
+ problematic_subs = {"mov_text"}
+ if any(sc in problematic_subs for sc in subtitle_codecs):
+ self._log("WARN", f"Dropping unsupported subtitle codecs ({', '.join(subtitle_codecs)}) for {basename}")
+ cmd += ["-sn"]
+ else:
+ cmd += ["-c:s", "copy"]
+
+ cmd += [
+ "-progress",
+ "pipe:1",
+ "-nostats",
+ "-loglevel",
+ "error",
+ temp_path,
+ ]
+
+ process = subprocess.Popen(
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, text=True, bufsize=1
+ )
+
+ progress_buf: dict = {}
+ for raw_line in process.stdout:
+ if self._stop_event.is_set():
+ process.kill()
+ break
+
+ line = raw_line.strip()
+ if "=" not in line:
+ continue
+ key, _, val = line.partition("=")
+ progress_buf[key.strip()] = val.strip()
+
+ if key.strip() == "progress":
+ fps = _safe_float(progress_buf.get("fps", "0"))
+ speed_str = progress_buf.get("speed", "0x") or "0x"
+ bitrate = progress_buf.get("bitrate", "0kbits/s") or "0kbits/s"
+
+ try:
+ out_us = int(progress_buf.get("out_time_us", "0"))
+ except (ValueError, TypeError):
+ out_us = 0
+
+ time_done = out_us / 1_000_000
+
+ pct = 0.0
+ eta = "calculating..."
+ if duration and duration > 0:
+ pct = min(100.0, (time_done / duration) * 100.0)
+ speed_val = _safe_float(speed_str.replace("x", ""))
+ if speed_val > 0:
+ remaining = (duration - time_done) / speed_val
+ eta = f"{int(remaining // 60)}m {int(remaining % 60)}s"
+
+ with self._lock:
+ if basename in self.active_encodes:
+ self.active_encodes[basename].update(
+ {
+ "fps": round(fps, 1),
+ "speed": speed_str,
+ "bitrate": bitrate,
+ "progress": round(pct, 1),
+ "eta": eta,
+ }
+ )
+ progress_buf = {}
+
+ process.wait()
+
+ with self._lock:
+ self.active_encodes.pop(basename, None)
+
+ if self._stop_event.is_set():
+ if os.path.exists(temp_path):
+ os.remove(temp_path)
+ return False
+
+ if process.returncode != 0 or not os.path.exists(temp_path):
+ self._log("ERROR", f"Encode failed (exit {process.returncode}): {basename}")
+ if os.path.exists(temp_path):
+ os.remove(temp_path)
+ return False
+
+ new_size = os.path.getsize(temp_path)
+ if new_size >= original_size:
+ self._log("WARN", f"New file is NOT smaller โ keeping anyway: {basename}")
+
+ # Confirm it's hevc and mkv
+ probe_codec, probe_container, _probe_audio_codec = self._get_video_info(temp_path)
+ if probe_codec == "hevc" and probe_container == "mkv" and new_size > 0:
+ try:
+ if os.path.exists(filepath) and filepath != final_path:
+ os.remove(filepath)
+ except Exception as e:
+ self._log("ERROR", f"Failed to delete original file {filepath}: {e}")
+
+ try:
+ # Use safe move to handle cross-filesystem moves without metadata copy issues
+ self._safe_move(temp_path, final_path)
+ except Exception as e:
+ self._log("ERROR", f"CRITICAL: Failed to move temp file {temp_path} to final path {final_path}: {e}")
+ self._log("ERROR", f"Media file remains saved at: {temp_path}")
+ return False
+
+ saved_bytes = max(0, original_size - new_size)
+ if new_size >= original_size:
+ self._log("WARN", f"Encoded output is larger than source for {basename}; recording 0 B saved.")
+ self._log("SUCCESS", f"Encoded {basename} -> Saved {_human_size(saved_bytes)}")
+ self._db_execute(
+ "UPDATE media SET status = 'done', codec = 'hevc', container = 'mkv', saved_bytes = ?, filepath = ?, basename = ? WHERE filepath = ?",
+ (saved_bytes, final_path, os.path.basename(final_path), filepath),
+ )
+ # Fire Discord webhook if configured
+ discord_webhook = getattr(self, "_discord_webhook", "")
+ if discord_webhook:
+ _send_discord_webhook(discord_webhook, os.path.basename(final_path), saved_bytes)
+ return True
+ else:
+ self._log("ERROR", f"Verification failed for encoded file {basename}. Codec: {probe_codec}, Container: {probe_container}, Size: {new_size} B. Discarding.")
+ if os.path.exists(temp_path):
+ os.remove(temp_path)
+ return False
+
+ def start_scan(self, config: dict):
+ if self._worker_thread and self._worker_thread.is_alive():
+ return {"error": "Worker already running"}
+ self._worker_thread = threading.Thread(target=self._run_scan, args=(config,), daemon=True, name="hevc-scan")
+ self._worker_thread.start()
+ return {"ok": True}
+
+ def start_process(self, config: dict):
+ if self._worker_thread and self._worker_thread.is_alive():
+ return {"error": "Worker already running"}
+ self._worker_thread = threading.Thread(target=self._run_process, args=(config,), daemon=True, name="hevc-process")
+ self._worker_thread.start()
+ return {"ok": True}
+
+ def pause(self):
+ self._pause_event.clear()
+ self.state = WorkerState.PAUSED
+ self._log("INFO", "Pausing โ current encodes finish before queue halts.")
+
+ def resume(self):
+ self._pause_event.set()
+ if self._worker_thread and self._worker_thread.is_alive():
+ self.state = WorkerState.RUNNING
+ self._log("INFO", "Resumed.")
+
+ def stop(self):
+ self._stop_event.set()
+ self._pause_event.set()
+ self._log("INFO", "Stop signal sent.")
+
+ def clear_database(self):
+ with sqlite3.connect(DB_FILE) as conn:
+ conn.execute("DELETE FROM media")
+ with self._lock:
+ self.active_encodes.clear()
+ self._log("INFO", "Database cleared.")
+
+ def get_status(self) -> dict:
+ with self._lock:
+ state_val = self.state.value
+
+ total = self._db_fetchone("SELECT COUNT(*) FROM media")[0]
+ pending = self._db_fetchone("SELECT COUNT(*) FROM media WHERE status='pending'")[0]
+ done = self._db_fetchone("SELECT COUNT(*) FROM media WHERE status='done'")[0]
+ ignored = self._db_fetchone("SELECT COUNT(*) FROM media WHERE status='ignored'")[0]
+ saved = self._db_fetchone("SELECT SUM(CASE WHEN saved_bytes > 0 THEN saved_bytes ELSE 0 END) FROM media")[0] or 0
+
+ # Additional stats for redesign
+ total_original_bytes = self._db_fetchone("SELECT SUM(original_size) FROM media")[0] or 0
+ done_original_bytes = self._db_fetchone("SELECT SUM(original_size) FROM media WHERE status='done'")[0] or 0
+
+ compression_ratio = 0.0
+ if done_original_bytes > 0:
+ compression_ratio = round((saved / done_original_bytes) * 100, 1)
+
+ recent = self._db_fetchall("SELECT basename, saved_bytes FROM media WHERE status='done' ORDER BY rowid DESC LIMIT 20")
+ recent_done = [{"file": r[0], "saved_human": _human_size(max(0, r[1] or 0))} for r in recent]
+
+ with self._lock:
+ active = list(self.active_encodes.values())
+
+ return {
+ "state": state_val,
+ "recommended_threads": self._recommended_workers(),
+ "total_files": total,
+ "queued_count": pending,
+ "done_count": done,
+ "skipped_count": ignored,
+ "total_saved_bytes": saved,
+ "total_saved_human": _human_size(saved),
+ "total_original_bytes": total_original_bytes,
+ "total_original_human": _human_size(total_original_bytes),
+ "done_original_bytes": done_original_bytes,
+ "done_original_human": _human_size(done_original_bytes),
+ "compression_ratio": compression_ratio,
+ "active_encodes": active,
+ "recent_done": recent_done,
+ }
+
+ def get_logs(self, since_index: int = 0) -> list:
+ with self._log_lock:
+ return list(self.log_buffer)[since_index:]
+
+
+def _human_size(n: int) -> str:
+ for unit in ("B", "KB", "MB", "GB", "TB"):
+ if n < 1024:
+ return f"{n:.1f} {unit}"
+ n /= 1024
+ return f"{n:.1f} PB"
+
+
+def _safe_float(s, default=0.0) -> float:
+ try:
+ return float(s)
+ except (TypeError, ValueError):
+ return default
+
+
+def _send_discord_webhook(webhook_url: str, filename: str, saved_bytes: int):
+ try:
+ import datetime
+ timestamp = datetime.datetime.utcnow().isoformat() + "Z"
+ payload = {
+ "embeds": [
+ {
+ "title": "๐ฅ HEVC Process Completed",
+ "color": 3066993, # Emerald green (#2ECC71)
+ "description": f"Successfully processed and compressed **{filename}**.",
+ "fields": [
+ {
+ "name": "File Name",
+ "value": f"`{filename}`",
+ "inline": False
+ },
+ {
+ "name": "Space Saved",
+ "value": f"**{_human_size(saved_bytes)}**",
+ "inline": True
+ }
+ ],
+ "timestamp": timestamp
+ }
+ ]
+ }
+ data = json.dumps(payload).encode("utf-8")
+ req = urllib.request.Request(
+ webhook_url,
+ data=data,
+ headers={
+ "Content-Type": "application/json",
+ "User-Agent": "HEVC-Dashboard-Worker"
+ },
+ method="POST"
+ )
+ with urllib.request.urlopen(req, timeout=10) as response:
+ pass
+ except Exception as e:
+ try:
+ worker._log("WARN", f"Failed to send Discord webhook: {e}")
+ except Exception:
+ print(f"[Discord Webhook Error] Failed to send webhook: {e}", flush=True)
+
+
+worker = HEVCWorker()
+
diff --git a/server/worker_api.py b/server/worker_api.py
new file mode 100644
index 0000000..8dcfd81
--- /dev/null
+++ b/server/worker_api.py
@@ -0,0 +1,315 @@
+#!/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)
diff --git a/start_dashboard.bat b/start_dashboard.bat
new file mode 100644
index 0000000..13473c7
--- /dev/null
+++ b/start_dashboard.bat
@@ -0,0 +1,203 @@
+@echo off
+setlocal EnableExtensions EnableDelayedExpansion
+
+set "ROOT_DIR=%~dp0"
+set "SERVER_DIR=%ROOT_DIR%server"
+set "DASH_DIR=%ROOT_DIR%dashboard"
+set "SERVER_PY=%SERVER_DIR%\venv\Scripts\python.exe"
+set "DASH_PY=%DASH_DIR%\venv\Scripts\python.exe"
+set "CHOICE="
+set "WORKER_PID="
+
+call :banner
+call :check_deps
+
+if "%~1"=="" (
+ call :menu
+) else (
+ set "CHOICE=%~1"
+ call :dispatch
+)
+
+exit /b %ERRORLEVEL%
+
+:main
+exit /b 0
+
+:banner
+echo.
+echo HEVC Dashboard Launcher
+echo -----------------------
+echo Location: %ROOT_DIR%
+echo.
+exit /b 0
+
+:check_deps
+set "MISSING="
+for %%C in (python ffmpeg ffprobe) do (
+ where %%C >nul 2>&1
+ if errorlevel 1 (
+ set "MISSING=!MISSING! %%C"
+ echo %%C - MISSING
+ ) else (
+ echo %%C - OK
+ )
+)
+
+echo.
+if defined MISSING (
+ echo Missing:%MISSING%
+ where choco >nul 2>&1
+ if not errorlevel 1 (
+ echo Chocolatey found. Install the missing packages manually if needed.
+ ) else (
+ echo Please install the missing dependencies manually (python, ffmpeg, ffprobe)
+ )
+)
+exit /b 0
+
+:menu
+echo.
+echo Options:
+echo 1^) Setup venvs only
+echo 2^) Start Worker API only
+echo 3^) Start Dashboard only
+echo 4^) Start Worker API + Dashboard
+echo 5^) Cleanup venvs and pycache
+echo 6^) Exit
+echo.
+set /p "CHOICE=Choose an option [4]: "
+if not defined CHOICE set "CHOICE=4"
+call :dispatch
+exit /b %ERRORLEVEL%
+
+:dispatch
+if /I "%CHOICE%"=="--setup" set "CHOICE=1"
+if /I "%CHOICE%"=="--worker" set "CHOICE=2"
+if /I "%CHOICE%"=="--dashboard" set "CHOICE=3"
+if /I "%CHOICE%"=="--cleanup" set "CHOICE=5"
+
+if "%CHOICE%"=="1" (
+ call :setup_venvs
+ exit /b %ERRORLEVEL%
+)
+if "%CHOICE%"=="2" (
+ call :start_worker
+ exit /b %ERRORLEVEL%
+)
+if "%CHOICE%"=="3" (
+ call :start_dashboard
+ exit /b %ERRORLEVEL%
+)
+if "%CHOICE%"=="4" (
+ call :setup_venvs
+ if errorlevel 1 exit /b %ERRORLEVEL%
+ call :start_worker
+ if errorlevel 1 exit /b %ERRORLEVEL%
+ call :wait_for_worker
+ call :start_dashboard
+ echo.
+ pause
+ exit /b %ERRORLEVEL%
+)
+if "%CHOICE%"=="5" (
+ call :cleanup_workspace
+ echo Cleanup complete.
+ exit /b 0
+)
+if "%CHOICE%"=="6" (
+ echo Bye.
+ exit /b 0
+)
+
+echo Invalid choice: %CHOICE%
+exit /b 1
+
+:setup_venvs
+if not exist "%SERVER_DIR%" mkdir "%SERVER_DIR%"
+if not exist "%SERVER_DIR%\venv\Scripts\python.exe" (
+ echo Creating server venv...
+ python -m venv "%SERVER_DIR%\venv"
+ if not exist "%SERVER_DIR%\venv\Scripts\python.exe" (
+ echo Failed to create server virtual environment.
+ exit /b 1
+ )
+ "%SERVER_DIR%\venv\Scripts\python.exe" -m pip install -q -r "%SERVER_DIR%\requirements.txt"
+) else (
+ echo Server venv already exists. Skipping creation.
+)
+if not exist "%SERVER_DIR%\venv\Scripts\python.exe" (
+ echo Failed to create server virtual environment.
+ exit /b 1
+)
+
+if not exist "%DASH_DIR%" mkdir "%DASH_DIR%"
+if not exist "%DASH_DIR%\venv\Scripts\python.exe" (
+ echo Creating dashboard venv...
+ python -m venv "%DASH_DIR%\venv"
+ if not exist "%DASH_DIR%\venv\Scripts\python.exe" (
+ echo Failed to create dashboard virtual environment.
+ exit /b 1
+ )
+ "%DASH_DIR%\venv\Scripts\python.exe" -m pip install -q -r "%DASH_DIR%\requirements.txt"
+) else (
+ echo Dashboard venv already exists. Skipping creation.
+)
+if not exist "%DASH_DIR%\venv\Scripts\python.exe" (
+ echo Failed to create dashboard virtual environment.
+ exit /b 1
+)
+
+echo Virtual environments ready.
+exit /b 0
+
+:start_worker
+if not exist "%SERVER_PY%" (
+ echo Server venv missing. Run option 1 first.
+ exit /b 1
+)
+
+echo Starting Worker API (detached)...
+start "HEVC Worker API" /D "%SERVER_DIR%" cmd /c ""%SERVER_DIR%\venv\Scripts\activate.bat" && python worker_api.py --port 5001 > worker.log 2>&1"
+exit /b 0
+
+:wait_for_worker
+set "WORKER_READY="
+for /L %%I in (1,1,30) do (
+ powershell -NoProfile -Command "try { $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 1 http://127.0.0.1:5001/api/status; if ($r.StatusCode -eq 200) { exit 0 } } catch { exit 1 }" >nul 2>&1
+ if not errorlevel 1 (
+ set "WORKER_READY=1"
+ goto :wait_for_worker_done
+ )
+ timeout /t 1 /nobreak >nul
+)
+:wait_for_worker_done
+if not defined WORKER_READY (
+ echo Worker did not confirm readiness, continuing anyway.
+)
+exit /b 0
+
+:start_dashboard
+if not exist "%DASH_PY%" (
+ echo Dashboard venv missing. Run option 1 first.
+ exit /b 1
+)
+
+set "WORKER_URL=http://127.0.0.1:5001"
+start "" "http://localhost:5000"
+echo Starting Dashboard (foreground)... Press Ctrl+C to stop.
+"%DASH_PY%" "%DASH_DIR%\dashboard.py"
+exit /b 0
+
+:cleanup_workspace
+echo Stopping launcher-related Python processes...
+powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'worker_api\.py|dashboard\.py' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
+
+echo Removing server and dashboard venvs...
+if exist "%SERVER_DIR%\venv" rmdir /s /q "%SERVER_DIR%\venv"
+if exist "%DASH_DIR%\venv" rmdir /s /q "%DASH_DIR%\venv"
+
+echo Removing __pycache__ folders...
+powershell -NoProfile -Command "Get-ChildItem -Path '%SERVER_DIR%','%DASH_DIR%' -Directory -Recurse -Filter '__pycache__' -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue"
+
+exit /b 0
diff --git a/start_dashboard.sh b/start_dashboard.sh
new file mode 100644
index 0000000..2c76e0e
--- /dev/null
+++ b/start_dashboard.sh
@@ -0,0 +1,138 @@
+#!/usr/bin/env bash
+
+# Simple non-interactive hevc-dashboard launcher for Linux
+set -euo pipefail
+
+ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+SERVER_DIR="$ROOT_DIR/server"
+DASH_DIR="$ROOT_DIR/dashboard"
+
+usage() {
+ cat </dev/null 2>&1; }
+
+required=(python3 ffmpeg ffprobe)
+missing=()
+for cmd in "${required[@]}"; do
+ if ! check_cmd "$cmd"; then
+ missing+=("$cmd")
+ fi
+done
+
+if [ ${#missing[@]} -gt 0 ]; then
+ echo "Missing dependencies: ${missing[*]}"
+ echo "Please install them before proceeding."
+fi
+
+do_setup=false
+do_worker=false
+do_dashboard=false
+do_cleanup=false
+
+if [ "$#" -eq 0 ]; then
+ do_setup=true
+ do_worker=true
+ do_dashboard=true
+else
+ while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --setup) do_setup=true ;;
+ --worker) do_worker=true ;;
+ --dashboard) do_dashboard=true ;;
+ --cleanup) do_cleanup=true ;;
+ --help) usage; exit 0 ;;
+ *) echo "Unknown option: $1"; usage; exit 1 ;;
+ esac
+ shift
+ done
+fi
+
+if [ "$do_cleanup" = true ]; then
+ cleanup_workspace
+ exit 0
+fi
+
+setup_venvs() {
+ echo "Setting up server venv..."
+ mkdir -p "$SERVER_DIR"
+ pushd "$SERVER_DIR" >/dev/null
+ if [ ! -d venv ]; then
+ python3 -m venv venv
+ fi
+ source venv/bin/activate
+ pip install -q -r requirements.txt
+ deactivate
+ popd >/dev/null
+
+ echo "Setting up dashboard venv..."
+ pushd "$DASH_DIR" >/dev/null
+ if [ ! -d venv ]; then
+ python3 -m venv venv
+ fi
+ source venv/bin/activate
+ pip install -q -r requirements.txt
+ deactivate
+ popd >/dev/null
+ echo "Virtual environments ready."
+}
+
+cleanup_workspace() {
+ echo "Removing server and dashboard venvs..."
+ rm -rf "$SERVER_DIR/venv" "$DASH_DIR/venv"
+
+ echo "Removing __pycache__ folders..."
+ find "$SERVER_DIR" "$DASH_DIR" -type d -name '__pycache__' -prune -exec rm -rf {} +
+
+ echo "Cleanup complete."
+}
+
+start_worker() {
+ pushd "$SERVER_DIR" >/dev/null
+ source venv/bin/activate || true
+ echo "Starting Worker API..."
+ nohup python3 worker_api.py --port 5001 > worker.log 2>&1 &
+ WORKER_PID=$!
+ echo "Worker PID: $WORKER_PID"
+ popd >/dev/null
+}
+
+start_dashboard() {
+ pushd "$DASH_DIR" >/dev/null
+ source venv/bin/activate || true
+ export WORKER_URL="http://127.0.0.1:5001"
+ if command -v xdg-open >/dev/null 2>&1; then
+ (sleep 1 && xdg-open "http://localhost:5000" >/dev/null 2>&1 || true) &
+ fi
+ echo "Starting Dashboard (foreground)... Press Ctrl+C to stop."
+ python3 dashboard.py
+ popd >/dev/null
+}
+
+if [ "$do_setup" = true ]; then
+ setup_venvs
+fi
+
+if [ "$do_worker" = true ]; then
+ start_worker
+fi
+
+if [ "$do_dashboard" = true ]; then
+ start_dashboard
+fi
+
+if [ -n "${WORKER_PID-}" ]; then
+ trap 'echo "Stopping worker..."; kill "$WORKER_PID" 2>/dev/null || true' EXIT INT TERM
+fi
+
+exit 0