from __future__ import annotations import asyncio import json import traceback from pathlib import Path from typing import Any, Dict, Set from app.services import action as action_svc from app.settings import Settings from app.state import HealthSnapshot, app_state 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 load_processed(path: Path) -> Set[str]: if not path.is_file(): return set() try: with open(path, encoding="utf-8") as f: data: Any = json.load(f) if isinstance(data, list): return set(str(x) for x in data) if isinstance(data, dict) and "processed" in data: return set(str(x) for x in data["processed"]) except (json.JSONDecodeError, OSError): pass return set() def save_processed(path: Path, processed: Set[str]) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(path.suffix + ".tmp") with open(tmp, "w", encoding="utf-8") as f: json.dump(sorted(processed), f, indent=0, ensure_ascii=False) tmp.replace(path) 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 print(f"[action-watch] inference: {mp4}", flush=True) async with app_state.action_lock: app_state.action_status = "running" try: snap = await asyncio.to_thread(action_svc.run_full_action, mp4, settings) app_state.last_health = snap app_state.action_status = "idle" processed.add(key) if settings.action_watch_use_state_file: save_processed(state_file, processed) pred = (snap.raw_class_en or "").strip() print(f"[action-watch] done: {mp4.name} -> {pred}", flush=True) except Exception as e: app_state.last_health = HealthSnapshot( behavior_result="", health_result="", error=str(e), ) app_state.action_status = "error" print(f"[action-watch] error on {mp4}: {e}", flush=True) traceback.print_exc() raise 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: try: await _run_inference_and_state(mp4, settings, processed, state_file) stability.pop(key, None) did = True except Exception: stability[key] = (size, 1) 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(): print(f"[action-watch] skip: not a directory: {wd}", flush=True) return state_file = _state_path(settings) processed: Set[str] = ( load_processed(state_file) if settings.action_watch_use_state_file else set() ) stability: Dict[str, tuple[int, int]] = {} print( f"[action-watch] watching {wd} " f"(poll={settings.action_watch_poll_interval}s, " f"stable_polls={settings.action_watch_stable_polls}, " f"state={'on' if settings.action_watch_use_state_file else 'off'} " f"{state_file if settings.action_watch_use_state_file else ''})", flush=True, ) while True: await watch_tick(settings, processed, stability, state_file) await asyncio.sleep(max(settings.action_watch_poll_interval, 0.1))