"""REST: архив — список сегментов, отдача mp4 (Range), перекодирование H.265→H.264.""" from __future__ import annotations import asyncio import os from fastapi import APIRouter, HTTPException, Query from fastapi.responses import FileResponse, StreamingResponse from ..runtime import runtime from ..transcode import (H264_PROFILES, BITRATES, encode_args, norm_profile, norm_bitrate, norm_preset, norm_keyint, norm_codec, norm_rc, norm_qp) router = APIRouter(prefix="/api/recordings", tags=["recordings"]) @router.get("") async def list_recordings( camera: str | None = Query(None), ts_from: int | None = Query(None, alias="from"), ts_to: int | None = Query(None, alias="to"), ) -> dict: recs = await runtime.db.list_recordings(camera, ts_from, ts_to) return { "recordings": [ { "id": r.id, "camera_id": r.camera_id, "started_at": r.started_at, "ended_at": r.ended_at, "duration_s": r.duration_s, "size_bytes": r.size_bytes, } for r in recs ] } @router.get("/{rec_id}/file") async def recording_file(rec_id: int) -> FileResponse: """Отдаёт mp4 как есть (H.265). Range поддерживается FileResponse.""" rec = await runtime.db.get_recording(rec_id) if not rec or not os.path.exists(rec.path): raise HTTPException(404, "recording not found") return FileResponse(rec.path, media_type="video/mp4", filename=os.path.basename(rec.path)) @router.get("/{rec_id}/play.mp4") async def recording_transcoded( rec_id: int, profile: str = Query(None), bitrate: str = Query(None), preset: str = Query(None), keyint: str = Query(None), codec: str = Query(None), rc: str = Query(None), qp: str = Query(None), start: float = Query(0.0), ): """Перекодирование H.265→H.264 на лету (fragmented mp4) — для браузеров без HEVC. Профиль и битрейт задаёт клиент (настройка в браузере). start — секунда, с которой начать перекодирование (перемотка): транскод из пайпа не seekable, поэтому seek реализуется перезапуском с -ss.""" rec = await runtime.db.get_recording(rec_id) if not rec or not os.path.exists(rec.path): raise HTTPException(404, "recording not found") args = [ "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", # -ss ДО -i — быстрый input-seek к ближайшему ключевому кадру ≤ start, # выходные PTS обнуляются → новый поток идёт с 0 (плеер ведёт виртуальный таймлайн) *(["-ss", f"{start:.3f}"] if start and start > 0 else []), "-i", rec.path, *encode_args(norm_codec(codec), norm_profile(profile), norm_bitrate(bitrate), preset=norm_preset(preset), keyint_sec=norm_keyint(keyint), rc=norm_rc(rc), qp=norm_qp(qp)), "-an", "-movflags", "frag_keyframe+empty_moov+default_base_moof", "-f", "mp4", "pipe:1", ] proc = await asyncio.create_subprocess_exec( *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, ) async def stream(): assert proc.stdout is not None try: while True: chunk = await proc.stdout.read(64 * 1024) if not chunk: break yield chunk finally: if proc.returncode is None: try: proc.kill() except ProcessLookupError: pass await proc.wait() return StreamingResponse(stream(), media_type="video/mp4")