#!/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/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 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()