Merge main: metadata-driven uploads, multi-file queue, collision suffixes
# Conflicts: # README.md # index.html # server.py
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user