"""声呐视频:由显式文件或专用目录中的最新 MP4 发布 H.264 到 MEDIA_ROOT。 与水上视频使用相同的转码路径(``transcode_src_to_h264_dst`` / ffmpeg)。 不切片:整段源转成一个固定文件,每次 GET 返回同一 ``video_path`` URL。 """ from __future__ import annotations import asyncio import shutil from pathlib import Path from typing import Optional, Tuple from loguru import logger from app.compat import to_thread from app.services.action_watch import iter_mp4 from app.services.measure import transcode_src_to_h264_dst from app.settings import Settings _publish_lock = asyncio.Lock() DEFAULT_CLIENT_ID = "default" # 源路径 + mtime,用于跳过重复转码 _last_src_key = None # type: Optional[Tuple[str, float]] _cached_public_url: str = "" def _public_media_url(settings: Settings, basename: str) -> str: base = settings.public_base_url.rstrip("/") return f"{base}/media/{basename}" def _safe_sonar_media_basename(raw: str) -> str: n = (raw or "").strip() if not n: return "biomass_sonar.mp4" return Path(n).name or "biomass_sonar.mp4" def resolve_sonar_video_source(settings: Settings) -> Optional[Path]: """优先 BIOMASS_SONAR_VIDEO_SOURCE;否则在 BIOMASS_SONAR_VIDEO_DIR 中取 mtime 最新的 .mp4。""" cfg = settings.biomass_sonar_video_source if cfg is not None: if cfg.is_file(): return cfg logger.warning( "[sonar-video] BIOMASS_SONAR_VIDEO_SOURCE is not a file: {}", cfg, ) return None d = settings.biomass_sonar_video_dir if d is None or not d.is_dir(): return None mp4s = iter_mp4(d, settings.biomass_sonar_video_recursive) if not mp4s: return None try: return max(mp4s, key=lambda p: p.stat().st_mtime) except OSError as e: logger.warning("[sonar-video] could not pick latest mp4: {}", e) return None async def _publish_video( src: Path, dst: Path, settings: Settings, ) -> str: """与 water_video._publish_video 相同:ffmpeg H.264,失败则回退复制。""" tmp = dst.with_name(dst.stem + "_tmp.mp4") tmp.unlink(missing_ok=True) try: ok = await to_thread(transcode_src_to_h264_dst, src, tmp) if ok and tmp.is_file() and tmp.stat().st_size > 0: tmp.replace(dst) logger.info("[sonar-video] published H.264: {} -> {}", src.name, dst.name) else: tmp.unlink(missing_ok=True) await to_thread(shutil.copy2, src, dst) logger.warning( "[sonar-video] transcode failed, copied raw: {} -> {}", src.name, dst.name, ) return _public_media_url(settings, dst.name) except Exception: logger.exception("[sonar-video] publish failed") tmp.unlink(missing_ok=True) if dst.is_file(): return _public_media_url(settings, dst.name) return "" async def get_sonar_video_public_url( settings: Settings, _client_id: str = DEFAULT_CLIENT_ID, ) -> str: """转码并发布到 MEDIA_ROOT 后返回绝对 URL;无可用源且无已发布文件时返回空串。 始终对应同一发布文件(BIOMASS_SONAR_VIDEO_MEDIA_NAME),所有客户端每次请求返回同一 URL。 """ settings.media_root.mkdir(parents=True, exist_ok=True) global _last_src_key, _cached_public_url async with _publish_lock: basename = _safe_sonar_media_basename(settings.biomass_sonar_video_media_name) dst = settings.media_root / basename src = resolve_sonar_video_source(settings) if src is None: _last_src_key = None _cached_public_url = "" if dst.is_file(): return _public_media_url(settings, dst.name) return "" try: key = (str(src.resolve()), src.stat().st_mtime) except OSError: if dst.is_file(): return _public_media_url(settings, dst.name) return "" if key == _last_src_key and _cached_public_url: return _cached_public_url url = await _publish_video(src, dst, settings) if url: _last_src_key = key _cached_public_url = url return url if dst.is_file(): u = _public_media_url(settings, dst.name) _last_src_key = key _cached_public_url = u return u return ""