Compare commits

15 changed files with 908 additions and 26 deletions

No files matched your search

+3
View File
@@ -25,6 +25,9 @@ the same mount, otherwise moving turns into copy + delete.
| `WORDARR_EXTRA_EBOOK_EXTENSIONS` | – | extra ebook formats the scan should offer, e.g. `.kepub,.lit,.rtf` |
| `WORDARR_HARDCOVER_TOKEN` | – | Hardcover API token, the best ebook source; without it Hardcover is skipped |
| `WORDARR_GOOGLE_BOOKS_KEY` | – | optional, only needed when the keyless Google Books quota runs dry |
| `WORDARR_ABS_URL` | – | Audiobookshelf base URL, e.g. `http://audiobookshelf`; after an import, relocate or retag it scans the libraries whose folders hold the files, so both containers must mount them under the same path |
| `WORDARR_ABS_TOKEN` | – | API token of an Audiobookshelf admin (only admins may start a scan) |
| `WORDARR_WEBHOOK_URL` | – | gets every history row as JSON (`event`, `request_id`, `title`, `media_type`, `source`, `dest`, `message`) |
| `WORDARR_BUILD` | – | shown next to the version, e.g. the git sha; also a build arg of the image |
The header shows the running version and, behind it, the build stamp (the newest
+215 -9
View File
@@ -84,16 +84,16 @@ function libOptions(mediaType) {
// ---- missing badge ----
async function refreshMissingBadge() {
try {
const reqs = await api("/api/requests?status=missing");
const { count } = await api("/api/requests/count?status=missing");
const badge = $("#missing-badge");
badge.textContent = reqs.length;
badge.hidden = reqs.length === 0;
badge.textContent = count;
badge.hidden = count === 0;
} catch {}
}
// ---- navigation ----
// the view lives in the hash so reload, back button and links keep it
const VIEWS = ["search", "missing", "imported", "import", "settings"];
const VIEWS = ["search", "missing", "imported", "import", "activity", "settings", "system"];
function showView() {
const hash = location.hash.slice(1);
@@ -101,6 +101,8 @@ function showView() {
$$("nav button[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
$$("main > section").forEach((s) => (s.hidden = s.id !== "view-" + name));
if (name === "missing" || name === "imported") loadRequests(name);
if (name === "activity") loadHistory();
if (name === "system") loadSystem();
if (name === "settings") loadLibraries().catch((err) => toast(err.message, true));
}
@@ -116,6 +118,7 @@ window.addEventListener("hashchange", showView);
// ---- libraries ----
async function loadLibraries() {
libraries = await api("/api/libraries");
refreshHealth();
const tbody = $("#lib-table tbody");
tbody.innerHTML = libraries
.map(
@@ -125,6 +128,7 @@ async function loadLibraries() {
<td>${l.language ? esc(languageName(l.language)) : "—"}</td>
<td class="row">
<button class="secondary" data-edit-lib="${l.id}">${t("common.edit")}</button>
<button class="secondary" data-adopt-lib="${l.id}" title="${esc(t("adopt.buttonTitle"))}">${t("adopt.button")}</button>
<button class="danger" data-del-lib="${l.id}">${t("common.delete")}</button>
</td>
</tr>`
@@ -133,6 +137,9 @@ async function loadLibraries() {
tbody.querySelectorAll("[data-edit-lib]").forEach((b) =>
b.addEventListener("click", () => startLibraryEdit(parseInt(b.dataset.editLib)))
);
tbody.querySelectorAll("[data-adopt-lib]").forEach((b) =>
b.addEventListener("click", () => openAdopt(parseInt(b.dataset.adoptLib)))
);
tbody.querySelectorAll("[data-del-lib]").forEach((b) =>
b.addEventListener("click", async () => {
if (!confirm(t("lib.confirmDelete"))) return;
@@ -518,6 +525,22 @@ function requestMatchesFilter(r, text) {
return hay.includes(text.toLowerCase());
}
// the server already sends newest first
const SORTS = {
title: (a, b) => a.title.localeCompare(b.title, undefined, { numeric: true }),
author: (a, b) => a.authors.localeCompare(b.authors) || SORTS.title(a, b),
series: (a, b) => (a.series || a.title).localeCompare(b.series || b.title)
|| (a.volume ?? 0) - (b.volume ?? 0) || SORTS.title(a, b),
};
for (const status of ["missing", "imported"]) {
const sel = $(`#${status}-sort`);
try { sel.value = localStorage.getItem("wordarr.sort." + status) || "newest"; } catch {}
sel.addEventListener("change", () => {
try { localStorage.setItem("wordarr.sort." + status, sel.value); } catch {}
renderRequests(status);
});
}
async function loadRequests(status) {
const params = new URLSearchParams({ status });
if (status === "missing") {
@@ -542,11 +565,15 @@ async function loadRequests(status) {
function renderRequests(status) {
const text = status === "missing" ? $("#missing-search").value : $("#imported-search").value;
const reqs = currentRequests[status].filter((r) => requestMatchesFilter(r, text));
const sort = SORTS[$(`#${status}-sort`).value];
if (sort) reqs.sort(sort);
const box = status === "missing" ? $("#missing-list") : $("#imported-list");
const bulkBtn = status === "missing" ? $("#missing-bulk-delete") : $("#imported-bulk-delete");
const moveSel = status === "missing" ? $("#missing-move-library") : null;
if (!reqs.length) {
box.innerHTML = `<p class='muted'>${t("common.nothingHere")}</p>`;
bulkBtn.hidden = true;
if (moveSel) moveSel.hidden = true;
return;
}
box.innerHTML = reqs
@@ -581,6 +608,10 @@ function renderRequests(status) {
const n = box.querySelectorAll("[data-sel-req]:checked").length;
bulkBtn.hidden = n === 0;
bulkBtn.textContent = t("common.removeSelectedN", { n });
if (!moveSel) return;
moveSel.hidden = n === 0;
moveSel.innerHTML = `<option value="">${esc(t("list.moveTo", { n }))}</option>` +
libraries.map((l) => `<option value="${l.id}">${esc(l.name)} (${esc(l.media_type)})</option>`).join("");
};
box.querySelectorAll("[data-sel-req]").forEach((c) =>
c.addEventListener("change", updateBulkBtn)
@@ -595,14 +626,29 @@ async function bulkDeleteRequests(status) {
const hint = t(status === "imported" ? "list.confirmDeleteImported" : "list.confirmDeleteRequests",
{ n: ids.length });
if (!confirm(hint)) return;
for (const id of ids) {
try { await api("/api/requests/" + id, { method: "DELETE" }); }
catch (err) { toast(err.message, true); }
}
toast(t("list.deletedN", { n: ids.length }));
try {
const res = await api("/api/requests/bulk", {
method: "DELETE", body: JSON.stringify({ ids: ids.map(Number) }),
});
toast(t("list.deletedN", { n: res.deleted }));
} catch (err) { toast(err.message, true); }
loadRequests(status);
}
$("#missing-move-library").addEventListener("change", async (ev) => {
const lib = libraries.find((l) => String(l.id) === ev.target.value);
if (!lib) return;
const ids = [...$("#missing-list").querySelectorAll("[data-sel-req]:checked")]
.map((c) => Number(c.dataset.selReq));
try {
const res = await api("/api/requests/bulk", {
method: "PUT", body: JSON.stringify({ ids, library_id: lib.id }),
});
toast(t("list.movedN", { n: res.updated, library: lib.name }));
} catch (err) { toast(err.message, true); }
loadRequests("missing");
});
$("#imported-bulk-delete").addEventListener("click", () => bulkDeleteRequests("imported"));
function selectAllRequests(status) {
@@ -622,6 +668,92 @@ $("#imported-search").addEventListener("input", () => renderRequests("imported")
$("#missing-bulk-delete").addEventListener("click", () => bulkDeleteRequests("missing"));
// ---- activity ----
let historyRows = [];
const HISTORY_STATE = { imported: "done", appended: "done", adopted: "done", failed: "failed" };
async function loadHistory() {
try {
historyRows = await api("/api/history");
} catch (err) {
toast(err.message, true);
}
renderHistory();
}
function renderHistory() {
const tbody = $("#history-table tbody");
if (!historyRows.length) {
tbody.innerHTML = `<tr><td colspan="4" class="muted">${t("common.nothingHere")}</td></tr>`;
return;
}
const locale = lang === "de" ? "de-DE" : "en-GB";
tbody.innerHTML = historyRows
.map((h) => {
const where = h.message || (h.source && h.dest ? `${h.source} → ${h.dest}` : h.dest || h.source);
return `<tr>
<td class="state">${esc(new Date(h.at).toLocaleString(locale))}</td>
<td class="state ${HISTORY_STATE[h.event] || ""}">${esc(t("history." + h.event))}</td>
<td>${esc(h.title)} <span class="tag fmt">${esc(h.media_type)}</span></td>
<td class="muted mono">${esc(where)}</td>
</tr>`;
})
.join("");
}
// ---- system ----
let healthIssues = [];
let logRows = [];
// also called after library changes, so a fixed root path clears the badge
async function refreshHealth() {
try {
healthIssues = await api("/api/system/health");
} catch {
return;
}
const badge = $("#health-badge");
badge.textContent = healthIssues.length;
badge.hidden = !healthIssues.length;
badge.classList.toggle("error", healthIssues.some((i) => i.level === "error"));
badge.classList.toggle("warn", !healthIssues.some((i) => i.level === "error"));
renderHealth();
}
async function loadSystem() {
refreshHealth();
try {
logRows = await api("/api/system/logs");
} catch (err) {
toast(err.message, true);
}
renderLogs();
}
function renderHealth() {
$("#health-list").innerHTML = healthIssues.length
? healthIssues.map((i) => `<li class="${i.level}">${esc(t("health." + i.code, i))}</li>`).join("")
: `<li class="ok">${t("system.healthy")}</li>`;
}
function renderLogs() {
const tbody = $("#log-table tbody");
if (!logRows.length) {
tbody.innerHTML = `<tr><td colspan="4" class="muted">${t("common.nothingHere")}</td></tr>`;
return;
}
const locale = lang === "de" ? "de-DE" : "en-GB";
const LEVEL_STATE = { WARNING: "warn", ERROR: "failed", CRITICAL: "failed" };
tbody.innerHTML = logRows
.map((r) => `<tr>
<td class="state">${esc(new Date(r.at).toLocaleString(locale))}</td>
<td class="state ${LEVEL_STATE[r.level] || ""}">${esc(r.level)}</td>
<td class="muted mono">${esc(r.logger)}</td>
<td class="mono">${esc(r.message)}</td>
</tr>`)
.join("");
}
// ---- request detail dialog ----
let detailRequest = null;
@@ -1745,6 +1877,75 @@ function setImportProgress(done, total) {
`${done}/${total} · ${Math.round((done / total) * 100)} %`;
}
// ---- adopt files already in a library ----
let adoptLib = null;
let adoptScan = null;
async function openAdopt(id) {
adoptLib = libraries.find((l) => l.id === id);
adoptScan = null;
$("#adopt-heading").textContent = t("adopt.heading", { name: adoptLib.name });
$("#adopt-intro").innerHTML = `<span class="spinner"></span> ${t("import.scanning")}`;
$("#adopt-list").innerHTML = "";
$("#adopt-submit").disabled = true;
$("#adopt-dialog").showModal();
try {
adoptScan = await api("/api/import/library/" + id);
} catch (err) {
$("#adopt-intro").textContent = "";
toast(err.message, true);
return;
}
renderAdopt();
}
function renderAdopt() {
if (!adoptScan) return;
// only files with a suggestion: a select per unmatched file would bury them
const matched = adoptScan.items.filter((i) => i.suggested_request_id);
$("#adopt-intro").textContent = t("adopt.intro", {
n: matched.length, rest: adoptScan.items.length - matched.length,
});
const options = adoptScan.requests
.map((r) => [r.id, r.authors ? `${r.authors}: ${r.title}` : r.title]);
$("#adopt-list").innerHTML = matched
.map((item) => `<label>
<input type="checkbox" checked data-adopt-path="${esc(item.path)}" />
<span>${esc(item.name)} <span class="muted">${esc(item.rel_dir)}</span></span>
<select>${options.map(([id, label]) =>
`<option value="${id}"${id === item.suggested_request_id ? " selected" : ""}>${esc(label)}</option>`
).join("")}</select>
</label>`)
.join("");
$("#adopt-submit").disabled = matched.length === 0;
}
$("#adopt-cancel").addEventListener("click", () => $("#adopt-dialog").close());
$("#adopt-submit").addEventListener("click", async () => {
const items = [...$$("#adopt-list [data-adopt-path]:checked")].map((box) => ({
path: box.dataset.adoptPath,
request_id: parseInt(box.closest("label").querySelector("select").value),
}));
if (!items.length) return;
$("#adopt-submit").disabled = true;
try {
const { results } = await api("/api/import/library/" + adoptLib.id, {
method: "POST", body: JSON.stringify({ items }),
});
const failed = results.filter((r) => !r.ok);
toast(failed.length
? t("adopt.partial", { n: results.length - failed.length, failed: failed.map((r) => `${r.path}: ${r.error}`).join("; ") })
: t("adopt.done", { n: results.length }), failed.length > 0);
$("#adopt-dialog").close();
refreshMissingBadge();
} catch (err) {
toast(err.message, true);
} finally {
$("#adopt-submit").disabled = false;
}
});
// ---- language ----
function updateLangButton() {
$("#lang-toggle").textContent = lang === "de" ? "EN" : "DE";
@@ -1758,10 +1959,14 @@ function onLangChange() {
renderSearchResults();
renderRequests("missing");
renderRequests("imported");
renderHistory();
renderHealth();
renderLogs();
if (libraries.length) loadLibraries();
const editId = $("#lib-form").dataset.editId;
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
if (scanItems.length) renderImportTable();
renderAdopt();
}
// ---- version ----
@@ -1772,6 +1977,7 @@ async function showVersion() {
// the version alone rarely moves, so the stamp is what tells deploys apart
$("#app-version").textContent = `v${v.version} · ${v.build || v.built_at}`;
$("#app-version").title = `${v.built_at} UTC${v.build ? " · " + v.build : ""}`;
$("#system-version").textContent = `v${v.version}${v.build ? " · " + v.build : ""} · ${v.built_at} UTC`;
} catch {}
}
+76
View File
@@ -46,7 +46,38 @@ const STRINGS = {
"nav.imported": "Importiert",
"nav.import": "Import",
"nav.settings": "Libraries",
"nav.activity": "Aktivität",
"nav.system": "System",
"system.health": "Zustand",
"system.healthy": "Alles in Ordnung.",
"system.version": "Version",
"system.logs": "Letzte Log-Einträge",
"system.level": "Stufe",
"system.source": "Quelle",
"system.message": "Meldung",
"health.download.missing": "Der Download-Ordner {path} existiert nicht.",
"health.download.readonly": "wordarr kann im Download-Ordner {path} nicht schreiben, Importe schlagen fehl.",
"health.library.missing": "Library „{name}“: Der Ordner {path} existiert nicht.",
"health.library.readonly": "Library „{name}“: wordarr kann in {path} nicht schreiben.",
"health.hardcover.token": "Kein Hardcover-Token gesetzt (WORDARR_HARDCOVER_TOKEN), die Ebook-Suche fällt auf Google Books und Open Library zurück.",
"health.abs.incomplete": "Audiobookshelf ist nur halb eingerichtet: WORDARR_ABS_URL und WORDARR_ABS_TOKEN müssen beide gesetzt sein.",
"nav.langToggle": "Switch to English",
"history.time": "Zeit",
"history.event": "Ereignis",
"history.imported": "importiert",
"history.appended": "ergänzt",
"history.relocated": "verschoben",
"history.retagged": "neu getaggt",
"history.deleted": "gelöscht",
"history.failed": "fehlgeschlagen",
"history.adopted": "übernommen",
"adopt.button": "Einlesen",
"adopt.buttonTitle": "Dateien, die schon in der Library liegen, offenen Anfragen zuordnen",
"adopt.heading": "„{name}“ einlesen",
"adopt.intro": "{n} Dateien passen zu offenen Anfragen dieser Library und bleiben, wo sie sind. {rest} weitere ohne Treffer.",
"adopt.submit": "Übernehmen",
"adopt.done": "{n} übernommen",
"adopt.partial": "{n} übernommen, Fehler: {failed}",
"search.languageTitle": "Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche",
"search.allLanguages": "Alle Sprachen",
@@ -97,6 +128,13 @@ const STRINGS = {
"list.confirmDeleteRequests": "{n} Anfrage(n) entfernen?",
"list.confirmDeleteImported": "{n} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.",
"list.deletedN": "{n} entfernt",
"list.sort": "Sortierung",
"list.sortNewest": "Neueste zuerst",
"list.sortTitle": "Titel",
"list.sortAuthor": "Autor",
"list.sortSeries": "Serie und Band",
"list.moveTo": "Bibliothek ändern ({n})…",
"list.movedN": "{n} nach {library} verschoben",
"detail.statusMissing": "fehlt noch",
"detail.statusImported": "importiert",
@@ -270,7 +308,38 @@ const STRINGS = {
"nav.imported": "Imported",
"nav.import": "Import",
"nav.settings": "Libraries",
"nav.activity": "Activity",
"nav.system": "System",
"system.health": "Health",
"system.healthy": "All good.",
"system.version": "Version",
"system.logs": "Recent log entries",
"system.level": "Level",
"system.source": "Source",
"system.message": "Message",
"health.download.missing": "The download folder {path} does not exist.",
"health.download.readonly": "wordarr cannot write to the download folder {path}, imports will fail.",
"health.library.missing": "Library “{name}”: the folder {path} does not exist.",
"health.library.readonly": "Library “{name}”: wordarr cannot write to {path}.",
"health.hardcover.token": "No Hardcover token set (WORDARR_HARDCOVER_TOKEN), ebook search falls back to Google Books and Open Library.",
"health.abs.incomplete": "Audiobookshelf is only half set up: WORDARR_ABS_URL and WORDARR_ABS_TOKEN must both be set.",
"nav.langToggle": "Auf Deutsch umschalten",
"history.time": "Time",
"history.event": "Event",
"history.imported": "imported",
"history.appended": "appended",
"history.relocated": "relocated",
"history.retagged": "retagged",
"history.deleted": "deleted",
"history.failed": "failed",
"history.adopted": "adopted",
"adopt.button": "Scan",
"adopt.buttonTitle": "Link files already in the library to open requests",
"adopt.heading": "Scan “{name}”",
"adopt.intro": "{n} files match open requests of this library and stay where they are. {rest} more without a match.",
"adopt.submit": "Adopt",
"adopt.done": "{n} adopted",
"adopt.partial": "{n} adopted, errors: {failed}",
"search.languageTitle": "Edition language — filters the ebook and audiobook search",
"search.allLanguages": "All languages",
@@ -321,6 +390,13 @@ const STRINGS = {
"list.confirmDeleteRequests": "Remove {n} request(s)?",
"list.confirmDeleteImported": "Remove {n} entr(y/ies)? The imported files stay in the library.",
"list.deletedN": "{n} removed",
"list.sort": "Sort order",
"list.sortNewest": "Newest first",
"list.sortTitle": "Title",
"list.sortAuthor": "Author",
"list.sortSeries": "Series and volume",
"list.moveTo": "Change library ({n})…",
"list.movedN": "{n} moved to {library}",
"detail.statusMissing": "still missing",
"detail.statusImported": "imported",
+65
View File
@@ -20,7 +20,12 @@
</button>
<button data-view="imported" data-i18n="nav.imported"></button>
<button data-view="import" data-i18n="nav.import"></button>
<button data-view="activity" data-i18n="nav.activity"></button>
<button data-view="settings" data-i18n="nav.settings"></button>
<button data-view="system">
<span data-i18n="nav.system"></span>
<span id="health-badge" class="badge" hidden></span>
</button>
<button id="lang-toggle" class="secondary" data-i18n-title="nav.langToggle"></button>
</nav>
</header>
@@ -109,7 +114,14 @@
<option value="" data-i18n="list.allLibraries"></option>
</select>
<input id="missing-search" data-i18n-placeholder="list.filterPlaceholder" />
<select id="missing-sort" data-i18n-title="list.sort">
<option value="newest" data-i18n="list.sortNewest"></option>
<option value="title" data-i18n="list.sortTitle"></option>
<option value="author" data-i18n="list.sortAuthor"></option>
<option value="series" data-i18n="list.sortSeries"></option>
</select>
<button id="missing-select-all" class="secondary" data-i18n="common.selectAll"></button>
<select id="missing-move-library" hidden></select>
<button id="missing-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
<div class="view-toggle" role="group" data-i18n-aria="list.view">
<button data-viewmode="list" data-i18n-title="list.viewList">☰</button>
@@ -123,6 +135,12 @@
<section id="view-imported" hidden>
<div class="bar wrap">
<input id="imported-search" data-i18n-placeholder="list.filterPlaceholder" />
<select id="imported-sort" data-i18n-title="list.sort">
<option value="newest" data-i18n="list.sortNewest"></option>
<option value="title" data-i18n="list.sortTitle"></option>
<option value="author" data-i18n="list.sortAuthor"></option>
<option value="series" data-i18n="list.sortSeries"></option>
</select>
<button id="imported-select-all" class="secondary" data-i18n="common.selectAll"></button>
<button id="imported-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
<div class="view-toggle" role="group" data-i18n-aria="list.view">
@@ -269,6 +287,43 @@
</dialog>
</section>
<section id="view-activity" hidden>
<div class="table-scroll">
<table id="history-table">
<thead>
<tr>
<th data-i18n="history.time"></th>
<th data-i18n="history.event"></th>
<th data-i18n="common.title"></th>
<th data-i18n="common.path"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section id="view-system" hidden>
<h3 data-i18n="system.health"></h3>
<ul id="health-list"></ul>
<h3 data-i18n="system.version"></h3>
<p id="system-version" class="mono"></p>
<h3 data-i18n="system.logs"></h3>
<div class="table-scroll">
<table id="log-table">
<thead>
<tr>
<th data-i18n="history.time"></th>
<th data-i18n="system.level"></th>
<th data-i18n="system.source"></th>
<th data-i18n="system.message"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section id="view-settings" hidden>
<h3 data-i18n="lib.heading"></h3>
<div class="table-scroll">
@@ -349,6 +404,16 @@
</form>
</dialog>
<dialog id="adopt-dialog">
<h3 id="adopt-heading"></h3>
<p class="muted" id="adopt-intro"></p>
<div id="adopt-list" class="series-list"></div>
<div class="row">
<button type="button" id="adopt-cancel" class="secondary" data-i18n="common.cancel"></button>
<button type="button" id="adopt-submit" data-i18n="adopt.submit"></button>
</div>
</dialog>
<div id="toast" hidden></div>
<script src="/i18n.js"></script>
<script src="/app.js"></script>
+9 -2
View File
@@ -74,6 +74,13 @@ td.state { white-space: nowrap; font-size: 0.85rem; }
td.state.done { color: var(--ok); }
td.state.failed { color: #e08585; }
td.state.running { color: var(--muted); }
td.state.warn { color: var(--warn); }
.badge.warn { background: var(--warn); color: #000; }
.badge.error { background: var(--danger); }
#health-list { list-style: none; margin: 0.5rem 0 1rem; }
#health-list li { background: var(--panel); border-left: 3px solid var(--warn); border-radius: 4px; padding: 0.5rem 0.8rem; margin: 0.3rem 0; }
#health-list li.error { border-left-color: var(--danger); }
#health-list li.ok { border-left-color: var(--ok); }
.tag { display: inline-block; margin-left: 0.4rem; padding: 0.05rem 0.4rem; border-radius: 4px; background: var(--accent); color: #fff; font-size: 0.7rem; font-family: system-ui, sans-serif; vertical-align: 0.1em; }
/* view modes (Missing/Importiert) */
@@ -104,8 +111,8 @@ td select { max-width: 340px; }
.score-none { color: var(--muted); }
.volume { width: 90px; }
/* dialogs */
dialog { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 1.2rem; min-width: 340px; max-width: min(640px, 92vw); }
/* dialogs; margin: auto undoes the * reset, which pins them to the top left */
dialog { margin: auto; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 1.2rem; min-width: 340px; max-width: min(640px, 92vw); }
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); flex: 1; }
.detail-head { display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 0.5rem; }
+174
View File
@@ -1185,3 +1185,177 @@ def test_version_endpoint_reports_a_build_stamp(client):
assert data["version"]
# "2026-09-01 15:43" - the newest source file, so a stale deploy stands out
assert len(data["built_at"]) == 16 and data["built_at"][4] == "-"
def test_history_records_what_happened_to_a_request(client):
root = client.tmp_path / "library" / "hist"
lib_id = client.post("/api/libraries", json={
"name": "hist", "media_type": "ebook", "root_path": str(root),
"folder_template": "{Title}", "file_template": "{Title}",
}).json()["id"]
req_id = client.post("/api/requests", json={
"library_id": lib_id, "title": "Momo"}).json()["id"]
gone = client.downloads / "gone.epub"
res = client.post("/api/import", json={"items": [{
"path": str(gone), "is_dir": False, "files": [str(gone)], "request_id": req_id,
}]}).json()["results"][0]
assert not res["ok"]
src = client.downloads / "Momo.epub"
src.write_bytes(b"x")
dest = client.post("/api/import", json={"items": [{
"path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id,
}]}).json()["results"][0]["dest"]
client.put(f"/api/requests/{req_id}", json={"library_id": lib_id, "title": "Momo 2"})
assert client.post(f"/api/requests/{req_id}/relocate").json()["moved"]
# a relocate that moves nothing is not worth a row
client.post(f"/api/requests/{req_id}/relocate")
client.delete(f"/api/requests/{req_id}")
rows = client.get("/api/history").json()
assert [r["event"] for r in rows] == ["deleted", "relocated", "imported", "failed"]
assert all(r["request_id"] == req_id for r in rows)
assert rows[0]["title"] == "Momo 2" # survives the deleted request
assert rows[2]["dest"] == dest
assert rows[3]["message"]
assert rows[0]["at"].endswith("+00:00")
def test_connections_hear_about_committed_rows(client, monkeypatch):
import httpx
from wordarr import connect
calls = []
def handler(request):
calls.append((request.method, str(request.url)))
if request.url.path == "/api/libraries":
return httpx.Response(200, json={"libraries": [
{"id": "lib-audio", "name": "Audio",
"folders": [{"fullPath": str(client.tmp_path / "library")}]},
{"id": "lib-pod", "name": "Pod", "folders": [{"fullPath": "/podcasts"}]},
]})
if request.url.host == "hook":
return httpx.Response(500) # a broken webhook must not stop the scan
return httpx.Response(200)
real_client = httpx.Client
monkeypatch.setattr(connect.httpx, "Client",
lambda **kw: real_client(transport=httpx.MockTransport(handler), **kw))
monkeypatch.setattr(connect, "WEBHOOK_URL", "http://hook/in")
monkeypatch.setattr(connect, "ABS_URL", "http://abs")
monkeypatch.setattr(connect, "ABS_TOKEN", "t")
root = client.tmp_path / "library" / "conn"
lib_id = client.post("/api/libraries", json={
"name": "conn", "media_type": "ebook", "root_path": str(root),
"folder_template": "{Title}", "file_template": "{Title}",
}).json()["id"]
req_id = client.post("/api/requests", json={
"library_id": lib_id, "title": "Momo"}).json()["id"]
src = client.downloads / "Momo.epub"
src.write_bytes(b"x")
assert client.post("/api/import", json={"items": [{
"path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id,
}]}).json()["results"][0]["ok"]
assert calls == [
("POST", "http://hook/in"),
("GET", "http://abs/api/libraries"),
("POST", "http://abs/api/libraries/lib-audio/scan"),
]
calls.clear()
client.delete(f"/api/requests/{req_id}")
assert calls == [("POST", "http://hook/in")] # nothing new on disk, no scan
def test_bulk_move_delete_and_count(client):
def lib(name, media_type):
return client.post("/api/libraries", json={
"name": name, "media_type": media_type,
"root_path": str(client.tmp_path / name)}).json()["id"]
a, b, audio = lib("a", "ebook"), lib("b", "ebook"), lib("audio", "audiobook")
ids = [client.post("/api/requests", json={"library_id": a, "title": f"T{i}"}).json()["id"]
for i in range(3)]
assert client.get("/api/requests/count?status=missing").json() == {"count": 3}
assert client.put("/api/requests/bulk", json={
"ids": ids[:2], "library_id": audio}).status_code == 400
assert client.put("/api/requests/bulk", json={
"ids": ids[:2], "library_id": b}).json() == {"updated": 2}
assert [r["id"] for r in client.get(f"/api/requests?library_id={b}").json()] \
== sorted(ids[:2], reverse=True)
assert client.request("DELETE", "/api/requests/bulk",
json={"ids": ids[1:]}).json() == {"deleted": 2}
assert client.get("/api/requests/count?status=missing").json() == {"count": 1}
assert [r["event"] for r in client.get("/api/history").json()] == ["deleted"] * 2
def test_adopt_files_already_in_the_library(client):
root = client.tmp_path / "audio"
lib = client.post("/api/libraries", json={
"name": "Audio", "media_type": "audiobook", "root_path": str(root)}).json()["id"]
hobbit = client.post("/api/requests", json={
"library_id": lib, "title": "Der Hobbit", "authors": "Tolkien"}).json()["id"]
known = client.post("/api/requests", json={
"library_id": lib, "title": "Momo", "authors": "Ende"}).json()["id"]
for folder in ("Tolkien/Der Hobbit", "Ende/Momo"):
(root / folder).mkdir(parents=True)
for i in (1, 2):
(root / folder / f"{i:02d}.mp3").write_bytes(b"")
(root / "Tolkien" / "Notizen.epub").write_bytes(b"") # other media type
# Momo already points at its folder, so only the Hobbit is offered
adopted = client.post(f"/api/import/library/{lib}", json={"items": [
{"path": str(root / "Ende/Momo"), "request_id": known}]}).json()
assert adopted == {"results": [{"path": str(root / "Ende/Momo"), "ok": True}]}
items = client.get(f"/api/import/library/{lib}").json()["items"]
assert [(i["name"], i["suggested_request_id"]) for i in items] == [("Der Hobbit", hobbit)]
resp = client.post(f"/api/import/library/{lib}", json={"items": [
{"path": items[0]["path"], "request_id": hobbit},
{"path": str(root / "Ende/Momo"), "request_id": known}, # no longer open
{"path": str(client.downloads), "request_id": hobbit},
]}).json()["results"]
assert [r["ok"] for r in resp] == [True, False, False]
reqs = {r["id"]: r for r in client.get("/api/requests?status=imported").json()}
assert reqs[hobbit]["imported_path"] == str(root / "Tolkien/Der Hobbit")
assert (root / "Tolkien/Der Hobbit/01.mp3").exists() # nothing moved
assert client.get(f"/api/import/library/{lib}").json()["items"] == []
assert [h["event"] for h in client.get("/api/history").json()] == ["adopted"] * 2
def test_health_reports_problems_as_codes(client, monkeypatch):
from wordarr import connect
from wordarr.metadata import hardcover
monkeypatch.setattr(hardcover, "TOKEN", "")
monkeypatch.setattr(connect, "ABS_URL", "http://abs")
monkeypatch.setattr(connect, "ABS_TOKEN", "")
gone = client.tmp_path / "gone"
client.post("/api/libraries", json={
"name": "Weg", "media_type": "ebook", "root_path": str(gone)})
client.post("/api/libraries", json={
"name": "Da", "media_type": "ebook", "root_path": str(client.downloads)})
issues = client.get("/api/system/health").json()
assert {(i["code"], i.get("name")) for i in issues} == {
("library.missing", "Weg"), ("hardcover.token", None), ("abs.incomplete", None)}
monkeypatch.setattr(hardcover, "TOKEN", "x")
monkeypatch.setattr(connect, "ABS_URL", "")
gone.mkdir()
assert client.get("/api/system/health").json() == []
def test_recent_logs_newest_first(client):
import logging
logging.getLogger("wordarr.test").info("first")
logging.getLogger("wordarr.test").warning("second")
rows = client.get("/api/system/logs").json()
assert [r["message"] for r in rows[:2]] == ["second", "first"]
assert rows[0]["level"] == "WARNING"
+2
View File
@@ -97,6 +97,8 @@ def test_import_dialog_opens_and_closes(page):
page.click("[data-quick='0']")
page.wait_for_selector("#quick-dialog[open]")
assert page.locator("#quick-dialog").is_visible()
box = page.locator("#quick-dialog").bounding_box()
assert abs(box["x"] + box["width"] / 2 - 600) < 10 # centered in the viewport
# everything reachable, nothing cut off at the bottom
for sel in ("#quick-m-submit", "#quick-close"):
box = page.locator(sel).bounding_box()
+36
View File
@@ -0,0 +1,36 @@
import datetime
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict, field_serializer
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..db import History, get_session
router = APIRouter(prefix="/api/history", tags=["history"])
class HistoryOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
at: datetime.datetime
event: str
request_id: int | None
title: str
media_type: str
source: str
dest: str
message: str
# SQLite drops the zone, and a bare timestamp reads as local time in JS
@field_serializer("at")
def _utc(self, at: datetime.datetime) -> str:
return at.replace(tzinfo=datetime.UTC).isoformat()
@router.get("", response_model=list[HistoryOut])
def list_history(limit: int = 200, session: Session = Depends(get_session)):
"""Newest first."""
stmt = select(History).order_by(History.id.desc()).limit(min(limit, 1000))
return session.scalars(stmt).all()
+95 -6
View File
@@ -1,13 +1,13 @@
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, BackgroundTasks, 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 ..fsnames import decode_path, readable
from .. import config, connect
from ..db import BookRequest, Library, get_session, record
from ..fsnames import decode_path, is_broken, readable
from ..importer import matcher, mover, scanner
router = APIRouter(prefix="/api/import", tags=["import"])
@@ -58,8 +58,9 @@ class ImportIn(BaseModel):
@router.post("")
def do_import(data: ImportIn, session: Session = Depends(get_session)):
results = []
def do_import(data: ImportIn, background: BackgroundTasks,
session: Session = Depends(get_session)):
results, rows = [], []
download_root = Path(config.DOWNLOAD_DIR).resolve()
for item in data.items:
# the client hands back whatever the scan produced, base64 included
@@ -91,9 +92,12 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
try:
dest = mover.append_to_import(item.files, req, req.library)
except Exception as exc:
rows.append(record(session, "failed", req, source=shown, message=str(exc)))
session.commit()
results.append({"path": shown, "ok": False, "error": str(exc)})
continue
req.imported_path = dest
rows.append(record(session, "appended", req, source=shown, dest=dest))
session.commit()
results.append({"path": shown, "ok": True, "dest": dest, "appended": True})
continue
@@ -111,12 +115,97 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library)
except Exception as exc:
session.rollback() # the next commit must not keep the folder's series
rows.append(record(session, "failed", req, source=shown, message=str(exc)))
session.commit()
results.append({"path": shown, "ok": False, "error": str(exc)})
continue
req.status = "imported"
req.imported_path = dest
rows.append(record(session, "imported", req, source=shown, dest=dest))
session.commit()
results.append({"path": shown, "ok": True, "dest": dest})
if not results:
raise HTTPException(400, "nothing to import")
connect.schedule(background, rows)
return {"results": results}
def _library(session: Session, library_id: int) -> Library:
lib = session.get(Library, library_id)
if not lib:
raise HTTPException(404, "library not found")
return lib
def _open_for(session: Session, lib: Library) -> list[BookRequest]:
missing = session.scalars(select(BookRequest).where(
BookRequest.status == "missing", BookRequest.library_id == lib.id)).all()
return list(missing) + [r for r in _orphaned(session) if r.library_id == lib.id]
@router.get("/library/{library_id}")
def scan_library(library_id: int, session: Session = Depends(get_session)):
"""Files already in the library that no request points at, matched against
the library's open requests, so an existing collection need not be imported
again."""
lib = _library(session, library_id)
imported = session.scalars(select(BookRequest.imported_path).where(
BookRequest.status == "imported", BookRequest.imported_path != "")).all()
covered = {Path(p) for p in imported}
# a folder holding an imported file, or a file inside an imported folder
above = {a for p in covered for a in p.parents}
def known(item: dict) -> bool:
p = Path(decode_path(item["path"]))
return p in covered or p in above or any(a in covered for a in p.parents)
items = [i for i in scanner.scan(Path(lib.root_path))
if i["media_type"] == lib.media_type and not known(i)]
open_reqs = _open_for(session, lib)
return {"root_path": lib.root_path,
"items": matcher.best_matches(items, open_reqs),
"requests": [{"id": r.id, "title": r.title, "authors": r.authors}
for r in open_reqs]}
class AdoptItem(BaseModel):
path: str
request_id: int
class AdoptIn(BaseModel):
items: list[AdoptItem]
@router.post("/library/{library_id}")
def adopt(library_id: int, data: AdoptIn, background: BackgroundTasks,
session: Session = Depends(get_session)):
"""Point open requests at files that are already in place. Nothing moves."""
lib = _library(session, library_id)
root = Path(lib.root_path).resolve()
open_ids = {r.id for r in _open_for(session, lib)}
results, rows = [], []
for item in data.items:
path = decode_path(item.path)
shown = readable(path)
req = session.get(BookRequest, item.request_id)
if is_broken(path):
error = "file name is not valid UTF-8, rename it first"
elif root not in Path(path).resolve().parents:
error = "path outside the library"
elif not Path(path).exists():
error = "path no longer exists"
elif not req or req.id not in open_ids:
error = f"request #{item.request_id} is not open in this library"
else:
error = ""
if error:
results.append({"path": shown, "ok": False, "error": error})
continue
req.status, req.imported_path = "imported", path
rows.append(record(session, "adopted", req, dest=path))
open_ids.discard(req.id) # two files for one request: the first wins
results.append({"path": shown, "ok": True})
session.commit()
connect.schedule(background, rows)
return {"results": results}
+65 -7
View File
@@ -1,12 +1,12 @@
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from .. import config
from ..db import BookRequest, Library, get_session
from .. import config, connect
from ..db import BookRequest, Library, get_session, record
from ..importer import mover, tagger
router = APIRouter(prefix="/api/requests", tags=["requests"])
@@ -176,6 +176,51 @@ def create_bulk_items(data: BulkItemsIn, session: Session = Depends(get_session)
return {"created": [_out(r) for r in created], "skipped": skipped}
@router.get("/count")
def count_requests(status: str | None = None, session: Session = Depends(get_session)):
stmt = select(func.count()).select_from(BookRequest)
if status:
stmt = stmt.where(BookRequest.status == status)
return {"count": session.scalar(stmt)}
class BulkIdsIn(BaseModel):
ids: list[int]
class BulkUpdateIn(BulkIdsIn):
library_id: int
# registered before the /{request_id} routes, which would try "bulk" as an id
@router.put("/bulk")
def update_bulk(data: BulkUpdateIn, session: Session = Depends(get_session)):
"""Move requests to another library. Imported files stay where they are
until the request is relocated."""
lib = session.get(Library, data.library_id)
if not lib:
raise HTTPException(404, "library not found")
reqs = session.scalars(select(BookRequest).where(BookRequest.id.in_(data.ids))).all()
if any(r.media_type != lib.media_type for r in reqs):
raise HTTPException(400, "library has a different media type")
for r in reqs:
r.library_id = lib.id
session.commit()
return {"updated": len(reqs)}
@router.delete("/bulk")
def delete_bulk(data: BulkIdsIn, background: BackgroundTasks,
session: Session = Depends(get_session)):
reqs = session.scalars(select(BookRequest).where(BookRequest.id.in_(data.ids))).all()
rows = [record(session, "deleted", r, source=r.imported_path) for r in reqs]
for r in reqs:
session.delete(r)
session.commit()
connect.schedule(background, rows)
return {"deleted": len(reqs)}
@router.put("/{request_id}", response_model=RequestOut)
def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)):
req = session.get(BookRequest, request_id)
@@ -193,7 +238,8 @@ def update_request(request_id: int, data: RequestIn, session: Session = Depends(
@router.post("/{request_id}/retag")
def retag_request(request_id: int, session: Session = Depends(get_session)):
def retag_request(request_id: int, background: BackgroundTasks,
session: Session = Depends(get_session)):
"""Rewrite the audio tags of an already imported request from its current
metadata (e.g. after adding series/volume/narrator later)."""
req = session.get(BookRequest, request_id)
@@ -213,15 +259,22 @@ def retag_request(request_id: int, session: Session = Depends(get_session)):
raise HTTPException(404, f"no audio files in {path}")
for i, f in enumerate(files, 1):
tagger.tag_audio(f, req, track=i, total=len(files))
row = record(session, "retagged", req, dest=str(path))
session.commit()
connect.schedule(background, [row])
return {"ok": True, "files": len(files)}
if path.is_file():
tagger.tag_audio(path, req)
row = record(session, "retagged", req, dest=str(path))
session.commit()
connect.schedule(background, [row])
return {"ok": True, "files": 1}
raise HTTPException(404, f"imported path no longer exists: {path}")
@router.post("/{request_id}/relocate")
def relocate_request(request_id: int, session: Session = Depends(get_session)):
def relocate_request(request_id: int, background: BackgroundTasks,
session: Session = Depends(get_session)):
"""Re-apply the library's naming scheme to an imported title, e.g. after
fixing a series that Audible had filed differently."""
req = session.get(BookRequest, request_id)
@@ -237,15 +290,20 @@ def relocate_request(request_id: int, session: Session = Depends(get_session)):
except FileExistsError as exc:
raise HTTPException(409, str(exc))
req.imported_path = dest
rows = [record(session, "relocated", req, source=old, dest=dest)] if dest != old else []
session.commit()
connect.schedule(background, rows)
return {"ok": True, "moved": dest != old, "from": old, "dest": dest}
@router.delete("/{request_id}")
def delete_request(request_id: int, session: Session = Depends(get_session)):
def delete_request(request_id: int, background: BackgroundTasks,
session: Session = Depends(get_session)):
req = session.get(BookRequest, request_id)
if not req:
raise HTTPException(404, "request not found")
row = record(session, "deleted", req, source=req.imported_path)
session.delete(req)
session.commit()
connect.schedule(background, [row])
return {"ok": True}
+44
View File
@@ -0,0 +1,44 @@
import os
from pathlib import Path
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import config, connect, logs
from ..db import Library, get_session
from ..metadata import hardcover
router = APIRouter(prefix="/api/system", tags=["system"])
def _dir_problem(path: Path) -> str | None:
if not path.is_dir():
return "missing"
if not os.access(path, os.W_OK | os.X_OK):
return "readonly"
return None
@router.get("/health")
def health(session: Session = Depends(get_session)):
"""Problems only, as codes the UI translates; an empty list means healthy."""
issues = []
if problem := _dir_problem(config.DOWNLOAD_DIR):
issues.append({"code": f"download.{problem}", "level": "error",
"path": str(config.DOWNLOAD_DIR)})
for lib in session.scalars(select(Library).order_by(Library.name)):
if problem := _dir_problem(Path(lib.root_path)):
issues.append({"code": f"library.{problem}", "level": "error",
"name": lib.name, "path": lib.root_path})
if not hardcover.TOKEN:
issues.append({"code": "hardcover.token", "level": "warning"})
if bool(connect.ABS_URL) != bool(connect.ABS_TOKEN):
issues.append({"code": "abs.incomplete", "level": "warning"})
return issues
@router.get("/logs")
def recent_logs():
"""Newest first."""
return list(reversed(logs.RECORDS))
+60
View File
@@ -0,0 +1,60 @@
"""Tell other services about history rows once they are committed: a generic
webhook gets every row, Audiobookshelf a scan of the libraries holding new
files. Runs as a background task, so a slow or dead service never fails the
request that caused it; errors only reach the log."""
import logging
import os
import httpx
from fastapi import BackgroundTasks
from .db import History
ABS_URL = os.environ.get("WORDARR_ABS_URL", "").strip().rstrip("/")
ABS_TOKEN = os.environ.get("WORDARR_ABS_TOKEN", "").strip()
WEBHOOK_URL = os.environ.get("WORDARR_WEBHOOK_URL", "").strip()
log = logging.getLogger(__name__)
def schedule(background: BackgroundTasks, rows: list[History]) -> None:
"""Queue the notification; it runs after the response is sent."""
events = [
{"event": r.event, "request_id": r.request_id, "title": r.title,
"media_type": r.media_type, "source": r.source, "dest": r.dest,
"message": r.message}
for r in rows
]
if events:
background.add_task(notify, events)
def notify(events: list[dict]) -> None:
with httpx.Client(timeout=10) as client:
if WEBHOOK_URL:
for e in events:
try:
client.post(WEBHOOK_URL, json=e).raise_for_status()
except httpx.HTTPError as exc:
log.warning("webhook %s failed: %s", WEBHOOK_URL, exc)
dests = [e["dest"] for e in events if e["dest"] and e["event"] != "failed"]
if ABS_URL and ABS_TOKEN and dests:
try:
_scan_audiobookshelf(client, dests)
except httpx.HTTPError as exc:
log.warning("audiobookshelf scan failed: %s", exc)
def _scan_audiobookshelf(client: httpx.Client, dests: list[str]) -> None:
"""Scan only the libraries whose folders contain a destination. That needs
both containers to mount the media under the same path."""
headers = {"Authorization": f"Bearer {ABS_TOKEN}"}
resp = client.get(f"{ABS_URL}/api/libraries", headers=headers)
resp.raise_for_status()
for lib in resp.json().get("libraries") or []:
folders = [str(f.get("fullPath") or "").rstrip("/")
for f in lib.get("folders") or []]
if any(f and (d == f or d.startswith(f + "/")) for f in folders for d in dests):
client.post(f"{ABS_URL}/api/libraries/{lib.get('id')}/scan",
headers=headers).raise_for_status()
log.info("audiobookshelf scans library %s", lib.get("name"))
+28
View File
@@ -48,6 +48,34 @@ class BookRequest(Base):
library: Mapped[Library] = relationship(back_populates="requests")
class History(Base):
"""One row per thing done to a request. Title and type are copied, and
there is no foreign key, so an entry outlives a deleted request."""
__tablename__ = "history"
id: Mapped[int] = mapped_column(primary_key=True)
at: Mapped[datetime.datetime] = mapped_column(
default=lambda: datetime.datetime.now(datetime.UTC)
)
# imported | appended | relocated | retagged | deleted | failed
event: Mapped[str] = mapped_column(String)
request_id: Mapped[int | None] = mapped_column(default=None)
title: Mapped[str] = mapped_column(String)
media_type: Mapped[str] = mapped_column(String)
source: Mapped[str] = mapped_column(String, default="")
dest: Mapped[str] = mapped_column(String, default="")
message: Mapped[str] = mapped_column(String, default="")
def record(session, event: str, req: BookRequest, **fields: str) -> History:
"""Add a history row; it is saved with the caller's next commit."""
row = History(event=event, request_id=req.id, title=req.title,
media_type=req.media_type, **fields)
session.add(row)
return row
_engine = None
SessionLocal = None
+30
View File
@@ -0,0 +1,30 @@
"""Keep the latest log records in memory for the system page."""
import collections
import datetime
import logging
# shortcut: lost on restart, write to /config if older entries are ever needed
RECORDS: collections.deque[dict] = collections.deque(maxlen=200)
class _Buffer(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
RECORDS.append({
"at": datetime.datetime.fromtimestamp(record.created, datetime.UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": self.format(record),
})
def install() -> None:
"""A handler on root would silence the last-resort stderr output, so
basicConfig keeps the container log. uvicorn's loggers do not propagate,
its errors are caught on uvicorn.error itself."""
if any(isinstance(h, _Buffer) for h in logging.getLogger().handlers):
return
logging.basicConfig(format="%(levelname)s:%(name)s: %(message)s")
logging.getLogger("wordarr").setLevel(logging.INFO)
buffer = _Buffer(logging.INFO)
logging.getLogger().addHandler(buffer)
logging.getLogger("uvicorn.error").addHandler(buffer)
+6 -2
View File
@@ -4,8 +4,10 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from . import db, version
from .api import fs, imports, libraries, requests, search
from . import db, logs, version
from .api import fs, history, imports, libraries, requests, search, system
logs.install()
@asynccontextmanager
@@ -21,6 +23,8 @@ app.include_router(search.router)
app.include_router(requests.router)
app.include_router(imports.router)
app.include_router(fs.router)
app.include_router(history.router)
app.include_router(system.router)
@app.get("/api/version")