mover and matches fix
This commit is contained in:
+5
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
Reference in New Issue
Block a user