Files
FishServer/fish_api/app/services/sonar_video.py

141 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""声呐视频:由显式文件或专用目录中的最新 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 loguru import logger
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: tuple[str, float] | None = None
_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) -> Path | None:
"""优先 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 asyncio.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 asyncio.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 ""