Files
recorder-player/server.py
T

268 lines
10 KiB
Python

#!/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 == "/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)
self.send_error(404)
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.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
if __name__ == "__main__":
main()