diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c317d6f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+.venv/
+__pycache__/
+*.egg-info/
+config/
+*.db
+.pytest_cache/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4b58cbc
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+COPY pyproject.toml .
+COPY wordarr ./wordarr
+COPY static ./static
+RUN pip install --no-cache-dir .
+
+ENV WORDARR_CONFIG_DIR=/config \
+ WORDARR_DOWNLOAD_DIR=/mnt/downloads
+
+VOLUME /config
+EXPOSE 8787
+
+CMD ["uvicorn", "wordarr.main:app", "--host", "0.0.0.0", "--port", "8787"]
diff --git a/README.md b/README.md
index c37515c..b845a5f 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,40 @@
# wordarr
+Sonarr/Radarr-Style Request- & Import-Manager für **Ebooks**, **Comics/Mangas** und **Audiobooks**.
+
+## Funktionsweise
+
+1. **Anfragen**: Im Web-UI per Titel/Autor/ISBN suchen (Ebooks: Open Library · Audiobooks: Audible · Manga: AniList) oder manuell anlegen. Beim Request wählst du die **Ziel-Library** (z.B. deine 8 Bookorbit-Libraries oder die 3 Audiobookshelf-Libraries english/adult/kids). Der Titel erscheint als **Missing**.
+2. **Download-Ordner**: wordarr überwacht keinen Downloader aktiv — du legst Dateien selbst in den Download-Ordner (`/mnt/downloads`).
+3. **Import missing**: Im Tab *Import* den Ordner scannen. wordarr schlägt per Fuzzy-Matching Datei→Request-Zuordnungen vor; du bestätigst oder korrigierst. Beim Import wird die Datei nach dem Namensschema der Library **umbenannt und verschoben**. Ordner mit mehreren Audio-Dateien werden als ein Audiobook behandelt (`Titel - Part 01.mp3`, …).
+
+## Setup (Docker)
+
+```bash
+docker compose up -d --build
+# Web-UI: http://localhost:8787
+```
+
+`docker-compose.yml` anpassen: `/mnt/downloads` und die Library-Roots so mounten, dass die in wordarr konfigurierten `root_path`-Werte im Container existieren. Download-Ordner und Libraries sollten auf demselben Mount liegen, sonst wird das Verschieben zum Kopieren+Löschen (funktioniert, dauert nur länger).
+
+| Env | Default | Beschreibung |
+|---|---|---|
+| `WORDARR_DOWNLOAD_DIR` | `/mnt/downloads` | Gescannter Download-Ordner |
+| `WORDARR_CONFIG_DIR` | `/config` | Ablage der SQLite-DB |
+
+## Namensschemata
+
+Pro Library konfigurierbar (Tab *Libraries*), Platzhalter: `{Author}` `{Authors}` `{Title}` `{Year}` `{Series}` `{Volume}`.
+
+Defaults:
+- Ebook: `{Author}/{Title} ({Year})` / `{Author} - {Title}`
+- Audiobook: `{Author}/{Title}` / `{Title}`
+- Comic: `{Series}` / `{Series} - Band {Volume}`
+
+## Entwicklung
+
+```bash
+python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
+.venv/bin/pytest
+WORDARR_CONFIG_DIR=./config WORDARR_DOWNLOAD_DIR=/mnt/downloads .venv/bin/uvicorn wordarr.main:app --port 8787
+```
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..caa24be
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,14 @@
+services:
+ wordarr:
+ build: .
+ container_name: wordarr
+ ports:
+ - "8787:8787"
+ volumes:
+ - ./config:/config
+ - /mnt/downloads:/mnt/downloads
+ # Library-Roots so mounten, wie sie in den wordarr-Libraries konfiguriert sind:
+ - /mnt/library:/library
+ environment:
+ - WORDARR_DOWNLOAD_DIR=/mnt/downloads
+ restart: unless-stopped
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..c29ae99
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,23 @@
+[project]
+name = "wordarr"
+version = "0.1.0"
+description = "Sonarr/Radarr-style request & import manager for ebooks, comics/manga and audiobooks"
+requires-python = ">=3.11"
+dependencies = [
+ "fastapi>=0.110",
+ "uvicorn[standard]>=0.29",
+ "sqlalchemy>=2.0",
+ "httpx>=0.27",
+ "rapidfuzz>=3.6",
+ "pydantic>=2.6",
+]
+
+[project.optional-dependencies]
+dev = ["pytest>=8.0", "httpx>=0.27"]
+
+[build-system]
+requires = ["setuptools>=68"]
+build-backend = "setuptools.build_meta"
+
+[tool.setuptools.packages.find]
+include = ["wordarr*"]
diff --git a/static/app.js b/static/app.js
new file mode 100644
index 0000000..58b2442
--- /dev/null
+++ b/static/app.js
@@ -0,0 +1,291 @@
+const $ = (sel) => document.querySelector(sel);
+const $$ = (sel) => document.querySelectorAll(sel);
+
+let libraries = [];
+
+async function api(path, opts = {}) {
+ const resp = await fetch(path, {
+ headers: { "Content-Type": "application/json" },
+ ...opts,
+ });
+ if (!resp.ok) {
+ let msg = resp.statusText;
+ try { msg = (await resp.json()).detail || msg; } catch {}
+ throw new Error(msg);
+ }
+ return resp.json();
+}
+
+function toast(msg, isError = false) {
+ const t = $("#toast");
+ t.textContent = msg;
+ t.className = isError ? "error" : "";
+ t.hidden = false;
+ setTimeout(() => (t.hidden = true), 4000);
+}
+
+function esc(s) {
+ const d = document.createElement("div");
+ d.textContent = s ?? "";
+ return d.innerHTML;
+}
+
+// ---- navigation ----
+$$("nav button").forEach((btn) =>
+ btn.addEventListener("click", () => {
+ $$("nav button").forEach((b) => b.classList.remove("active"));
+ btn.classList.add("active");
+ $$("main > section").forEach((s) => (s.hidden = true));
+ $("#view-" + btn.dataset.view).hidden = false;
+ if (btn.dataset.view === "missing") loadRequests("missing");
+ if (btn.dataset.view === "imported") loadRequests("imported");
+ if (btn.dataset.view === "settings") loadLibraries();
+ })
+);
+
+// ---- libraries ----
+async function loadLibraries() {
+ libraries = await api("/api/libraries");
+ const tbody = $("#lib-table tbody");
+ tbody.innerHTML = libraries
+ .map(
+ (l) => `
+ | ${esc(l.name)} | ${esc(l.media_type)} | ${esc(l.root_path)} |
+ ${esc(l.folder_template)} | ${esc(l.file_template)} |
+ |
+
`
+ )
+ .join("");
+ tbody.querySelectorAll("[data-del-lib]").forEach((b) =>
+ b.addEventListener("click", async () => {
+ if (!confirm("Library löschen?")) return;
+ try {
+ await api("/api/libraries/" + b.dataset.delLib, { method: "DELETE" });
+ loadLibraries();
+ } catch (e) { toast(e.message, true); }
+ })
+ );
+ const filter = $("#missing-filter-library");
+ filter.innerHTML =
+ '' +
+ libraries.map((l) => ``).join("");
+}
+
+$("#lib-form").addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const data = Object.fromEntries(new FormData(e.target));
+ if (!data.folder_template) delete data.folder_template;
+ if (!data.file_template) delete data.file_template;
+ try {
+ await api("/api/libraries", { method: "POST", body: JSON.stringify(data) });
+ e.target.reset();
+ loadLibraries();
+ toast("Library angelegt");
+ } catch (err) { toast(err.message, true); }
+});
+
+function libOptions(mediaType) {
+ return libraries
+ .filter((l) => l.media_type === mediaType)
+ .map((l) => ``)
+ .join("");
+}
+
+// ---- search & request ----
+$("#search-form").addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const type = $("#search-type").value;
+ const q = $("#search-q").value;
+ const box = $("#search-results");
+ box.innerHTML = "Suche läuft…
";
+ try {
+ const results = await api(`/api/search?media_type=${type}&q=${encodeURIComponent(q)}`);
+ if (!results.length) { box.innerHTML = "Keine Treffer.
"; return; }
+ const opts = libOptions(type);
+ box.innerHTML = results
+ .map(
+ (r, i) => `
+ ${r.cover_url ? `
})
` : '
?
'}
+
+
${esc(r.title)}
+
${esc(r.authors)}
+
${r.year ?? ""} ${r.external_id ? "· " + esc(r.external_id) : ""}
+ ${type === "comic" ? `
` : ""}
+
+
+
+
+
+
`
+ )
+ .join("");
+ box.querySelectorAll("[data-req]").forEach((btn) =>
+ btn.addEventListener("click", async () => {
+ const i = btn.dataset.req;
+ const r = results[i];
+ const libId = box.querySelector(`[data-lib="${i}"]`).value;
+ if (!libId) { toast("Erst eine Library für diesen Typ anlegen (Tab Libraries)", true); return; }
+ const volInput = box.querySelector(`[data-vol="${i}"]`);
+ try {
+ await api("/api/requests", {
+ method: "POST",
+ body: JSON.stringify({
+ library_id: parseInt(libId),
+ title: r.title, authors: r.authors, external_id: r.external_id,
+ year: r.year, series: r.series, cover_url: r.cover_url,
+ volume: volInput && volInput.value ? parseInt(volInput.value) : null,
+ }),
+ });
+ btn.textContent = "✓ Angefragt";
+ btn.disabled = true;
+ } catch (err) { toast(err.message, true); }
+ })
+ );
+ } catch (err) {
+ box.innerHTML = "";
+ toast(err.message, true);
+ }
+});
+
+// ---- manual request ----
+$("#manual-btn").addEventListener("click", () => {
+ const dlg = $("#manual-dialog");
+ const typeSel = dlg.querySelector("[name=media_type]");
+ const updateLibs = () => {
+ $("#manual-library").innerHTML = libOptions(typeSel.value) || "";
+ };
+ typeSel.onchange = updateLibs;
+ updateLibs();
+ dlg.showModal();
+});
+
+$("#manual-form").addEventListener("submit", async (e) => {
+ if (e.submitter && e.submitter.value === "cancel") return;
+ const data = Object.fromEntries(new FormData(e.target));
+ if (!data.library_id) { toast("Keine Library gewählt", true); return; }
+ try {
+ await api("/api/requests", {
+ method: "POST",
+ body: JSON.stringify({
+ library_id: parseInt(data.library_id),
+ title: data.title, authors: data.authors, series: data.series,
+ external_id: data.external_id,
+ year: data.year ? parseInt(data.year) : null,
+ volume: data.volume ? parseInt(data.volume) : null,
+ }),
+ });
+ e.target.reset();
+ toast("Anfrage angelegt");
+ } catch (err) { toast(err.message, true); }
+});
+
+// ---- missing / imported lists ----
+async function loadRequests(status) {
+ const params = new URLSearchParams({ status });
+ if (status === "missing") {
+ const t = $("#missing-filter-type").value;
+ const l = $("#missing-filter-library").value;
+ if (t) params.set("media_type", t);
+ if (l) params.set("library_id", l);
+ }
+ const reqs = await api("/api/requests?" + params);
+ const box = status === "missing" ? $("#missing-list") : $("#imported-list");
+ if (!reqs.length) { box.innerHTML = "Nichts hier.
"; return; }
+ box.innerHTML = reqs
+ .map(
+ (r) => `
+ ${r.cover_url ? `
})
` : '
?
'}
+
+
${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""}
+
${esc(r.authors)}
+
${esc(r.media_type)} → ${esc(r.library_name)}
+ ${status === "imported" ? `
${esc(r.imported_path)}` : ""}
+
+
+
`
+ )
+ .join("");
+ box.querySelectorAll("[data-del-req]").forEach((b) =>
+ b.addEventListener("click", async () => {
+ if (!confirm("Anfrage entfernen?")) return;
+ await api("/api/requests/" + b.dataset.delReq, { method: "DELETE" });
+ loadRequests(status);
+ })
+ );
+}
+$("#missing-filter-type").addEventListener("change", () => loadRequests("missing"));
+$("#missing-filter-library").addEventListener("change", () => loadRequests("missing"));
+
+// ---- import ----
+let scanItems = [];
+let missingReqs = [];
+
+$("#scan-btn").addEventListener("click", async () => {
+ $("#scan-info").textContent = "Scanne…";
+ try {
+ const [scan, reqs] = await Promise.all([
+ api("/api/import/scan"),
+ api("/api/requests?status=missing"),
+ ]);
+ scanItems = scan.items;
+ missingReqs = reqs;
+ $("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`;
+ renderImportTable();
+ } catch (err) {
+ $("#scan-info").textContent = "";
+ toast(err.message, true);
+ }
+});
+
+function renderImportTable() {
+ const table = $("#import-table");
+ const tbody = table.querySelector("tbody");
+ if (!scanItems.length) { table.hidden = true; $("#import-btn").hidden = true; return; }
+ tbody.innerHTML = scanItems
+ .map((item, i) => {
+ const opts = missingReqs
+ .filter((r) => r.media_type === item.media_type)
+ .map(
+ (r) =>
+ ``
+ )
+ .join("");
+ return `
+ |
+ ${esc(item.name)}${item.is_dir ? " 📁" : ""} |
+ ${esc(item.media_type)} |
+ |
+ ${item.suggested_request_id ? item.score : "—"} |
+
`;
+ })
+ .join("");
+ table.hidden = false;
+ $("#import-btn").hidden = false;
+}
+
+$("#import-btn").addEventListener("click", async () => {
+ const items = [];
+ scanItems.forEach((item, i) => {
+ const checked = document.querySelector(`[data-check="${i}"]`).checked;
+ const reqId = document.querySelector(`[data-select="${i}"]`).value;
+ if (checked && reqId) {
+ items.push({ path: item.path, is_dir: item.is_dir, files: item.files, request_id: parseInt(reqId) });
+ }
+ });
+ if (!items.length) { toast("Nichts ausgewählt", true); return; }
+ try {
+ const res = await api("/api/import", { method: "POST", body: JSON.stringify({ items }) });
+ $("#import-results").innerHTML = res.results
+ .map((r) =>
+ r.ok
+ ? `✓ ${esc(r.path)} → ${esc(r.dest)}
`
+ : `✗ ${esc(r.path)}: ${esc(r.error)}
`
+ )
+ .join("");
+ $("#scan-btn").click();
+ } catch (err) { toast(err.message, true); }
+});
+
+// ---- init ----
+loadLibraries();
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..3b740af
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,116 @@
+
+
+
+
+
+wordarr
+
+
+
+
+ 📚 wordarr
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Datei/Ordner | Typ | Zuordnung | Score |
+
+
+
+
+
+
+
+
+
+ Libraries
+
+ | Name | Typ | Pfad | Ordner-Schema | Datei-Schema | |
+
+
+ Neue Library
+
+ Platzhalter: {Author} {Authors} {Title} {Year} {Series} {Volume}
+
+
+
+
+
+
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..7c88e61
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,46 @@
+* { box-sizing: border-box; margin: 0; padding: 0; }
+:root {
+ --bg: #1a1d24; --panel: #23272f; --border: #333945;
+ --text: #e6e8ec; --muted: #8b93a1; --accent: #4c8fdd; --danger: #c0504e;
+}
+body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
+header {
+ display: flex; align-items: center; gap: 2rem;
+ padding: 0.8rem 1.2rem; background: var(--panel); border-bottom: 1px solid var(--border);
+}
+h1 { font-size: 1.2rem; }
+nav { display: flex; gap: 0.4rem; }
+nav button { background: none; border: none; color: var(--muted); padding: 0.5rem 0.9rem; cursor: pointer; border-radius: 4px; font-size: 0.95rem; }
+nav button.active, nav button:hover { color: var(--text); background: var(--bg); }
+main { padding: 1.2rem; max-width: 1100px; margin: 0 auto; }
+.bar { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
+.bar.wrap { flex-wrap: wrap; }
+input, select { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 0.5rem 0.7rem; font-size: 0.95rem; }
+input.wide { min-width: 260px; }
+#search-q { flex: 1; }
+button { background: var(--accent); color: #fff; border: none; border-radius: 4px; padding: 0.5rem 1rem; cursor: pointer; font-size: 0.95rem; }
+button:hover { filter: brightness(1.1); }
+button.secondary { background: var(--border); }
+button.danger { background: var(--danger); padding: 0.3rem 0.7rem; font-size: 0.85rem; }
+button:disabled { opacity: 0.6; cursor: default; }
+.cards { display: flex; flex-direction: column; gap: 0.6rem; }
+.card { display: flex; gap: 0.8rem; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem; }
+.card img, .nocover { width: 60px; height: 90px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
+.nocover { background: var(--bg); display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 1.5rem; }
+.card-body { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; }
+.row { display: flex; gap: 0.5rem; margin-top: 0.4rem; align-items: center; }
+.muted { color: var(--muted); font-size: 0.88rem; }
+.mono { font-family: ui-monospace, monospace; font-size: 0.85rem; word-break: break-all; }
+table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; }
+th, td { text-align: left; padding: 0.5rem 0.7rem; border-bottom: 1px solid var(--border); font-size: 0.92rem; }
+th { color: var(--muted); font-weight: 500; }
+td select { max-width: 340px; }
+.volume { width: 90px; }
+dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 1.2rem; min-width: 340px; }
+dialog::backdrop { background: rgba(0,0,0,0.6); }
+dialog label { display: flex; flex-direction: column; gap: 0.2rem; margin: 0.5rem 0; font-size: 0.9rem; color: var(--muted); }
+.ok { color: #6fbf73; }
+.error { color: #e08585; }
+#toast { position: fixed; bottom: 1.2rem; right: 1.2rem; background: var(--accent); color: #fff; padding: 0.7rem 1.1rem; border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
+#toast.error { background: var(--danger); }
+h3, h4 { margin: 0.8rem 0 0.6rem; }
diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py
new file mode 100644
index 0000000..09d5c0d
--- /dev/null
+++ b/tests/test_import_flow.py
@@ -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
diff --git a/tests/test_matcher.py b/tests/test_matcher.py
new file mode 100644
index 0000000..e1663b4
--- /dev/null
+++ b/tests/test_matcher.py
@@ -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
diff --git a/tests/test_naming.py b/tests/test_naming.py
new file mode 100644
index 0000000..551bbde
--- /dev/null
+++ b/tests/test_naming.py
@@ -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: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"
diff --git a/wordarr/__init__.py b/wordarr/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/wordarr/api/__init__.py b/wordarr/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/wordarr/api/imports.py b/wordarr/api/imports.py
new file mode 100644
index 0000000..e02d4e4
--- /dev/null
+++ b/wordarr/api/imports.py
@@ -0,0 +1,63 @@
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from .. import config
+from ..db import BookRequest, get_session
+from ..importer import matcher, mover, scanner
+
+router = APIRouter(prefix="/api/import", tags=["import"])
+
+
+@router.get("/scan")
+def scan(session: Session = Depends(get_session)):
+ """Scan the download dir and suggest matches against open requests."""
+ items = scanner.scan(Path(config.DOWNLOAD_DIR))
+ missing = session.scalars(
+ select(BookRequest).where(BookRequest.status == "missing")
+ ).all()
+ return {
+ "download_dir": str(config.DOWNLOAD_DIR),
+ "items": matcher.best_matches(items, missing),
+ }
+
+
+class ImportItem(BaseModel):
+ path: str
+ is_dir: bool
+ files: list[str]
+ request_id: int
+
+
+class ImportIn(BaseModel):
+ items: list[ImportItem]
+
+
+@router.post("")
+def do_import(data: ImportIn, session: Session = Depends(get_session)):
+ results = []
+ download_root = Path(config.DOWNLOAD_DIR).resolve()
+ for item in data.items:
+ req = session.get(BookRequest, item.request_id)
+ if not req or req.status != "missing":
+ results.append({"path": item.path, "ok": False, "error": "request not found or not missing"})
+ continue
+ src = Path(item.path).resolve()
+ if download_root not in src.parents and src != download_root:
+ results.append({"path": item.path, "ok": False, "error": "path outside download dir"})
+ continue
+ try:
+ dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library)
+ except Exception as exc:
+ results.append({"path": item.path, "ok": False, "error": str(exc)})
+ continue
+ req.status = "imported"
+ req.imported_path = dest
+ session.commit()
+ results.append({"path": item.path, "ok": True, "dest": dest})
+ if not results:
+ raise HTTPException(400, "nothing to import")
+ return {"results": results}
diff --git a/wordarr/api/libraries.py b/wordarr/api/libraries.py
new file mode 100644
index 0000000..3b3e17c
--- /dev/null
+++ b/wordarr/api/libraries.py
@@ -0,0 +1,78 @@
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from .. import config
+from ..db import Library, get_session
+
+router = APIRouter(prefix="/api/libraries", tags=["libraries"])
+
+
+class LibraryIn(BaseModel):
+ name: str
+ media_type: str # ebook | audiobook | comic
+ root_path: str
+ folder_template: str | None = None
+ file_template: str | None = None
+
+
+class LibraryOut(LibraryIn):
+ id: int
+
+ class Config:
+ from_attributes = True
+
+
+def _validate(data: LibraryIn):
+ if data.media_type not in config.DEFAULT_NAMING:
+ raise HTTPException(400, f"invalid media_type: {data.media_type}")
+
+
+@router.get("", response_model=list[LibraryOut])
+def list_libraries(session: Session = Depends(get_session)):
+ return session.scalars(select(Library).order_by(Library.media_type, Library.name)).all()
+
+
+@router.post("", response_model=LibraryOut)
+def create_library(data: LibraryIn, session: Session = Depends(get_session)):
+ _validate(data)
+ defaults = config.DEFAULT_NAMING[data.media_type]
+ lib = Library(
+ name=data.name,
+ media_type=data.media_type,
+ root_path=data.root_path,
+ folder_template=data.folder_template or defaults["folder"],
+ file_template=data.file_template or defaults["file"],
+ )
+ session.add(lib)
+ session.commit()
+ return lib
+
+
+@router.put("/{library_id}", response_model=LibraryOut)
+def update_library(library_id: int, data: LibraryIn, session: Session = Depends(get_session)):
+ _validate(data)
+ lib = session.get(Library, library_id)
+ if not lib:
+ raise HTTPException(404, "library not found")
+ defaults = config.DEFAULT_NAMING[data.media_type]
+ lib.name = data.name
+ lib.media_type = data.media_type
+ lib.root_path = data.root_path
+ lib.folder_template = data.folder_template or defaults["folder"]
+ lib.file_template = data.file_template or defaults["file"]
+ session.commit()
+ return lib
+
+
+@router.delete("/{library_id}")
+def delete_library(library_id: int, session: Session = Depends(get_session)):
+ lib = session.get(Library, library_id)
+ if not lib:
+ raise HTTPException(404, "library not found")
+ if lib.requests:
+ raise HTTPException(409, "library has requests; delete them first")
+ session.delete(lib)
+ session.commit()
+ return {"ok": True}
diff --git a/wordarr/api/requests.py b/wordarr/api/requests.py
new file mode 100644
index 0000000..883351c
--- /dev/null
+++ b/wordarr/api/requests.py
@@ -0,0 +1,71 @@
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from ..db import BookRequest, Library, get_session
+
+router = APIRouter(prefix="/api/requests", tags=["requests"])
+
+
+class RequestIn(BaseModel):
+ library_id: int
+ title: str
+ authors: str = ""
+ external_id: str = ""
+ year: int | None = None
+ series: str = ""
+ volume: int | None = None
+ cover_url: str = ""
+
+
+class RequestOut(RequestIn):
+ id: int
+ media_type: str
+ status: str
+ imported_path: str
+ library_name: str = ""
+
+ class Config:
+ from_attributes = True
+
+
+def _out(r: BookRequest) -> RequestOut:
+ out = RequestOut.model_validate(r)
+ out.library_name = r.library.name if r.library else ""
+ return out
+
+
+@router.get("", response_model=list[RequestOut])
+def list_requests(status: str | None = None, media_type: str | None = None,
+ library_id: int | None = None, session: Session = Depends(get_session)):
+ stmt = select(BookRequest).order_by(BookRequest.created_at.desc())
+ if status:
+ stmt = stmt.where(BookRequest.status == status)
+ if media_type:
+ stmt = stmt.where(BookRequest.media_type == media_type)
+ if library_id:
+ stmt = stmt.where(BookRequest.library_id == library_id)
+ return [_out(r) for r in session.scalars(stmt).all()]
+
+
+@router.post("", response_model=RequestOut)
+def create_request(data: RequestIn, session: Session = Depends(get_session)):
+ lib = session.get(Library, data.library_id)
+ if not lib:
+ raise HTTPException(404, "library not found")
+ req = BookRequest(media_type=lib.media_type, **data.model_dump())
+ session.add(req)
+ session.commit()
+ session.refresh(req)
+ return _out(req)
+
+
+@router.delete("/{request_id}")
+def delete_request(request_id: int, session: Session = Depends(get_session)):
+ req = session.get(BookRequest, request_id)
+ if not req:
+ raise HTTPException(404, "request not found")
+ session.delete(req)
+ session.commit()
+ return {"ok": True}
diff --git a/wordarr/api/search.py b/wordarr/api/search.py
new file mode 100644
index 0000000..0b141ce
--- /dev/null
+++ b/wordarr/api/search.py
@@ -0,0 +1,17 @@
+from fastapi import APIRouter, HTTPException
+
+from ..metadata import PROVIDERS
+from ..metadata.base import MetadataResult
+
+router = APIRouter(prefix="/api/search", tags=["search"])
+
+
+@router.get("", response_model=list[MetadataResult])
+async def search(media_type: str, q: str):
+ provider = PROVIDERS.get(media_type)
+ if not provider:
+ raise HTTPException(400, f"invalid media_type: {media_type}")
+ try:
+ return await provider(q)
+ except Exception as exc:
+ raise HTTPException(502, f"metadata provider error: {exc}")
diff --git a/wordarr/config.py b/wordarr/config.py
new file mode 100644
index 0000000..f542205
--- /dev/null
+++ b/wordarr/config.py
@@ -0,0 +1,26 @@
+import os
+from pathlib import Path
+
+DOWNLOAD_DIR = Path(os.environ.get("WORDARR_DOWNLOAD_DIR", "/mnt/downloads"))
+CONFIG_DIR = Path(os.environ.get("WORDARR_CONFIG_DIR", "/config"))
+DB_PATH = Path(os.environ.get("WORDARR_DB_PATH", str(CONFIG_DIR / "wordarr.db")))
+
+EBOOK_EXTENSIONS = {".epub", ".pdf", ".mobi", ".azw3", ".azw", ".fb2", ".djvu"}
+AUDIOBOOK_EXTENSIONS = {".m4b", ".m4a", ".mp3", ".flac", ".ogg", ".opus"}
+COMIC_EXTENSIONS = {".cbz", ".cbr", ".cb7"}
+ALL_EXTENSIONS = EBOOK_EXTENSIONS | AUDIOBOOK_EXTENSIONS | COMIC_EXTENSIONS
+
+DEFAULT_NAMING = {
+ "ebook": {
+ "folder": "{Author}/{Title} ({Year})",
+ "file": "{Author} - {Title}",
+ },
+ "audiobook": {
+ "folder": "{Author}/{Title}",
+ "file": "{Title}",
+ },
+ "comic": {
+ "folder": "{Series}",
+ "file": "{Series} - Band {Volume}",
+ },
+}
diff --git a/wordarr/db.py b/wordarr/db.py
new file mode 100644
index 0000000..f3b260d
--- /dev/null
+++ b/wordarr/db.py
@@ -0,0 +1,67 @@
+import datetime
+
+from sqlalchemy import ForeignKey, String, create_engine
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
+
+from . import config
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+class Library(Base):
+ __tablename__ = "libraries"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ name: Mapped[str] = mapped_column(String, unique=True)
+ media_type: Mapped[str] = mapped_column(String) # ebook | audiobook | comic
+ root_path: Mapped[str] = mapped_column(String)
+ folder_template: Mapped[str] = mapped_column(String)
+ file_template: Mapped[str] = mapped_column(String)
+
+ requests: Mapped[list["BookRequest"]] = relationship(back_populates="library")
+
+
+class BookRequest(Base):
+ __tablename__ = "requests"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ media_type: Mapped[str] = mapped_column(String)
+ library_id: Mapped[int] = mapped_column(ForeignKey("libraries.id"))
+ title: Mapped[str] = mapped_column(String)
+ authors: Mapped[str] = mapped_column(String, default="") # comma-separated
+ external_id: Mapped[str] = mapped_column(String, default="") # ISBN / ASIN / AniList id
+ year: Mapped[int | None] = mapped_column(default=None)
+ series: Mapped[str] = mapped_column(String, default="")
+ volume: Mapped[int | None] = mapped_column(default=None)
+ cover_url: Mapped[str] = mapped_column(String, default="")
+ status: Mapped[str] = mapped_column(String, default="missing") # missing | imported
+ created_at: Mapped[datetime.datetime] = mapped_column(
+ default=lambda: datetime.datetime.now(datetime.UTC)
+ )
+ imported_path: Mapped[str] = mapped_column(String, default="")
+
+ library: Mapped[Library] = relationship(back_populates="requests")
+
+
+_engine = None
+SessionLocal = None
+
+
+def init_db(db_path=None):
+ global _engine, SessionLocal
+ path = db_path or config.DB_PATH
+ path.parent.mkdir(parents=True, exist_ok=True)
+ _engine = create_engine(f"sqlite:///{path}", connect_args={"check_same_thread": False})
+ Base.metadata.create_all(_engine)
+ SessionLocal = sessionmaker(bind=_engine, expire_on_commit=False)
+ return _engine
+
+
+def get_session():
+ session = SessionLocal()
+ try:
+ yield session
+ finally:
+ session.close()
diff --git a/wordarr/importer/__init__.py b/wordarr/importer/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/wordarr/importer/matcher.py b/wordarr/importer/matcher.py
new file mode 100644
index 0000000..8c824da
--- /dev/null
+++ b/wordarr/importer/matcher.py
@@ -0,0 +1,37 @@
+import re
+
+from rapidfuzz import fuzz
+
+
+def normalize(s: str) -> str:
+ s = s.lower()
+ s = re.sub(r"[._\-\[\]()]+", " ", s)
+ s = re.sub(r"\b(epub|pdf|mobi|azw3|m4b|mp3|flac|cbz|cbr|retail|unabridged|audiobook|ebook|v\d+)\b", " ", s)
+ s = re.sub(r"\s+", " ", s)
+ return s.strip()
+
+
+def score(item_name: str, request) -> float:
+ target = normalize(f"{request.authors} {request.title}")
+ name = normalize(item_name)
+ s = fuzz.token_set_ratio(name, target)
+ title_only = fuzz.token_set_ratio(name, normalize(request.title))
+ return max(s, title_only * 0.95)
+
+
+def best_matches(items: list[dict], requests) -> list[dict]:
+ """For each scanned item, attach the best-scoring open request of the same media type."""
+ out = []
+ for item in items:
+ candidates = [r for r in requests if r.media_type == item["media_type"]]
+ best, best_score = None, 0.0
+ for r in candidates:
+ sc = score(item["name"], r)
+ if sc > best_score:
+ best, best_score = r, sc
+ out.append({
+ **item,
+ "suggested_request_id": best.id if best and best_score >= 50 else None,
+ "score": round(best_score, 1),
+ })
+ return out
diff --git a/wordarr/importer/mover.py b/wordarr/importer/mover.py
new file mode 100644
index 0000000..a1f760f
--- /dev/null
+++ b/wordarr/importer/mover.py
@@ -0,0 +1,33 @@
+import shutil
+from pathlib import Path
+
+from ..naming import render_template, sanitize
+
+
+def import_item(item_path: str, files: list[str], is_dir: bool, request, library) -> str:
+ """Move scanned files into the library, renamed per the library's templates.
+ Returns the destination path (folder for multi-file audiobooks, else the file)."""
+ root = Path(library.root_path)
+ folder = root / render_template(library.folder_template, request)
+ folder.mkdir(parents=True, exist_ok=True)
+ base = render_template(library.file_template, request)
+
+ paths = [Path(f) for f in files]
+ if is_dir and len(paths) > 1:
+ width = max(2, len(str(len(paths))))
+ for i, src in enumerate(paths, 1):
+ dest = folder / sanitize(f"{base} - Part {i:0{width}d}{src.suffix.lower()}")
+ shutil.move(str(src), dest)
+ # remove the source dir if nothing meaningful is left
+ src_dir = Path(item_path)
+ leftovers = [p for p in src_dir.rglob("*") if p.is_file()]
+ if not leftovers:
+ shutil.rmtree(src_dir, ignore_errors=True)
+ return str(folder)
+
+ src = paths[0]
+ dest = folder / sanitize(f"{base}{src.suffix.lower()}")
+ if dest.exists():
+ raise FileExistsError(f"Destination already exists: {dest}")
+ shutil.move(str(src), dest)
+ return str(dest)
diff --git a/wordarr/importer/scanner.py b/wordarr/importer/scanner.py
new file mode 100644
index 0000000..d2250ed
--- /dev/null
+++ b/wordarr/importer/scanner.py
@@ -0,0 +1,58 @@
+from pathlib import Path
+
+from .. import config
+
+
+def media_type_for(ext: str) -> str | None:
+ ext = ext.lower()
+ if ext in config.EBOOK_EXTENSIONS:
+ return "ebook"
+ if ext in config.AUDIOBOOK_EXTENSIONS:
+ return "audiobook"
+ if ext in config.COMIC_EXTENSIONS:
+ return "comic"
+ return None
+
+
+def scan(root: Path) -> list[dict]:
+ """Scan the download dir. Returns items: single files, or a directory that is
+ an audiobook unit (contains >1 audio file and nothing but audio/junk)."""
+ items = []
+ if not root.is_dir():
+ return items
+
+ def audio_files(d: Path) -> list[Path]:
+ return sorted(
+ f for f in d.iterdir()
+ if f.is_file() and f.suffix.lower() in config.AUDIOBOOK_EXTENSIONS
+ )
+
+ def walk(d: Path):
+ audio = audio_files(d)
+ if len(audio) > 1:
+ items.append({
+ "path": str(d),
+ "name": d.name,
+ "media_type": "audiobook",
+ "is_dir": True,
+ "files": [str(f) for f in audio],
+ })
+ return
+ for entry in sorted(d.iterdir()):
+ if entry.name.startswith("."):
+ continue
+ if entry.is_dir():
+ walk(entry)
+ elif entry.is_file():
+ mt = media_type_for(entry.suffix)
+ if mt:
+ items.append({
+ "path": str(entry),
+ "name": entry.stem,
+ "media_type": mt,
+ "is_dir": False,
+ "files": [str(entry)],
+ })
+
+ walk(root)
+ return items
diff --git a/wordarr/main.py b/wordarr/main.py
new file mode 100644
index 0000000..cadcde7
--- /dev/null
+++ b/wordarr/main.py
@@ -0,0 +1,26 @@
+from contextlib import asynccontextmanager
+from pathlib import Path
+
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+
+from . import db
+from .api import imports, libraries, requests, search
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ db.init_db()
+ yield
+
+
+app = FastAPI(title="wordarr", lifespan=lifespan)
+
+app.include_router(libraries.router)
+app.include_router(search.router)
+app.include_router(requests.router)
+app.include_router(imports.router)
+
+
+static_dir = Path(__file__).parent.parent / "static"
+app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
diff --git a/wordarr/metadata/__init__.py b/wordarr/metadata/__init__.py
new file mode 100644
index 0000000..ce6b57b
--- /dev/null
+++ b/wordarr/metadata/__init__.py
@@ -0,0 +1,7 @@
+from . import anilist, audible, openlibrary
+
+PROVIDERS = {
+ "ebook": openlibrary.search,
+ "audiobook": audible.search,
+ "comic": anilist.search,
+}
diff --git a/wordarr/metadata/anilist.py b/wordarr/metadata/anilist.py
new file mode 100644
index 0000000..b4e7efd
--- /dev/null
+++ b/wordarr/metadata/anilist.py
@@ -0,0 +1,44 @@
+import httpx
+
+from .base import MetadataResult
+
+_QUERY = """
+query ($search: String) {
+ Page(perPage: 10) {
+ media(search: $search, type: MANGA) {
+ id
+ title { romaji english }
+ startDate { year }
+ coverImage { medium }
+ staff(perPage: 3) { nodes { name { full } } }
+ }
+ }
+}
+"""
+
+
+async def search(query: str) -> list[MetadataResult]:
+ async with httpx.AsyncClient(timeout=15) as client:
+ resp = await client.post(
+ "https://graphql.anilist.co",
+ json={"query": _QUERY, "variables": {"search": query}},
+ )
+ resp.raise_for_status()
+ media = resp.json()["data"]["Page"]["media"]
+
+ results = []
+ for m in media:
+ title = m["title"].get("english") or m["title"].get("romaji") or ""
+ staff = [n["name"]["full"] for n in m["staff"]["nodes"]]
+ results.append(
+ MetadataResult(
+ media_type="comic",
+ title=title,
+ series=title,
+ authors=", ".join(staff),
+ external_id=str(m["id"]),
+ year=(m.get("startDate") or {}).get("year"),
+ cover_url=(m.get("coverImage") or {}).get("medium") or "",
+ )
+ )
+ return results
diff --git a/wordarr/metadata/audible.py b/wordarr/metadata/audible.py
new file mode 100644
index 0000000..699ae04
--- /dev/null
+++ b/wordarr/metadata/audible.py
@@ -0,0 +1,32 @@
+import httpx
+
+from .base import MetadataResult
+
+
+async def search(query: str) -> list[MetadataResult]:
+ params = {
+ "keywords": query,
+ "num_results": 10,
+ "response_groups": "media,contributors,product_desc,product_attrs",
+ "products_sort_by": "Relevance",
+ }
+ async with httpx.AsyncClient(timeout=15) as client:
+ resp = await client.get("https://api.audible.com/1.0/catalog/products", params=params)
+ resp.raise_for_status()
+ products = resp.json().get("products", [])
+
+ results = []
+ for p in products:
+ images = p.get("product_images") or {}
+ release = p.get("release_date") or ""
+ results.append(
+ MetadataResult(
+ media_type="audiobook",
+ title=p.get("title", ""),
+ authors=", ".join(a.get("name", "") for a in p.get("authors") or []),
+ external_id=p.get("asin", ""),
+ year=int(release[:4]) if release[:4].isdigit() else None,
+ cover_url=next(iter(images.values()), ""),
+ )
+ )
+ return results
diff --git a/wordarr/metadata/base.py b/wordarr/metadata/base.py
new file mode 100644
index 0000000..6897c7b
--- /dev/null
+++ b/wordarr/metadata/base.py
@@ -0,0 +1,13 @@
+from pydantic import BaseModel
+
+
+class MetadataResult(BaseModel):
+ media_type: str
+ title: str
+ authors: str = ""
+ external_id: str = ""
+ year: int | None = None
+ series: str = ""
+ volume: int | None = None
+ cover_url: str = ""
+ description: str = ""
diff --git a/wordarr/metadata/openlibrary.py b/wordarr/metadata/openlibrary.py
new file mode 100644
index 0000000..4d1c54c
--- /dev/null
+++ b/wordarr/metadata/openlibrary.py
@@ -0,0 +1,37 @@
+import re
+
+import httpx
+
+from .base import MetadataResult
+
+_ISBN = re.compile(r"^(97[89])?\d{9}[\dXx]$")
+
+
+async def search(query: str) -> list[MetadataResult]:
+ query = query.strip()
+ isbn = query.replace("-", "").replace(" ", "")
+ async with httpx.AsyncClient(timeout=15) as client:
+ if _ISBN.match(isbn):
+ params = {"q": f"isbn:{isbn}", "limit": 10}
+ else:
+ params = {"q": query, "limit": 10}
+ params["fields"] = "title,author_name,first_publish_year,isbn,cover_i,key"
+ resp = await client.get("https://openlibrary.org/search.json", params=params)
+ resp.raise_for_status()
+ docs = resp.json().get("docs", [])
+
+ results = []
+ for doc in docs:
+ isbns = doc.get("isbn") or []
+ cover = doc.get("cover_i")
+ results.append(
+ MetadataResult(
+ media_type="ebook",
+ title=doc.get("title", ""),
+ authors=", ".join(doc.get("author_name") or []),
+ external_id=isbn if _ISBN.match(isbn) else (isbns[0] if isbns else ""),
+ year=doc.get("first_publish_year"),
+ cover_url=f"https://covers.openlibrary.org/b/id/{cover}-M.jpg" if cover else "",
+ )
+ )
+ return results
diff --git a/wordarr/naming.py b/wordarr/naming.py
new file mode 100644
index 0000000..8278266
--- /dev/null
+++ b/wordarr/naming.py
@@ -0,0 +1,31 @@
+import re
+
+_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
+
+
+def sanitize(part: str, max_len: int = 120) -> str:
+ part = _FORBIDDEN.sub("", part).strip(" .")
+ part = re.sub(r"\s+", " ", part)
+ return part[:max_len].strip(" .") or "Unknown"
+
+
+def render_template(template: str, request) -> str:
+ """Render a naming template against a BookRequest. Each path segment is sanitized."""
+ author = (request.authors or "").split(",")[0].strip() or "Unknown Author"
+ values = {
+ "Author": author,
+ "Authors": request.authors or author,
+ "Title": request.title or "Unknown Title",
+ "Year": str(request.year) if request.year else "",
+ "Series": request.series or request.title or "Unknown Series",
+ "Volume": f"{request.volume:02d}" if request.volume is not None else "",
+ }
+ segments = []
+ for seg in template.split("/"):
+ for key, val in values.items():
+ seg = seg.replace("{" + key + "}", val)
+ # drop empty parenthesized/dangling bits from missing values, e.g. "Title ()"
+ seg = re.sub(r"\(\s*\)", "", seg)
+ seg = re.sub(r"(-|–)\s*$", "", seg).strip()
+ segments.append(sanitize(seg))
+ return "/".join(segments)