Tighten media cache headers

This commit is contained in:
2026-05-31 02:45:16 +00:00
parent a4b0cd916a
commit f79c044250
+136 -3
View File
@@ -20,6 +20,7 @@ import threading
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from collections import deque
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
@@ -296,7 +297,55 @@ class PlaybackStateCache:
return merged return merged
class EventLoopLagMonitor:
"""Tracks event-loop scheduling lag over a sliding window."""
def __init__(self, sample_interval: float = 0.05, window_seconds: float = 10.0) -> None:
self._sample_interval = sample_interval
self._window_seconds = window_seconds
self._task: asyncio.Task | None = None
self._last_lag_ms = 0.0
self._samples: deque[tuple[float, float]] = deque()
def start(self) -> None:
if self._task is not None and not self._task.done():
return
self._task = asyncio.create_task(self._run(), name="mediahive-event-loop-lag")
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
with suppress(asyncio.CancelledError):
await self._task
self._task = None
def snapshot(self) -> tuple[float, float]:
now = time.perf_counter()
self._prune(now)
max_window_ms = max((lag for _, lag in self._samples), default=0.0)
return self._last_lag_ms, max_window_ms
async def _run(self) -> None:
interval = self._sample_interval
next_tick = time.perf_counter() + interval
while True:
await asyncio.sleep(interval)
now = time.perf_counter()
lag_ms = max(0.0, (now - next_tick) * 1000.0)
self._last_lag_ms = lag_ms
self._samples.append((now, lag_ms))
self._prune(now)
next_tick = now + interval
def _prune(self, now: float) -> None:
cutoff = now - self._window_seconds
while self._samples and self._samples[0][0] < cutoff:
self._samples.popleft()
playback_state_cache = PlaybackStateCache() playback_state_cache = PlaybackStateCache()
event_loop_lag_monitor = EventLoopLagMonitor()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -685,6 +734,7 @@ async def _activate_all_roots() -> None:
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
await frontend.load() await frontend.load()
playback_state_cache.start() playback_state_cache.start()
event_loop_lag_monitor.start()
# Defer root activation to a background task so the server starts # Defer root activation to a background task so the server starts
# immediately and macOS permission dialogs do not block startup. # immediately and macOS permission dialogs do not block startup.
@@ -697,6 +747,7 @@ async def lifespan(_app: FastAPI):
yield yield
finally: finally:
playback_state_cache.stop() playback_state_cache.stop()
await event_loop_lag_monitor.stop()
activation_task.cancel() activation_task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
@@ -896,45 +947,102 @@ async def list_players():
@app.post("/api/play/{root_id}") @app.post("/api/play/{root_id}")
async def play_media(root_id: str, request: Request): async def play_media(root_id: str, request: Request, response: Response):
"""Open a media file with the selected player.""" """Open a media file with the selected player."""
req_start = time.perf_counter()
trace_id = request.headers.get("x-mediahive-trace-id", "")
client_sent_ms_hdr = request.headers.get("x-mediahive-client-sent-ms")
client_to_server_ms: float | None = None
if client_sent_ms_hdr:
with suppress(ValueError):
client_to_server_ms = max(0.0, (time.time() * 1000.0) - float(client_sent_ms_hdr))
ctx = _get_context(root_id) ctx = _get_context(root_id)
body_t0 = time.perf_counter()
req = msgspec.json.decode(await request.body(), type=PlayMediaRequest) req = msgspec.json.decode(await request.body(), type=PlayMediaRequest)
decode_ms = (time.perf_counter() - body_t0) * 1000.0
resolve_t0 = time.perf_counter()
file_path = _resolve_root_scoped_path(ctx.root_path, req.file_path) file_path = _resolve_root_scoped_path(ctx.root_path, req.file_path)
resolve_ms = (time.perf_counter() - resolve_t0) * 1000.0
if not file_path.exists(): if not file_path.exists():
raise HTTPException(status_code=404, detail=f"File not found: {req.file_path}") raise HTTPException(status_code=404, detail=f"File not found: {req.file_path}")
# Resolve player path if a specific detected player was chosen # Resolve player path if a specific detected player was chosen
player_path: str | None = None player_path: str | None = None
detect_ms = 0.0
if req.player_id and req.player_id not in ("default", "custom"): if req.player_id and req.player_id not in ("default", "custom"):
detect_t0 = time.perf_counter()
for p in detect_players(): for p in detect_players():
if p.id == req.player_id: if p.id == req.player_id:
player_path = p.path player_path = p.path
break break
detect_ms = (time.perf_counter() - detect_t0) * 1000.0
if not player_path: if not player_path:
raise HTTPException( raise HTTPException(
status_code=400, detail=f"Player not found: {req.player_id}" status_code=400, detail=f"Player not found: {req.player_id}"
) )
try: try:
launch_t0 = time.perf_counter()
launch_player( launch_player(
req.player_id or "default", req.player_id or "default",
file_path, file_path,
player_path=player_path, player_path=player_path,
custom_cmd=req.player_custom_cmd, custom_cmd=req.player_custom_cmd,
) )
launch_ms = (time.perf_counter() - launch_t0) * 1000.0
total_ms = (time.perf_counter() - req_start) * 1000.0
loop_lag_ms, loop_lag_max_ms = event_loop_lag_monitor.snapshot()
if trace_id:
response.headers["X-MediaHive-Trace-Id"] = trace_id
response.headers["Server-Timing"] = (
f"app;dur={total_ms:.1f},"
f"decode;dur={decode_ms:.1f},"
f"resolve;dur={resolve_ms:.1f},"
f"detect;dur={detect_ms:.1f},"
f"launch;dur={launch_ms:.1f},"
f"looplag;dur={loop_lag_ms:.1f},"
f"looplagmax;dur={loop_lag_max_ms:.1f}"
)
request.state.log_extra = (
f"trace={trace_id or '-'} "
f"phase[decode={decode_ms:.1f}ms resolve={resolve_ms:.1f}ms "
f"detect={detect_ms:.1f}ms launch={launch_ms:.1f}ms] "
f"loopLag={loop_lag_ms:.1f}/{loop_lag_max_ms:.1f}ms"
)
if client_to_server_ms is not None:
request.state.log_extra = (
f"{request.state.log_extra} clientToServer={client_to_server_ms:.1f}ms"
)
return {"status": "ok"} return {"status": "ok"}
except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e: except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e:
raise HTTPException(status_code=500, detail=f"Failed to play media: {e}") raise HTTPException(status_code=500, detail=f"Failed to play media: {e}")
@app.post("/api/open-folder/{root_id}") @app.post("/api/open-folder/{root_id}")
async def open_folder(root_id: str, request: Request): async def open_folder(root_id: str, request: Request, response: Response):
"""Open a folder in the system file explorer.""" """Open a folder in the system file explorer."""
req_start = time.perf_counter()
trace_id = request.headers.get("x-mediahive-trace-id", "")
client_sent_ms_hdr = request.headers.get("x-mediahive-client-sent-ms")
client_to_server_ms: float | None = None
if client_sent_ms_hdr:
with suppress(ValueError):
client_to_server_ms = max(0.0, (time.time() * 1000.0) - float(client_sent_ms_hdr))
ctx = _get_context(root_id) ctx = _get_context(root_id)
body_t0 = time.perf_counter()
req = msgspec.json.decode(await request.body(), type=OpenFolderRequest) req = msgspec.json.decode(await request.body(), type=OpenFolderRequest)
decode_ms = (time.perf_counter() - body_t0) * 1000.0
resolve_t0 = time.perf_counter()
target_path = _resolve_root_scoped_path(ctx.root_path, req.folder_path) target_path = _resolve_root_scoped_path(ctx.root_path, req.folder_path)
resolve_ms = (time.perf_counter() - resolve_t0) * 1000.0
if not target_path.exists(): if not target_path.exists():
raise HTTPException( raise HTTPException(
@@ -942,6 +1050,7 @@ async def open_folder(root_id: str, request: Request):
) )
try: try:
open_t0 = time.perf_counter()
if sys.platform == "win32": if sys.platform == "win32":
native_path = str(target_path).replace("/", "\\") native_path = str(target_path).replace("/", "\\")
if target_path.is_file(): if target_path.is_file():
@@ -959,6 +1068,30 @@ async def open_folder(root_id: str, request: Request):
folder = target_path.parent if target_path.is_file() else target_path folder = target_path.parent if target_path.is_file() else target_path
subprocess.Popen(["xdg-open", str(folder)]) subprocess.Popen(["xdg-open", str(folder)])
open_ms = (time.perf_counter() - open_t0) * 1000.0
total_ms = (time.perf_counter() - req_start) * 1000.0
loop_lag_ms, loop_lag_max_ms = event_loop_lag_monitor.snapshot()
if trace_id:
response.headers["X-MediaHive-Trace-Id"] = trace_id
response.headers["Server-Timing"] = (
f"app;dur={total_ms:.1f},"
f"decode;dur={decode_ms:.1f},"
f"resolve;dur={resolve_ms:.1f},"
f"open;dur={open_ms:.1f},"
f"looplag;dur={loop_lag_ms:.1f},"
f"looplagmax;dur={loop_lag_max_ms:.1f}"
)
request.state.log_extra = (
f"trace={trace_id or '-'} "
f"phase[decode={decode_ms:.1f}ms resolve={resolve_ms:.1f}ms open={open_ms:.1f}ms] "
f"loopLag={loop_lag_ms:.1f}/{loop_lag_max_ms:.1f}ms"
)
if client_to_server_ms is not None:
request.state.log_extra = (
f"{request.state.log_extra} clientToServer={client_to_server_ms:.1f}ms"
)
return {"status": "ok"} return {"status": "ok"}
except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e: except (OSError, subprocess.SubprocessError, RuntimeError, ValueError) as e:
raise HTTPException(status_code=500, detail=f"Failed to open folder: {e}") raise HTTPException(status_code=500, detail=f"Failed to open folder: {e}")
@@ -1058,7 +1191,7 @@ def _serve_file_response(full_path: Path, file_path: str, request: Request):
file_stat = full_path.stat() file_stat = full_path.stat()
file_size = file_stat.st_size file_size = file_stat.st_size
etag = _build_file_etag(file_size, file_stat.st_mtime_ns) etag = _build_file_etag(file_size, file_stat.st_mtime_ns)
cache_control = "public, max-age=600" cache_control = "public, max-age=604800, immutable"
range_header = request.headers.get("range") range_header = request.headers.get("range")
if not range_header and _etag_matches_if_none_match( if not range_header and _etag_matches_if_none_match(