#!/usr/bin/env python3 import argparse import concurrent.futures import json import re import subprocess import threading 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) 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 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 == "/video": return self.send_video(request.query) self.send_error(404) def do_POST(self): if urlparse(self.path).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 = str(item["date"]) start = float(item["start"]) end = float(item["end"]) if not DATE_RE.fullmatch(date) or 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", "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 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 main(): parser = argparse.ArgumentParser(description="Play dated recorder AVI files in a browser") parser.add_argument( "root", nargs="?", type=Path, default=Path("/home/human/Videos/Yassine Salon/C41/media-mp4"), help="folder containing YYYYMMDD recording directories", ) parser.add_argument("--port", type=int, default=8765) parser.add_argument( "--source", type=Path, default=Path("/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(("127.0.0.1", 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.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()