diff --git a/README.md b/README.md index 8b388fe..c2fd34f 100644 --- a/README.md +++ b/README.md @@ -34,26 +34,6 @@ python3 server.py Open in a browser. -## Docker - -Docker keeps the application and FFmpeg inside a container while the recorder -files remain on the host. From this directory, run: - -```sh -docker compose up -d --build -``` - -Open . The Compose configuration mounts the original -archive read-only at `/data/source` and stores encoded files persistently in -`/data/encoded`. Stop it with: - -```sh -docker compose down -``` - -To use different host folders, edit the two `volumes` entries in -`compose.yaml`. Do not remove `:ro` from the source mount. - The default source archive is: `/home/human/Videos/Yassine Salon/C41/media` diff --git a/index.html b/index.html index 0cfec76..ef7a317 100644 --- a/index.html +++ b/index.html @@ -23,10 +23,6 @@ /* player wrapper */ .player-wrap { position: relative; background: #000; border-radius: 12px; overflow: hidden; } .player-wrap video { display: block; width: 100%; aspect-ratio: 16/9; object-fit: contain; background: #000; } - .empty-state { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 10px; color: #222; background: #f1f1f1; pointer-events: none; } - .empty-state svg { width: 52px; height: 52px; fill: #222; } - .empty-state span { font-size: 14px; } - .player-wrap.has-video .empty-state { display: none; } /* big center play */ .big-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); width: 68px; height: 48px; background: rgba(255,255,255,.92); color: #111; border-radius: 12px; display: grid; place-items: center; pointer-events: none; opacity: 0; transition: opacity .15s; box-shadow: 0 2px 12px rgba(0,0,0,.35); } @@ -91,6 +87,39 @@ .encode-body progress { flex: 1; height: 4px; accent-color: var(--accent); } .encode-body .enc-status { font-size: 12px; color: var(--dim); min-width: 180px; text-align: right; } + /* upload panel */ + .upload-toggle svg { fill: #8ab4ff; } + .upload-body { border-top: 1px solid #1c1c1c; } + .upload-drop { flex: 1; min-width: 240px; border: 2px dashed #555; border-radius: 8px; padding: 22px 16px; text-align: center; color: var(--dim); font-size: 13px; cursor: pointer; transition: border-color .15s, background .15s; } + .upload-drop:hover, .upload-drop.drag { border-color: var(--accent); background: rgba(62,166,255,.08); color: var(--text); } + .upload-drop b { color: var(--accent); font-weight: 500; } + .upload-drop input { display: none; } + .upload-file-chip { display: none; align-items: center; gap: 8px; background: #1a1a1a; border: 1px solid #3ea6ff55; border-radius: 6px; padding: 8px 12px; font-size: 12px; max-width: 320px; } + .upload-file-chip.show { display: flex; } + .upload-file-chip .chip-name { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .upload-file-chip .chip-size { color: var(--dim); white-space: nowrap; } + .upload-file-chip .chip-x { background: none; border: none; color: var(--dim); cursor: pointer; font-size: 14px; padding: 0 2px; } + .upload-file-chip .chip-x:hover { color: var(--red); } + .upload-queue { display: none; flex-direction: column; gap: 4px; flex: 1 1 100%; min-width: 260px; max-height: 240px; overflow-y: auto; } + .upload-queue.show { display: flex; } + .q-count { color: var(--dim); font-size: 11px; padding: 2px 0; } + .q-item { display: flex; align-items: center; gap: 8px; font-size: 12px; background: #1a1a1a; border-radius: 4px; padding: 5px 8px; } + .q-item .q-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .q-item .q-size { color: var(--dim); white-space: nowrap; } + .q-item .q-state { min-width: 70px; text-align: right; color: var(--dim); white-space: nowrap; } + .q-item.ok .q-state { color: #7bd97b; } + .q-item.err .q-state { color: var(--red); } + .q-item.busy .q-state { color: var(--accent); } + .q-item .q-bar { position: relative; width: 60px; height: 3px; background: #333; border-radius: 2px; overflow: hidden; flex: 0 0 60px; } + .q-item .q-bar i { position: absolute; inset: 0; background: var(--accent); transform-origin: left; transform: scaleX(0); display: block; } + .upload-btn { position: relative; overflow: hidden; } + .upload-btn.busy { color: transparent; pointer-events: none; } + .upload-btn.busy::after { content: ""; position: absolute; inset: 0; margin: auto; width: 14px; height: 14px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; } + @keyframes spin { to { transform: rotate(360deg); } } + .upload-status { transition: color .2s; } + .upload-status.ok { color: #7bd97b; } + .upload-status.err { color: var(--red); } + @media (max-width: 600px) { .page { padding: 8px; } .ctrl-row { gap: 3px; } @@ -125,7 +154,6 @@
-
Select a recording from the timeline
@@ -174,6 +202,21 @@
No highlights yet.
+
+ +
+
+ Drop MP4/AVI files here or browse
multiple files allowed; date & time come from each file's metadata + +
+
+ Upload date: - + + + Ready +
+
+
@@ -472,7 +515,7 @@ // load date async function loadDate(date) { - video.pause(); video.removeAttribute("src"); activeClip = null; wrap.classList.remove("has-video"); setPlaying(false); + video.pause(); video.removeAttribute("src"); activeClip = null; setPlaying(false); const r = await fetch(`/api/recordings?date=${encodeURIComponent(date)}`); const d = await r.json(); clips = d.recordings; @@ -517,6 +560,151 @@ else if (e.code === "KeyM") { video.muted = !video.muted; updateVolIcon(); } }); + // upload + const uploadToggle = document.querySelector("#upload-toggle"); + uploadToggle.addEventListener("click", () => { + uploadToggle.classList.toggle("open"); + document.querySelector("#upload-body").classList.toggle("show"); + document.querySelector("#upload-date").textContent = dateSelect.value || "-"; + }); + const uploadFile = document.querySelector("#upload-file"); + const uploadBtn = document.querySelector("#upload-btn"); + const uploadDrop = document.querySelector("#upload-drop"); + const uploadQueue = document.querySelector("#upload-queue"); + const uploadStatus = document.querySelector("#upload-status"); + const overrideDate = document.querySelector("#upload-date-override"); + overrideDate.addEventListener("change", () => { + renderQueue(); + if (overrideDate.value) setStatus(`Override active: all files go to ${overrideDate.value.replaceAll("-", "")}`, "ok"); + else setStatus("Ready"); + }); + let queue = []; + function setStatus(msg, cls) { + uploadStatus.textContent = msg; + uploadStatus.className = "enc-status upload-status" + (cls ? " " + cls : ""); + } + function renderQueue() { + uploadQueue.replaceChildren(); + uploadQueue.classList.toggle("show", queue.length > 0); + if (!queue.length) return; + const counts = queue.reduce((acc, e) => (acc[e.state || "queued"] = (acc[e.state || "queued"] || 0) + 1, acc), {}); + const summary = document.createElement("div"); + summary.className = "q-count"; + summary.textContent = `${queue.length} file${queue.length > 1 ? "s" : ""}: ${counts.queued || 0} queued, ${counts.busy || 0} sending, ${counts.ok || 0} stored, ${counts.err || 0} failed`; + uploadQueue.append(summary); + queue.forEach(entry => { + const row = document.createElement("div"); + row.className = "q-item" + (entry.state ? " " + entry.state : ""); + const name = document.createElement("span"); name.className = "q-name"; name.textContent = entry.file.name; + const stamp = fileStamp(entry); + const slot = document.createElement("span"); slot.className = "q-size"; + slot.textContent = stamp.date + " " + (stamp.time ? stamp.time : "mtime time"); + const size = document.createElement("span"); size.className = "q-size"; size.textContent = (entry.file.size / 1048576).toFixed(1) + " MB"; + const bar = document.createElement("span"); bar.className = "q-bar"; + const fill = document.createElement("i"); bar.append(fill); + entry.bar = fill; + const state = document.createElement("span"); state.className = "q-state"; + state.textContent = entry.state === "ok" ? "stored" : entry.state === "err" ? entry.msg : entry.state === "busy" ? "sending" : "queued"; + row.append(name, slot, size, bar, state); + uploadQueue.append(row); + }); + uploadBtn.disabled = !queue.some(e => e.state === "queued"); + } + function addFiles(files) { + for (const file of files) { + if (!/\.(mp4|avi)$/i.test(file.name)) continue; + queue.push({file, state: "queued"}); + } + renderQueue(); + if (queue.length) setStatus(`${queue.length} file${queue.length > 1 ? "s" : ""} queued${overrideDate.value ? " for " + overrideDate.value.replaceAll("-", "") : " (mtimes decide)"}`); + } + function fileStamp(entry) { + if (overrideDate.value) { + return {date: overrideDate.value.replaceAll("-", ""), time: null}; + } + const d = new Date(entry.file.lastModified); + const pad = n => String(n).padStart(2, "0"); + return { + date: `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`, + time: `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`, + }; + } + function clearDone() { + queue = queue.filter(e => e.state === "err"); + renderQueue(); + } + uploadDrop.addEventListener("click", () => uploadFile.click()); + uploadFile.addEventListener("change", () => addFiles(uploadFile.files)); + ["dragover", "dragenter"].forEach(ev => uploadDrop.addEventListener(ev, e => { e.preventDefault(); uploadDrop.classList.add("drag"); })); + ["dragleave", "drop"].forEach(ev => uploadDrop.addEventListener(ev, e => { e.preventDefault(); uploadDrop.classList.remove("drag"); })); + uploadDrop.addEventListener("drop", e => addFiles(e.dataTransfer.files)); + function normName(name) { + const dot = name.lastIndexOf("."); + if (dot <= 0) return null; + const base = name.slice(0, dot).padStart(8, "0").slice(-8); + const ext = name.slice(dot).toLowerCase(); + return /^\d{2}\.\d{2}\.\d{2}\.(mp4|avi)$/.test(base + ext) ? base + ext : null; + } + async function uploadOne(entry) { + const stamp = fileStamp(entry); + if (!stamp.date) { + entry.state = "err"; entry.msg = "no date"; + renderQueue(); + return false; + } + entry.state = "busy"; renderQueue(); + return new Promise(resolve => { + const xhr = new XMLHttpRequest(); + let url = `/api/upload?date=${stamp.date}&filename=${encodeURIComponent(entry.file.name)}`; + if (stamp.time) url += `&time=${encodeURIComponent(stamp.time)}`; + xhr.open("POST", url); + xhr.upload.addEventListener("progress", e => { + if (e.lengthComputable && entry.bar) entry.bar.style.transform = `scaleX(${e.loaded / e.total})`; + }); + xhr.addEventListener("load", () => { + let d = {}; + try { d = JSON.parse(xhr.responseText); } catch {} + entry.state = xhr.status < 300 ? "ok" : "err"; + entry.msg = xhr.status < 300 ? "stored" : (d.message || `HTTP ${xhr.status}`).slice(0, 40); + if (entry.bar) entry.bar.style.transform = "scaleX(1)"; + renderQueue(); + resolve(xhr.status < 300); + }); + xhr.addEventListener("error", () => { + entry.state = "err"; entry.msg = "network"; + renderQueue(); + resolve(false); + }); + xhr.send(entry.file); + }); + } + uploadBtn.addEventListener("click", async () => { + const pending = queue.filter(e => e.state === "queued"); + if (!pending.length) return; + if (!fileStamp(pending[0]).date) { + setStatus("No upload date: set the override date or file mtimes.", "err"); + return; + } + uploadBtn.disabled = true; + let okCount = 0, done = 0; + const workers = Array.from({length: Math.min(3, pending.length)}, async () => { + while (pending.length) { + const entry = pending.shift(); + if (await uploadOne(entry)) okCount++; + done++; + setStatus(`${done}/${pending.length + done} sent...`); + } + }); + await Promise.all(workers); + const errs = queue.filter(e => e.state === "err").length; + setStatus(okCount && !errs ? `All ${okCount} stored` : `${okCount} stored${errs ? `, ${errs} failed` : ""}`, errs ? "err" : "ok"); + const touched = new Set(queue.filter(e => e.state === "ok").map(e => fileStamp(e).date)); + if (touched.size) { + loadDate(dateSelect.value || [...touched][0]); + } + if (!errs) clearDone(); + }); + dateSelect.addEventListener("change", () => loadDate(dateSelect.value)); addEventListener("resize", drawTimeline); loadDate("").catch(() => {}); diff --git a/server.py b/server.py index 5fdb7fa..8ca1551 100644 --- a/server.py +++ b/server.py @@ -2,11 +2,11 @@ import argparse import concurrent.futures import json +import os import re import subprocess import struct import threading -import os from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -110,7 +110,10 @@ class PlayerHandler(BaseHTTPRequestHandler): self.send_error(404) def do_POST(self): - if urlparse(self.path).path == "/api/encode": + path = urlparse(self.path).path + if path == "/api/upload": + return self.handle_upload() + if path == "/api/encode": if not self.server.encode_status["running"]: threading.Thread(target=run_encoding, args=(self.server,), daemon=True).start() return self.send_json(self.server.encode_status) @@ -141,6 +144,72 @@ class PlayerHandler(BaseHTTPRequestHandler): return self.send_json(highlight) self.send_error(404) + def handle_upload(self): + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + return self.send_error(400, "Invalid content length") + max_bytes = 2 * 1024 * 1024 * 1024 + if not 0 < length <= max_bytes: + return self.send_error(413, "Missing or oversized body (limit 2 GiB)") + params = parse_qs(urlparse(self.path).query) + filename = params.get("filename", [""])[0] + if not re.fullmatch(r"[A-Za-z0-9._ -]{1,120}\.(mp4|avi)", filename, re.IGNORECASE): + return self.send_error(400, "filename must be a simple MP4/AVI name") + suffix = filename.rsplit(".", 1)[1].lower() + try: + day = normalize_date(params.get("date", [""])[0]) + except ValueError: + return self.send_error(400, "date must be YYYYMMDD or YYYY-MM-DD") + try: + start = parse_clock(params.get("time", [""])[0]) + if not (0 <= start < 86400): + raise ValueError + except ValueError: + return self.send_error(400, "time must be HH:MM[:SS] within one day") + + target_dir = self.server.output_root / day + if suffix == "avi": + # AVI sources mirror the real recorder: stored in the source tree for the Encoding menu + target_dir = self.server.source_root / day + # Archive-canonical name from the actual start time; collisions with a + # different original name get a numeric suffix. + base = f"{start // 3600:02d}.{start % 3600 // 60:02d}.{start % 60:02d}" + target = target_dir / f"{base}.{suffix}" + serial = 1 + while target.exists() or temporary_exists(target_dir, target.name): + base_alt = f"{base}-{serial:02d}" + target = target_dir / f"{base_alt}.{suffix}" + serial += 1 + temporary = target.with_suffix(f".uploading.{suffix}") + try: + target_dir.mkdir(parents=True, exist_ok=True) + received = 0 + with temporary.open("wb") as sink: + while received < length: + chunk = self.rfile.read(min(256 * 1024, length - received)) + if not chunk: + raise OSError("client disconnected mid-upload") + sink.write(chunk) + received += len(chunk) + if target.exists(): + temporary.unlink() + return self.send_error(409, "A recording already exists at that time; nothing was overwritten") + temporary.replace(target) + except OSError: + temporary.unlink(missing_ok=True) + return self.send_error(500, "Failed to store upload") + self.server.recordings.setdefault(day, []) + if suffix == "mp4" and not any(clip["name"] == target.name for clip in self.server.recordings[day]): + self.server.recordings[day].append({ + "name": target.name, + "start": start, + "duration": 120, + "path": target, + }) + self.server.recordings[day].sort(key=lambda clip: clip["start"]) + return self.send_json({"ok": True, "file": f"{day}/{target.name}"}) + def do_DELETE(self): request = urlparse(self.path) if request.path != "/api/highlights": @@ -273,6 +342,11 @@ class PlayerHandler(BaseHTTPRequestHandler): print(f"{self.client_address[0]} - {message % args}") +def temporary_exists(directory, final_name): + stem = final_name.rsplit(".", 1)[0] + return any(path.name.startswith(stem + ".uploading.") for path in directory.glob("*.uploading.*")) if directory.is_dir() else False + + def main(): parser = argparse.ArgumentParser(description="Play dated recorder AVI files in a browser") parser.add_argument(