Add wordarr: request & import manager for ebooks, comics and audiobooks
FastAPI + SQLite backend with vanilla-JS web UI. Requests via Open Library / Audible / AniList metadata search (or manual entry) with per-request target library selection. Manual import flow: scan the download dir, fuzzy-match files against missing requests, review, then move+rename into the library per configurable naming templates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3789ad0a37
commit
508ae5f26e
@@ -0,0 +1,108 @@
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
downloads = tmp_path / "downloads"
|
||||
downloads.mkdir()
|
||||
monkeypatch.setenv("WORDARR_DOWNLOAD_DIR", str(downloads))
|
||||
monkeypatch.setenv("WORDARR_DB_PATH", str(tmp_path / "test.db"))
|
||||
|
||||
from wordarr import config
|
||||
importlib.reload(config)
|
||||
from wordarr import db
|
||||
db.init_db(tmp_path / "test.db")
|
||||
from wordarr import main
|
||||
importlib.reload(main)
|
||||
|
||||
with TestClient(main.app) as c:
|
||||
c.tmp_path = tmp_path
|
||||
c.downloads = downloads
|
||||
yield c
|
||||
|
||||
|
||||
def test_full_import_flow(client):
|
||||
library_root = client.tmp_path / "library" / "kids"
|
||||
resp = client.post("/api/libraries", json={
|
||||
"name": "Kids", "media_type": "ebook", "root_path": str(library_root),
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
lib_id = resp.json()["id"]
|
||||
|
||||
resp = client.post("/api/requests", json={
|
||||
"library_id": lib_id, "title": "The Hobbit",
|
||||
"authors": "J.R.R. Tolkien", "year": 1937, "external_id": "9780261103573",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
req_id = resp.json()["id"]
|
||||
assert resp.json()["status"] == "missing"
|
||||
|
||||
(client.downloads / "J.R.R. Tolkien - The Hobbit [retail].epub").write_bytes(b"fake epub")
|
||||
|
||||
resp = client.get("/api/import/scan")
|
||||
items = resp.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["suggested_request_id"] == req_id
|
||||
|
||||
resp = client.post("/api/import", json={"items": [{
|
||||
"path": items[0]["path"], "is_dir": False,
|
||||
"files": items[0]["files"], "request_id": req_id,
|
||||
}]})
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()["results"][0]
|
||||
assert result["ok"], result
|
||||
|
||||
dest = library_root / "J.R.R. Tolkien" / "The Hobbit (1937)" / "J.R.R. Tolkien - The Hobbit.epub"
|
||||
assert dest.is_file()
|
||||
assert not (client.downloads / "J.R.R. Tolkien - The Hobbit [retail].epub").exists()
|
||||
|
||||
resp = client.get("/api/requests", params={"status": "imported"})
|
||||
assert [r["id"] for r in resp.json()] == [req_id]
|
||||
|
||||
|
||||
def test_audiobook_folder_import(client):
|
||||
root = client.tmp_path / "library" / "audio"
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
"name": "English", "media_type": "audiobook", "root_path": str(root),
|
||||
}).json()["id"]
|
||||
req_id = client.post("/api/requests", json={
|
||||
"library_id": lib_id, "title": "Dune", "authors": "Frank Herbert",
|
||||
}).json()["id"]
|
||||
|
||||
book_dir = client.downloads / "Frank Herbert - Dune (Unabridged)"
|
||||
book_dir.mkdir()
|
||||
for i in range(3):
|
||||
(book_dir / f"track{i}.mp3").write_bytes(b"audio")
|
||||
|
||||
items = client.get("/api/import/scan").json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["is_dir"] is True
|
||||
assert items[0]["media_type"] == "audiobook"
|
||||
|
||||
resp = client.post("/api/import", json={"items": [{
|
||||
"path": items[0]["path"], "is_dir": True,
|
||||
"files": items[0]["files"], "request_id": req_id,
|
||||
}]})
|
||||
assert resp.json()["results"][0]["ok"]
|
||||
|
||||
dest = root / "Frank Herbert" / "Dune"
|
||||
assert sorted(p.name for p in dest.iterdir()) == [
|
||||
"Dune - Part 01.mp3", "Dune - Part 02.mp3", "Dune - Part 03.mp3",
|
||||
]
|
||||
assert not book_dir.exists()
|
||||
|
||||
|
||||
def test_import_rejects_path_outside_download_dir(client):
|
||||
lib_id = client.post("/api/libraries", json={
|
||||
"name": "E", "media_type": "ebook", "root_path": str(client.tmp_path / "lib"),
|
||||
}).json()["id"]
|
||||
req_id = client.post("/api/requests", json={"library_id": lib_id, "title": "X"}).json()["id"]
|
||||
outside = client.tmp_path / "outside.epub"
|
||||
outside.write_bytes(b"x")
|
||||
resp = client.post("/api/import", json={"items": [{
|
||||
"path": str(outside), "is_dir": False, "files": [str(outside)], "request_id": req_id,
|
||||
}]})
|
||||
assert resp.json()["results"][0]["ok"] is False
|
||||
@@ -0,0 +1,38 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from wordarr.importer.matcher import best_matches, normalize
|
||||
|
||||
|
||||
def req(id, title, authors="", media_type="ebook"):
|
||||
return SimpleNamespace(id=id, title=title, authors=authors, media_type=media_type)
|
||||
|
||||
|
||||
def test_normalize():
|
||||
assert normalize("J.R.R._Tolkien-The_Hobbit[retail].epub") == "j r r tolkien the hobbit"
|
||||
|
||||
|
||||
def test_match_finds_right_request():
|
||||
requests = [
|
||||
req(1, "The Hobbit", "J.R.R. Tolkien"),
|
||||
req(2, "Dune", "Frank Herbert"),
|
||||
]
|
||||
items = [{"path": "/d/x.epub", "name": "Frank Herbert - Dune (1965) retail", "media_type": "ebook",
|
||||
"is_dir": False, "files": ["/d/x.epub"]}]
|
||||
out = best_matches(items, requests)
|
||||
assert out[0]["suggested_request_id"] == 2
|
||||
|
||||
|
||||
def test_no_match_for_unrelated_file():
|
||||
requests = [req(1, "The Hobbit", "Tolkien")]
|
||||
items = [{"path": "/d/y.epub", "name": "Cooking for Dummies 2019", "media_type": "ebook",
|
||||
"is_dir": False, "files": ["/d/y.epub"]}]
|
||||
out = best_matches(items, requests)
|
||||
assert out[0]["suggested_request_id"] is None
|
||||
|
||||
|
||||
def test_media_type_filter():
|
||||
requests = [req(1, "Dune", "Herbert", media_type="audiobook")]
|
||||
items = [{"path": "/d/dune.epub", "name": "Herbert - Dune", "media_type": "ebook",
|
||||
"is_dir": False, "files": ["/d/dune.epub"]}]
|
||||
out = best_matches(items, requests)
|
||||
assert out[0]["suggested_request_id"] is None
|
||||
@@ -0,0 +1,33 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from wordarr.naming import render_template, sanitize
|
||||
|
||||
|
||||
def req(**kw):
|
||||
base = dict(title="", authors="", year=None, series="", volume=None)
|
||||
base.update(kw)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
def test_sanitize_removes_forbidden_chars():
|
||||
assert sanitize('A<b>:c/d\\e|f?g*h"') == "Abcdefgh"
|
||||
|
||||
|
||||
def test_ebook_template():
|
||||
r = req(title="The Hobbit", authors="J.R.R. Tolkien", year=1937)
|
||||
assert render_template("{Author}/{Title} ({Year})", r) == "J.R.R. Tolkien/The Hobbit (1937)"
|
||||
|
||||
|
||||
def test_missing_year_drops_parens():
|
||||
r = req(title="The Hobbit", authors="Tolkien")
|
||||
assert render_template("{Title} ({Year})", r) == "The Hobbit"
|
||||
|
||||
|
||||
def test_comic_template():
|
||||
r = req(title="One Piece", series="One Piece", volume=3)
|
||||
assert render_template("{Series}/{Series} - Band {Volume}", r) == "One Piece/One Piece - Band 03"
|
||||
|
||||
|
||||
def test_first_author_only():
|
||||
r = req(title="X", authors="Alice A, Bob B")
|
||||
assert render_template("{Author}", r) == "Alice A"
|
||||
Reference in New Issue
Block a user