270 lines
10 KiB
Python
270 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import concurrent.futures
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import threading
|
|
import os
|
|
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(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.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()
|