mover and matches fix
Build and Deploy / build (push) Successful in 1m14s
Build and Deploy / deploy (push) Successful in 36s

This commit is contained in:
2026-07-02 11:42:33 +02:00
parent b7bec295f2
commit be2c227e1d
7 changed files with 95 additions and 12 deletions
+5 -1
View File
@@ -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
+6 -1
View File
@@ -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}"
+36 -6
View File
@@ -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
+5 -1
View File
@@ -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 "",
+27
View File
@@ -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,
+12 -2
View File
@@ -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)
+4 -1
View File
@@ -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 "",