from __future__ import annotations from typing import Optional from fastapi import APIRouter, Depends, Header, Query from starlette.responses import JSONResponse from app.db import normalize_client_id, pop_next_health, pop_next_measure from app.services.water_video import get_water_video_public_url from app.settings import Settings, get_settings router = APIRouter(prefix="/api/v1/biomass", tags=["biomass"]) # 是否有新快照被本次 GET 消费(1/0);body 保持与客户端约定字段一致,不写入 has_new。 HEADER_BIOMASS_NEW = "X-Fish-Biomass-New" def _new_headers(has_new: bool) -> dict[str, str]: return {HEADER_BIOMASS_NEW: "1" if has_new else "0"} def _resolve_client_id( x_fish_client_id: Optional[str] = Header(None, alias="X-Fish-Client-Id"), client_id: Optional[str] = Query( None, description="客户端标识;与请求头 X-Fish-Client-Id 二选一(优先头)。未带时共用 default 游标", ), ) -> str: if x_fish_client_id is not None and str(x_fish_client_id).strip(): return normalize_client_id(x_fish_client_id) if client_id is not None and str(client_id).strip(): return normalize_client_id(client_id) return normalize_client_id(None) @router.get("/real/camera/") async def get_real_camera( settings: Settings = Depends(get_settings), client_id: str = Depends(_resolve_client_id), ): """双目实时结果:每次 GET 投递该客户端下一条未消费的 FishMeasure 快照(按 client_id 独立游标)。""" m, has_new, _ = pop_next_measure(settings, client_id) if not has_new: return JSONResponse( content={ "code": 200, "msg": "成功", "data": { "result": [], "video_left": "", "video_right": "", }, }, headers=_new_headers(False), ) if m.error: return JSONResponse( content={ "code": 500, "msg": m.error, "data": { "result": [], "video_left": "", "video_right": "", }, }, headers=_new_headers(True), ) return JSONResponse( content={ "code": 200, "msg": "成功", "data": { "result": m.result, "video_left": m.video_left, "video_right": m.video_right, }, }, headers=_new_headers(True), ) @router.get("/health/result/") async def get_health_result( settings: Settings = Depends(get_settings), client_id: str = Depends(_resolve_client_id), ): """行为 / 健康结果:每次 GET 投递该客户端下一条未消费的 FishAction 快照(按 client_id 独立游标)。 每个视频切片被视为独立的视频,会分别投递。 """ h, has_new, _ = pop_next_health(settings, client_id) if not has_new: return JSONResponse( content={ "code": 200, "msg": "成功", "data": { "behavior_result": "", "health_result": "", }, }, headers=_new_headers(False), ) if h.error: return JSONResponse( content={ "code": 500, "msg": h.error, "data": { "behavior_result": "", "health_result": "", }, }, headers=_new_headers(True), ) return JSONResponse( content={ "code": 200, "msg": "成功", "data": { "behavior_result": h.behavior_result, "health_result": h.health_result, }, }, headers=_new_headers(True), ) @router.get("/water/video/") async def get_water_video( settings: Settings = Depends(get_settings), client_id: str = Depends(_resolve_client_id), ): """水上视频:FishAction 输入 mp4 经 H.264 转码后托管在 /media/,返回 `video_path` 绝对 URL。 如果视频较长(超过15秒),会自动切分为多个10秒的片段。 每个切片被视为独立的视频,每次调用返回一个切片的URL(按顺序轮流返回)。 对齐机制:使用 client_id 参数(请求头 X-Fish-Client-Id 或查询参数 client_id) 确保与 /health/result/ 端点对齐返回同一切片。 """ video_path = await get_water_video_public_url(settings, client_id) return JSONResponse( content={ "code": 200, "msg": "成功", "data": { "video_path": video_path, }, }, ) @router.get("/sonar/video/") async def get_sonar_video(): """声纳图像信息:当前返回空 `video_path`;后续将提供 H.264 编码 MP4 的绝对 URL。""" return JSONResponse( content={ "code": 200, "msg": "成功", "data": { "video_path": "", }, }, )