425 lines
17 KiB
Python
425 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import concurrent.futures
|
|
import json
|
|
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/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)")
|
|
filename = parse_qs(urlparse(self.path).query).get("filename", [""])[0]
|
|
match = VIDEO_RE.fullmatch(filename)
|
|
if not match:
|
|
return self.send_error(400, "Filename must be HH.MM.SS.mp4 or HH.MM.SS.avi (e.g. 09.28.36.mp4)")
|
|
hour, minute, second = map(int, match.groups()[:3])
|
|
if hour > 23 or minute > 59 or second > 59:
|
|
return self.send_error(400, "Invalid time in filename")
|
|
try:
|
|
day = normalize_date(parse_qs(urlparse(self.path).query).get("date", [""])[0])
|
|
except ValueError:
|
|
return self.send_error(400, "date must be YYYYMMDD or YYYY-MM-DD")
|
|
suffix = match.group(4).lower()
|
|
if suffix == "avi":
|
|
return self.send_error(400, "Only pre-encoded .mp4 uploads are accepted; AVI sources go through the Encoding menu")
|
|
|
|
target_dir = self.server.output_root / day
|
|
target = target_dir / f"{hour:02d}.{minute:02d}.{second:02d}.mp4"
|
|
temporary = target.with_suffix(".uploading.mp4")
|
|
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 not any(clip["name"] == target.name for clip in self.server.recordings[day]):
|
|
self.server.recordings[day].append({
|
|
"name": target.name,
|
|
"start": hour * 3600 + minute * 60 + second,
|
|
"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 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.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()
|