114 lines
3.3 KiB
Python
114 lines
3.3 KiB
Python
#!/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)
|