from __future__ import annotations import asyncio from pathlib import Path from typing import Dict, Set from loguru import logger from app.db import ( add_watch_processed, health_snapshot_deliverable, load_watch_processed, save_health_snapshot, ) from app.services import action as action_svc from app.settings import Settings from app.state import app_state from app.watch_idle import IdleWatchWarnState, idle_warn_interval_sec, maybe_warn_idle_watch _ACTION_IDLE_WARN_INTERVAL_SEC = idle_warn_interval_sec( "FISH_ACTION_WATCH_IDLE_WARN_INTERVAL_SEC" ) def _state_path(settings: Settings) -> Path: if settings.action_watch_state_file is not None: return settings.action_watch_state_file assert settings.action_watch_dir is not None return settings.action_watch_dir / ".fishaction_watch_processed.json" def iter_mp4(watch_dir: Path, recursive: bool) -> list[Path]: if recursive: return sorted( p for p in watch_dir.rglob("*") if p.is_file() and p.suffix.lower() == ".mp4" ) return sorted( p for p in watch_dir.iterdir() if p.is_file() and p.suffix.lower() == ".mp4" ) async def _run_inference_and_state( mp4: Path, settings: Settings, processed: Set[str], state_file: Path, ) -> None: key = str(mp4.resolve()) if key in processed: return logger.info("[action-watch] inference: {}", mp4) async with app_state.action_lock: app_state.action_status = "running" try: # 返回 (第一个快照, 所有切片快照列表) first_snap, all_snaps = await asyncio.to_thread( action_svc.run_full_action, mp4, settings ) # 将所有切片作为独立视频保存到数据库 saved_count = 0 for i, snap in enumerate(all_snaps): if health_snapshot_deliverable(snap): # 为每个切片生成独立的 source_path slice_key = f"{key}#slice{i}" save_health_snapshot(settings, snap, source_path=slice_key) saved_count += 1 if saved_count == 0: logger.warning( "[action-watch] no deliverable health snapshot for {}, skip SQLite", mp4.name, ) app_state.action_status = "idle" processed.add(key) if settings.action_watch_use_state_file: add_watch_processed(settings, key, "action") pred = (first_snap.raw_class_en or "").strip() logger.info( "[action-watch] done: {} -> {} (saved {} slices)", mp4.name, pred, saved_count, ) except Exception as e: logger.exception("[action-watch] error on {}: {}", mp4, e) app_state.action_status = "idle" processed.add(key) if settings.action_watch_use_state_file: add_watch_processed(settings, key, "action") async def watch_tick( settings: Settings, processed: Set[str], stability: Dict[str, tuple[int, int]], state_file: Path, ) -> bool: """处理一轮目录扫描;若处理了至少一个文件返回 True。""" assert settings.action_watch_dir is not None watch_dir = settings.action_watch_dir did = False seen_keys: Set[str] = set() for mp4 in iter_mp4(watch_dir, settings.action_watch_recursive): key = str(mp4.resolve()) seen_keys.add(key) if key in processed: continue try: st = mp4.stat() except OSError: continue size = int(st.st_size) if size <= 0: stability.pop(key, None) continue last = stability.get(key) if last is None or last[0] != size: stability[key] = (size, 1) else: _, cnt = last stability[key] = (size, cnt + 1) _, cnt = stability[key] if cnt >= settings.action_watch_stable_polls: await _run_inference_and_state(mp4, settings, processed, state_file) stability.pop(key, None) did = True for k in list(stability.keys()): if k not in seen_keys: del stability[k] return did async def run_action_watch_loop(settings: Settings) -> None: assert settings.action_watch_dir is not None wd = settings.action_watch_dir if not wd.is_dir(): logger.warning("[action-watch] skip: not a directory: {}", wd) return state_file = _state_path(settings) processed: Set[str] = ( load_watch_processed(settings, state_file, "action") if settings.action_watch_use_state_file else set() ) stability: Dict[str, tuple[int, int]] = {} logger.info( "[action-watch] watching {} (poll={}s, stable_polls={}, state={} {})", wd, settings.action_watch_poll_interval, settings.action_watch_stable_polls, "on" if settings.action_watch_use_state_file else "off", state_file if settings.action_watch_use_state_file else "", ) idle_warn_state = IdleWatchWarnState() while True: did = await watch_tick(settings, processed, stability, state_file) maybe_warn_idle_watch( did_work=did, log_tag="action-watch", algo_name="FishAction", idle_hint="目录内无 .mp4、已全部处理完毕,或文件尚未达到稳定轮询次数", watch_dir=wd, state=idle_warn_state, interval_sec=_ACTION_IDLE_WARN_INTERVAL_SEC, ) await asyncio.sleep(max(settings.action_watch_poll_interval, 0.1))