From 35be3fc2e7092326a12d62776e3421df2efa3021 Mon Sep 17 00:00:00 2001 From: Omarchy-Big-PC Date: Fri, 28 Aug 2026 14:30:18 +0100 Subject: [PATCH] Add web upload: POST /api/upload stores MP4s into dated archive, plus Upload UI panel --- index.html | 48 ++++++++++++++++++++++++++++++++++++++++++++ server.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 8c1b3a2..9fbb807 100644 --- a/index.html +++ b/index.html @@ -169,6 +169,16 @@
No highlights yet.
+
+ +
+ + Date: - + + Pick an MP4 named HH.MM.SS.mp4 +
+
+
@@ -512,6 +522,44 @@ 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"); + uploadFile.addEventListener("change", () => { + uploadBtn.disabled = !uploadFile.files.length; + document.querySelector("#upload-status").textContent = uploadFile.files.length + ? `${uploadFile.files[0].name} (${(uploadFile.files[0].size / 1048576).toFixed(1)} MB)` : "Pick an MP4 named HH.MM.SS.mp4"; + }); + uploadBtn.addEventListener("click", async () => { + const file = uploadFile.files[0]; + if (!file) return; + const name = file.name; + const dot = name.lastIndexOf("."); + const filename = dot > 0 ? name.slice(0, dot).padStart(8, "0").slice(-8) + name.slice(dot).toLowerCase() : name; + if (!/^\d{2}\.\d{2}\.\d{2}\.mp4$/i.test(filename)) { + document.querySelector("#upload-status").textContent = "Bad name: use HH.MM.SS.mp4 (e.g. 09.28.36.mp4)"; + return; + } + uploadBtn.disabled = true; + document.querySelector("#upload-status").textContent = "Uploading..."; + try { + const r = await fetch(`/api/upload?date=${dateSelect.value}&filename=${encodeURIComponent(filename)}`, {method: "POST", body: file}); + const d = await r.json().catch(() => ({})); + document.querySelector("#upload-status").textContent = r.ok + ? `Stored: ${d.file}` : `Failed: ${d.message || r.status}`; + if (r.ok) { uploadFile.value = ""; loadDate(dateSelect.value); } + } catch (err) { + document.querySelector("#upload-status").textContent = "Failed: network error"; + } + uploadBtn.disabled = !uploadFile.files.length; + }); + dateSelect.addEventListener("change", () => loadDate(dateSelect.value)); addEventListener("resize", drawTimeline); loadDate("").catch(() => {}); diff --git a/server.py b/server.py index 3118738..ed5a6ad 100644 --- a/server.py +++ b/server.py @@ -109,7 +109,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) @@ -140,6 +143,60 @@ 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)") + filename = parse_qs(urlparse(self.path).query).get("filename", [""])[0] + match = VIDEO_RE.fullmatch(filename) + if not match: + return self.send_error(400, "Filename must be HH.MM.SS.mp4 or HH.MM.SS.avi (e.g. 09.28.36.mp4)") + hour, minute, second = map(int, match.groups()[:3]) + if hour > 23 or minute > 59 or second > 59: + return self.send_error(400, "Invalid time in filename") + try: + day = normalize_date(parse_qs(urlparse(self.path).query).get("date", [""])[0]) + except ValueError: + return self.send_error(400, "date must be YYYYMMDD or YYYY-MM-DD") + suffix = match.group(4).lower() + if suffix == "avi": + return self.send_error(400, "Only pre-encoded .mp4 uploads are accepted; AVI sources go through the Encoding menu") + + target_dir = self.server.output_root / day + target = target_dir / f"{hour:02d}.{minute:02d}.{second:02d}.mp4" + temporary = target.with_suffix(".uploading.mp4") + 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 not any(clip["name"] == target.name for clip in self.server.recordings[day]): + self.server.recordings[day].append({ + "name": target.name, + "start": hour * 3600 + minute * 60 + second, + "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":