Merge main: metadata-driven uploads, multi-file queue, collision suffixes

# Conflicts:
#	README.md
#	index.html
#	server.py
This commit is contained in:
2026-08-28 16:04:48 +01:00
3 changed files with 270 additions and 28 deletions
+76 -2
View File
@@ -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(