first commit
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Doorbell JPEG proxy: prefetch from Frigate and serve a baseline 480px-wide JPEG."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
from flask import Flask, request
|
||||
from PIL import Image
|
||||
|
||||
FRIGATE_URL = os.environ.get(
|
||||
"FRIGATE_URL", "http://10.0.1.197:5050/api/doorbell/latest.jpg?h=270"
|
||||
)
|
||||
TARGET_WIDTH = int(os.environ.get("TARGET_WIDTH", "480"))
|
||||
JPEG_QUALITY = int(os.environ.get("JPEG_QUALITY", "40"))
|
||||
FETCH_TIMEOUT = float(os.environ.get("FETCH_TIMEOUT", "5"))
|
||||
PREFETCH_HZ = float(os.environ.get("PREFETCH_HZ", "10"))
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_jpeg = b""
|
||||
_etag = ""
|
||||
_width = 0
|
||||
_height = 0
|
||||
|
||||
|
||||
def _encode(frame: Image.Image) -> tuple[bytes, int, int]:
|
||||
frame = frame.convert("RGB")
|
||||
if frame.width != TARGET_WIDTH:
|
||||
height = max(1, round(frame.height * TARGET_WIDTH / frame.width))
|
||||
frame = frame.resize((TARGET_WIDTH, height), Image.BILINEAR)
|
||||
buf = io.BytesIO()
|
||||
frame.save(
|
||||
buf,
|
||||
format="JPEG",
|
||||
quality=JPEG_QUALITY,
|
||||
subsampling=2,
|
||||
optimize=False,
|
||||
progressive=False,
|
||||
)
|
||||
return buf.getvalue(), frame.width, frame.height
|
||||
|
||||
|
||||
def _prefetch_loop() -> None:
|
||||
session = requests.Session()
|
||||
interval = 1.0 / max(PREFETCH_HZ, 0.5)
|
||||
while True:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
response = session.get(FRIGATE_URL, timeout=FETCH_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
with Image.open(io.BytesIO(response.content)) as image:
|
||||
encoded, width, height = _encode(image)
|
||||
etag = '"' + hashlib.md5(encoded).hexdigest() + '"'
|
||||
with _lock:
|
||||
global _jpeg, _etag, _width, _height
|
||||
_jpeg = encoded
|
||||
_etag = etag
|
||||
_width = width
|
||||
_height = height
|
||||
except Exception as exc: # noqa: BLE001 — keep last good frame
|
||||
print(f"prefetch failed: {exc}", flush=True)
|
||||
delay = interval - (time.monotonic() - started)
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
|
||||
|
||||
def _snapshot() -> tuple[bytes, str, int, int]:
|
||||
with _lock:
|
||||
return _jpeg, _etag, _width, _height
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@app.get("/latest.jpg")
|
||||
def latest():
|
||||
body, etag, width, height = _snapshot()
|
||||
if not body:
|
||||
return "Image not ready", 503, {"Content-Type": "text/plain"}
|
||||
|
||||
if request.headers.get("If-None-Match") == etag:
|
||||
return ("", 304, {"ETag": etag, "Cache-Control": "no-cache"})
|
||||
|
||||
return (
|
||||
body,
|
||||
200,
|
||||
{
|
||||
"Content-Type": "image/jpeg",
|
||||
"Content-Length": str(len(body)),
|
||||
"ETag": etag,
|
||||
"Last-Modified": time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime()),
|
||||
"Cache-Control": "no-cache",
|
||||
"Accept-Ranges": "none",
|
||||
"X-Image-Size": f"{width}x{height}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
body, _, width, height = _snapshot()
|
||||
status = 200 if body else 503
|
||||
return {"ready": bool(body), "bytes": len(body), "size": f"{width}x{height}"}, status
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
threading.Thread(target=_prefetch_loop, name="prefetch", daemon=True).start()
|
||||
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "80")), threaded=True)
|
||||
Reference in New Issue
Block a user