Files
recorder-player/server.py
T

490 lines
20 KiB
Python

#!/usr/bin/env python3
import argparse
import concurrent.futures
import json
import os
import re
import subprocess
import struct
import threading
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
DATE_RE = re.compile(r"^\d{8}$")
VIDEO_RE = re.compile(r"^(\d{2})\.(\d{2})\.(\d{2})\.(avi|mp4)$", re.IGNORECASE)
TIME_RE = re.compile(r"^(\d{1,2}):(\d{2})(?::(\d{2}))?$")
APP_DIR = Path(__file__).resolve().parent
def scan_recordings(root):
recordings = {}
if not root.is_dir():
return recordings
for day_dir in root.iterdir():
if not day_dir.is_dir() or not DATE_RE.fullmatch(day_dir.name):
continue
clips = []
for path in day_dir.iterdir():
match = VIDEO_RE.fullmatch(path.name)
if not path.is_file() or not match:
continue
hour, minute, second = map(int, match.groups()[:3])
if hour > 23 or minute > 59 or second > 59:
continue
clips.append({
"name": path.name,
"start": hour * 3600 + minute * 60 + second,
"duration": 120,
"path": path,
})
if clips:
recordings[day_dir.name] = sorted(clips, key=lambda clip: clip["start"])
return recordings
def parse_clock(value):
if isinstance(value, (int, float)):
return float(value)
match = TIME_RE.fullmatch(str(value).strip())
if not match:
raise ValueError
hour, minute, second = int(match.group(1)), int(match.group(2)), int(match.group(3) or 0)
if hour > 23 or minute > 59 or second > 59:
raise ValueError
return hour * 3600 + minute * 60 + second
def normalize_date(value):
date = str(value).replace("-", "")
if not DATE_RE.fullmatch(date):
raise ValueError
return date
def scan_system_highlights(root):
try:
data = (root / "fileindex.dat").read_bytes()
except OSError:
return []
highlights = []
for offset in range(0, len(data) - 15, 16):
timestamp, packed, _, _ = struct.unpack_from("<4I", data, offset)
duration = packed & 0xffff
event = (packed >> 16) & 1
if not event or not duration:
continue
moment = datetime.fromtimestamp(timestamp)
start = moment.hour * 3600 + moment.minute * 60 + moment.second
highlights.append({"date": moment.strftime("%Y%m%d"), "start": start, "end": min(86400, start + duration)})
return highlights
class PlayerHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_HEAD(self):
if urlparse(self.path).path == "/video":
self.send_video(self.path.split("?", 1)[1] if "?" in self.path else "", head_only=True)
else:
self.send_error(404)
def do_GET(self):
request = urlparse(self.path)
if request.path == "/":
return self.send_file(APP_DIR / "index.html", "text/html; charset=utf-8")
if request.path == "/api/recordings":
return self.send_recordings(request.query)
if request.path == "/api/encode/status":
return self.send_json(self.server.encode_status)
if request.path == "/api/highlights":
return self.send_json(self.server.highlights)
if request.path == "/api/highlights/export":
return self.export_highlights()
if request.path == "/api/system-highlights":
date = parse_qs(request.query).get("date", [""])[0]
return self.send_json([item for item in self.server.system_highlights if item["date"] == date])
if request.path == "/video":
return self.send_video(request.query)
self.send_error(404)
def do_POST(self):
path = urlparse(self.path).path
if path == "/api/rescan":
self.server.recordings = scan_recordings(self.server.output_root)
self.server.system_highlights = scan_system_highlights(self.server.source_root)
count = sum(map(len, self.server.recordings.values()))
return self.send_json({"ok": True, "days": len(self.server.recordings), "clips": count})
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)
if urlparse(self.path).path == "/api/highlights":
try:
length = int(self.headers.get("Content-Length", "0"))
item = json.loads(self.rfile.read(length))
date = normalize_date(item["date"])
start = parse_clock(item.get("start", item.get("start_time")))
end_value = item.get("end", item.get("end_time"))
if end_value is None and item.get("duration") is not None:
end = start + parse_clock(item["duration"])
else:
end = parse_clock(end_value)
if not (0 <= start < end <= 86400):
raise ValueError
highlight = {
"id": max((entry["id"] for entry in self.server.highlights), default=0) + 1,
"date": date,
"start": start,
"end": end,
"label": str(item.get("label", item.get("title", "Untitled highlight")))[:120],
}
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
return self.send_error(400, "Invalid highlight")
self.server.highlights.append(highlight)
save_highlights(self.server.highlights_path, self.server.highlights)
return self.send_json(highlight)
self.send_error(404)
def export_highlights(self):
fmt = parse_qs(urlparse(self.path).query).get("format", ["csv"])[0].lower()
items = sorted(self.server.highlights, key=lambda h: (h["date"], h["start"], h["id"]))
seen = {}
rows = []
for item in items:
# same label may repeat across days; within one day duplicates get -2, -3...
key = (item["date"], item["label"])
seen[key] = seen.get(key, 0) + 1
name = item["label"] if seen[key] == 1 else f"{item['label']} ({seen[key]})"
def clock(sec):
return f"{sec // 3600:02d}:{sec % 3600 // 60:02d}:{sec % 60:02d}"
rows.append({
"date": f"{item['date'][:4]}-{item['date'][4:6]}-{item['date'][6:]}",
"name": name,
"start": clock(item["start"]),
"end": clock(item["end"]),
"duration_sec": item["end"] - item["start"],
"clip": f"{item['date']}/{item['start'] // 3600:02d}.{item['start'] % 3600 // 60:02d}.{item['start'] % 60:02d}",
})
if fmt == "json":
data = json.dumps(rows, indent=2).encode()
ctype = "application/json"
else:
import io
buf = io.StringIO()
writer = __import__("csv").writer(buf)
writer.writerow(["date", "name", "start", "end", "duration_sec", "clip"])
writer.writerows((r["date"], r["name"], r["start"], r["end"], r["duration_sec"], r["clip"]) for r in rows)
data = buf.getvalue().encode()
ctype = "text/csv"
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
self.send_response(200)
self.send_header("Content-Type", f"{ctype}; charset=utf-8")
self.send_header("Content-Disposition", f'attachment; filename="highlights-{stamp}.{fmt}"')
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
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":
return self.send_error(404)
try:
highlight_id = int(parse_qs(request.query).get("id", [""])[0])
except ValueError:
return self.send_error(400, "Invalid highlight id")
self.server.highlights[:] = [item for item in self.server.highlights if item["id"] != highlight_id]
save_highlights(self.server.highlights_path, self.server.highlights)
self.send_json({"ok": True})
def send_json(self, payload):
data = json.dumps(payload).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def send_file(self, path, content_type):
data = path.read_bytes()
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def send_recordings(self, query):
params = parse_qs(query)
day = params.get("date", [""])[0]
clips = self.server.recordings.get(day, [])
payload = {
"dates": sorted(self.server.recordings, reverse=True),
"date": day,
"recordings": [
{key: clip[key] for key in ("name", "start", "duration")}
for clip in clips
],
}
self.send_json(payload)
def send_video(self, query, head_only=False):
params = parse_qs(query)
day = params.get("date", [""])[0]
name = params.get("name", [""])[0]
clip = next(
(item for item in self.server.recordings.get(day, []) if item["name"] == name),
None,
)
if not clip:
return self.send_error(404, "Recording not found")
try:
offset = max(0, min(float(params.get("offset", ["0"])[0]), 119.9))
except ValueError:
return self.send_error(400, "Invalid offset")
# Pre-encoded clips can be served immediately; the offset is applied by the UI.
if clip["path"].suffix.lower() == ".mp4":
return self.send_range_file(clip["path"], "video/mp4", head_only)
command = [
"ffmpeg", "-hide_banner", "-loglevel", "error", "-ss", str(offset),
"-i", str(clip["path"]), "-map", "0:v:0", "-map", "0:a:0?",
"-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency",
"-crf", "28", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "96k",
"-g", "20", "-flush_packets", "1",
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-f", "mp4", "pipe:1",
]
process = subprocess.Popen(command, stdout=subprocess.PIPE)
self.send_response(200)
self.send_header("Content-Type", "video/mp4")
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "close")
self.end_headers()
try:
while chunk := process.stdout.read(256 * 1024):
self.wfile.write(chunk)
except (BrokenPipeError, ConnectionResetError):
pass
finally:
process.kill()
process.wait()
def send_range_file(self, path, content_type, head_only=False):
size = path.stat().st_size
range_header = self.headers.get("Range")
start, end = 0, size - 1
if range_header and range_header.startswith("bytes="):
try:
value = range_header[6:].split(",", 1)[0]
first, last = value.split("-", 1)
if first:
start = int(first)
end = int(last) if last else size - 1
else:
start = max(0, size - int(last))
end = min(end, size - 1)
if start > end or start >= size:
self.send_response(416)
self.send_header("Content-Range", f"bytes */{size}")
self.end_headers()
return
except ValueError:
pass
length = end - start + 1
self.send_response(206 if range_header else 200)
self.send_header("Content-Type", content_type)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(length))
if range_header:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.end_headers()
if head_only:
return
with path.open("rb") as source:
source.seek(start)
remaining = length
while remaining:
chunk = source.read(min(256 * 1024, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
def log_message(self, message, *args):
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(
"root",
nargs="?",
type=Path,
default=Path(os.environ.get("C41_OUTPUT", "/home/human/Videos/Yassine Salon/C41/media-mp4")),
help="folder containing YYYYMMDD recording directories",
)
parser.add_argument("--port", type=int, default=int(os.environ.get("C41_PORT", "8765")))
parser.add_argument("--host", default=os.environ.get("C41_HOST", "127.0.0.1"))
parser.add_argument(
"--source",
type=Path,
default=Path(os.environ.get("C41_SOURCE", "/home/human/Videos/Yassine Salon/C41/media")),
help="folder containing source AVI files for manual encoding",
)
args = parser.parse_args()
recordings = scan_recordings(args.root.expanduser().resolve())
server = ThreadingHTTPServer((args.host, args.port), PlayerHandler)
server.recordings = recordings
server.source_root = args.source.expanduser().resolve()
server.output_root = args.root.expanduser().resolve()
server.highlights_path = server.output_root / "highlights.json"
server.highlights = load_highlights(server.highlights_path)
server.system_highlights = scan_system_highlights(server.source_root)
server.encode_status = {"running": False, "total": 0, "completed": 0, "skipped": 0, "failed": 0}
count = sum(map(len, recordings.values()))
print(f"Indexed {count} recordings across {len(recordings)} days")
print(f"Open http://127.0.0.1:{args.port}")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nStopped")
def run_encoding(server):
source_files = sorted(path for path in server.source_root.rglob("*") if path.is_file() and path.suffix.lower() == ".avi")
status = {"running": True, "total": len(source_files), "completed": 0, "skipped": 0, "failed": 0}
server.encode_status = status
lock = threading.Lock()
def encode(source):
relative = source.relative_to(server.source_root)
output = server.output_root / relative.with_suffix(".mp4")
output.parent.mkdir(parents=True, exist_ok=True)
temporary = output.with_suffix(".encoding.mp4")
if output.exists():
return "skipped"
command = [
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-i", str(source),
"-map", "0:v:0", "-map", "0:a:0?", "-vf", "scale=854:-2",
"-c:v", "libx264", "-preset", "ultrafast", "-tune", "zerolatency",
"-crf", "32", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "64k",
"-movflags", "+faststart", "-f", "mp4", str(temporary),
]
try:
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
temporary.unlink(missing_ok=True)
return "failed"
temporary.replace(output)
return "completed"
except OSError:
temporary.unlink(missing_ok=True)
return "failed"
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as workers:
futures = [workers.submit(encode, source) for source in source_files]
for future in concurrent.futures.as_completed(futures):
result = future.result()
with lock:
status[result] += 1
status["running"] = False
def load_highlights(path):
try:
data = json.loads(path.read_text())
return data if isinstance(data, list) else []
except (FileNotFoundError, json.JSONDecodeError, OSError):
return []
def save_highlights(path, highlights):
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(highlights, indent=2) + "\n")
temporary.replace(path)
if __name__ == "__main__":
main()