Added stop to the container if no device needs frames

This commit is contained in:
2026-09-12 17:05:13 +02:00
parent 539df2182a
commit 1c56217927
4 changed files with 62 additions and 229 deletions
+44 -3
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env python3
"""JPEG proxy: prefetch from Frigate per camera/size and serve cover-cropped JPEGs."""
"""JPEG proxy: prefetch from Frigate per camera/size and serve cover-cropped JPEGs.
Prefetch runs only while clients are requesting a stream; after IDLE_TIMEOUT
seconds with no hits it pauses until the next request.
"""
from __future__ import annotations
@@ -20,6 +24,8 @@ from PIL import Image
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"))
# Stop prefetching a stream after this many seconds with no HTTP clients.
IDLE_TIMEOUT = float(os.environ.get("IDLE_TIMEOUT", "30"))
DEFAULT_SIZE = int(os.environ.get("DEFAULT_SIZE", "480"))
MAX_SIZE = int(os.environ.get("MAX_SIZE", "720"))
MIN_SIZE = int(os.environ.get("MIN_SIZE", "64"))
@@ -58,10 +64,12 @@ class StreamConfig:
class CameraStream:
config: StreamConfig
lock: threading.Lock = field(default_factory=threading.Lock)
wake: threading.Event = field(default_factory=threading.Event)
jpeg: bytes = b""
etag: str = ""
width: int = 0
height: int = 0
last_access: float = 0.0
def snapshot(self) -> tuple[bytes, str, int, int]:
with self.lock:
@@ -74,6 +82,20 @@ class CameraStream:
self.width = width
self.height = height
def touch(self) -> None:
with self.lock:
self.last_access = time.monotonic()
self.wake.set()
def is_active(self, now: float | None = None) -> bool:
if IDLE_TIMEOUT <= 0:
return True
with self.lock:
last = self.last_access
if last <= 0:
return False
return ((now if now is not None else time.monotonic()) - last) < IDLE_TIMEOUT
class StreamRegistry:
def __init__(self, sources: dict[str, CameraSource]) -> None:
@@ -228,7 +250,20 @@ def _prefetch_loop(stream: CameraStream) -> None:
cfg = stream.config
session = requests.Session()
interval = 1.0 / max(PREFETCH_HZ, 0.5)
idle = True
while True:
if not stream.is_active():
if not idle:
print(f"[{cfg.label}] prefetch paused (idle)", flush=True)
idle = True
stream.wake.wait(timeout=1.0)
stream.wake.clear()
continue
if idle:
print(f"[{cfg.label}] prefetch active", flush=True)
idle = False
started = time.monotonic()
try:
source_url = _frigate_url(cfg.source.url, cfg.width, cfg.height, cfg.zoom)
@@ -244,10 +279,13 @@ def _prefetch_loop(stream: CameraStream) -> None:
print(f"[{cfg.label}] prefetch failed: {exc}", flush=True)
delay = interval - (time.monotonic() - started)
if delay > 0:
time.sleep(delay)
# Wake early if a client touches the stream (or it goes idle).
stream.wake.wait(timeout=delay)
stream.wake.clear()
def _jpeg_response(stream: CameraStream):
stream.touch()
body, etag, width, height = stream.snapshot()
if not body:
return "Image not ready", 503, {"Content-Type": "text/plain"}
@@ -288,19 +326,22 @@ def create_app() -> Flask:
@app.get("/health")
def health():
now = time.monotonic()
result = {}
for stream in registry.all_streams():
body, _, width, height = stream.snapshot()
label = stream.config.label
result[label] = {
"ready": bool(body),
"active": stream.is_active(now),
"bytes": len(body),
"size": f"{width}x{height}",
"target": f"{stream.config.width}x{stream.config.height}",
"zoom": stream.config.zoom,
"source": stream.config.source.url,
}
return {"streams": result}, 200 if any(s["ready"] for s in result.values()) else 503
# Healthy once the process is up; streams may be idle with no clients.
return {"streams": result, "idle_timeout": IDLE_TIMEOUT}, 200
@app.get("/")
@app.get("/latest.jpg")