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)