diff --git a/.env.example b/.env.example index a33a046..39846a7 100644 --- a/.env.example +++ b/.env.example @@ -9,8 +9,12 @@ HOST_SUWAYOMI_PATH=/path/to/suwayomi/downloads HOST_KAVITA_PATH=/path/to/kavita/library HOST_MANGA_CONFIG_PATH=/path/to/manga-config MANGA_WEB_PORT=8080 -SETTLE_SECONDS=600 DELETE_SOURCE=true +# Folder watcher (auto-move new downloads). Polling is required for SMB/NFS. +WATCHER_ENABLED=true +SETTLE_SECONDS=600 +WATCHER_POLLING=true +WATCHER_POLL_INTERVAL=30 # Periodic updaters (volume/cover + global person sync) run together on # this cron. Sundays 10:00. Person updater also covers LN libraries. UPDATER_ENABLED=true diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1e8d272..9629d5a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -10,9 +10,14 @@ services: KAVITA_URL: "${KAVITA_URL}" KAVITA_API_KEY: "${KAVITA_API_KEY}" LANGUAGE: "${LANGUAGE:-en}" - SETTLE_SECONDS: "${SETTLE_SECONDS:-600}" DELETE_SOURCE: "${DELETE_SOURCE:-true}" MATCH_PATH: "/config/matches.json" + # Folder watcher: auto-move a series after its downloads settle. + # Polling is on by default — required for SMB/NFS bind mounts. + WATCHER_ENABLED: "${WATCHER_ENABLED:-true}" + SETTLE_SECONDS: "${SETTLE_SECONDS:-600}" + WATCHER_POLLING: "${WATCHER_POLLING:-true}" + WATCHER_POLL_INTERVAL: "${WATCHER_POLL_INTERVAL:-30}" # Periodic updaters (volume/cover back-fill + global person sync) run # together on this cron. "0 10 * * 0" = Sundays 10:00 (local time, see TZ) UPDATER_ENABLED: "${UPDATER_ENABLED:-true}" diff --git a/main_manga.py b/main_manga.py index b8dc0d0..379419e 100644 --- a/main_manga.py +++ b/main_manga.py @@ -22,7 +22,11 @@ Environment variables SUWAYOMI_PATH default /mnt/suwayomi KAVITA_PATH default /mnt/kavita LANGUAGE default en + WATCHER_ENABLED default true (auto-move new Suwayomi downloads) SETTLE_SECONDS default 600 (10-minute quiet window) + WATCHER_POLLING default true (poll instead of inotify; required for + SMB/NFS mounts) + WATCHER_POLL_INTERVAL default 30 (seconds between polls) REQUEST_TIMEOUT default 30 DELETE_SOURCE default true (delete source folders after pack) MATCH_PATH default /config/matches.json @@ -46,6 +50,7 @@ Environment variables from __future__ import annotations import os +import signal import sys from pathlib import Path try: @@ -62,7 +67,7 @@ sys.path.insert(0, str(_BASE / "src")) sys.path.insert(0, str(_BASE / "src" / "manga")) from SuwayomiMover import SuwayomiMover # noqa: E402 -from SuwayomiFolderWatcher import SuwayomiFolderWatcher # noqa: E402,F401 +from SuwayomiFolderWatcher import SuwayomiFolderWatcher # noqa: E402 from MatchesCache import MatchesCache # noqa: E402 from MatchesWebApp import MatchesWebApp # noqa: E402 from KavitaVolumeCoverUpdater import KavitaVolumeCoverUpdater # noqa: E402 @@ -106,7 +111,10 @@ def main() -> int: kavita_url = _env_str("KAVITA_URL", "http://kavita:5000") kavita_api_key = _env_str("KAVITA_API_KEY", "") language = _env_str("LANGUAGE", "en") or "en" + watcher_enabled = _env_bool("WATCHER_ENABLED", True) settle_seconds = _env_int("SETTLE_SECONDS", 600) + watcher_polling = _env_bool("WATCHER_POLLING", True) + poll_interval = _env_int("WATCHER_POLL_INTERVAL", 30) request_timeout = _env_int("REQUEST_TIMEOUT", 30) delete_source = _env_bool("DELETE_SOURCE", True) match_path = _env_str("MATCH_PATH", "/config/matches.json") @@ -153,8 +161,6 @@ def main() -> int: request_timeout=request_timeout) person_updater = KavitaPersonUpdater(kavita_client) - # watcher = SuwayomiFolderWatcher(suwayomi_path, mover, settle_seconds=settle_seconds) - web_app = MatchesWebApp( matches_cache, mover=mover, person_updater=person_updater, person_trigger="web", @@ -189,9 +195,33 @@ def main() -> int: print(f"[main] UPDATER_SCHEDULE invalid ({exc}); " f"scheduled updaters DISABLED", flush=True) - # watcher.start() - # watcher.wait() # blocks until stop() is called via a signal - web_app.wait() # keep process alive while the watcher is disabled + # Folder watcher: auto-move a series once its download folder has been + # quiet for SETTLE_SECONDS. Polling observer by default so it also works + # on SMB/NFS mounts (inotify does not deliver events there). + watcher = None + if watcher_enabled: + watcher = SuwayomiFolderWatcher( + suwayomi_path, mover, + settle_seconds=settle_seconds, + use_polling=watcher_polling, + poll_interval=poll_interval) + + def _shutdown(signum, _frame): + print(f"[main] received signal {signum}", flush=True) + watcher.stop() + + signal.signal(signal.SIGTERM, _shutdown) + signal.signal(signal.SIGINT, _shutdown) + try: + watcher.start() + except FileNotFoundError as exc: + print(f"[main] watcher not started: {exc}", flush=True) + watcher = None + + if watcher is not None: + watcher.wait() # blocks until stop() (signal) is called + else: + web_app.wait() # no watcher — keep the process alive via the web thread return 0 diff --git a/src/manga/ComicInfoBuilder.py b/src/manga/ComicInfoBuilder.py index 50d09a0..af9b794 100644 --- a/src/manga/ComicInfoBuilder.py +++ b/src/manga/ComicInfoBuilder.py @@ -438,7 +438,11 @@ class ComicInfoBuilder: series = data[0] if data else None if series and self._matches_cache is not None: - self._matches_cache.add( + # Only persist a genuinely new match — never overwrite an entry + # the user may have corrected by hand. (This path is reached + # for uncached titles, or when a cached id failed to resolve; in + # the latter case the manual entry must be preserved.) + self._matches_cache.add_if_absent( title, mangabaka_id=series.get("id"), mangabaka_name=series.get("title") or "", diff --git a/src/manga/MatchesCache.py b/src/manga/MatchesCache.py index 23afa1e..21b65c6 100644 --- a/src/manga/MatchesCache.py +++ b/src/manga/MatchesCache.py @@ -74,6 +74,33 @@ class MatchesCache: self._save_unlocked() return dict(entry) + def add_if_absent(self, title: str, *, + mangabaka_id, + mangabaka_name: str, + image_url: "str | None") -> bool: + """ + Adds a match only if the (normalised) title is not already present. + + Returns True if a new entry was written, False if one already + existed. Used by the automatic match/move runs so they only ever + *append* — never clobber an entry the user manually corrected in + the web UI (which uses upsert). The check + write happen under one + lock so concurrent runs cannot race. + """ + norm = _norm_key(title) + with self._lock: + if norm in self._data["matches"]: + return False + self._data["matches"][norm] = { + "folderTitle": title, + "mangabakaId": str(mangabaka_id) if mangabaka_id is not None else "", + "mangabakaName": mangabaka_name or "", + "imageUrl": image_url or "", + "firstMatchTime": int(time.time()), + } + self._save_unlocked() + return True + def upsert(self, title: str, *, mangabaka_id=None, mangabaka_name=None, diff --git a/src/manga/SuwayomiFolderWatcher.py b/src/manga/SuwayomiFolderWatcher.py index 0e89a58..6602dac 100644 --- a/src/manga/SuwayomiFolderWatcher.py +++ b/src/manga/SuwayomiFolderWatcher.py @@ -34,6 +34,7 @@ from pathlib import Path from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer +from watchdog.observers.polling import PollingObserver from SuwayomiMover import SuwayomiMover @@ -56,13 +57,21 @@ class SuwayomiFolderWatcher: settle_seconds : Quiet period before a series is processed. Each new filesystem event resets the per-series timer. Default: 600 (10 minutes). + use_polling : Use a PollingObserver instead of the native (inotify) + one. Default True — inotify does NOT deliver events on + SMB/NFS/CIFS network mounts, which is the usual Docker + bind-mount for the Suwayomi downloads. Polling works + everywhere at the cost of periodic directory stats. + poll_interval : Seconds between polls when use_polling is True. """ def __init__(self, suwayomi_path, mover: SuwayomiMover, *, - settle_seconds: int = 600): + settle_seconds: int = 600, + use_polling: bool = True, + poll_interval: float = 30.0): self._suwayomi_path = Path(suwayomi_path).resolve() self._mover = mover self._settle = settle_seconds @@ -75,7 +84,8 @@ class SuwayomiFolderWatcher: self._queue: "queue.Queue[str]" = queue.Queue() self._stop = threading.Event() - self._observer = Observer() + self._observer = (PollingObserver(timeout=poll_interval) + if use_polling else Observer()) self._worker = threading.Thread( target=self._worker_loop, name="SuwayomiWorker", daemon=True) diff --git a/src/manga/SuwayomiMover.py b/src/manga/SuwayomiMover.py index 5cd31ad..df92cc9 100644 --- a/src/manga/SuwayomiMover.py +++ b/src/manga/SuwayomiMover.py @@ -473,7 +473,10 @@ class SuwayomiMover: print(f" [warn] no MangaBaka match for {builder_title!r}") continue series = data[0] - self._matches_cache.add( + # add_if_absent (never overwrite) — the get() above + # already skips cached titles; this is a safety net so a + # match run can only ever append, never replace. + self._matches_cache.add_if_absent( builder_title, mangabaka_id=series.get("id"), mangabaka_name=series.get("title") or "",