Initial commit: CCTV player with 24h timeline
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
#!/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_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):
|
||||
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_file(clip["path"], "video/mp4")
|
||||
|
||||
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 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(server.source_root.rglob("*.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()
|
||||
Reference in New Issue
Block a user