1351 lines
52 KiB
Python
1351 lines
52 KiB
Python
from __future__ import annotations
|
||
|
||
import inspect
|
||
import re
|
||
from collections.abc import Awaitable, Callable, Mapping
|
||
from threading import Lock
|
||
from typing import Annotated, Any, Literal, Protocol
|
||
from urllib.parse import quote
|
||
|
||
from fastapi import APIRouter, Body, Header, HTTPException, Query, Response
|
||
from fastapi.responses import FileResponse, JSONResponse
|
||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator, model_validator
|
||
from starlette.concurrency import run_in_threadpool
|
||
from starlette.types import Receive, Scope, Send
|
||
|
||
from k1link.sessions import (
|
||
RECORDED_MEDIA_MANIFEST_SCHEMA,
|
||
LayoutConflictError,
|
||
MaterializedRecording,
|
||
RecordedMediaFile,
|
||
RecordedMediaInspector,
|
||
RecordedMediaManifest,
|
||
RecordingMaterializationError,
|
||
RecordingPreparationQueueFull,
|
||
RecordingPreparationSnapshot,
|
||
ReplayCommand,
|
||
SessionIntegrityError,
|
||
SessionNotFoundError,
|
||
SessionNotReplayableError,
|
||
SessionRecordingPreparationManager,
|
||
SessionStore,
|
||
validate_recorded_media_timeline,
|
||
)
|
||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||
from k1link.viewer.rrd_export import (
|
||
APPLICATION_ID as RECORDED_APPLICATION_ID,
|
||
)
|
||
from k1link.viewer.rrd_export import (
|
||
RrdExportError,
|
||
recorded_blueprint_rrd,
|
||
)
|
||
|
||
DEFAULT_REPLAY_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
|
||
DEFAULT_REPLAY_ACTION_ID = "stream.start-replay"
|
||
SAFE_SOURCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")
|
||
SAFE_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
||
REVALIDATED_RECORDING_CACHE_CONTROL = "private, no-cache, no-transform"
|
||
IMMUTABLE_RECORDING_CACHE_CONTROL = (
|
||
"private, max-age=31536000, immutable, no-transform"
|
||
)
|
||
|
||
|
||
class _ReleasingFileResponse(FileResponse):
|
||
"""Release a cache pin exactly once after every ASGI completion path."""
|
||
|
||
def __init__(
|
||
self,
|
||
*args: Any,
|
||
release: Callable[[], None],
|
||
**kwargs: Any,
|
||
) -> None:
|
||
super().__init__(*args, **kwargs)
|
||
self._release = release
|
||
self._release_guard = Lock()
|
||
self._released = False
|
||
|
||
def _release_once(self) -> None:
|
||
with self._release_guard:
|
||
if self._released:
|
||
return
|
||
self._released = True
|
||
self._release()
|
||
|
||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||
try:
|
||
await super().__call__(scope, receive, send)
|
||
finally:
|
||
# Starlette background tasks do not run when `send` is cancelled
|
||
# or raises. The finally block makes the response lifetime the
|
||
# authoritative pin lifetime for success, disconnect and failure.
|
||
self._release_once()
|
||
|
||
|
||
class StrictApiModel(BaseModel):
|
||
model_config = ConfigDict(extra="forbid")
|
||
|
||
|
||
class ReplayRequest(StrictApiModel):
|
||
speed: float = Field(default=1.0, ge=0.0, le=100.0)
|
||
loop: bool = False
|
||
|
||
|
||
class RecordedBlueprintRequest(StrictApiModel):
|
||
application_id: Literal["nodedc_mission_core_recorded"]
|
||
recording_id: str = Field(
|
||
min_length=1,
|
||
max_length=128,
|
||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$",
|
||
)
|
||
accumulation_seconds: float = Field(strict=True, ge=0.0, le=3600.0)
|
||
show_points: StrictBool
|
||
show_trajectory: StrictBool
|
||
show_grid: StrictBool
|
||
point_size: float = Field(default=2.5, strict=True, ge=0.1, le=32.0)
|
||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
|
||
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||
|
||
|
||
class SceneSettingsDocument(StrictApiModel):
|
||
projection: Literal["3d", "2d", "map"]
|
||
point_size: float = Field(ge=0.1, le=32.0)
|
||
color_mode: Literal["intensity", "height", "distance", "rgb", "class"]
|
||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"]
|
||
custom_color: str = Field(pattern=r"^#[0-9A-Fa-f]{6}$")
|
||
accumulation_seconds: float = Field(ge=0.0, le=3600.0)
|
||
show_points: bool
|
||
show_trajectory: bool
|
||
show_grid: bool
|
||
show_labels: bool
|
||
show_camera_frustums: bool
|
||
|
||
|
||
class ToolWindowsDocument(StrictApiModel):
|
||
sources_open: bool
|
||
display_open: bool
|
||
layers_open: bool
|
||
order: list[Literal["sources", "display", "layers"]]
|
||
|
||
@field_validator("order")
|
||
@classmethod
|
||
def validate_order(cls, value: list[str]) -> list[str]:
|
||
if len(value) != 3 or len(value) != len(set(value)):
|
||
raise ValueError("tool window order must be a permutation of all three tools")
|
||
return value
|
||
|
||
|
||
class NormalizedWindowRect(StrictApiModel):
|
||
x: float = Field(ge=0.0, le=1.0)
|
||
y: float = Field(ge=0.0, le=1.0)
|
||
width: float = Field(gt=0.0, le=1.0)
|
||
height: float = Field(gt=0.0, le=1.0)
|
||
|
||
@model_validator(mode="after")
|
||
def validate_bounds(self) -> NormalizedWindowRect:
|
||
epsilon = 1e-9
|
||
if self.x + self.width > 1.0 + epsilon or self.y + self.height > 1.0 + epsilon:
|
||
raise ValueError("normalized window rectangle escapes the viewport")
|
||
return self
|
||
|
||
|
||
class ViewportSize(StrictApiModel):
|
||
width: float = Field(gt=0.0, le=100_000.0)
|
||
height: float = Field(gt=0.0, le=100_000.0)
|
||
|
||
|
||
class LayoutPutRequest(StrictApiModel):
|
||
version: Literal[1]
|
||
revision: int = Field(ge=0, le=MAX_SAFE_INTEGER)
|
||
workspace_id: Literal["observation.spatial"]
|
||
scene_settings: SceneSettingsDocument
|
||
tool_windows: ToolWindowsDocument
|
||
visible_source_ids: list[str]
|
||
active_floating_source_id: str | None
|
||
window_rects: dict[str, NormalizedWindowRect]
|
||
viewport_size: ViewportSize
|
||
|
||
@field_validator("visible_source_ids")
|
||
@classmethod
|
||
def validate_visible_source_ids(cls, value: list[str]) -> list[str]:
|
||
if len(value) > 256 or len(value) != len(set(value)):
|
||
raise ValueError("visible source identifiers must be unique and bounded")
|
||
for source_id in value:
|
||
_validate_source_id(source_id)
|
||
return value
|
||
|
||
@field_validator("active_floating_source_id")
|
||
@classmethod
|
||
def validate_active_source_id(cls, value: str | None) -> str | None:
|
||
if value is not None:
|
||
_validate_source_id(value)
|
||
return value
|
||
|
||
@field_validator("window_rects")
|
||
@classmethod
|
||
def validate_window_rect_ids(
|
||
cls,
|
||
value: dict[str, NormalizedWindowRect],
|
||
) -> dict[str, NormalizedWindowRect]:
|
||
if len(value) > 256:
|
||
raise ValueError("window rectangle catalog exceeds its boundary")
|
||
for source_id in value:
|
||
_validate_source_id(source_id)
|
||
return value
|
||
|
||
@model_validator(mode="after")
|
||
def validate_active_source_visibility(self) -> LayoutPutRequest:
|
||
if (
|
||
self.active_floating_source_id is not None
|
||
and self.active_floating_source_id not in self.visible_source_ids
|
||
):
|
||
raise ValueError("active floating source must be visible")
|
||
return self
|
||
|
||
|
||
class ReplayLauncher(Protocol):
|
||
def __call__(
|
||
self,
|
||
command: ReplayCommand,
|
||
) -> Mapping[str, Any] | Awaitable[Mapping[str, Any]]: ...
|
||
|
||
|
||
class RecordingMaterializer(Protocol):
|
||
def __call__(self, command: ReplayCommand) -> MaterializedRecording: ...
|
||
|
||
|
||
class CatalogRefresher(Protocol):
|
||
"""Synchronous app-owned recovery + discovery transaction hook.
|
||
|
||
The host composes hardware-specific recovery before generic session
|
||
discovery, then passes the resulting callable here. Return values stay
|
||
internal and are intentionally ignored by the API.
|
||
"""
|
||
|
||
def __call__(self) -> object: ...
|
||
|
||
|
||
def build_session_router(
|
||
store: SessionStore,
|
||
*,
|
||
catalog_refresher: CatalogRefresher | None = None,
|
||
replay_launcher: ReplayLauncher | None = None,
|
||
recording_materializer: RecordingMaterializer | None = None,
|
||
recording_preparation_manager: SessionRecordingPreparationManager | None = None,
|
||
media_inspector: RecordedMediaInspector | None = None,
|
||
allow_synchronous_recording_fallback: bool = False,
|
||
replay_plugin_id: str = DEFAULT_REPLAY_PLUGIN_ID,
|
||
replay_action_id: str = DEFAULT_REPLAY_ACTION_ID,
|
||
) -> APIRouter:
|
||
"""Build generic host APIs without exposing filesystem locators to clients."""
|
||
|
||
router = APIRouter(tags=["observation-sessions"])
|
||
recorded_media_inspector = media_inspector or RecordedMediaInspector()
|
||
|
||
@router.get("/api/v1/observation-sessions")
|
||
def list_observation_sessions(
|
||
limit: int = Query(default=20, ge=1, le=100),
|
||
cursor: str | None = Query(default=None, max_length=128),
|
||
) -> dict[str, Any]:
|
||
try:
|
||
_refresh_catalog(catalog_refresher)
|
||
page = store.list_recent(limit=limit, cursor=cursor)
|
||
return {
|
||
"items": [
|
||
{
|
||
"id": item.session_id,
|
||
"label": item.display_name,
|
||
"started_at_utc": item.started_at_utc,
|
||
"completed_at_utc": item.completed_at_utc,
|
||
"status": item.status,
|
||
"modalities": list(item.modalities),
|
||
"duration_seconds": item.duration_seconds or 0.0,
|
||
"replayable": item.replayable,
|
||
**(
|
||
{
|
||
"preparation": _catalog_preparation_document(
|
||
store,
|
||
recording_preparation_manager,
|
||
item.session_id,
|
||
item.replayable,
|
||
)
|
||
}
|
||
if recording_preparation_manager is not None
|
||
else {}
|
||
),
|
||
}
|
||
for item in page.items
|
||
if item.started_at_utc is not None
|
||
]
|
||
}
|
||
except SessionNotFoundError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail="Некорректные параметры запроса.") from exc
|
||
|
||
@router.get("/api/v1/observation-sessions/{session_id}")
|
||
def get_observation_session(session_id: str) -> dict[str, Any]:
|
||
try:
|
||
_refresh_catalog(catalog_refresher)
|
||
return store.get_session(session_id).as_dict()
|
||
except SessionNotFoundError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="Некорректный идентификатор сессии.",
|
||
) from exc
|
||
|
||
@router.post("/api/v1/observation-sessions/{session_id}/replay")
|
||
async def replay_observation_session(
|
||
session_id: str,
|
||
request: Annotated[ReplayRequest | None, Body()] = None,
|
||
) -> Any:
|
||
replay_request = request or ReplayRequest()
|
||
try:
|
||
command = await run_in_threadpool(
|
||
_prepare_replay,
|
||
store,
|
||
catalog_refresher,
|
||
session_id,
|
||
replay_request.speed,
|
||
replay_request.loop,
|
||
True,
|
||
)
|
||
except SessionNotFoundError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except SessionNotReplayableError as exc:
|
||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||
except SessionIntegrityError as exc:
|
||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail="Некорректные параметры повтора.") from exc
|
||
|
||
if recording_preparation_manager is not None:
|
||
try:
|
||
# Enqueue performs only catalog identity/lstat checks. A cold
|
||
# RRD cache and every camera index are validated exclusively
|
||
# by the process-owned worker.
|
||
snapshot = recording_preparation_manager.enqueue(
|
||
command,
|
||
retry_failed=True,
|
||
)
|
||
except RecordingPreparationQueueFull as exc:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail="Очередь подготовки записей переполнена.",
|
||
) from exc
|
||
if snapshot.state != "ready" or snapshot.recording is None:
|
||
return _preparation_response(snapshot)
|
||
reserved = recording_preparation_manager.reserve_ready(
|
||
command.session_id,
|
||
preparation_id=snapshot.preparation_id,
|
||
)
|
||
if reserved is None or reserved.recording is None:
|
||
try:
|
||
replacement = recording_preparation_manager.enqueue(
|
||
command,
|
||
retry_failed=True,
|
||
)
|
||
except RecordingPreparationQueueFull as exc:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail="Очередь подготовки записей переполнена.",
|
||
) from exc
|
||
return _preparation_response(replacement)
|
||
return _recording_launch_document(
|
||
reserved.recording,
|
||
command,
|
||
reserved.recorded_media or (),
|
||
)
|
||
|
||
if recording_materializer is not None and allow_synchronous_recording_fallback:
|
||
recording = await _materialize(recording_materializer, command)
|
||
try:
|
||
recorded_media = await run_in_threadpool(
|
||
_inspect_recorded_media,
|
||
store,
|
||
recorded_media_inspector,
|
||
command,
|
||
recording,
|
||
)
|
||
except SessionIntegrityError as exc:
|
||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||
return _recording_launch_document(recording, command, recorded_media)
|
||
if recording_materializer is not None:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail="Сервис фоновой подготовки записей не настроен.",
|
||
)
|
||
|
||
runtime_state: dict[str, Any] | None = None
|
||
dispatched = replay_launcher is not None
|
||
if replay_launcher is not None:
|
||
launched = replay_launcher(command)
|
||
result = await launched if inspect.isawaitable(launched) else launched
|
||
runtime_state = dict(result)
|
||
return {
|
||
"schema_version": "missioncore.observation-session-replay/v1",
|
||
"launch": {
|
||
"kind": "plugin-action",
|
||
"plugin_id": replay_plugin_id,
|
||
"action_id": replay_action_id,
|
||
"session_id": command.session_id,
|
||
"speed": command.speed,
|
||
"loop": command.loop,
|
||
"dispatched": dispatched,
|
||
},
|
||
"runtime_state": runtime_state,
|
||
}
|
||
|
||
@router.get("/api/v1/observation-sessions/{session_id}/recording-preparation")
|
||
async def get_recording_preparation(
|
||
session_id: str,
|
||
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
||
) -> Any:
|
||
if recording_preparation_manager is None:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="Сервис фоновой подготовки записей не настроен.",
|
||
)
|
||
snapshot = recording_preparation_manager.status(session_id)
|
||
if snapshot is None:
|
||
if if_match is None:
|
||
raise HTTPException(
|
||
status_code=428,
|
||
detail="Укажите ETag подготовки в заголовке If-Match.",
|
||
)
|
||
# A polling URL is generation-bound. It must never silently create
|
||
# a replacement and bind an old browser poller to the new job.
|
||
raise HTTPException(
|
||
status_code=412,
|
||
detail="Подготовка была заменена более новой операцией.",
|
||
)
|
||
_require_matching_preparation(snapshot, if_match)
|
||
if snapshot.state == "ready" and snapshot.recording is not None:
|
||
reserved = recording_preparation_manager.reserve_ready(
|
||
session_id,
|
||
preparation_id=snapshot.preparation_id,
|
||
)
|
||
if reserved is None or reserved.recording is None:
|
||
raise HTTPException(
|
||
status_code=412,
|
||
detail="Подготовка была заменена более новой операцией.",
|
||
)
|
||
snapshot = reserved
|
||
return JSONResponse(
|
||
content=_recording_launch_document(
|
||
reserved.recording,
|
||
snapshot.command,
|
||
snapshot.recorded_media or (),
|
||
),
|
||
headers={
|
||
"Cache-Control": "no-store",
|
||
"ETag": _preparation_etag(snapshot.preparation_id),
|
||
"X-Content-Type-Options": "nosniff",
|
||
},
|
||
)
|
||
return _preparation_response(snapshot)
|
||
|
||
@router.delete(
|
||
"/api/v1/observation-sessions/{session_id}/recording-preparation",
|
||
status_code=204,
|
||
)
|
||
def delete_recording_preparation(
|
||
session_id: str,
|
||
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
||
) -> Response:
|
||
if recording_preparation_manager is None:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="Сервис фоновой подготовки записей не настроен.",
|
||
)
|
||
snapshot = recording_preparation_manager.status(session_id)
|
||
if snapshot is None:
|
||
raise HTTPException(status_code=404, detail="Подготовка записи не найдена.")
|
||
_require_matching_preparation(snapshot, if_match)
|
||
recording_preparation_manager.cancel(
|
||
session_id,
|
||
preparation_id=snapshot.preparation_id,
|
||
)
|
||
return Response(status_code=204)
|
||
|
||
@router.get("/api/v1/observation-sessions/{session_id}/recording.rrd")
|
||
async def get_observation_session_recording(
|
||
session_id: str,
|
||
generation: Annotated[str | None, Query(max_length=64)] = None,
|
||
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
||
if_none_match: Annotated[str | None, Header()] = None,
|
||
) -> Response:
|
||
if generation is not None and SAFE_SHA256.fullmatch(generation) is None:
|
||
raise HTTPException(
|
||
status_code=412,
|
||
detail="Поколение подготовленной записи не совпадает.",
|
||
)
|
||
if recording_materializer is None:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="Сервис seekable-записей не настроен.",
|
||
)
|
||
release_recording: Callable[[], None] | None
|
||
if recording_preparation_manager is not None:
|
||
if if_match is None and generation is None:
|
||
raise HTTPException(
|
||
status_code=428,
|
||
detail="Укажите digest записи через generation или If-Match.",
|
||
)
|
||
snapshot = recording_preparation_manager.status(session_id)
|
||
if snapshot is None:
|
||
try:
|
||
command = await run_in_threadpool(
|
||
_prepare_replay,
|
||
store,
|
||
catalog_refresher,
|
||
session_id,
|
||
1.0,
|
||
False,
|
||
False,
|
||
)
|
||
snapshot = recording_preparation_manager.enqueue(command)
|
||
except SessionNotFoundError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except (SessionNotReplayableError, SessionIntegrityError) as exc:
|
||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||
except RecordingPreparationQueueFull as exc:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail="Очередь подготовки записей переполнена.",
|
||
) from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="Некорректный идентификатор сессии.",
|
||
) from exc
|
||
if snapshot.state in {
|
||
"queued",
|
||
"validating",
|
||
"exporting",
|
||
"finalizing",
|
||
}:
|
||
return _preparation_response(snapshot)
|
||
if snapshot.state != "ready" or snapshot.recording is None:
|
||
return _preparation_response(snapshot)
|
||
recording = snapshot.recording
|
||
etag = f'"sha256:{recording.sha256}"'
|
||
if generation is not None:
|
||
_require_matching_recording_generation(recording.sha256, generation)
|
||
if if_match is not None:
|
||
_require_matching_digest(etag, if_match)
|
||
pinned = recording_preparation_manager.pin_ready(
|
||
session_id,
|
||
preparation_id=snapshot.preparation_id,
|
||
)
|
||
if pinned is None:
|
||
raise HTTPException(
|
||
status_code=412,
|
||
detail="Подготовленная запись была заменена.",
|
||
)
|
||
snapshot, pinned_release = pinned
|
||
release_recording = pinned_release
|
||
pinned_recording = snapshot.recording
|
||
if pinned_recording is None:
|
||
release_recording()
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="Подготовленная запись сессии недоступна.",
|
||
)
|
||
recording = pinned_recording
|
||
elif allow_synchronous_recording_fallback:
|
||
try:
|
||
command = await run_in_threadpool(
|
||
_prepare_replay,
|
||
store,
|
||
catalog_refresher,
|
||
session_id,
|
||
1.0,
|
||
False,
|
||
False,
|
||
)
|
||
except SessionNotFoundError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except (SessionNotReplayableError, SessionIntegrityError) as exc:
|
||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="Некорректный идентификатор сессии.",
|
||
) from exc
|
||
recording, release_recording = await _materialize_pinned(
|
||
recording_materializer,
|
||
command,
|
||
)
|
||
try:
|
||
if generation is not None:
|
||
_require_matching_recording_generation(recording.sha256, generation)
|
||
if if_match is not None:
|
||
_require_matching_digest(f'"sha256:{recording.sha256}"', if_match)
|
||
except HTTPException:
|
||
if release_recording is not None:
|
||
release_recording()
|
||
raise
|
||
else:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail="Сервис фоновой подготовки записей не настроен.",
|
||
)
|
||
etag = f'"sha256:{recording.sha256}"'
|
||
cache_control = (
|
||
IMMUTABLE_RECORDING_CACHE_CONTROL
|
||
if generation is not None
|
||
else REVALIDATED_RECORDING_CACHE_CONTROL
|
||
)
|
||
if _if_none_match_matches(if_none_match, etag):
|
||
if release_recording is not None:
|
||
release_recording()
|
||
return Response(
|
||
status_code=304,
|
||
headers={
|
||
"Cache-Control": cache_control,
|
||
"ETag": etag,
|
||
"X-Content-Type-Options": "nosniff",
|
||
},
|
||
)
|
||
try:
|
||
stat_result = recording.path.stat()
|
||
except OSError as exc:
|
||
if release_recording is not None:
|
||
release_recording()
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="Подготовленная запись сессии недоступна.",
|
||
) from exc
|
||
response_type = _ReleasingFileResponse if release_recording is not None else FileResponse
|
||
response_kwargs: dict[str, Any] = {}
|
||
if release_recording is not None:
|
||
response_kwargs["release"] = release_recording
|
||
return response_type(
|
||
recording.path,
|
||
media_type=recording.media_type,
|
||
filename=f"{recording.session_id}.rrd",
|
||
content_disposition_type="inline",
|
||
stat_result=stat_result,
|
||
headers={
|
||
"Cache-Control": cache_control,
|
||
"ETag": etag,
|
||
"Content-Length": str(recording.byte_length),
|
||
"X-Content-Type-Options": "nosniff",
|
||
},
|
||
**response_kwargs,
|
||
)
|
||
|
||
@router.post("/api/v1/observation-sessions/{session_id}/blueprint.rrd")
|
||
async def get_observation_session_blueprint(
|
||
session_id: str,
|
||
request: RecordedBlueprintRequest,
|
||
) -> Response:
|
||
try:
|
||
await run_in_threadpool(
|
||
_prepare_replay,
|
||
store,
|
||
catalog_refresher,
|
||
session_id,
|
||
1.0,
|
||
False,
|
||
False,
|
||
)
|
||
payload = await run_in_threadpool(
|
||
recorded_blueprint_rrd,
|
||
RerunSceneSettings(
|
||
point_size=request.point_size,
|
||
palette=request.palette,
|
||
custom_color=request.custom_color,
|
||
accumulation_seconds=request.accumulation_seconds,
|
||
show_points=request.show_points,
|
||
show_trajectory=request.show_trajectory,
|
||
show_grid=request.show_grid,
|
||
),
|
||
application_id=RECORDED_APPLICATION_ID,
|
||
recording_id=request.recording_id,
|
||
)
|
||
except SessionNotFoundError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except (SessionNotReplayableError, SessionIntegrityError) as exc:
|
||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||
except RrdExportError as exc:
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="Не удалось подготовить настройки визуализатора.",
|
||
) from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="Некорректный идентификатор сессии.",
|
||
) from exc
|
||
return Response(
|
||
content=payload,
|
||
media_type="application/vnd.rerun.rrd",
|
||
headers={
|
||
"Cache-Control": "no-store",
|
||
"X-Content-Type-Options": "nosniff",
|
||
"Content-Disposition": 'inline; filename="blueprint.rrd"',
|
||
},
|
||
)
|
||
|
||
@router.get("/api/v1/observation-sessions/{session_id}/media/{artifact_id}/manifest")
|
||
def get_recorded_media_manifest(
|
||
session_id: str,
|
||
artifact_id: str,
|
||
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
||
) -> JSONResponse:
|
||
manifest = _resolve_recorded_media_manifest(
|
||
store,
|
||
recorded_media_inspector,
|
||
recording_preparation_manager,
|
||
allow_synchronous_recording_fallback,
|
||
catalog_refresher,
|
||
session_id,
|
||
artifact_id,
|
||
)
|
||
manifest_etag = f'"sha256:{manifest.generation_sha256}"'
|
||
_require_matching_digest(manifest_etag, if_match)
|
||
return JSONResponse(
|
||
content=_recorded_media_manifest_document(manifest),
|
||
headers={
|
||
"Cache-Control": "private, no-cache",
|
||
"ETag": manifest_etag,
|
||
"X-Content-Type-Options": "nosniff",
|
||
},
|
||
)
|
||
|
||
@router.get(
|
||
"/api/v1/observation-sessions/{session_id}/media/{artifact_id}/"
|
||
"epochs/{epoch_ordinal}/init.mp4"
|
||
)
|
||
def get_recorded_media_init(
|
||
session_id: str,
|
||
artifact_id: str,
|
||
epoch_ordinal: int,
|
||
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
||
range_header: Annotated[str | None, Header(alias="Range")] = None,
|
||
) -> Response:
|
||
manifest = _resolve_recorded_media_manifest(
|
||
store,
|
||
recorded_media_inspector,
|
||
recording_preparation_manager,
|
||
allow_synchronous_recording_fallback,
|
||
catalog_refresher,
|
||
session_id,
|
||
artifact_id,
|
||
)
|
||
expected_sha256 = _recorded_media_init_sha256(manifest, epoch_ordinal)
|
||
_require_matching_digest(f'"sha256:{expected_sha256}"', if_match)
|
||
try:
|
||
media_file = recorded_media_inspector.get_init(manifest, epoch_ordinal)
|
||
except SessionIntegrityError as exc:
|
||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||
return _recorded_media_file_response(media_file, range_header)
|
||
|
||
@router.get(
|
||
"/api/v1/observation-sessions/{session_id}/media/{artifact_id}/"
|
||
"epochs/{epoch_ordinal}/segments/{segment_sequence}.m4s"
|
||
)
|
||
def get_recorded_media_segment(
|
||
session_id: str,
|
||
artifact_id: str,
|
||
epoch_ordinal: int,
|
||
segment_sequence: int,
|
||
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
||
range_header: Annotated[str | None, Header(alias="Range")] = None,
|
||
) -> Response:
|
||
manifest = _resolve_recorded_media_manifest(
|
||
store,
|
||
recorded_media_inspector,
|
||
recording_preparation_manager,
|
||
allow_synchronous_recording_fallback,
|
||
catalog_refresher,
|
||
session_id,
|
||
artifact_id,
|
||
)
|
||
expected_sha256 = _recorded_media_segment_sha256(
|
||
manifest,
|
||
epoch_ordinal,
|
||
segment_sequence,
|
||
)
|
||
_require_matching_digest(f'"sha256:{expected_sha256}"', if_match)
|
||
try:
|
||
media_file = recorded_media_inspector.get_segment(
|
||
manifest,
|
||
epoch_ordinal,
|
||
segment_sequence,
|
||
)
|
||
except SessionIntegrityError as exc:
|
||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||
return _recorded_media_file_response(media_file, range_header)
|
||
|
||
@router.get("/api/v1/workspace-layouts/{workspace_id}")
|
||
def get_workspace_layout(workspace_id: str, response: Response) -> dict[str, Any]:
|
||
try:
|
||
stored = store.get_layout(workspace_id)
|
||
document = _layout_document(stored.workspace_id, stored.revision, stored.layout)
|
||
response.headers["ETag"] = f'"{stored.revision}"'
|
||
return document
|
||
except SessionNotFoundError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail="Некорректный workspace id.") from exc
|
||
|
||
@router.put("/api/v1/workspace-layouts/{workspace_id}")
|
||
def put_workspace_layout(
|
||
workspace_id: str,
|
||
request: LayoutPutRequest,
|
||
response: Response,
|
||
if_match: Annotated[str, Header(alias="If-Match")],
|
||
) -> dict[str, Any]:
|
||
header_revision = _parse_if_match(if_match)
|
||
if workspace_id != request.workspace_id:
|
||
raise HTTPException(status_code=422, detail="Workspace id не совпадает с документом.")
|
||
if header_revision != request.revision:
|
||
raise HTTPException(status_code=412, detail="Ревизия документа устарела.")
|
||
payload = request.model_dump(
|
||
mode="json",
|
||
exclude={"version", "revision", "workspace_id"},
|
||
)
|
||
try:
|
||
stored = store.save_layout(
|
||
workspace_id,
|
||
schema_version=request.version,
|
||
expected_revision=request.revision,
|
||
name="Пространственная сцена",
|
||
layout=payload,
|
||
)
|
||
response.headers["ETag"] = f'"{stored.revision}"'
|
||
return _layout_document(stored.workspace_id, stored.revision, stored.layout)
|
||
except LayoutConflictError as exc:
|
||
raise HTTPException(status_code=412, detail=str(exc)) from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=422, detail="Некорректная компоновка.") from exc
|
||
|
||
return router
|
||
|
||
|
||
def _refresh_catalog(refresher: CatalogRefresher | None) -> None:
|
||
if refresher is None:
|
||
return
|
||
try:
|
||
result = refresher()
|
||
if inspect.isawaitable(result):
|
||
if inspect.iscoroutine(result):
|
||
result.close()
|
||
raise TypeError("catalog refresher must be synchronous")
|
||
except Exception as exc:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail="Не удалось обновить каталог сохранённых сессий.",
|
||
) from exc
|
||
|
||
|
||
def _prepare_replay(
|
||
store: SessionStore,
|
||
catalog_refresher: CatalogRefresher | None,
|
||
session_id: str,
|
||
speed: float,
|
||
loop: bool,
|
||
refresh_first: bool,
|
||
) -> ReplayCommand:
|
||
if refresh_first:
|
||
_refresh_catalog(catalog_refresher)
|
||
try:
|
||
return store.prepare_replay(session_id, speed=speed, loop=loop)
|
||
except SessionNotFoundError:
|
||
if refresh_first or catalog_refresher is None:
|
||
raise
|
||
_refresh_catalog(catalog_refresher)
|
||
return store.prepare_replay(session_id, speed=speed, loop=loop)
|
||
|
||
|
||
def _validate_source_id(source_id: str) -> None:
|
||
if SAFE_SOURCE_ID.fullmatch(source_id) is None:
|
||
raise ValueError("source identifier has an invalid shape")
|
||
|
||
|
||
def _parse_if_match(value: str) -> int:
|
||
normalized = value.strip()
|
||
if len(normalized) < 3 or normalized[0] != '"' or normalized[-1] != '"':
|
||
raise HTTPException(status_code=422, detail="If-Match должен содержать quoted revision.")
|
||
revision = normalized[1:-1]
|
||
if not revision.isascii() or not revision.isdecimal():
|
||
raise HTTPException(status_code=422, detail="If-Match содержит некорректную ревизию.")
|
||
return int(revision)
|
||
|
||
|
||
def _layout_document(
|
||
workspace_id: str,
|
||
revision: int,
|
||
layout: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
try:
|
||
document = LayoutPutRequest.model_validate(
|
||
{
|
||
"version": 1,
|
||
"revision": revision,
|
||
"workspace_id": workspace_id,
|
||
**layout,
|
||
}
|
||
)
|
||
except ValueError as exc:
|
||
raise SessionIntegrityError("stored workspace layout violates schema version 1") from exc
|
||
return document.model_dump(mode="json")
|
||
|
||
|
||
ReplayLauncherCallable = Callable[
|
||
[ReplayCommand],
|
||
Mapping[str, Any] | Awaitable[Mapping[str, Any]],
|
||
]
|
||
|
||
|
||
async def _materialize(
|
||
materializer: RecordingMaterializer,
|
||
command: ReplayCommand,
|
||
) -> MaterializedRecording:
|
||
try:
|
||
recording = await run_in_threadpool(materializer, command)
|
||
except RecordingMaterializationError as exc:
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="Не удалось подготовить запись сессии.",
|
||
) from exc
|
||
if not isinstance(recording, MaterializedRecording):
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="Сервис записи вернул некорректный результат.",
|
||
)
|
||
return recording
|
||
|
||
|
||
async def _materialize_pinned(
|
||
materializer: RecordingMaterializer,
|
||
command: ReplayCommand,
|
||
) -> tuple[MaterializedRecording, Callable[[], None] | None]:
|
||
pin = getattr(materializer, "materialize_pinned", None)
|
||
if not callable(pin):
|
||
return await _materialize(materializer, command), None
|
||
try:
|
||
result = await run_in_threadpool(pin, command)
|
||
except RecordingMaterializationError as exc:
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="Не удалось подготовить запись сессии.",
|
||
) from exc
|
||
if (
|
||
not isinstance(result, tuple)
|
||
or len(result) != 2
|
||
or not isinstance(result[0], MaterializedRecording)
|
||
or not callable(result[1])
|
||
):
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail="Сервис записи вернул некорректный pinned-результат.",
|
||
)
|
||
return result[0], result[1]
|
||
|
||
|
||
def _preparation_response(snapshot: RecordingPreparationSnapshot) -> JSONResponse:
|
||
encoded_session_id = quote(snapshot.session_id, safe="")
|
||
status_url = f"/api/v1/observation-sessions/{encoded_session_id}/recording-preparation"
|
||
preparation: dict[str, Any] = {
|
||
"preparation_id": snapshot.preparation_id,
|
||
"session_id": snapshot.session_id,
|
||
"state": snapshot.state,
|
||
"progress": snapshot.progress,
|
||
"updated_at_utc": snapshot.updated_at_utc,
|
||
"status_url": status_url,
|
||
"cancellable": snapshot.cancellable,
|
||
}
|
||
terminal_failure = snapshot.state in {"failed", "cancelled"}
|
||
if terminal_failure:
|
||
preparation["retryable"] = snapshot.retryable
|
||
preparation["error"] = snapshot.error or "Подготовка записи отменена."
|
||
headers = {
|
||
"Location": status_url,
|
||
"Cache-Control": "no-store",
|
||
"ETag": _preparation_etag(snapshot.preparation_id),
|
||
"X-Content-Type-Options": "nosniff",
|
||
}
|
||
if not terminal_failure:
|
||
headers["Retry-After"] = "1"
|
||
return JSONResponse(
|
||
status_code=409 if terminal_failure else 202,
|
||
content={
|
||
"schema_version": "missioncore.observation-session-preparation/v1",
|
||
"preparation": preparation,
|
||
},
|
||
headers=headers,
|
||
)
|
||
|
||
|
||
def _preparation_etag(preparation_id: str) -> str:
|
||
return f'"{preparation_id}"'
|
||
|
||
|
||
def _require_matching_preparation(
|
||
snapshot: RecordingPreparationSnapshot,
|
||
if_match: str | None,
|
||
) -> None:
|
||
if if_match is None:
|
||
raise HTTPException(
|
||
status_code=428,
|
||
detail="Укажите ETag подготовки в заголовке If-Match.",
|
||
)
|
||
if if_match.strip() != _preparation_etag(snapshot.preparation_id):
|
||
raise HTTPException(
|
||
status_code=412,
|
||
detail="Подготовка была заменена более новой операцией.",
|
||
)
|
||
|
||
|
||
def _require_matching_digest(expected_etag: str, if_match: str | None) -> None:
|
||
if if_match is None:
|
||
raise HTTPException(
|
||
status_code=428,
|
||
detail="Укажите digest поколения в заголовке If-Match.",
|
||
)
|
||
if if_match.strip() != expected_etag:
|
||
raise HTTPException(
|
||
status_code=412,
|
||
detail="Запрошенное поколение данных устарело.",
|
||
)
|
||
|
||
|
||
def _require_matching_recording_generation(
|
||
expected_sha256: str,
|
||
generation: str,
|
||
) -> None:
|
||
if SAFE_SHA256.fullmatch(generation) is None or generation != expected_sha256:
|
||
raise HTTPException(
|
||
status_code=412,
|
||
detail="Поколение подготовленной записи не совпадает.",
|
||
)
|
||
|
||
|
||
def _catalog_preparation_document(
|
||
store: SessionStore,
|
||
manager: SessionRecordingPreparationManager | None,
|
||
session_id: str,
|
||
replayable: bool,
|
||
) -> dict[str, Any] | None:
|
||
if manager is None or not replayable:
|
||
return None
|
||
snapshot = manager.status(session_id)
|
||
if snapshot is None:
|
||
try:
|
||
snapshot = manager.enqueue(store.prepare_replay(session_id))
|
||
except (
|
||
RecordingPreparationQueueFull,
|
||
SessionNotFoundError,
|
||
SessionNotReplayableError,
|
||
SessionIntegrityError,
|
||
ValueError,
|
||
):
|
||
return None
|
||
document: dict[str, Any] = {
|
||
"preparation_id": snapshot.preparation_id,
|
||
"state": snapshot.state,
|
||
"progress": snapshot.progress,
|
||
"updated_at_utc": snapshot.updated_at_utc,
|
||
"cancellable": snapshot.cancellable,
|
||
"retryable": snapshot.retryable,
|
||
}
|
||
if snapshot.error is not None:
|
||
document["error"] = snapshot.error
|
||
return document
|
||
|
||
|
||
def _recording_launch_document(
|
||
recording: MaterializedRecording,
|
||
command: ReplayCommand,
|
||
recorded_media: tuple[RecordedMediaManifest, ...] = (),
|
||
) -> dict[str, Any]:
|
||
encoded_session_id = quote(recording.session_id, safe="")
|
||
source_url = f"/api/v1/observation-sessions/{encoded_session_id}/recording.rrd"
|
||
return {
|
||
"schema_version": "missioncore.observation-session-replay/v2",
|
||
"launch": {
|
||
"kind": "rerun-recording",
|
||
"session_id": recording.session_id,
|
||
"source_url": source_url,
|
||
"viewer_source_url": f"{source_url}?generation={recording.sha256}",
|
||
"media_type": recording.media_type,
|
||
"timeline": recording.timeline,
|
||
"timeline_start_seconds": recording.timeline_start_ns / 1_000_000_000,
|
||
"timeline_end_seconds": recording.timeline_end_ns / 1_000_000_000,
|
||
"seekable": True,
|
||
"byte_length": recording.byte_length,
|
||
"sha256": recording.sha256,
|
||
"playback": {
|
||
"speed": command.speed,
|
||
"loop": command.loop,
|
||
},
|
||
"media_sources": [
|
||
_recorded_media_launch_source(
|
||
manifest,
|
||
index=index,
|
||
)
|
||
for index, manifest in enumerate(recorded_media, start=1)
|
||
],
|
||
},
|
||
}
|
||
|
||
|
||
def _inspect_recorded_media(
|
||
store: SessionStore,
|
||
inspector: RecordedMediaInspector,
|
||
command: ReplayCommand,
|
||
recording: MaterializedRecording,
|
||
) -> tuple[RecordedMediaManifest, ...]:
|
||
manifests = tuple(
|
||
inspector.inspect(artifact, command)
|
||
for artifact in store.list_recorded_media(command.session_id)
|
||
)
|
||
validate_recorded_media_timeline(
|
||
manifests,
|
||
recording_start_seconds=recording.timeline_start_ns / 1_000_000_000,
|
||
recording_end_seconds=recording.timeline_end_ns / 1_000_000_000,
|
||
)
|
||
return manifests
|
||
|
||
|
||
def _recorded_media_launch_source(
|
||
manifest: RecordedMediaManifest,
|
||
*,
|
||
index: int,
|
||
) -> dict[str, Any]:
|
||
encoded_session_id = quote(manifest.session_id, safe="")
|
||
encoded_artifact_id = quote(manifest.artifact_id, safe="")
|
||
return {
|
||
"id": manifest.public_source_id,
|
||
"label": f"Записанная камера {index}",
|
||
"modality": "video",
|
||
"manifest_url": (
|
||
f"/api/v1/observation-sessions/{encoded_session_id}/media/"
|
||
f"{encoded_artifact_id}/manifest"
|
||
),
|
||
"media_type": "video/mp4",
|
||
"manifest_generation_sha256": manifest.generation_sha256,
|
||
"byte_length": manifest.byte_length,
|
||
"timeline_start_seconds": manifest.timeline_start_seconds,
|
||
"timeline_end_seconds": manifest.timeline_end_seconds,
|
||
"seekable": True,
|
||
"synchronization": manifest.synchronization,
|
||
}
|
||
|
||
|
||
def _recorded_media_manifest_document(
|
||
manifest: RecordedMediaManifest,
|
||
) -> dict[str, Any]:
|
||
encoded_session_id = quote(manifest.session_id, safe="")
|
||
encoded_artifact_id = quote(manifest.artifact_id, safe="")
|
||
base = f"/api/v1/observation-sessions/{encoded_session_id}/media/{encoded_artifact_id}"
|
||
return {
|
||
"schema_version": RECORDED_MEDIA_MANIFEST_SCHEMA,
|
||
"source_id": manifest.public_source_id,
|
||
"generation_sha256": manifest.generation_sha256,
|
||
"byte_length": manifest.byte_length,
|
||
"timeline_start_seconds": manifest.timeline_start_seconds,
|
||
"timeline_end_seconds": manifest.timeline_end_seconds,
|
||
"synchronization": manifest.synchronization,
|
||
"epochs": [
|
||
{
|
||
"ordinal": epoch.ordinal,
|
||
"timeline_start_seconds": epoch.timeline_start_seconds,
|
||
"timeline_end_seconds": epoch.timeline_end_seconds,
|
||
"media_type": epoch.media_type,
|
||
"init_url": f"{base}/epochs/{epoch.ordinal}/init.mp4",
|
||
"init_byte_length": epoch.init_byte_length,
|
||
"init_sha256": epoch.init_sha256,
|
||
"segment_count": len(epoch.segments),
|
||
"segment_url_prefix": f"{base}/epochs/{epoch.ordinal}/segments/",
|
||
"segments": [
|
||
{
|
||
"sequence": segment.sequence,
|
||
"url": (
|
||
f"{base}/epochs/{epoch.ordinal}/segments/"
|
||
f"{segment.sequence}.m4s"
|
||
),
|
||
"byte_length": segment.byte_length,
|
||
"sha256": segment.sha256,
|
||
}
|
||
for segment in epoch.segments
|
||
],
|
||
}
|
||
for epoch in manifest.epochs
|
||
],
|
||
}
|
||
|
||
|
||
def _resolve_recorded_media_manifest(
|
||
store: SessionStore,
|
||
inspector: RecordedMediaInspector,
|
||
manager: SessionRecordingPreparationManager | None,
|
||
allow_synchronous_fallback: bool,
|
||
catalog_refresher: CatalogRefresher | None,
|
||
session_id: str,
|
||
artifact_id: str,
|
||
) -> RecordedMediaManifest:
|
||
if manager is not None:
|
||
try:
|
||
_validate_source_id(artifact_id)
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="Некорректный идентификатор записанного медиаканала.",
|
||
) from exc
|
||
snapshot = manager.status(session_id)
|
||
if (
|
||
snapshot is None
|
||
or snapshot.state != "ready"
|
||
or snapshot.recorded_media is None
|
||
):
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="Медиаканал ещё не подготовлен.",
|
||
)
|
||
matches = tuple(
|
||
manifest
|
||
for manifest in snapshot.recorded_media
|
||
if manifest.artifact_id == artifact_id
|
||
)
|
||
if len(matches) != 1:
|
||
raise HTTPException(status_code=404, detail="Записанный медиаканал не найден.")
|
||
return matches[0]
|
||
if not allow_synchronous_fallback:
|
||
raise HTTPException(
|
||
status_code=503,
|
||
detail="Сервис фоновой подготовки медиаканалов не настроен.",
|
||
)
|
||
try:
|
||
command = _prepare_replay(
|
||
store,
|
||
catalog_refresher,
|
||
session_id,
|
||
1.0,
|
||
False,
|
||
False,
|
||
)
|
||
artifact = store.get_recorded_media(session_id, artifact_id)
|
||
return inspector.inspect(artifact, command)
|
||
except SessionNotFoundError as exc:
|
||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||
except (SessionNotReplayableError, SessionIntegrityError) as exc:
|
||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||
except ValueError as exc:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="Некорректный идентификатор записанного медиаканала.",
|
||
) from exc
|
||
|
||
|
||
def _recorded_media_init_sha256(
|
||
manifest: RecordedMediaManifest,
|
||
epoch_ordinal: int,
|
||
) -> str:
|
||
matches = tuple(epoch for epoch in manifest.epochs if epoch.ordinal == epoch_ordinal)
|
||
if len(matches) != 1:
|
||
raise HTTPException(status_code=404, detail="Эпоха записанного медиаканала не найдена.")
|
||
return matches[0].init_sha256
|
||
|
||
|
||
def _recorded_media_segment_sha256(
|
||
manifest: RecordedMediaManifest,
|
||
epoch_ordinal: int,
|
||
segment_sequence: int,
|
||
) -> str:
|
||
matches = tuple(epoch for epoch in manifest.epochs if epoch.ordinal == epoch_ordinal)
|
||
if len(matches) != 1:
|
||
raise HTTPException(status_code=404, detail="Эпоха записанного медиаканала не найдена.")
|
||
segments = tuple(
|
||
segment for segment in matches[0].segments if segment.sequence == segment_sequence
|
||
)
|
||
if len(segments) != 1:
|
||
raise HTTPException(status_code=404, detail="Сегмент записанного медиаканала не найден.")
|
||
return segments[0].sha256
|
||
|
||
|
||
def _if_none_match_matches(value: str | None, etag: str) -> bool:
|
||
if value is None:
|
||
return False
|
||
# RFC 9110 requires weak comparison for If-None-Match. The recording ETag
|
||
# is content-addressed, so accepting its weak spelling is safe and lets a
|
||
# browser reuse an already-downloaded multi-hundred-megabyte RRD.
|
||
expected = etag.removeprefix("W/")
|
||
for candidate in value.split(","):
|
||
normalized = candidate.strip()
|
||
if normalized == "*" or normalized.removeprefix("W/") == expected:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _recorded_media_file_response(
|
||
media_file: RecordedMediaFile,
|
||
range_header: str | None,
|
||
) -> Response:
|
||
payload = media_file.payload
|
||
status_code = 200
|
||
content_range: str | None = None
|
||
if range_header is not None:
|
||
start, end = _parse_byte_range(range_header, len(payload))
|
||
payload = payload[start : end + 1]
|
||
status_code = 206
|
||
content_range = f"bytes {start}-{end}/{media_file.byte_length}"
|
||
headers = {
|
||
"Accept-Ranges": "bytes",
|
||
"Cache-Control": "private, max-age=31536000, immutable, no-transform",
|
||
"ETag": f'"sha256:{media_file.sha256}"',
|
||
"Content-Length": str(len(payload)),
|
||
"X-Content-Type-Options": "nosniff",
|
||
"Content-Disposition": f'inline; filename="{media_file.filename}"',
|
||
}
|
||
if content_range is not None:
|
||
headers["Content-Range"] = content_range
|
||
return Response(
|
||
content=payload,
|
||
status_code=status_code,
|
||
media_type=media_file.media_type,
|
||
headers=headers,
|
||
)
|
||
|
||
|
||
def _parse_byte_range(value: str, byte_length: int) -> tuple[int, int]:
|
||
match = re.fullmatch(r"bytes=(\d*)-(\d*)", value.strip())
|
||
if match is None or byte_length < 1:
|
||
raise HTTPException(
|
||
status_code=416,
|
||
detail="Некорректный Range для записанного медиасегмента.",
|
||
headers={"Content-Range": f"bytes */{byte_length}"},
|
||
)
|
||
raw_start, raw_end = match.groups()
|
||
if not raw_start and not raw_end:
|
||
raise HTTPException(
|
||
status_code=416,
|
||
detail="Некорректный Range для записанного медиасегмента.",
|
||
headers={"Content-Range": f"bytes */{byte_length}"},
|
||
)
|
||
if not raw_start:
|
||
suffix = int(raw_end)
|
||
if suffix < 1:
|
||
raise HTTPException(
|
||
status_code=416,
|
||
detail="Некорректный Range для записанного медиасегмента.",
|
||
headers={"Content-Range": f"bytes */{byte_length}"},
|
||
)
|
||
start = max(0, byte_length - suffix)
|
||
return start, byte_length - 1
|
||
start = int(raw_start)
|
||
end = byte_length - 1 if not raw_end else int(raw_end)
|
||
if start >= byte_length or end < start:
|
||
raise HTTPException(
|
||
status_code=416,
|
||
detail="Range выходит за границы записанного медиасегмента.",
|
||
headers={"Content-Range": f"bytes */{byte_length}"},
|
||
)
|
||
return start, min(end, byte_length - 1)
|