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_KAVITA_PATH=/path/to/kavita/library
HOST_MANGA_CONFIG_PATH=/path/to/manga-config HOST_MANGA_CONFIG_PATH=/path/to/manga-config
MANGA_WEB_PORT=8080 MANGA_WEB_PORT=8080
SETTLE_SECONDS=600
DELETE_SOURCE=true 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 # Periodic updaters (volume/cover + global person sync) run together on
# this cron. Sundays 10:00. Person updater also covers LN libraries. # this cron. Sundays 10:00. Person updater also covers LN libraries.
UPDATER_ENABLED=true UPDATER_ENABLED=true
+6 -1
View File
@@ -10,9 +10,14 @@ services:
KAVITA_URL: "${KAVITA_URL}" KAVITA_URL: "${KAVITA_URL}"
KAVITA_API_KEY: "${KAVITA_API_KEY}" KAVITA_API_KEY: "${KAVITA_API_KEY}"
LANGUAGE: "${LANGUAGE:-en}" LANGUAGE: "${LANGUAGE:-en}"
SETTLE_SECONDS: "${SETTLE_SECONDS:-600}"
DELETE_SOURCE: "${DELETE_SOURCE:-true}" DELETE_SOURCE: "${DELETE_SOURCE:-true}"
MATCH_PATH: "/config/matches.json" 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 # Periodic updaters (volume/cover back-fill + global person sync) run
# together on this cron. "0 10 * * 0" = Sundays 10:00 (local time, see TZ) # together on this cron. "0 10 * * 0" = Sundays 10:00 (local time, see TZ)
UPDATER_ENABLED: "${UPDATER_ENABLED:-true}" UPDATER_ENABLED: "${UPDATER_ENABLED:-true}"
+36 -6
View File
@@ -22,7 +22,11 @@ Environment variables
SUWAYOMI_PATH default /mnt/suwayomi SUWAYOMI_PATH default /mnt/suwayomi
KAVITA_PATH default /mnt/kavita KAVITA_PATH default /mnt/kavita
LANGUAGE default en LANGUAGE default en
WATCHER_ENABLED default true (auto-move new Suwayomi downloads)
SETTLE_SECONDS default 600 (10-minute quiet window) 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 REQUEST_TIMEOUT default 30
DELETE_SOURCE default true (delete source folders after pack) DELETE_SOURCE default true (delete source folders after pack)
MATCH_PATH default /config/matches.json MATCH_PATH default /config/matches.json
@@ -46,6 +50,7 @@ Environment variables
from __future__ import annotations from __future__ import annotations
import os import os
import signal
import sys import sys
from pathlib import Path from pathlib import Path
try: try:
@@ -62,7 +67,7 @@ sys.path.insert(0, str(_BASE / "src"))
sys.path.insert(0, str(_BASE / "src" / "manga")) sys.path.insert(0, str(_BASE / "src" / "manga"))
from SuwayomiMover import SuwayomiMover # noqa: E402 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 MatchesCache import MatchesCache # noqa: E402
from MatchesWebApp import MatchesWebApp # noqa: E402 from MatchesWebApp import MatchesWebApp # noqa: E402
from KavitaVolumeCoverUpdater import KavitaVolumeCoverUpdater # 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_url = _env_str("KAVITA_URL", "http://kavita:5000")
kavita_api_key = _env_str("KAVITA_API_KEY", "") kavita_api_key = _env_str("KAVITA_API_KEY", "")
language = _env_str("LANGUAGE", "en") or "en" language = _env_str("LANGUAGE", "en") or "en"
watcher_enabled = _env_bool("WATCHER_ENABLED", True)
settle_seconds = _env_int("SETTLE_SECONDS", 600) 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) request_timeout = _env_int("REQUEST_TIMEOUT", 30)
delete_source = _env_bool("DELETE_SOURCE", True) delete_source = _env_bool("DELETE_SOURCE", True)
match_path = _env_str("MATCH_PATH", "/config/matches.json") match_path = _env_str("MATCH_PATH", "/config/matches.json")
@@ -153,8 +161,6 @@ def main() -> int:
request_timeout=request_timeout) request_timeout=request_timeout)
person_updater = KavitaPersonUpdater(kavita_client) person_updater = KavitaPersonUpdater(kavita_client)
# watcher = SuwayomiFolderWatcher(suwayomi_path, mover, settle_seconds=settle_seconds)
web_app = MatchesWebApp( web_app = MatchesWebApp(
matches_cache, mover=mover, matches_cache, mover=mover,
person_updater=person_updater, person_trigger="web", person_updater=person_updater, person_trigger="web",
@@ -189,9 +195,33 @@ def main() -> int:
print(f"[main] UPDATER_SCHEDULE invalid ({exc}); " print(f"[main] UPDATER_SCHEDULE invalid ({exc}); "
f"scheduled updaters DISABLED", flush=True) f"scheduled updaters DISABLED", flush=True)
# watcher.start() # Folder watcher: auto-move a series once its download folder has been
# watcher.wait() # blocks until stop() is called via a signal # quiet for SETTLE_SECONDS. Polling observer by default so it also works
web_app.wait() # keep process alive while the watcher is disabled # 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 return 0
+5 -1
View File
@@ -438,7 +438,11 @@ class ComicInfoBuilder:
series = data[0] if data else None series = data[0] if data else None
if series and self._matches_cache is not 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, title,
mangabaka_id=series.get("id"), mangabaka_id=series.get("id"),
mangabaka_name=series.get("title") or "", mangabaka_name=series.get("title") or "",
+27
View File
@@ -74,6 +74,33 @@ class MatchesCache:
self._save_unlocked() self._save_unlocked()
return dict(entry) 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, *, def upsert(self, title: str, *,
mangabaka_id=None, mangabaka_id=None,
mangabaka_name=None, mangabaka_name=None,
+12 -2
View File
@@ -34,6 +34,7 @@ from pathlib import Path
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.observers.polling import PollingObserver
from SuwayomiMover import SuwayomiMover from SuwayomiMover import SuwayomiMover
@@ -56,13 +57,21 @@ class SuwayomiFolderWatcher:
settle_seconds : Quiet period before a series is processed. Each new settle_seconds : Quiet period before a series is processed. Each new
filesystem event resets the per-series timer. filesystem event resets the per-series timer.
Default: 600 (10 minutes). 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, def __init__(self,
suwayomi_path, suwayomi_path,
mover: SuwayomiMover, 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._suwayomi_path = Path(suwayomi_path).resolve()
self._mover = mover self._mover = mover
self._settle = settle_seconds self._settle = settle_seconds
@@ -75,7 +84,8 @@ class SuwayomiFolderWatcher:
self._queue: "queue.Queue[str]" = queue.Queue() self._queue: "queue.Queue[str]" = queue.Queue()
self._stop = threading.Event() self._stop = threading.Event()
self._observer = Observer() self._observer = (PollingObserver(timeout=poll_interval)
if use_polling else Observer())
self._worker = threading.Thread( self._worker = threading.Thread(
target=self._worker_loop, name="SuwayomiWorker", daemon=True) 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}") print(f" [warn] no MangaBaka match for {builder_title!r}")
continue continue
series = data[0] 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, builder_title,
mangabaka_id=series.get("id"), mangabaka_id=series.get("id"),
mangabaka_name=series.get("title") or "", mangabaka_name=series.get("title") or "",