Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46b88e2fd5 | ||
|
|
07c988c443 | ||
|
|
f7f89a3cc7 | ||
|
|
40a829c80c | ||
|
|
0c504790df |
No files matched your search
@@ -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_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_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_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 |
|
| `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
|
The header shows the running version and, behind it, the build stamp (the newest
|
||||||
|
|||||||
+83
-9
@@ -84,16 +84,16 @@ function libOptions(mediaType) {
|
|||||||
// ---- missing badge ----
|
// ---- missing badge ----
|
||||||
async function refreshMissingBadge() {
|
async function refreshMissingBadge() {
|
||||||
try {
|
try {
|
||||||
const reqs = await api("/api/requests?status=missing");
|
const { count } = await api("/api/requests/count?status=missing");
|
||||||
const badge = $("#missing-badge");
|
const badge = $("#missing-badge");
|
||||||
badge.textContent = reqs.length;
|
badge.textContent = count;
|
||||||
badge.hidden = reqs.length === 0;
|
badge.hidden = count === 0;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- navigation ----
|
// ---- navigation ----
|
||||||
// the view lives in the hash so reload, back button and links keep it
|
// 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"];
|
||||||
|
|
||||||
function showView() {
|
function showView() {
|
||||||
const hash = location.hash.slice(1);
|
const hash = location.hash.slice(1);
|
||||||
@@ -101,6 +101,7 @@ function showView() {
|
|||||||
$$("nav button[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
|
$$("nav button[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
|
||||||
$$("main > section").forEach((s) => (s.hidden = s.id !== "view-" + name));
|
$$("main > section").forEach((s) => (s.hidden = s.id !== "view-" + name));
|
||||||
if (name === "missing" || name === "imported") loadRequests(name);
|
if (name === "missing" || name === "imported") loadRequests(name);
|
||||||
|
if (name === "activity") loadHistory();
|
||||||
if (name === "settings") loadLibraries().catch((err) => toast(err.message, true));
|
if (name === "settings") loadLibraries().catch((err) => toast(err.message, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,6 +519,22 @@ function requestMatchesFilter(r, text) {
|
|||||||
return hay.includes(text.toLowerCase());
|
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) {
|
async function loadRequests(status) {
|
||||||
const params = new URLSearchParams({ status });
|
const params = new URLSearchParams({ status });
|
||||||
if (status === "missing") {
|
if (status === "missing") {
|
||||||
@@ -542,11 +559,15 @@ async function loadRequests(status) {
|
|||||||
function renderRequests(status) {
|
function renderRequests(status) {
|
||||||
const text = status === "missing" ? $("#missing-search").value : $("#imported-search").value;
|
const text = status === "missing" ? $("#missing-search").value : $("#imported-search").value;
|
||||||
const reqs = currentRequests[status].filter((r) => requestMatchesFilter(r, text));
|
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 box = status === "missing" ? $("#missing-list") : $("#imported-list");
|
||||||
const bulkBtn = status === "missing" ? $("#missing-bulk-delete") : $("#imported-bulk-delete");
|
const bulkBtn = status === "missing" ? $("#missing-bulk-delete") : $("#imported-bulk-delete");
|
||||||
|
const moveSel = status === "missing" ? $("#missing-move-library") : null;
|
||||||
if (!reqs.length) {
|
if (!reqs.length) {
|
||||||
box.innerHTML = `<p class='muted'>${t("common.nothingHere")}</p>`;
|
box.innerHTML = `<p class='muted'>${t("common.nothingHere")}</p>`;
|
||||||
bulkBtn.hidden = true;
|
bulkBtn.hidden = true;
|
||||||
|
if (moveSel) moveSel.hidden = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
box.innerHTML = reqs
|
box.innerHTML = reqs
|
||||||
@@ -581,6 +602,10 @@ function renderRequests(status) {
|
|||||||
const n = box.querySelectorAll("[data-sel-req]:checked").length;
|
const n = box.querySelectorAll("[data-sel-req]:checked").length;
|
||||||
bulkBtn.hidden = n === 0;
|
bulkBtn.hidden = n === 0;
|
||||||
bulkBtn.textContent = t("common.removeSelectedN", { n });
|
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) =>
|
box.querySelectorAll("[data-sel-req]").forEach((c) =>
|
||||||
c.addEventListener("change", updateBulkBtn)
|
c.addEventListener("change", updateBulkBtn)
|
||||||
@@ -595,14 +620,29 @@ async function bulkDeleteRequests(status) {
|
|||||||
const hint = t(status === "imported" ? "list.confirmDeleteImported" : "list.confirmDeleteRequests",
|
const hint = t(status === "imported" ? "list.confirmDeleteImported" : "list.confirmDeleteRequests",
|
||||||
{ n: ids.length });
|
{ n: ids.length });
|
||||||
if (!confirm(hint)) return;
|
if (!confirm(hint)) return;
|
||||||
for (const id of ids) {
|
try {
|
||||||
try { await api("/api/requests/" + id, { method: "DELETE" }); }
|
const res = await api("/api/requests/bulk", {
|
||||||
catch (err) { toast(err.message, true); }
|
method: "DELETE", body: JSON.stringify({ ids: ids.map(Number) }),
|
||||||
}
|
});
|
||||||
toast(t("list.deletedN", { n: ids.length }));
|
toast(t("list.deletedN", { n: res.deleted }));
|
||||||
|
} catch (err) { toast(err.message, true); }
|
||||||
loadRequests(status);
|
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"));
|
$("#imported-bulk-delete").addEventListener("click", () => bulkDeleteRequests("imported"));
|
||||||
|
|
||||||
function selectAllRequests(status) {
|
function selectAllRequests(status) {
|
||||||
@@ -622,6 +662,39 @@ $("#imported-search").addEventListener("input", () => renderRequests("imported")
|
|||||||
|
|
||||||
$("#missing-bulk-delete").addEventListener("click", () => bulkDeleteRequests("missing"));
|
$("#missing-bulk-delete").addEventListener("click", () => bulkDeleteRequests("missing"));
|
||||||
|
|
||||||
|
// ---- activity ----
|
||||||
|
let historyRows = [];
|
||||||
|
const HISTORY_STATE = { imported: "done", appended: "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("");
|
||||||
|
}
|
||||||
|
|
||||||
// ---- request detail dialog ----
|
// ---- request detail dialog ----
|
||||||
let detailRequest = null;
|
let detailRequest = null;
|
||||||
|
|
||||||
@@ -1758,6 +1831,7 @@ function onLangChange() {
|
|||||||
renderSearchResults();
|
renderSearchResults();
|
||||||
renderRequests("missing");
|
renderRequests("missing");
|
||||||
renderRequests("imported");
|
renderRequests("imported");
|
||||||
|
renderHistory();
|
||||||
if (libraries.length) loadLibraries();
|
if (libraries.length) loadLibraries();
|
||||||
const editId = $("#lib-form").dataset.editId;
|
const editId = $("#lib-form").dataset.editId;
|
||||||
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
|
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
|
||||||
|
|||||||
@@ -46,7 +46,16 @@ const STRINGS = {
|
|||||||
"nav.imported": "Importiert",
|
"nav.imported": "Importiert",
|
||||||
"nav.import": "Import",
|
"nav.import": "Import",
|
||||||
"nav.settings": "Libraries",
|
"nav.settings": "Libraries",
|
||||||
|
"nav.activity": "Aktivität",
|
||||||
"nav.langToggle": "Switch to English",
|
"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",
|
||||||
|
|
||||||
"search.languageTitle": "Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche",
|
"search.languageTitle": "Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche",
|
||||||
"search.allLanguages": "Alle Sprachen",
|
"search.allLanguages": "Alle Sprachen",
|
||||||
@@ -97,6 +106,13 @@ const STRINGS = {
|
|||||||
"list.confirmDeleteRequests": "{n} Anfrage(n) entfernen?",
|
"list.confirmDeleteRequests": "{n} Anfrage(n) entfernen?",
|
||||||
"list.confirmDeleteImported": "{n} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.",
|
"list.confirmDeleteImported": "{n} Eintrag/Einträge entfernen? Die importierten Dateien in der Library bleiben erhalten.",
|
||||||
"list.deletedN": "{n} entfernt",
|
"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.statusMissing": "fehlt noch",
|
||||||
"detail.statusImported": "importiert",
|
"detail.statusImported": "importiert",
|
||||||
@@ -270,7 +286,16 @@ const STRINGS = {
|
|||||||
"nav.imported": "Imported",
|
"nav.imported": "Imported",
|
||||||
"nav.import": "Import",
|
"nav.import": "Import",
|
||||||
"nav.settings": "Libraries",
|
"nav.settings": "Libraries",
|
||||||
|
"nav.activity": "Activity",
|
||||||
"nav.langToggle": "Auf Deutsch umschalten",
|
"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",
|
||||||
|
|
||||||
"search.languageTitle": "Edition language — filters the ebook and audiobook search",
|
"search.languageTitle": "Edition language — filters the ebook and audiobook search",
|
||||||
"search.allLanguages": "All languages",
|
"search.allLanguages": "All languages",
|
||||||
@@ -321,6 +346,13 @@ const STRINGS = {
|
|||||||
"list.confirmDeleteRequests": "Remove {n} request(s)?",
|
"list.confirmDeleteRequests": "Remove {n} request(s)?",
|
||||||
"list.confirmDeleteImported": "Remove {n} entr(y/ies)? The imported files stay in the library.",
|
"list.confirmDeleteImported": "Remove {n} entr(y/ies)? The imported files stay in the library.",
|
||||||
"list.deletedN": "{n} removed",
|
"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.statusMissing": "still missing",
|
||||||
"detail.statusImported": "imported",
|
"detail.statusImported": "imported",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button data-view="imported" data-i18n="nav.imported"></button>
|
<button data-view="imported" data-i18n="nav.imported"></button>
|
||||||
<button data-view="import" data-i18n="nav.import"></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="settings" data-i18n="nav.settings"></button>
|
||||||
<button id="lang-toggle" class="secondary" data-i18n-title="nav.langToggle"></button>
|
<button id="lang-toggle" class="secondary" data-i18n-title="nav.langToggle"></button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -109,7 +110,14 @@
|
|||||||
<option value="" data-i18n="list.allLibraries"></option>
|
<option value="" data-i18n="list.allLibraries"></option>
|
||||||
</select>
|
</select>
|
||||||
<input id="missing-search" data-i18n-placeholder="list.filterPlaceholder" />
|
<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>
|
<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>
|
<button id="missing-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></button>
|
||||||
<div class="view-toggle" role="group" data-i18n-aria="list.view">
|
<div class="view-toggle" role="group" data-i18n-aria="list.view">
|
||||||
<button data-viewmode="list" data-i18n-title="list.viewList">☰</button>
|
<button data-viewmode="list" data-i18n-title="list.viewList">☰</button>
|
||||||
@@ -123,6 +131,12 @@
|
|||||||
<section id="view-imported" hidden>
|
<section id="view-imported" hidden>
|
||||||
<div class="bar wrap">
|
<div class="bar wrap">
|
||||||
<input id="imported-search" data-i18n-placeholder="list.filterPlaceholder" />
|
<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-select-all" class="secondary" data-i18n="common.selectAll"></button>
|
||||||
<button id="imported-bulk-delete" class="danger" hidden data-i18n="common.removeSelected"></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">
|
<div class="view-toggle" role="group" data-i18n-aria="list.view">
|
||||||
@@ -269,6 +283,22 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
</section>
|
</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-settings" hidden>
|
<section id="view-settings" hidden>
|
||||||
<h3 data-i18n="lib.heading"></h3>
|
<h3 data-i18n="lib.heading"></h3>
|
||||||
<div class="table-scroll">
|
<div class="table-scroll">
|
||||||
|
|||||||
@@ -1185,3 +1185,112 @@ def test_version_endpoint_reports_a_build_stamp(client):
|
|||||||
assert data["version"]
|
assert data["version"]
|
||||||
# "2026-09-01 15:43" - the newest source file, so a stale deploy stands out
|
# "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] == "-"
|
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
|
||||||
@@ -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()
|
||||||
+13
-5
@@ -1,12 +1,12 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import config
|
from .. import config, connect
|
||||||
from ..db import BookRequest, get_session
|
from ..db import BookRequest, get_session, record
|
||||||
from ..fsnames import decode_path, readable
|
from ..fsnames import decode_path, readable
|
||||||
from ..importer import matcher, mover, scanner
|
from ..importer import matcher, mover, scanner
|
||||||
|
|
||||||
@@ -58,8 +58,9 @@ class ImportIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
def do_import(data: ImportIn, session: Session = Depends(get_session)):
|
def do_import(data: ImportIn, background: BackgroundTasks,
|
||||||
results = []
|
session: Session = Depends(get_session)):
|
||||||
|
results, rows = [], []
|
||||||
download_root = Path(config.DOWNLOAD_DIR).resolve()
|
download_root = Path(config.DOWNLOAD_DIR).resolve()
|
||||||
for item in data.items:
|
for item in data.items:
|
||||||
# the client hands back whatever the scan produced, base64 included
|
# 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:
|
try:
|
||||||
dest = mover.append_to_import(item.files, req, req.library)
|
dest = mover.append_to_import(item.files, req, req.library)
|
||||||
except Exception as exc:
|
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)})
|
results.append({"path": shown, "ok": False, "error": str(exc)})
|
||||||
continue
|
continue
|
||||||
req.imported_path = dest
|
req.imported_path = dest
|
||||||
|
rows.append(record(session, "appended", req, source=shown, dest=dest))
|
||||||
session.commit()
|
session.commit()
|
||||||
results.append({"path": shown, "ok": True, "dest": dest, "appended": True})
|
results.append({"path": shown, "ok": True, "dest": dest, "appended": True})
|
||||||
continue
|
continue
|
||||||
@@ -111,12 +115,16 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
|
|||||||
dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library)
|
dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
session.rollback() # the next commit must not keep the folder's series
|
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)})
|
results.append({"path": shown, "ok": False, "error": str(exc)})
|
||||||
continue
|
continue
|
||||||
req.status = "imported"
|
req.status = "imported"
|
||||||
req.imported_path = dest
|
req.imported_path = dest
|
||||||
|
rows.append(record(session, "imported", req, source=shown, dest=dest))
|
||||||
session.commit()
|
session.commit()
|
||||||
results.append({"path": shown, "ok": True, "dest": dest})
|
results.append({"path": shown, "ok": True, "dest": dest})
|
||||||
if not results:
|
if not results:
|
||||||
raise HTTPException(400, "nothing to import")
|
raise HTTPException(400, "nothing to import")
|
||||||
|
connect.schedule(background, rows)
|
||||||
return {"results": results}
|
return {"results": results}
|
||||||
+65
-7
@@ -1,12 +1,12 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import config
|
from .. import config, connect
|
||||||
from ..db import BookRequest, Library, get_session
|
from ..db import BookRequest, Library, get_session, record
|
||||||
from ..importer import mover, tagger
|
from ..importer import mover, tagger
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/requests", tags=["requests"])
|
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}
|
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)
|
@router.put("/{request_id}", response_model=RequestOut)
|
||||||
def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)):
|
def update_request(request_id: int, data: RequestIn, session: Session = Depends(get_session)):
|
||||||
req = session.get(BookRequest, request_id)
|
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")
|
@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
|
"""Rewrite the audio tags of an already imported request from its current
|
||||||
metadata (e.g. after adding series/volume/narrator later)."""
|
metadata (e.g. after adding series/volume/narrator later)."""
|
||||||
req = session.get(BookRequest, request_id)
|
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}")
|
raise HTTPException(404, f"no audio files in {path}")
|
||||||
for i, f in enumerate(files, 1):
|
for i, f in enumerate(files, 1):
|
||||||
tagger.tag_audio(f, req, track=i, total=len(files))
|
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)}
|
return {"ok": True, "files": len(files)}
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
tagger.tag_audio(path, req)
|
tagger.tag_audio(path, req)
|
||||||
|
row = record(session, "retagged", req, dest=str(path))
|
||||||
|
session.commit()
|
||||||
|
connect.schedule(background, [row])
|
||||||
return {"ok": True, "files": 1}
|
return {"ok": True, "files": 1}
|
||||||
raise HTTPException(404, f"imported path no longer exists: {path}")
|
raise HTTPException(404, f"imported path no longer exists: {path}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{request_id}/relocate")
|
@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
|
"""Re-apply the library's naming scheme to an imported title, e.g. after
|
||||||
fixing a series that Audible had filed differently."""
|
fixing a series that Audible had filed differently."""
|
||||||
req = session.get(BookRequest, request_id)
|
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:
|
except FileExistsError as exc:
|
||||||
raise HTTPException(409, str(exc))
|
raise HTTPException(409, str(exc))
|
||||||
req.imported_path = dest
|
req.imported_path = dest
|
||||||
|
rows = [record(session, "relocated", req, source=old, dest=dest)] if dest != old else []
|
||||||
session.commit()
|
session.commit()
|
||||||
|
connect.schedule(background, rows)
|
||||||
return {"ok": True, "moved": dest != old, "from": old, "dest": dest}
|
return {"ok": True, "moved": dest != old, "from": old, "dest": dest}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{request_id}")
|
@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)
|
req = session.get(BookRequest, request_id)
|
||||||
if not req:
|
if not req:
|
||||||
raise HTTPException(404, "request not found")
|
raise HTTPException(404, "request not found")
|
||||||
|
row = record(session, "deleted", req, source=req.imported_path)
|
||||||
session.delete(req)
|
session.delete(req)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
connect.schedule(background, [row])
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -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"))
|
||||||
@@ -48,6 +48,34 @@ class BookRequest(Base):
|
|||||||
library: Mapped[Library] = relationship(back_populates="requests")
|
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
|
_engine = None
|
||||||
SessionLocal = None
|
SessionLocal = None
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import db, version
|
from . import db, version
|
||||||
from .api import fs, imports, libraries, requests, search
|
from .api import fs, history, imports, libraries, requests, search
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -21,6 +21,7 @@ app.include_router(search.router)
|
|||||||
app.include_router(requests.router)
|
app.include_router(requests.router)
|
||||||
app.include_router(imports.router)
|
app.include_router(imports.router)
|
||||||
app.include_router(fs.router)
|
app.include_router(fs.router)
|
||||||
|
app.include_router(history.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/version")
|
@app.get("/api/version")
|
||||||
|
|||||||
Reference in new issue
Block a user