First Public Release!
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
flask>=3.0
|
||||||
|
requests>=2.31
|
||||||
@@ -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 = '<div class="text-zinc-500 py-6 text-center text-xs">No active processes</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '';
|
||||||
|
list.forEach(enc => {
|
||||||
|
html += `
|
||||||
|
<div class="bg-zinc-900/30 border border-zinc-800/80 rounded-xl p-4 flex flex-col gap-3">
|
||||||
|
<div class="flex justify-between items-start gap-4">
|
||||||
|
<div class="font-medium text-xs text-zinc-200 truncate flex-1" title="${escAttr(enc.file)}">${escAttr(enc.file)}</div>
|
||||||
|
<div class="text-xs font-semibold text-emerald-500 font-mono">${enc.progress}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-zinc-950 h-1 rounded-full overflow-hidden border border-zinc-900">
|
||||||
|
<div class="bg-emerald-600 h-full rounded-full transition-all duration-300" style="width: ${enc.progress}%;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-zinc-400 font-medium">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5 text-zinc-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||||
|
${enc.eta}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5 text-zinc-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
||||||
|
${enc.fps} fps
|
||||||
|
</span>
|
||||||
|
<span class="text-zinc-500">·</span>
|
||||||
|
<span>${enc.speed}</span>
|
||||||
|
<span class="text-zinc-500">·</span>
|
||||||
|
<span>${enc.bitrate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
elActiveList.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecentDone(list) {
|
||||||
|
if (!list || list.length === 0) {
|
||||||
|
elRecentList.innerHTML = '<div class="text-zinc-500 py-6 text-center text-xs">Nothing finished yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '';
|
||||||
|
list.forEach(item => {
|
||||||
|
html += `
|
||||||
|
<div class="flex justify-between items-center gap-4 p-3 bg-zinc-950/40 border border-zinc-900/60 rounded-xl hover:border-zinc-800 transition-colors">
|
||||||
|
<div class="text-xs text-zinc-300 font-medium truncate flex-1" title="${escAttr(item.file)}">${escAttr(item.file)}</div>
|
||||||
|
<div class="text-xs text-emerald-500 font-semibold font-mono bg-emerald-950/20 px-2 py-0.5 rounded border border-emerald-900/30 whitespace-nowrap">Saved ${item.saved_human}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
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 = `
|
||||||
|
<span class="text-zinc-650 select-none">${data.time.split(' ')[1]}</span>
|
||||||
|
<span class="w-16 shrink-0 uppercase tracking-wider font-bold text-[10px] ${levelColor}">${data.level}</span>
|
||||||
|
<span class="break-all flex-1 text-zinc-300">${escAttr(data.message)}</span>
|
||||||
|
`;
|
||||||
|
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)} <span class="tag-remove text-zinc-500 hover:text-white cursor-pointer font-bold text-[11px]">×</span>`;
|
||||||
|
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 = '<div class="p-5 text-zinc-500 text-xs font-semibold">Please select a drive.</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('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 = '<div class="p-5 text-zinc-500 text-xs font-semibold">Please select a drive.</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('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 = '<div class="p-5 text-zinc-500 text-xs font-semibold">Please select a drive.</div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('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 = '<div class="p-5 text-zinc-500 text-xs font-semibold">Loading...</div>';
|
||||||
|
try {
|
||||||
|
const res = await GET(`/proxy/browse?path=${encodeURIComponent(path)}`);
|
||||||
|
if (res.error) {
|
||||||
|
elModalBody.innerHTML = `<div class="p-5 text-rose-500 text-xs font-semibold">${escAttr(res.error)}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
elModalPath.value = res.path;
|
||||||
|
renderBreadcrumb(res.path, res.parent);
|
||||||
|
|
||||||
|
if (!res.dirs || res.dirs.length === 0) {
|
||||||
|
elModalBody.innerHTML = '<div class="p-5 text-zinc-500 text-xs font-semibold">No subdirectories found.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
res.dirs.forEach(d => {
|
||||||
|
html += `
|
||||||
|
<div class="dir-item px-4 py-2.5 text-xs text-zinc-300 rounded-xl hover:bg-zinc-900 hover:text-white flex items-center gap-3 cursor-pointer transition-colors" data-path="${escAttr(d.full_path)}">
|
||||||
|
<svg class="w-4 h-4 text-zinc-500 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
||||||
|
<span class="truncate">${escAttr(d.name)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
elModalBody.innerHTML = html;
|
||||||
|
|
||||||
|
elModalBody.querySelectorAll('.dir-item').forEach(el => {
|
||||||
|
el.addEventListener('click', () => loadDir(el.dataset.path));
|
||||||
|
});
|
||||||
|
} catch (exc) {
|
||||||
|
elModalBody.innerHTML = `<div class="p-5 text-rose-500 text-xs font-semibold">Error loading directory</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBreadcrumb(path, parent) {
|
||||||
|
let html = '';
|
||||||
|
if (parent) {
|
||||||
|
html += `<div class="px-6 py-2 border-b border-zinc-900 bg-zinc-900/30 text-xs font-semibold text-zinc-400 cursor-pointer hover:bg-zinc-900 hover:text-white transition-colors" id="crumb-up">↑ Up to ${escAttr(parent)}</div>`;
|
||||||
|
}
|
||||||
|
html += `<div class="px-6 py-2 text-xs font-mono font-medium text-emerald-500 break-all select-all">${escAttr(path)}</div>`;
|
||||||
|
elModalCrumb.innerHTML = html;
|
||||||
|
|
||||||
|
$('crumb-up')?.addEventListener('click', () => loadDir(parent));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-full bg-black">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>HEVC Dashboard</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', 'sans-serif'],
|
||||||
|
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&display=swap">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-black text-zinc-100 min-h-screen font-sans antialiased overflow-x-hidden selection:bg-emerald-500/30 selection:text-white">
|
||||||
|
|
||||||
|
<div class="dashboard-shell w-full mx-auto flex flex-col gap-2">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="surface flex flex-col xl:flex-row items-start xl:items-center justify-between gap-2 px-4 py-1.75">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-9 h-9 rounded-xl bg-zinc-950 border border-zinc-800 flex items-center justify-center text-zinc-200 shadow-[0_0_0_1px_rgba(255,255,255,0.03)]">
|
||||||
|
<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<rect x="2" y="7" width="20" height="15" rx="2" ry="2"></rect>
|
||||||
|
<polyline points="17 2 12 7 7 2"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="card-title text-sm font-bold tracking-[0.32em] text-white uppercase">HEVC ENCODER</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hardware Specs -->
|
||||||
|
<div class="hidden xl:flex items-center gap-6 text-xs border-l border-zinc-800/80 pl-6 mr-auto">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-[9px] uppercase font-bold text-zinc-500 tracking-wider">Host CPU</span>
|
||||||
|
<span class="text-zinc-300 font-medium mt-0.5" id="spec-cpu">Detecting CPU...</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-[9px] uppercase font-bold text-zinc-500 tracking-wider">Host GPU</span>
|
||||||
|
<span class="text-zinc-300 font-medium mt-0.5" id="spec-gpu">Detecting GPU...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<!-- Worker URL Panel -->
|
||||||
|
<div class="surface-soft rounded-2xl px-4 py-2 text-xs flex items-center gap-3">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-[9px] uppercase font-bold text-zinc-500 tracking-wider">Worker API</span>
|
||||||
|
<span class="font-mono text-zinc-300 mt-0.5" id="worker-url-display">{{ worker_url }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="text-zinc-500 hover:text-white transition-colors" id="btn-edit-url">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
||||||
|
</button>
|
||||||
|
<div id="url-editor" class="items-center gap-2 is-hidden">
|
||||||
|
<input type="text" id="worker-url-input" value="{{ worker_url }}" class="bg-black border border-zinc-800 rounded px-2 py-0.5 text-xs text-zinc-200 focus:outline-none">
|
||||||
|
<button class="bg-white hover:bg-zinc-200 text-black px-2 py-0.5 rounded text-[10px] font-bold uppercase transition-colors" id="btn-save-url">Save</button>
|
||||||
|
<button class="text-zinc-400 hover:text-white px-1 text-[10px]" id="btn-cancel-url">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Badge -->
|
||||||
|
<div class="status-badge px-4 py-2 border border-zinc-800 text-zinc-400 bg-zinc-950/80 rounded-2xl text-xs font-bold tracking-wider flex items-center gap-2" id="status-indicator" data-state="idle">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-current" id="status-dot"></span>
|
||||||
|
<span class="font-mono uppercase" id="status-text">IDLE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-1.5">
|
||||||
|
<!-- Card 1: Total Scanned -->
|
||||||
|
<div class="surface stat-card flex flex-col justify-between hover:border-zinc-700/80 transition-all">
|
||||||
|
<span class="text-2xl font-bold text-white tracking-tight" id="stat-total">0</span>
|
||||||
|
<span class="text-[9px] uppercase font-bold tracking-widest text-zinc-500 mt-2">Scanned Files</span>
|
||||||
|
</div>
|
||||||
|
<!-- Card 2: Processed -->
|
||||||
|
<div class="surface stat-card flex flex-col justify-between hover:border-zinc-700/80 transition-all">
|
||||||
|
<span class="text-2xl font-bold text-white tracking-tight" id="stat-done">0</span>
|
||||||
|
<span class="text-[9px] uppercase font-bold tracking-widest text-zinc-500 mt-2">Processed</span>
|
||||||
|
</div>
|
||||||
|
<!-- Card 3: Ignored -->
|
||||||
|
<div class="surface stat-card flex flex-col justify-between hover:border-zinc-700/80 transition-all">
|
||||||
|
<span class="text-2xl font-bold text-white tracking-tight" id="stat-skipped">0</span>
|
||||||
|
<span class="text-[9px] uppercase font-bold tracking-widest text-zinc-500 mt-2">Ignored (Small)</span>
|
||||||
|
</div>
|
||||||
|
<!-- Card 4: Queue Remaining -->
|
||||||
|
<div class="surface stat-card flex flex-col justify-between hover:border-zinc-700/80 transition-all">
|
||||||
|
<span class="text-2xl font-bold text-white tracking-tight" id="stat-queued">0</span>
|
||||||
|
<span class="text-[9px] uppercase font-bold tracking-widest text-zinc-500 mt-2">Pending Process</span>
|
||||||
|
</div>
|
||||||
|
<!-- Card 5: Saved Space -->
|
||||||
|
<div class="surface stat-card flex flex-col justify-between hover:border-zinc-700/80 transition-all">
|
||||||
|
<span class="text-2xl font-bold text-emerald-500 tracking-tight" id="stat-saved">0 B</span>
|
||||||
|
<span class="text-[9px] uppercase font-bold tracking-widest text-zinc-500 mt-2">Storage Saved</span>
|
||||||
|
</div>
|
||||||
|
<!-- Card 6: Average Savings Ratio -->
|
||||||
|
<div class="surface stat-card flex flex-col justify-between hover:border-zinc-700/80 transition-all">
|
||||||
|
<span class="text-2xl font-bold text-emerald-500 tracking-tight" id="stat-ratio">0.0%</span>
|
||||||
|
<span class="text-[9px] uppercase font-bold tracking-widest text-zinc-500 mt-2">Avg. Compression</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,1.7fr)_minmax(380px,0.95fr)] gap-2 items-start">
|
||||||
|
|
||||||
|
<!-- Left Column: Encoding Queue, Finished Log, Live Terminal -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
|
||||||
|
<!-- Active encodes -->
|
||||||
|
<div class="surface surface-soft overflow-hidden flex flex-col">
|
||||||
|
<div class="panel-head border-b border-zinc-850 px-4 py-3 flex items-center justify-between">
|
||||||
|
<span class="text-xs font-bold uppercase tracking-[0.24em] text-zinc-400">Active Processing</span>
|
||||||
|
</div>
|
||||||
|
<div class="p-2.5 flex flex-col gap-2 min-h-[64px]" id="active-encodes-container">
|
||||||
|
<div class="text-zinc-500 py-6 text-center text-xs">No active processes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Finished -->
|
||||||
|
<div class="surface surface-soft overflow-hidden flex flex-col">
|
||||||
|
<div class="panel-head border-b border-zinc-850 px-4 py-3 flex items-center justify-between">
|
||||||
|
<span class="text-xs font-bold uppercase tracking-[0.24em] text-zinc-400">Recent Finished</span>
|
||||||
|
</div>
|
||||||
|
<div class="recent-pane p-2.5 flex flex-col gap-2 overflow-y-auto min-h-[54px]" id="recent-finished-container">
|
||||||
|
<div class="text-zinc-500 py-6 text-center text-xs">Nothing finished yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terminal Logs -->
|
||||||
|
<div class="surface surface-soft overflow-hidden flex flex-col">
|
||||||
|
<div class="panel-head border-b border-zinc-850 px-4 py-3 flex items-center justify-between">
|
||||||
|
<span class="text-xs font-bold uppercase tracking-[0.24em] text-zinc-400">Live Logs</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span id="log-count" class="text-xs text-zinc-500 font-semibold">0 lines</span>
|
||||||
|
<button class="px-2.5 py-1 border border-zinc-800 hover:bg-zinc-900 text-zinc-400 hover:text-white rounded-lg text-[10px] font-bold uppercase transition-colors" id="btn-clear-logs">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="logs-pane p-2.5 bg-black/90 border-t border-zinc-900 overflow-y-auto flex flex-col gap-1 text-[11px] font-mono leading-relaxed" id="logs-container">
|
||||||
|
<!-- Logs stream in here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Settings & Actions Panel -->
|
||||||
|
<div class="surface config-panel p-2.5 flex flex-col gap-1.5 xl:max-h-[calc(100vh-19rem)] xl:overflow-hidden">
|
||||||
|
<div class="border-b border-zinc-850 pb-2 flex justify-between items-center">
|
||||||
|
<span class="text-xs font-bold uppercase tracking-[0.24em] text-zinc-400">Configuration</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-grid grid grid-cols-1 sm:grid-cols-2 gap-1">
|
||||||
|
<div class="flex flex-col gap-1 sm:col-span-2">
|
||||||
|
<label class="text-[9px] uppercase font-bold tracking-[0.24em] text-zinc-500">Media Directory</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" id="media-dir" value="{{ media_root }}" placeholder="Click Browse to select folder..." class="flex-1">
|
||||||
|
<button class="px-3 bg-zinc-900 border border-zinc-800 hover:bg-zinc-800 text-zinc-300 rounded-2xl text-xs font-semibold transition-colors flex items-center gap-1" id="btn-browse">
|
||||||
|
<svg class="w-3.5 h-3.5 text-zinc-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 sm:col-span-2">
|
||||||
|
<label class="text-[9px] uppercase font-bold tracking-[0.24em] text-zinc-500">Temporary Directory</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" id="temp-dir" value="{{ temp_dir }}" placeholder="Click Browse to select folder..." class="flex-1">
|
||||||
|
<button class="px-3 bg-zinc-900 border border-zinc-800 hover:bg-zinc-800 text-zinc-300 rounded-2xl text-xs font-semibold transition-colors flex items-center gap-1" id="btn-browse-temp">
|
||||||
|
<svg class="w-3.5 h-3.5 text-zinc-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 sm:col-span-2">
|
||||||
|
<label class="text-[9px] uppercase font-bold tracking-[0.24em] text-zinc-500">Excluded Folder Tags</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="exclude-shell flex-1" id="excl-tags">
|
||||||
|
<input type="text" id="excl-input" placeholder="Folder..." class="exclude-shell-input flex-1 min-w-[70px] bg-transparent border-none text-xs text-zinc-200 outline-none p-1">
|
||||||
|
</div>
|
||||||
|
<button class="panel-button panel-button-small" id="btn-browse-excl">
|
||||||
|
<svg class="w-3.5 h-3.5 text-zinc-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[9px] uppercase font-bold tracking-[0.24em] text-zinc-500">Min Size (MB)</label>
|
||||||
|
<input type="number" id="min-size" value="0">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[9px] uppercase font-bold tracking-[0.24em] text-zinc-500">Threads</label>
|
||||||
|
<div class="relative select-shell">
|
||||||
|
<select id="threads">
|
||||||
|
<!-- Injected dynamically by app.js -->
|
||||||
|
</select>
|
||||||
|
<div class="select-chevron absolute inset-y-0 right-3 flex items-center pointer-events-none text-zinc-500">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 9l-7 7-7-7"></path></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[9px] uppercase font-bold tracking-[0.24em] text-zinc-500">Preset</label>
|
||||||
|
<div class="relative select-shell">
|
||||||
|
<select id="preset">
|
||||||
|
<option value="fast">fast</option>
|
||||||
|
<option value="medium" selected>medium</option>
|
||||||
|
<option value="slow">slow</option>
|
||||||
|
</select>
|
||||||
|
<div class="select-chevron absolute inset-y-0 right-3 flex items-center pointer-events-none text-zinc-500">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 9l-7 7-7-7"></path></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[9px] uppercase font-bold tracking-[0.24em] text-zinc-500">Video Encoder</label>
|
||||||
|
<div class="relative select-shell">
|
||||||
|
<select id="encoder">
|
||||||
|
<option value="libx265" {% if encoder == 'libx265' %}selected{% endif %}>CPU (libx265)</option>
|
||||||
|
<option value="hevc_nvenc" {% if encoder == 'hevc_nvenc' %}selected{% endif %}>NVIDIA GPU (hevc_nvenc)</option>
|
||||||
|
<option value="hevc_amf" {% if encoder == 'hevc_amf' %}selected{% endif %}>AMD GPU (hevc_amf)</option>
|
||||||
|
<option value="hevc_qsv" {% if encoder == 'hevc_qsv' %}selected{% endif %}>Intel GPU (hevc_qsv)</option>
|
||||||
|
</select>
|
||||||
|
<div class="select-chevron absolute inset-y-0 right-3 flex items-center pointer-events-none text-zinc-500">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 9l-7 7-7-7"></path></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[9px] uppercase font-bold tracking-[0.24em] text-zinc-500">Audio Codec</label>
|
||||||
|
<div class="relative select-shell">
|
||||||
|
<select id="audio-codec">
|
||||||
|
<option value="auto" {% if audio_codec == 'auto' %}selected{% endif %}>Auto (Source-aware)</option>
|
||||||
|
<option value="copy" {% if audio_codec == 'copy' %}selected{% endif %}>Copy (Original)</option>
|
||||||
|
<option value="aac" {% if audio_codec == 'aac' %}selected{% endif %}>AAC (Transcode)</option>
|
||||||
|
<option value="ac3" {% if audio_codec == 'ac3' %}selected{% endif %}>AC3 (Transcode)</option>
|
||||||
|
</select>
|
||||||
|
<div class="select-chevron absolute inset-y-0 right-3 flex items-center pointer-events-none text-zinc-500">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 9l-7 7-7-7"></path></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-[10px] text-zinc-500">Auto keeps AAC as AAC, copies other audio, and uses no audio if the source has none.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-card flex items-center justify-between gap-3 rounded-2xl border border-zinc-850 bg-zinc-950/80 px-3.5 py-2">
|
||||||
|
<div class="toggle-copy flex flex-col gap-1">
|
||||||
|
<label for="rename-output" class="text-[9px] uppercase font-bold tracking-[0.24em] text-zinc-500">Rename Output Files</label>
|
||||||
|
<p class="text-[10px] text-zinc-500">Uses a Plex/Radarr/Sonarr-friendly name with title, year or episode, quality, and codec tags.</p>
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center cursor-pointer select-none">
|
||||||
|
<input type="checkbox" id="rename-output" {% if rename_output %}checked{% endif %} class="sr-only peer">
|
||||||
|
<span class="toggle-switch relative h-5 w-10 rounded-full bg-zinc-800 transition-colors peer-checked:bg-emerald-500 after:absolute after:left-0.5 after:top-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-transform peer-checked:after:translate-x-5"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-card flex items-center justify-between gap-3 rounded-2xl border border-zinc-850 bg-zinc-950/80 px-3.5 py-2">
|
||||||
|
<div class="toggle-copy flex flex-col gap-1">
|
||||||
|
<label for="force-reencode" class="text-[9px] uppercase font-bold tracking-[0.24em] text-zinc-500">Force Re-encode</label>
|
||||||
|
<p class="text-[10px] text-zinc-500">Always re-encode to HEVC and chosen audio, even if already HEVC/AAC.</p>
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex items-center cursor-pointer select-none">
|
||||||
|
<input type="checkbox" id="force-reencode" {% if force_reencode %}checked{% endif %} class="sr-only peer">
|
||||||
|
<span class="toggle-switch relative h-5 w-10 rounded-full bg-zinc-800 transition-colors peer-checked:bg-emerald-500 after:absolute after:left-0.5 after:top-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-transform peer-checked:after:translate-x-5"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[9px] uppercase font-bold tracking-[0.24em] text-zinc-500">CRF Value (Quality)</label>
|
||||||
|
<input type="number" id="crf" value="22" min="18" max="28">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 sm:col-span-2">
|
||||||
|
<label class="text-[9px] uppercase font-bold tracking-[0.24em] text-zinc-500">Discord Webhook URL (Optional)</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" id="discord-webhook" value="{{ discord_webhook }}" placeholder="https://discord.com/api/webhooks/...">
|
||||||
|
<button class="px-3 bg-zinc-900 border border-zinc-800 hover:bg-zinc-800 text-zinc-300 rounded-2xl text-xs font-semibold transition-colors flex items-center justify-center whitespace-nowrap" id="btn-test-webhook">
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[9px] uppercase font-bold tracking-[0.24em] text-zinc-500">Custom FFmpeg Path</label>
|
||||||
|
<input type="text" id="custom-ffmpeg" value="{{ custom_ffmpeg }}" placeholder="Auto-detect if empty">
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-[9px] uppercase font-bold tracking-[0.24em] text-zinc-500">Custom FFprobe Path</label>
|
||||||
|
<input type="text" id="custom-ffprobe" value="{{ custom_ffprobe }}" placeholder="Auto-detect if empty">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions Panel -->
|
||||||
|
<div class="actions-panel grid grid-cols-1 gap-1 pt-1 border-t border-zinc-800 mt-1 sm:col-span-2">
|
||||||
|
<button class="panel-button panel-button-primary" id="btn-scan">
|
||||||
|
<svg class="w-4 h-4 text-black" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
||||||
|
Scan Media Library
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<button class="panel-button panel-button-success" id="btn-process">
|
||||||
|
<svg class="w-3.5 h-3.5 text-white" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
<button class="panel-button panel-button-neutral" id="btn-pause" disabled>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
<button class="panel-button panel-button-neutral is-hidden" id="btn-resume" disabled>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
||||||
|
Resume
|
||||||
|
</button>
|
||||||
|
<button class="panel-button panel-button-danger" id="btn-stop" disabled>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></svg>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="panel-button panel-button-muted" id="btn-clear-db">
|
||||||
|
Clear SQLite Cache
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Directory Browser Modal -->
|
||||||
|
<div class="modal-backdrop fixed inset-0 bg-black/85 backdrop-blur-sm z-50 flex items-center justify-center p-4 is-hidden" id="modal-backdrop">
|
||||||
|
<div class="bg-zinc-950 border border-zinc-800 w-full max-w-xl rounded-2xl overflow-hidden shadow-2xl flex flex-col max-h-[85vh]" id="modal-content">
|
||||||
|
<div class="px-6 py-4 border-b border-zinc-900 text-xs font-bold text-zinc-300 uppercase tracking-widest" id="modal-title">Browse Directory</div>
|
||||||
|
|
||||||
|
<div id="modal-drive-switcher" class="px-6 py-2.5 border-b border-zinc-900 bg-zinc-900/20 flex items-center gap-2 flex-wrap text-xs">
|
||||||
|
<span class="font-bold text-[10px] uppercase text-zinc-500 tracking-wider mr-2">Drives:</span>
|
||||||
|
<!-- Drive buttons will be injected here dynamically -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-breadcrumb" class="flex flex-col"></div>
|
||||||
|
<div class="p-2 overflow-y-auto flex-1 max-h-[450px]" id="modal-body"></div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 border-t border-zinc-900 bg-zinc-950/50 flex justify-end gap-3">
|
||||||
|
<input type="hidden" id="modal-current-path">
|
||||||
|
<button class="px-4 py-2 border border-zinc-800 hover:bg-zinc-900 text-zinc-400 hover:text-white rounded-xl text-xs font-semibold transition-colors" id="btn-modal-cancel">Cancel</button>
|
||||||
|
<button class="px-5 py-2 bg-white hover:bg-zinc-200 text-black rounded-xl text-xs font-semibold transition-colors" id="btn-modal-select">Select Folder</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
flask>=3.0
|
||||||
|
flask-cors>=4.0
|
||||||
+1237
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 <<EOF
|
||||||
|
Usage: $0 [--setup] [--worker] [--dashboard] [--cleanup] [--help]
|
||||||
|
|
||||||
|
No flags: start worker + dashboard (setup venvs if missing)
|
||||||
|
--setup only create virtual environments and install requirements
|
||||||
|
--worker start Worker API only (detached)
|
||||||
|
--dashboard start Dashboard only (foreground)
|
||||||
|
--cleanup remove server/dashboard venvs and __pycache__ folders
|
||||||
|
--help show this message
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
check_cmd() { command -v "$1" >/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
|
||||||
Reference in New Issue
Block a user