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:
Steppenstreuner
2026-08-28 12:34:07 +02:00
co-authored by Claude Fable 5
parent 3789ad0a37
commit 508ae5f26e
30 changed files with 1368 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.egg-info/
config/
*.db
.pytest_cache/
+15
View File
@@ -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"]
+38
View File
@@ -1,2 +1,40 @@
# wordarr # 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
```
+14
View File
@@ -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
+23
View File
@@ -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*"]
+291
View File
@@ -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) => `<tr>
<td>${esc(l.name)}</td><td>${esc(l.media_type)}</td><td>${esc(l.root_path)}</td>
<td class="mono">${esc(l.folder_template)}</td><td class="mono">${esc(l.file_template)}</td>
<td><button class="danger" data-del-lib="${l.id}">Löschen</button></td>
</tr>`
)
.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 =
'<option value="">Alle Libraries</option>' +
libraries.map((l) => `<option value="${l.id}">${esc(l.name)}</option>`).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) => `<option value="${l.id}">${esc(l.name)}</option>`)
.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 = "<p class='muted'>Suche läuft…</p>";
try {
const results = await api(`/api/search?media_type=${type}&q=${encodeURIComponent(q)}`);
if (!results.length) { box.innerHTML = "<p class='muted'>Keine Treffer.</p>"; return; }
const opts = libOptions(type);
box.innerHTML = results
.map(
(r, i) => `<div class="card">
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="">` : '<div class="nocover">?</div>'}
<div class="card-body">
<strong>${esc(r.title)}</strong>
<span>${esc(r.authors)}</span>
<span class="muted">${r.year ?? ""} ${r.external_id ? "· " + esc(r.external_id) : ""}</span>
${type === "comic" ? `<input type="number" min="0" placeholder="Band" class="volume" data-vol="${i}">` : ""}
<div class="row">
<select data-lib="${i}">${opts || "<option value=''>— keine Library —</option>"}</select>
<button data-req="${i}">Anfragen</button>
</div>
</div>
</div>`
)
.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) || "<option value=''>— keine Library —</option>";
};
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 = "<p class='muted'>Nichts hier.</p>"; return; }
box.innerHTML = reqs
.map(
(r) => `<div class="card">
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="">` : '<div class="nocover">?</div>'}
<div class="card-body">
<strong>${esc(r.title)}${r.volume != null ? " · Band " + r.volume : ""}</strong>
<span>${esc(r.authors)}</span>
<span class="muted">${esc(r.media_type)}${esc(r.library_name)}</span>
${status === "imported" ? `<span class="muted mono">${esc(r.imported_path)}</span>` : ""}
<div class="row"><button class="danger" data-del-req="${r.id}">Entfernen</button></div>
</div>
</div>`
)
.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) =>
`<option value="${r.id}" ${r.id === item.suggested_request_id ? "selected" : ""}>
${esc(r.title)}${esc(r.authors)} (${esc(r.library_name)})</option>`
)
.join("");
return `<tr>
<td><input type="checkbox" data-check="${i}" ${item.suggested_request_id ? "checked" : ""}></td>
<td class="mono">${esc(item.name)}${item.is_dir ? " 📁" : ""}</td>
<td>${esc(item.media_type)}</td>
<td><select data-select="${i}"><option value="">— überspringen —</option>${opts}</select></td>
<td>${item.suggested_request_id ? item.score : "—"}</td>
</tr>`;
})
.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
? `<p class="ok">✓ ${esc(r.path)}${esc(r.dest)}</p>`
: `<p class="error">✗ ${esc(r.path)}: ${esc(r.error)}</p>`
)
.join("");
$("#scan-btn").click();
} catch (err) { toast(err.message, true); }
});
// ---- init ----
loadLibraries();
+116
View File
@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>wordarr</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<h1>📚 wordarr</h1>
<nav>
<button data-view="search" class="active">Suche</button>
<button data-view="missing">Missing</button>
<button data-view="imported">Importiert</button>
<button data-view="import">Import</button>
<button data-view="settings">Libraries</button>
</nav>
</header>
<main>
<section id="view-search">
<form id="search-form" class="bar">
<select id="search-type">
<option value="ebook">Ebook</option>
<option value="audiobook">Audiobook</option>
<option value="comic">Comic/Manga</option>
</select>
<input id="search-q" placeholder="Titel, Autor oder ISBN…" required>
<button type="submit">Suchen</button>
<button type="button" id="manual-btn" class="secondary">Manuell anlegen</button>
</form>
<div id="search-results" class="cards"></div>
<dialog id="manual-dialog">
<form id="manual-form" method="dialog">
<h3>Manuell anfragen</h3>
<label>Typ
<select name="media_type">
<option value="ebook">Ebook</option>
<option value="audiobook">Audiobook</option>
<option value="comic">Comic/Manga</option>
</select>
</label>
<label>Titel <input name="title" required></label>
<label>Autor(en) <input name="authors"></label>
<label>Serie <input name="series"></label>
<label>Band <input name="volume" type="number" min="0"></label>
<label>Jahr <input name="year" type="number"></label>
<label>ISBN/ID <input name="external_id"></label>
<label>Library <select name="library_id" id="manual-library"></select></label>
<div class="row">
<button value="cancel" class="secondary">Abbrechen</button>
<button value="ok" id="manual-submit">Anfragen</button>
</div>
</form>
</dialog>
</section>
<section id="view-missing" hidden>
<div class="bar">
<select id="missing-filter-type">
<option value="">Alle Typen</option>
<option value="ebook">Ebook</option>
<option value="audiobook">Audiobook</option>
<option value="comic">Comic/Manga</option>
</select>
<select id="missing-filter-library"><option value="">Alle Libraries</option></select>
</div>
<div id="missing-list" class="cards"></div>
</section>
<section id="view-imported" hidden>
<div id="imported-list" class="cards"></div>
</section>
<section id="view-import" hidden>
<div class="bar">
<button id="scan-btn">Download-Ordner scannen</button>
<span id="scan-info" class="muted"></span>
</div>
<table id="import-table" hidden>
<thead><tr><th></th><th>Datei/Ordner</th><th>Typ</th><th>Zuordnung</th><th>Score</th></tr></thead>
<tbody></tbody>
</table>
<div class="bar">
<button id="import-btn" hidden>Ausgewählte importieren</button>
</div>
<div id="import-results"></div>
</section>
<section id="view-settings" hidden>
<h3>Libraries</h3>
<table id="lib-table">
<thead><tr><th>Name</th><th>Typ</th><th>Pfad</th><th>Ordner-Schema</th><th>Datei-Schema</th><th></th></tr></thead>
<tbody></tbody>
</table>
<h4>Neue Library</h4>
<form id="lib-form" class="bar wrap">
<input name="name" placeholder="Name (z.B. Kids)" required>
<select name="media_type">
<option value="ebook">Ebook</option>
<option value="audiobook">Audiobook</option>
<option value="comic">Comic/Manga</option>
</select>
<input name="root_path" placeholder="/library/ebooks/kids" required class="wide">
<input name="folder_template" placeholder="Ordner-Schema (optional)" class="wide">
<input name="file_template" placeholder="Datei-Schema (optional)" class="wide">
<button type="submit">Anlegen</button>
</form>
<p class="muted">Platzhalter: {Author} {Authors} {Title} {Year} {Series} {Volume}</p>
</section>
</main>
<div id="toast" hidden></div>
<script src="/app.js"></script>
</body>
</html>
+46
View File
@@ -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; }
+108
View File
@@ -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
+38
View File
@@ -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
+33
View File
@@ -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"
View File
View File
+63
View File
@@ -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}
+78
View File
@@ -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}
+71
View File
@@ -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}
+17
View File
@@ -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}")
+26
View File
@@ -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}",
},
}
+67
View File
@@ -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()
View File
+37
View File
@@ -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
+33
View File
@@ -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)
+58
View File
@@ -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
+26
View File
@@ -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")
+7
View File
@@ -0,0 +1,7 @@
from . import anilist, audible, openlibrary
PROVIDERS = {
"ebook": openlibrary.search,
"audiobook": audible.search,
"comic": anilist.search,
}
+44
View File
@@ -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
+32
View File
@@ -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
+13
View File
@@ -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 = ""
+37
View File
@@ -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
+31
View File
@@ -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)