First Public Release!
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user