feat(ui): add environment media playlists

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 14:20:19 +03:00
parent c455301764
commit 8b1f109b09
11 changed files with 1076 additions and 138 deletions
+289 -24
View File
@@ -11,6 +11,7 @@ from typing import Annotated, Literal
from urllib.parse import unquote, urlsplit
from fastapi import APIRouter, Header, HTTPException, Query, Request
from fastapi import Path as ApiPath
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -23,17 +24,32 @@ EnvironmentSurfaceId = Literal[
"system",
"polygon",
]
ENVIRONMENT_SURFACE_IDS: tuple[EnvironmentSurfaceId, ...] = (
"home",
"fleet",
"observation",
"missions",
"data",
"system",
"polygon",
)
EnvironmentMediaKind = Literal["image", "video"]
EnvironmentMediaSource = Literal["file", "url"]
ENVIRONMENT_SCHEMA_VERSION: Literal["missioncore.operator-environment/v2"] = (
"missioncore.operator-environment/v2"
ENVIRONMENT_SCHEMA_VERSION: Literal["missioncore.operator-environment/v3"] = (
"missioncore.operator-environment/v3"
)
ENVIRONMENT_MEDIA_SCHEMA_VERSION: Literal[
"missioncore.operator-environment-media/v1"
] = "missioncore.operator-environment-media/v1"
ENVIRONMENT_PLAYLIST_MEDIA_SCHEMA_VERSION: Literal[
"missioncore.operator-environment-media/v2"
] = "missioncore.operator-environment-media/v2"
MAX_ENVIRONMENT_MEDIA_BYTES = 256 * 1024 * 1024
MAX_ENVIRONMENT_MEDIA_ITEMS = 24
DEFAULT_IMAGE_DURATION_SECONDS = 10
SAFE_FILE_NAME = re.compile(r"[^A-Za-z0-9А-Яа-яЁё._ -]+")
SAFE_MEDIA_ITEM_ID = re.compile(r"^media-[a-z0-9-]{1,58}$")
SUPPORTED_MEDIA_TYPES: dict[str, tuple[EnvironmentMediaKind, str]] = {
"image/avif": ("image", ".avif"),
"image/gif": ("image", ".gif"),
@@ -50,25 +66,43 @@ class StrictApiModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class EnvironmentBackground(StrictApiModel):
enabled: bool = False
class EnvironmentMediaItem(StrictApiModel):
id: str = Field(pattern=r"^media-[a-z0-9-]{1,58}$")
source: EnvironmentMediaSource = "file"
url: str | None = Field(default=None, max_length=2048)
media_kind: EnvironmentMediaKind | None = None
url: str = Field(max_length=2048)
media_kind: EnvironmentMediaKind
file_name: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def validate_source(self) -> EnvironmentBackground:
if not self.enabled:
return self
if not self.url or self.media_kind is None:
raise ValueError("enabled background requires url and media kind")
def validate_source(self) -> EnvironmentMediaItem:
if self.source == "url":
parsed = urlsplit(self.url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("external background URL must use HTTP or HTTPS")
raise ValueError("external media URL must use HTTP or HTTPS")
elif not self.url.startswith("/api/v1/environment/media/"):
raise ValueError("file background must use the Mission Core media endpoint")
raise ValueError("file media must use the Mission Core media endpoint")
return self
class EnvironmentBackground(StrictApiModel):
enabled: bool = False
image_duration_seconds: int = Field(
default=DEFAULT_IMAGE_DURATION_SECONDS,
ge=1,
le=300,
)
items: list[EnvironmentMediaItem] = Field(
default_factory=list,
max_length=MAX_ENVIRONMENT_MEDIA_ITEMS,
)
@model_validator(mode="after")
def validate_playlist(self) -> EnvironmentBackground:
ids = [item.id for item in self.items]
if len(ids) != len(set(ids)):
raise ValueError("background media ids must be unique")
if self.enabled and not self.items:
raise ValueError("enabled background requires at least one media item")
return self
@@ -115,7 +149,7 @@ class EnvironmentSettingsPut(StrictApiModel):
class EnvironmentSettingsDocument(EnvironmentSettingsPut):
schema_version: Literal["missioncore.operator-environment/v2"] = ENVIRONMENT_SCHEMA_VERSION
schema_version: Literal["missioncore.operator-environment/v3"] = ENVIRONMENT_SCHEMA_VERSION
class EnvironmentMediaDocument(StrictApiModel):
@@ -131,6 +165,20 @@ class EnvironmentMediaDocument(StrictApiModel):
sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
class EnvironmentPlaylistMediaDocument(StrictApiModel):
schema_version: Literal["missioncore.operator-environment-media/v2"] = (
ENVIRONMENT_PLAYLIST_MEDIA_SCHEMA_VERSION
)
surface_id: EnvironmentSurfaceId
item_id: str = Field(pattern=r"^media-[a-z0-9-]{1,58}$")
url: str
file_name: str
media_kind: EnvironmentMediaKind
media_type: str
byte_length: int = Field(ge=1, le=MAX_ENVIRONMENT_MEDIA_BYTES)
sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
def default_environment_settings() -> EnvironmentSettingsDocument:
background = EnvironmentBackground()
return EnvironmentSettingsDocument(
@@ -221,11 +269,48 @@ def default_environment_settings() -> EnvironmentSettingsDocument:
)
def _upgrade_v1_environment(payload: object) -> EnvironmentSettingsDocument:
if not isinstance(payload, dict):
raise ValueError("environment document must be an object")
if payload.get("schema_version") != "missioncore.operator-environment/v1":
return EnvironmentSettingsDocument.model_validate(payload)
def _upgrade_legacy_background(
surface_id: str,
value: object,
) -> EnvironmentBackground:
if not isinstance(value, dict):
raise ValueError(f"legacy background {surface_id} must be an object")
enabled = value.get("enabled")
source = value.get("source")
url = value.get("url")
media_kind = value.get("media_kind")
file_name = value.get("file_name")
if not isinstance(enabled, bool):
raise ValueError(f"legacy background {surface_id} is incomplete")
if url is None and media_kind is None:
return EnvironmentBackground(enabled=False)
if source == "file":
source_value: EnvironmentMediaSource = "file"
elif source == "url":
source_value = "url"
else:
raise ValueError(f"legacy background {surface_id} has invalid source")
if not isinstance(url, str):
raise ValueError(f"legacy background {surface_id} has invalid URL")
if media_kind == "image":
media_kind_value: EnvironmentMediaKind = "image"
elif media_kind == "video":
media_kind_value = "video"
else:
raise ValueError(f"legacy background {surface_id} has invalid media kind")
if file_name is not None and not isinstance(file_name, str):
raise ValueError(f"legacy background {surface_id} has invalid file name")
item = EnvironmentMediaItem(
id=f"media-legacy-{surface_id}",
source=source_value,
url=url,
media_kind=media_kind_value,
file_name=file_name,
)
return EnvironmentBackground(enabled=enabled, items=[item])
def _upgrade_v1_environment(payload: dict[str, object]) -> EnvironmentSettingsDocument:
revision = payload.get("revision")
labels = payload.get("header_labels")
backgrounds = payload.get("backgrounds")
@@ -238,7 +323,6 @@ def _upgrade_v1_environment(payload: object) -> EnvironmentSettingsDocument:
pages: dict[str, EnvironmentPage] = {}
for surface_id, default_page in defaults.pages:
legacy_label = labels.get(surface_id)
legacy_background = backgrounds.get(surface_id)
pages[surface_id] = default_page.model_copy(
update={
"header_label": (
@@ -246,8 +330,9 @@ def _upgrade_v1_environment(payload: object) -> EnvironmentSettingsDocument:
if isinstance(legacy_label, str) and legacy_label.strip()
else default_page.header_label
),
"background": EnvironmentBackground.model_validate(
legacy_background
"background": _upgrade_legacy_background(
surface_id,
backgrounds.get(surface_id),
),
},
)
@@ -257,6 +342,42 @@ def _upgrade_v1_environment(payload: object) -> EnvironmentSettingsDocument:
)
def _upgrade_v2_environment(payload: dict[str, object]) -> EnvironmentSettingsDocument:
revision = payload.get("revision")
raw_pages = payload.get("pages")
if not isinstance(revision, int) or not isinstance(raw_pages, dict):
raise ValueError("v2 environment document is incomplete")
pages: dict[str, EnvironmentPage] = {}
for surface_id in ENVIRONMENT_SURFACE_IDS:
raw_page = raw_pages.get(surface_id)
if not isinstance(raw_page, dict):
raise ValueError(f"v2 environment page {surface_id} is incomplete")
pages[surface_id] = EnvironmentPage.model_validate(
{
**raw_page,
"background": _upgrade_legacy_background(
surface_id,
raw_page.get("background"),
),
}
)
return EnvironmentSettingsDocument(
revision=revision,
pages=EnvironmentPages.model_validate(pages),
)
def _upgrade_environment(payload: object) -> EnvironmentSettingsDocument:
if not isinstance(payload, dict):
raise ValueError("environment document must be an object")
schema_version = payload.get("schema_version")
if schema_version == "missioncore.operator-environment/v1":
return _upgrade_v1_environment(payload)
if schema_version == "missioncore.operator-environment/v2":
return _upgrade_v2_environment(payload)
return EnvironmentSettingsDocument.model_validate(payload)
class EnvironmentSettingsStore:
def __init__(self, root: Path) -> None:
self.root = root.expanduser().resolve()
@@ -270,7 +391,7 @@ class EnvironmentSettingsStore:
return default_environment_settings()
try:
payload = json.loads(self.settings_path.read_text(encoding="utf-8"))
return _upgrade_v1_environment(payload)
return _upgrade_environment(payload)
except (OSError, ValueError) as exc:
raise RuntimeError("operator environment settings are corrupt") from exc
@@ -302,7 +423,7 @@ class EnvironmentSettingsStore:
return default_environment_settings()
try:
payload = json.loads(self.settings_path.read_text(encoding="utf-8"))
return _upgrade_v1_environment(payload)
return _upgrade_environment(payload)
except (OSError, ValueError) as exc:
raise RuntimeError("operator environment settings are corrupt") from exc
@@ -368,6 +489,87 @@ class EnvironmentSettingsStore:
os.replace(metadata_temporary, metadata_path)
return document
def playlist_media_root(self, surface_id: EnvironmentSurfaceId) -> Path:
return self.media_root / surface_id
def playlist_media_metadata_path(
self,
surface_id: EnvironmentSurfaceId,
item_id: str,
) -> Path:
if not SAFE_MEDIA_ITEM_ID.fullmatch(item_id):
raise ValueError("invalid media item id")
return self.playlist_media_root(surface_id) / f"{item_id}.json"
def read_playlist_media(
self,
surface_id: EnvironmentSurfaceId,
item_id: str,
generation: str,
) -> tuple[Path, EnvironmentPlaylistMediaDocument]:
with self._lock:
metadata_path = self.playlist_media_metadata_path(surface_id, item_id)
try:
metadata = EnvironmentPlaylistMediaDocument.model_validate_json(
metadata_path.read_text(encoding="utf-8")
)
except (OSError, ValueError) as exc:
raise FileNotFoundError(item_id) from exc
if generation != metadata.sha256:
raise PermissionError("media generation changed")
extension = SUPPORTED_MEDIA_TYPES[metadata.media_type][1]
path = self.playlist_media_root(surface_id) / f"{item_id}{extension}"
if not path.is_file() or path.stat().st_size != metadata.byte_length:
raise FileNotFoundError(item_id)
return path, metadata
def finalize_playlist_media(
self,
*,
surface_id: EnvironmentSurfaceId,
item_id: str,
temporary_path: Path,
file_name: str,
media_type: str,
byte_length: int,
sha256: str,
) -> EnvironmentPlaylistMediaDocument:
if not SAFE_MEDIA_ITEM_ID.fullmatch(item_id):
raise ValueError("invalid media item id")
media_kind, extension = SUPPORTED_MEDIA_TYPES[media_type]
with self._lock:
item_root = self.playlist_media_root(surface_id)
item_root.mkdir(mode=0o700, parents=True, exist_ok=True)
destination = item_root / f"{item_id}{extension}"
metadata_path = self.playlist_media_metadata_path(surface_id, item_id)
for stale_path in item_root.glob(f"{item_id}.*"):
if stale_path == metadata_path:
continue
if stale_path != destination and stale_path.is_file():
stale_path.unlink()
os.replace(temporary_path, destination)
document = EnvironmentPlaylistMediaDocument(
surface_id=surface_id,
item_id=item_id,
url=(
f"/api/v1/environment/media/{surface_id}/{item_id}"
f"?generation={sha256}"
),
file_name=file_name,
media_kind=media_kind,
media_type=media_type,
byte_length=byte_length,
sha256=sha256,
)
metadata_temporary = metadata_path.with_suffix(".json.tmp")
metadata_temporary.write_text(
json.dumps(document.model_dump(mode="json"), ensure_ascii=False, indent=2)
+ "\n",
encoding="utf-8",
)
os.replace(metadata_temporary, metadata_path)
return document
async def _write_upload(
request: Request,
@@ -491,4 +693,67 @@ def build_environment_router(
},
)
@router.put("/media/{surface_id}/{item_id}")
async def upload_environment_playlist_media(
surface_id: EnvironmentSurfaceId,
item_id: Annotated[str, ApiPath(pattern=r"^media-[a-z0-9-]{1,58}$")],
request: Request,
file_name: Annotated[str | None, Header(alias="X-NODEDC-File-Name")] = None,
) -> EnvironmentPlaylistMediaDocument:
media_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if media_type not in SUPPORTED_MEDIA_TYPES:
raise HTTPException(
status_code=415,
detail="Поддерживаются PNG, JPEG, WebP, GIF, AVIF, MP4, WebM и MOV.",
)
content_length = request.headers.get("content-length")
if content_length is not None:
try:
if int(content_length) > MAX_ENVIRONMENT_MEDIA_BYTES:
raise HTTPException(status_code=413, detail="Файл превышает лимит 256 МБ.")
except ValueError as exc:
raise HTTPException(status_code=422, detail="Некорректный размер файла.") from exc
environment_store = store()
item_root = environment_store.playlist_media_root(surface_id)
item_root.mkdir(mode=0o700, parents=True, exist_ok=True)
temporary_path = item_root / f".{item_id}.{os.getpid()}.upload"
temporary_path.unlink(missing_ok=True)
byte_length, sha256 = await _write_upload(request, temporary_path)
extension = SUPPORTED_MEDIA_TYPES[media_type][1]
return environment_store.finalize_playlist_media(
surface_id=surface_id,
item_id=item_id,
temporary_path=temporary_path,
file_name=_safe_file_name(file_name, extension),
media_type=media_type,
byte_length=byte_length,
sha256=sha256,
)
@router.get("/media/{surface_id}/{item_id}")
def get_environment_playlist_media(
surface_id: EnvironmentSurfaceId,
item_id: Annotated[str, ApiPath(pattern=r"^media-[a-z0-9-]{1,58}$")],
generation: Annotated[str, Query(pattern=r"^[a-f0-9]{64}$")],
) -> FileResponse:
try:
path, metadata = store().read_playlist_media(
surface_id,
item_id,
generation,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="Медиафайл окружения не найден.") from exc
except PermissionError as exc:
raise HTTPException(status_code=412, detail="Медиафайл окружения был заменён.") from exc
return FileResponse(
path,
media_type=metadata.media_type,
headers={
"Cache-Control": "private, max-age=31536000, immutable, no-transform",
"ETag": f'"{metadata.sha256}"',
"X-Content-Type-Options": "nosniff",
},
)
return router