First Public Release!

This commit is contained in:
AshiePleb
2026-06-16 22:25:33 +01:00
parent 50c62a9765
commit 4b8c7a0220
11 changed files with 3554 additions and 1 deletions
+82 -1
View File
@@ -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.
+239
View File
@@ -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)
+2
View File
@@ -0,0 +1,2 @@
flask>=3.0
requests>=2.31
+629
View File
@@ -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,'&quot;');
}
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]">&times;</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));
}
});
+346
View File
@@ -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;
}
}
+361
View File
@@ -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>
+2
View File
@@ -0,0 +1,2 @@
flask>=3.0
flask-cors>=4.0
+1237
View File
File diff suppressed because it is too large Load Diff
+315
View File
@@ -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)
+203
View File
@@ -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
+138
View File
@@ -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