"""水上视频:从 FishAction 输入目录或显式路径发布 H.264 MP4 到 MEDIA_ROOT。""" 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() def _public_media_url(settings: Settings, basename: str) -> str: base = settings.public_base_url.rstrip("/") return f"{base}/media/{basename}" def _safe_water_media_basename(raw: str) -> str: n = (raw or "").strip() if not n: return "biomass_water_surface.mp4" return Path(n).name or "biomass_water_surface.mp4" def resolve_water_video_source(settings: Settings) -> Path | None: """优先 BIOMASS_WATER_VIDEO_SOURCE;否则取 ACTION_WATCH_DIR 中 mtime 最新的 .mp4。""" cfg = settings.biomass_water_video_source if cfg is not None: if cfg.is_file(): return cfg logger.warning( "[water-video] BIOMASS_WATER_VIDEO_SOURCE is not a file: {}", cfg, ) return None aw = settings.action_watch_dir if aw is None or not aw.is_dir(): return None mp4s = iter_mp4(aw, settings.action_watch_recursive) if not mp4s: return None try: return max(mp4s, key=lambda p: p.stat().st_mtime) except OSError as e: logger.warning("[water-video] could not pick latest mp4: {}", e) return None async def get_water_video_public_url(settings: Settings) -> str: """转码并发布到 MEDIA_ROOT 后返回绝对 URL;无可用源且无已发布文件时返回空串。""" settings.media_root.mkdir(parents=True, exist_ok=True) basename = _safe_water_media_basename(settings.biomass_water_video_media_name) dst = settings.media_root / basename src = resolve_water_video_source(settings) if src is None: if dst.is_file(): return _public_media_url(settings, dst.name) return "" async with _publish_lock: need_publish = True if dst.is_file(): try: if dst.stat().st_mtime >= src.stat().st_mtime: need_publish = False except OSError: pass if not need_publish: return _public_media_url(settings, dst.name) 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("[water-video] published H.264: {} -> {}", src.name, dst.name) else: tmp.unlink(missing_ok=True) await asyncio.to_thread(shutil.copy2, src, dst) logger.warning( "[water-video] transcode failed, copied raw: {} -> {}", src.name, dst.name, ) except Exception: logger.exception("[water-video] publish failed") tmp.unlink(missing_ok=True) if dst.is_file(): return _public_media_url(settings, dst.name) return "" if dst.is_file(): return _public_media_url(settings, dst.name) return ""