From 377d2c85575e19b9f9795d7574d0209aa74bf206 Mon Sep 17 00:00:00 2001 From: Steppenstreuner Date: Tue, 27 Oct 2026 06:44:38 +0100 Subject: [PATCH] fix(connect): queue new files with the audiobookshelf watcher instead of a full scan --- tests/test_import_flow.py | 29 ++++++++++++++++++++++------- wordarr/connect.py | 35 ++++++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index 9edb5f8..2ee071e 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -1,4 +1,5 @@ import importlib +import json import os import shutil from pathlib import Path @@ -1228,12 +1229,13 @@ def test_connections_hear_about_committed_rows(client, monkeypatch): from wordarr import connect calls = [] + watcher = {"disableWatcher": False} def handler(request): - calls.append((request.method, str(request.url))) + calls.append((request.method, str(request.url), request.content)) if request.url.path == "/api/libraries": return httpx.Response(200, json={"libraries": [ - {"id": "lib-audio", "name": "Audio", + {"id": "lib-audio", "name": "Audio", "settings": watcher, "folders": [{"fullPath": str(client.tmp_path / "library")}]}, {"id": "lib-pod", "name": "Pod", "folders": [{"fullPath": "/podcasts"}]}, ]}) @@ -1261,16 +1263,29 @@ def test_connections_hear_about_committed_rows(client, monkeypatch): "path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id, }]}).json()["results"][0]["ok"] - assert calls == [ + # no full scan while the watcher runs: the two raced and added the book twice + dest = str(root / "Momo" / "Momo.epub") + assert [c[:2] for c in calls] == [ + ("POST", "http://hook/in"), + ("GET", "http://abs/api/libraries"), + ("POST", "http://abs/api/watcher/update"), + ] + assert json.loads(calls[2][2]) == { + "libraryId": "lib-audio", "path": dest, "type": "add"} + + calls.clear() + client.delete(f"/api/requests/{req_id}") + assert [c[:2] for c in calls] == [("POST", "http://hook/in")] # nothing new on disk + + calls.clear() + watcher["disableWatcher"] = True + connect.notify([{"event": "imported", "dest": dest}]) + assert [c[:2] for c in calls] == [ ("POST", "http://hook/in"), ("GET", "http://abs/api/libraries"), ("POST", "http://abs/api/libraries/lib-audio/scan"), ] - calls.clear() - client.delete(f"/api/requests/{req_id}") - assert calls == [("POST", "http://hook/in")] # nothing new on disk, no scan - def test_bulk_move_delete_and_count(client): def lib(name, media_type): diff --git a/wordarr/connect.py b/wordarr/connect.py index 26aef51..c5e60c2 100644 --- a/wordarr/connect.py +++ b/wordarr/connect.py @@ -1,9 +1,10 @@ """Tell other services about history rows once they are committed: a generic -webhook gets every row, Audiobookshelf a scan of the libraries holding new -files. Runs as a background task, so a slow or dead service never fails the +webhook gets every row, Audiobookshelf the new files of the libraries holding +them. Runs as a background task, so a slow or dead service never fails the request that caused it; errors only reach the log.""" import logging import os +from pathlib import Path import httpx from fastapi import BackgroundTasks @@ -46,15 +47,39 @@ def notify(events: list[dict]) -> None: def _scan_audiobookshelf(client: httpx.Client, dests: list[str]) -> None: - """Scan only the libraries whose folders contain a destination. That needs - both containers to mount the media under the same path.""" + """Tell the libraries whose folders contain a destination about the new + files. That needs both containers to mount the media under the same path.""" headers = {"Authorization": f"Bearer {ABS_TOKEN}"} resp = client.get(f"{ABS_URL}/api/libraries", headers=headers) resp.raise_for_status() for lib in resp.json().get("libraries") or []: folders = [str(f.get("fullPath") or "").rstrip("/") for f in lib.get("folders") or []] - if any(f and (d == f or d.startswith(f + "/")) for f in folders for d in dests): + mine = [d for d in dests + if any(f and (d == f or d.startswith(f + "/")) for f in folders)] + if not mine: + continue + if (lib.get("settings") or {}).get("disableWatcher"): + # the watcher endpoint drops paths of unwatched libraries client.post(f"{ABS_URL}/api/libraries/{lib.get('id')}/scan", headers=headers).raise_for_status() log.info("audiobookshelf scans library %s", lib.get("name")) + continue + # a full scan racing the watcher adds the same folder twice, and it + # walks the whole library; this queues the files with the watcher + for path in _files(mine): + client.post(f"{ABS_URL}/api/watcher/update", headers=headers, + json={"libraryId": lib.get("id"), "path": path, + "type": "add"}).raise_for_status() + log.info("audiobookshelf watches new files in library %s", lib.get("name")) + + +def _files(dests: list[str]) -> list[str]: + """Imports are flat: a destination is a file or a folder of files.""" + out = [] + for d in map(Path, dests): + if d.is_file(): + out.append(str(d)) + elif d.is_dir(): + out += sorted(str(f) for f in d.iterdir() if f.is_file()) + return out