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 += ` +
+ + ${escAttr(d.name)} +
+ `; + }); + 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 + + + + + + + +
+ +
+
+
+ + + + +
+
+

HEVC ENCODER

+
+
+ + + + +
+ +
+
+ Worker API + {{ worker_url }} +
+ + +
+ + +
+ + IDLE +
+
+
+ + +
+ +
+ 0 + Scanned Files +
+ +
+ 0 + Processed +
+ +
+ 0 + Ignored (Small) +
+ +
+ 0 + Pending Process +
+ +
+ 0 B + Storage Saved +
+ +
+ 0.0% + Avg. Compression +
+
+ + +
+ + +
+ + +
+
+ Active Processing +
+
+
No active processes
+
+
+ + +
+
+ Recent Finished +
+
+
Nothing finished yet
+
+
+ + +
+
+ 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.

+
+ +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+ + +
+ + + + +
+ + +
+
+ +
+
+ + + + + + + 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