Merge pull request 'fix(connect): queue new files with the audiobookshelf watcher instead of a full scan' (#10) from bugfix/abs-targeted-scan into main
Build image / image (push) Successful in 1m41s

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
admin committed 2026-10-27 06:45:44 +01:00
commit 40af29ab15
2 files changed
+52 -12

No files matched your search

+22 -7
View File
@@ -1,4 +1,5 @@
import importlib import importlib
import json
import os import os
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -1228,12 +1229,13 @@ def test_connections_hear_about_committed_rows(client, monkeypatch):
from wordarr import connect from wordarr import connect
calls = [] calls = []
watcher = {"disableWatcher": False}
def handler(request): 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": if request.url.path == "/api/libraries":
return httpx.Response(200, json={"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")}]}, "folders": [{"fullPath": str(client.tmp_path / "library")}]},
{"id": "lib-pod", "name": "Pod", "folders": [{"fullPath": "/podcasts"}]}, {"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, "path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id,
}]}).json()["results"][0]["ok"] }]}).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"), ("POST", "http://hook/in"),
("GET", "http://abs/api/libraries"), ("GET", "http://abs/api/libraries"),
("POST", "http://abs/api/libraries/lib-audio/scan"), ("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 test_bulk_move_delete_and_count(client):
def lib(name, media_type): def lib(name, media_type):
+30 -5
View File
@@ -1,9 +1,10 @@
"""Tell other services about history rows once they are committed: a generic """Tell other services about history rows once they are committed: a generic
webhook gets every row, Audiobookshelf a scan of the libraries holding new webhook gets every row, Audiobookshelf the new files of the libraries holding
files. Runs as a background task, so a slow or dead service never fails the them. Runs as a background task, so a slow or dead service never fails the
request that caused it; errors only reach the log.""" request that caused it; errors only reach the log."""
import logging import logging
import os import os
from pathlib import Path
import httpx import httpx
from fastapi import BackgroundTasks from fastapi import BackgroundTasks
@@ -46,15 +47,39 @@ def notify(events: list[dict]) -> None:
def _scan_audiobookshelf(client: httpx.Client, dests: list[str]) -> None: def _scan_audiobookshelf(client: httpx.Client, dests: list[str]) -> None:
"""Scan only the libraries whose folders contain a destination. That needs """Tell the libraries whose folders contain a destination about the new
both containers to mount the media under the same path.""" files. That needs both containers to mount the media under the same path."""
headers = {"Authorization": f"Bearer {ABS_TOKEN}"} headers = {"Authorization": f"Bearer {ABS_TOKEN}"}
resp = client.get(f"{ABS_URL}/api/libraries", headers=headers) resp = client.get(f"{ABS_URL}/api/libraries", headers=headers)
resp.raise_for_status() resp.raise_for_status()
for lib in resp.json().get("libraries") or []: for lib in resp.json().get("libraries") or []:
folders = [str(f.get("fullPath") or "").rstrip("/") folders = [str(f.get("fullPath") or "").rstrip("/")
for f in lib.get("folders") or []] 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", client.post(f"{ABS_URL}/api/libraries/{lib.get('id')}/scan",
headers=headers).raise_for_status() headers=headers).raise_for_status()
log.info("audiobookshelf scans library %s", lib.get("name")) 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