Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d2ada3901 | ||
|
|
d0b12d5d09 | ||
|
|
46c3c91a8d | ||
|
|
44bf65b3a5 | ||
|
|
0f56d13577 | ||
|
|
4deaca4f1c | ||
|
|
025fedc556 | ||
|
|
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_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
|
||||
|
||||
+157
-9
@@ -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"];
|
||||
|
||||
function showView() {
|
||||
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));
|
||||
$$("main > section").forEach((s) => (s.hidden = s.id !== "view-" + name));
|
||||
if (name === "missing" || name === "imported") loadRequests(name);
|
||||
if (name === "activity") loadHistory();
|
||||
if (name === "settings") loadLibraries().catch((err) => toast(err.message, true));
|
||||
}
|
||||
|
||||
@@ -125,6 +126,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 +135,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 +523,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 +563,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 +606,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 +624,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 +666,39 @@ $("#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("");
|
||||
}
|
||||
|
||||
// ---- request detail dialog ----
|
||||
let detailRequest = null;
|
||||
|
||||
@@ -1745,6 +1822,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 +1904,12 @@ function onLangChange() {
|
||||
renderSearchResults();
|
||||
renderRequests("missing");
|
||||
renderRequests("imported");
|
||||
renderHistory();
|
||||
if (libraries.length) loadLibraries();
|
||||
const editId = $("#lib-form").dataset.editId;
|
||||
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
|
||||
if (scanItems.length) renderImportTable();
|
||||
renderAdopt();
|
||||
}
|
||||
|
||||
// ---- version ----
|
||||
|
||||
@@ -46,7 +46,24 @@ const STRINGS = {
|
||||
"nav.imported": "Importiert",
|
||||
"nav.import": "Import",
|
||||
"nav.settings": "Libraries",
|
||||
"nav.activity": "Aktivität",
|
||||
"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 +114,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 +294,24 @@ const STRINGS = {
|
||||
"nav.imported": "Imported",
|
||||
"nav.import": "Import",
|
||||
"nav.settings": "Libraries",
|
||||
"nav.activity": "Activity",
|
||||
"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 +362,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",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
</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 id="lang-toggle" class="secondary" data-i18n-title="nav.langToggle"></button>
|
||||
</nav>
|
||||
@@ -109,7 +110,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 +131,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 +283,22 @@
|
||||
</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-settings" hidden>
|
||||
<h3 data-i18n="lib.heading"></h3>
|
||||
<div class="table-scroll">
|
||||
@@ -349,6 +379,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>
|
||||
|
||||
@@ -1185,3 +1185,146 @@ 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
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
@@ -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")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import db, version
|
||||
from .api import fs, imports, libraries, requests, search
|
||||
from .api import fs, history, imports, libraries, requests, search
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -21,6 +21,7 @@ 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.get("/api/version")
|
||||
|
||||
Reference in new issue
Block a user