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