diff --git a/index.html b/index.html
index 8c1b3a2..9fbb807 100644
--- a/index.html
+++ b/index.html
@@ -169,6 +169,16 @@
@@ -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":