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

+ +
+
+ + + + + + + + + +
+ + + + 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)