drowzeys's picture
v1.0 alpha: keys-Auto Receipts Studio (iPhone / may add Autonomous Lamp Skill)
2edb151 verified
Raw
History Blame Contribute Delete
4.06 kB
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
from app.config import ACCEPTED_SUFFIXES, Settings
from app.pipeline import process_file
OnBatch = Callable[[list[Path]], None]
def is_ignored(path: Path) -> bool:
name = path.name
if name.startswith("."):
return True
if name.endswith(".tmp") or name.endswith(".part"):
return True
if ".syncthing." in name or name.startswith(".syncthing"):
return True
return path.suffix.lower() not in ACCEPTED_SUFFIXES
@dataclass
class _State:
sig: tuple[int, float]
last_change: float
class IdleBatchWatcher:
"""Settle files until size+mtime are unchanged for idle_seconds, then batch."""
def __init__(
self,
inbox: Path,
*,
idle_seconds: float = 30.0,
on_batch: OnBatch | None = None,
) -> None:
self.inbox = Path(inbox)
self.idle_seconds = idle_seconds
self.on_batch = on_batch
self._state: dict[Path, _State] = {}
self._lock = threading.Lock()
self._running = False
self._observer: Observer | None = None
self._thread: threading.Thread | None = None
def note(self, path: Path, now: float) -> None:
if not path.is_file() or is_ignored(path):
return
stat = path.stat()
sig = (stat.st_size, stat.st_mtime)
with self._lock:
prev = self._state.get(path)
if prev is None or prev.sig != sig:
self._state[path] = _State(sig=sig, last_change=now)
def tick(self, now: float | None = None) -> list[Path]:
clock = time.monotonic() if now is None else now
self.inbox.mkdir(parents=True, exist_ok=True)
for path in self.inbox.iterdir():
self.note(path, clock)
ready: list[Path] = []
with self._lock:
for path, state in list(self._state.items()):
if not path.is_file():
self._state.pop(path, None)
continue
if clock - state.last_change >= self.idle_seconds:
ready.append(path)
self._state.pop(path, None)
if ready and self.on_batch is not None:
self.on_batch(ready)
return ready
def start(self) -> None:
if self._running:
return
self._running = True
handler = _Handler(self)
observer = Observer()
observer.schedule(handler, str(self.inbox), recursive=False)
observer.start()
self._observer = observer
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
def stop(self) -> None:
self._running = False
if self._observer is not None:
self._observer.stop()
self._observer.join(timeout=2)
self._observer = None
def _loop(self) -> None:
while self._running:
self.tick()
time.sleep(min(0.25, max(0.05, self.idle_seconds / 4)))
class _Handler(FileSystemEventHandler):
def __init__(self, watcher: IdleBatchWatcher) -> None:
self.watcher = watcher
def on_any_event(self, event: FileSystemEvent) -> None:
if event.is_directory:
return
path = Path(str(event.src_path))
self.watcher.note(path, time.monotonic())
def process_batch(paths: list[Path], settings: Settings) -> None:
for path in paths:
try:
process_file(path, settings)
except Exception:
continue
def start_inbox_watcher(settings: Settings) -> IdleBatchWatcher:
watcher = IdleBatchWatcher(
settings.inbox_dir,
idle_seconds=settings.idle_seconds,
on_batch=lambda paths: process_batch(paths, settings),
)
watcher.start()
return watcher