Compare commits
11
Commits
ebd981cc3c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40af29ab15 | ||
|
|
377d2c8557 | ||
|
|
33d82ab7d4 | ||
|
|
173e24a362 | ||
|
|
98c7214140 | ||
|
|
be1da8bbf3 | ||
|
|
1487512183 | ||
|
|
55d7c8bdda | ||
|
|
0f48981478 | ||
|
|
175dface50 | ||
|
|
4441c5a24d |
No files matched your search
+176
-3
@@ -81,6 +81,11 @@ function libOptions(mediaType) {
|
||||
.join("");
|
||||
}
|
||||
|
||||
// the server keeps a copy; the url in the query makes a changed cover a new address
|
||||
function coverSrc(r) {
|
||||
return `/api/requests/${r.id}/cover?u=${encodeURIComponent(r.cover_url)}`;
|
||||
}
|
||||
|
||||
// ---- missing badge ----
|
||||
async function refreshMissingBadge() {
|
||||
try {
|
||||
@@ -93,7 +98,7 @@ async function refreshMissingBadge() {
|
||||
|
||||
// ---- navigation ----
|
||||
// the view lives in the hash so reload, back button and links keep it
|
||||
const VIEWS = ["search", "missing", "imported", "import", "activity", "settings"];
|
||||
const VIEWS = ["search", "missing", "imported", "import", "series", "activity", "settings", "system"];
|
||||
|
||||
function showView() {
|
||||
const hash = location.hash.slice(1);
|
||||
@@ -102,6 +107,8 @@ function showView() {
|
||||
$$("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 === "series") loadWatches();
|
||||
if (name === "settings") loadLibraries().catch((err) => toast(err.message, true));
|
||||
}
|
||||
|
||||
@@ -117,6 +124,7 @@ window.addEventListener("hashchange", showView);
|
||||
// ---- libraries ----
|
||||
async function loadLibraries() {
|
||||
libraries = await api("/api/libraries");
|
||||
refreshHealth();
|
||||
const tbody = $("#lib-table tbody");
|
||||
tbody.innerHTML = libraries
|
||||
.map(
|
||||
@@ -318,6 +326,8 @@ function renderSearchResults() {
|
||||
// ---- request a whole series ----
|
||||
let seriesEpisodes = [];
|
||||
let seriesType = "audiobook";
|
||||
let seriesResult = null;
|
||||
let seriesLanguage = "";
|
||||
|
||||
function renderSeriesList() {
|
||||
const from = parseInt($("#series-from").value);
|
||||
@@ -364,6 +374,8 @@ function updateSeriesCount() {
|
||||
async function openSeriesDialog(result, type = "audiobook", language = "") {
|
||||
const dlg = $("#series-dialog");
|
||||
seriesType = type;
|
||||
seriesResult = result;
|
||||
seriesLanguage = language;
|
||||
const unit = t(type === "ebook" ? "series.unitVolumes" : "series.unitEpisodes");
|
||||
$("#series-title").textContent = result.series || t("series.fallbackTitle");
|
||||
$("#series-info").innerHTML =
|
||||
@@ -409,6 +421,31 @@ $("#series-none").addEventListener("click", () => {
|
||||
});
|
||||
$("#series-cancel").addEventListener("click", () => $("#series-dialog").close());
|
||||
|
||||
$("#series-watch").addEventListener("click", async () => {
|
||||
const libId = $("#series-library").value;
|
||||
if (!libId) {
|
||||
toast(t("series.needLibrary", { type: seriesType === "ebook" ? "Ebook" : "Audiobook" }), true);
|
||||
return;
|
||||
}
|
||||
const btn = $("#series-watch");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api("/api/series", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
media_type: seriesType, library_id: parseInt(libId),
|
||||
series_id: seriesResult.series_id, source: seriesResult.source || "",
|
||||
title: seriesResult.series || seriesResult.title, language: seriesLanguage,
|
||||
}),
|
||||
});
|
||||
toast(t("watch.added"));
|
||||
} catch (err) {
|
||||
toast(err.status === 409 ? t("watch.already") : err.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$("#series-submit").addEventListener("click", async () => {
|
||||
const libId = $("#series-library").value;
|
||||
if (!libId) {
|
||||
@@ -577,7 +614,7 @@ function renderRequests(status) {
|
||||
box.innerHTML = reqs
|
||||
.map(
|
||||
(r) => `<div class="card clickable" data-detail="${r.id}" tabindex="0">
|
||||
${r.cover_url ? `<img src="${esc(r.cover_url)}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
|
||||
${r.cover_url ? `<img src="${esc(coverSrc(r))}" alt="" loading="lazy">` : '<div class="nocover">?</div>'}
|
||||
<div class="card-body">
|
||||
<strong>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""}</strong>
|
||||
<span>${esc(r.authors)}</span>
|
||||
@@ -699,6 +736,137 @@ function renderHistory() {
|
||||
.join("");
|
||||
}
|
||||
|
||||
// ---- watched series ----
|
||||
let watches = [];
|
||||
|
||||
async function loadWatches(path = "/api/series", opts = {}) {
|
||||
try {
|
||||
watches = await api(path, opts);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
return;
|
||||
}
|
||||
renderWatches();
|
||||
}
|
||||
|
||||
function renderWatches() {
|
||||
const n = watches.reduce((sum, w) => sum + w.new.length, 0);
|
||||
$("#watch-badge").textContent = n;
|
||||
$("#watch-badge").hidden = n === 0;
|
||||
const box = $("#watch-list");
|
||||
if (!watches.length) {
|
||||
box.innerHTML = `<p class="muted">${t("watch.empty")}</p>`;
|
||||
return;
|
||||
}
|
||||
const locale = lang === "de" ? "de-DE" : "en-GB";
|
||||
box.innerHTML = watches
|
||||
.map((w) => {
|
||||
const checked = w.checked_at ? new Date(w.checked_at).toLocaleString(locale) : "—";
|
||||
const parts = w.new
|
||||
.map((p) => `<tr>
|
||||
<td>${p.volume ?? ""}</td>
|
||||
<td>${esc(p.title)}${p.year ? ` <span class="muted">(${p.year})</span>` : ""}</td>
|
||||
<td class="muted">${esc(p.authors)}</td>
|
||||
<td class="row">
|
||||
<button data-part="${p.id}" data-action="request">${t("common.request")}</button>
|
||||
<button class="secondary" data-part="${p.id}" data-action="ignore">${t("watch.ignore")}</button>
|
||||
</td>
|
||||
</tr>`)
|
||||
.join("");
|
||||
return `<div class="watch">
|
||||
<div class="row">
|
||||
<strong>${esc(w.title)}</strong>
|
||||
<span class="muted">${esc(w.library_name)} · ${esc(w.media_type)} · ${t("watch.checked", { at: checked })}</span>
|
||||
<button class="danger" data-unwatch="${w.id}">${t("watch.remove")}</button>
|
||||
</div>
|
||||
${w.error ? `<p class="muted error-text">${esc(t("watch.error", { error: w.error }))}</p>` : ""}
|
||||
${parts ? `<table><tbody>${parts}</tbody></table>` : `<p class="muted">${t("watch.nothingNew")}</p>`}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
$("#watch-list").addEventListener("click", async (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
try {
|
||||
if (btn.dataset.part) {
|
||||
btn.disabled = true;
|
||||
await api("/api/series/parts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ids: [parseInt(btn.dataset.part)], action: btn.dataset.action }),
|
||||
});
|
||||
if (btn.dataset.action === "request") refreshMissingBadge();
|
||||
} else if (btn.dataset.unwatch) {
|
||||
if (!confirm(t("watch.confirmRemove"))) return;
|
||||
await api(`/api/series/${btn.dataset.unwatch}`, { method: "DELETE" });
|
||||
} else return;
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
loadWatches();
|
||||
});
|
||||
|
||||
$("#watch-check").addEventListener("click", async () => {
|
||||
const btn = $("#watch-check");
|
||||
btn.disabled = true;
|
||||
await loadWatches("/api/series/check", { method: "POST" });
|
||||
btn.disabled = false;
|
||||
});
|
||||
|
||||
// ---- 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;
|
||||
|
||||
@@ -725,7 +893,7 @@ function openDetail(id, status) {
|
||||
const img = $("#detail-cover");
|
||||
img.hidden = !r.cover_url;
|
||||
$("#detail-nocover").hidden = !!r.cover_url;
|
||||
if (r.cover_url) img.src = r.cover_url;
|
||||
if (r.cover_url) img.src = coverSrc(r);
|
||||
$("#detail-retag").hidden = !(r.status === "imported" && r.media_type === "audiobook");
|
||||
$("#detail-relocate").hidden = r.status !== "imported";
|
||||
$("#detail-dialog").showModal();
|
||||
@@ -1905,6 +2073,9 @@ function onLangChange() {
|
||||
renderRequests("missing");
|
||||
renderRequests("imported");
|
||||
renderHistory();
|
||||
renderHealth();
|
||||
renderLogs();
|
||||
renderWatches();
|
||||
if (libraries.length) loadLibraries();
|
||||
const editId = $("#lib-form").dataset.editId;
|
||||
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
|
||||
@@ -1920,6 +2091,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 {}
|
||||
}
|
||||
|
||||
@@ -1929,5 +2101,6 @@ updateLangButton();
|
||||
endLibraryEdit();
|
||||
loadLibraries();
|
||||
refreshMissingBadge();
|
||||
loadWatches();
|
||||
showVersion();
|
||||
showView();
|
||||
@@ -47,6 +47,33 @@ const STRINGS = {
|
||||
"nav.import": "Import",
|
||||
"nav.settings": "Libraries",
|
||||
"nav.activity": "Aktivität",
|
||||
"nav.series": "Serien",
|
||||
"watch.button": "Beobachten",
|
||||
"watch.buttonTitle": "Täglich nach neuen Teilen dieser Serie schauen",
|
||||
"watch.added": "Serie wird beobachtet",
|
||||
"watch.already": "Diese Serie wird schon beobachtet",
|
||||
"watch.check": "Jetzt prüfen",
|
||||
"watch.empty": "Noch keine Serie beobachtet. In der Suche unter „Ganze Serie…“ auf „Beobachten“ klicken.",
|
||||
"watch.checked": "geprüft {at}",
|
||||
"watch.error": "Letzte Prüfung fehlgeschlagen: {error}",
|
||||
"watch.nothingNew": "Nichts Neues.",
|
||||
"watch.ignore": "Ignorieren",
|
||||
"watch.remove": "Nicht mehr beobachten",
|
||||
"watch.confirmRemove": "Diese Serie nicht mehr beobachten?",
|
||||
"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",
|
||||
@@ -295,6 +322,33 @@ const STRINGS = {
|
||||
"nav.import": "Import",
|
||||
"nav.settings": "Libraries",
|
||||
"nav.activity": "Activity",
|
||||
"nav.series": "Series",
|
||||
"watch.button": "Watch",
|
||||
"watch.buttonTitle": "Look for new parts of this series every day",
|
||||
"watch.added": "Series is being watched",
|
||||
"watch.already": "This series is already watched",
|
||||
"watch.check": "Check now",
|
||||
"watch.empty": "No series watched yet. In the search, open “Whole series…” and click “Watch”.",
|
||||
"watch.checked": "checked {at}",
|
||||
"watch.error": "Last check failed: {error}",
|
||||
"watch.nothingNew": "Nothing new.",
|
||||
"watch.ignore": "Ignore",
|
||||
"watch.remove": "Stop watching",
|
||||
"watch.confirmRemove": "Stop watching this series?",
|
||||
"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",
|
||||
|
||||
@@ -20,8 +20,16 @@
|
||||
</button>
|
||||
<button data-view="imported" data-i18n="nav.imported"></button>
|
||||
<button data-view="import" data-i18n="nav.import"></button>
|
||||
<button data-view="series">
|
||||
<span data-i18n="nav.series"></span>
|
||||
<span id="watch-badge" class="badge" hidden></span>
|
||||
</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>
|
||||
@@ -93,6 +101,7 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<button type="button" id="series-cancel" class="secondary" data-i18n="common.cancel"></button>
|
||||
<button type="button" id="series-watch" class="secondary" data-i18n="watch.button" data-i18n-title="watch.buttonTitle"></button>
|
||||
<button type="button" id="series-submit" data-i18n="common.request"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -283,6 +292,13 @@
|
||||
</dialog>
|
||||
</section>
|
||||
|
||||
<section id="view-series" hidden>
|
||||
<div class="bar">
|
||||
<button type="button" id="watch-check" data-i18n="watch.check"></button>
|
||||
</div>
|
||||
<div id="watch-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="view-activity" hidden>
|
||||
<div class="table-scroll">
|
||||
<table id="history-table">
|
||||
@@ -299,6 +315,27 @@
|
||||
</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">
|
||||
|
||||
@@ -74,6 +74,17 @@ 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); }
|
||||
.watch { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem 0.9rem; margin-bottom: 0.8rem; }
|
||||
.watch .row:first-child { margin-top: 0; }
|
||||
.watch table { margin-top: 0.5rem; }
|
||||
.watch .error-text { color: #e08585; }
|
||||
#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) */
|
||||
|
||||
+153
-7
@@ -1,4 +1,5 @@
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
@@ -1228,12 +1229,13 @@ def test_connections_hear_about_committed_rows(client, monkeypatch):
|
||||
from wordarr import connect
|
||||
|
||||
calls = []
|
||||
watcher = {"disableWatcher": False}
|
||||
|
||||
def handler(request):
|
||||
calls.append((request.method, str(request.url)))
|
||||
calls.append((request.method, str(request.url), request.content))
|
||||
if request.url.path == "/api/libraries":
|
||||
return httpx.Response(200, json={"libraries": [
|
||||
{"id": "lib-audio", "name": "Audio",
|
||||
{"id": "lib-audio", "name": "Audio", "settings": watcher,
|
||||
"folders": [{"fullPath": str(client.tmp_path / "library")}]},
|
||||
{"id": "lib-pod", "name": "Pod", "folders": [{"fullPath": "/podcasts"}]},
|
||||
]})
|
||||
@@ -1261,16 +1263,29 @@ def test_connections_hear_about_committed_rows(client, monkeypatch):
|
||||
"path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id,
|
||||
}]}).json()["results"][0]["ok"]
|
||||
|
||||
assert calls == [
|
||||
# no full scan while the watcher runs: the two raced and added the book twice
|
||||
dest = str(root / "Momo" / "Momo.epub")
|
||||
assert [c[:2] for c in calls] == [
|
||||
("POST", "http://hook/in"),
|
||||
("GET", "http://abs/api/libraries"),
|
||||
("POST", "http://abs/api/watcher/update"),
|
||||
]
|
||||
assert json.loads(calls[2][2]) == {
|
||||
"libraryId": "lib-audio", "path": dest, "type": "add"}
|
||||
|
||||
calls.clear()
|
||||
client.delete(f"/api/requests/{req_id}")
|
||||
assert [c[:2] for c in calls] == [("POST", "http://hook/in")] # nothing new on disk
|
||||
|
||||
calls.clear()
|
||||
watcher["disableWatcher"] = True
|
||||
connect.notify([{"event": "imported", "dest": dest}])
|
||||
assert [c[:2] for c in 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):
|
||||
@@ -1328,3 +1343,134 @@ def test_adopt_files_already_in_the_library(client):
|
||||
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"
|
||||
|
||||
|
||||
def test_watched_series_suggests_only_new_parts(client, monkeypatch):
|
||||
import asyncio
|
||||
import datetime
|
||||
|
||||
from wordarr import db
|
||||
from wordarr.api import series
|
||||
from wordarr.metadata import SERIES_PROVIDERS
|
||||
from wordarr.metadata.base import MetadataResult
|
||||
|
||||
def part(volume, external_id=""):
|
||||
return MetadataResult(media_type="ebook", title=f"Band {volume}", volume=volume,
|
||||
series="Saga", external_id=external_id, source="hardcover")
|
||||
|
||||
hits = [part(1, "a1"), part(2, "a2")]
|
||||
calls = []
|
||||
|
||||
async def lookup(series_id, title, language, source):
|
||||
calls.append(series_id)
|
||||
if hits is None:
|
||||
raise RuntimeError("offline")
|
||||
return hits
|
||||
|
||||
monkeypatch.setitem(SERIES_PROVIDERS, "ebook", lookup)
|
||||
lib = client.post("/api/libraries", json={
|
||||
"name": "Saga", "media_type": "ebook", "root_path": str(client.tmp_path)}).json()
|
||||
client.post("/api/requests", json={"library_id": lib["id"], "title": "Band 3", "volume": 3})
|
||||
watch = {"media_type": "ebook", "library_id": lib["id"], "series_id": "s1",
|
||||
"source": "hardcover", "title": "Saga"}
|
||||
|
||||
resp = client.post("/api/series", json=watch)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["new"] == [] # what exists today is not news
|
||||
assert client.post("/api/series", json=watch).status_code == 409
|
||||
|
||||
hits += [part(3), part(4, "a4"), part(5, "a5")] # 3 is requested already
|
||||
[w] = client.post("/api/series/check").json()
|
||||
new = {p["title"]: p["id"] for p in w["new"]}
|
||||
assert set(new) == {"Band 4", "Band 5"}
|
||||
|
||||
client.post("/api/series/parts", json={"ids": [new["Band 4"]], "action": "request"})
|
||||
client.post("/api/series/parts", json={"ids": [new["Band 5"]], "action": "ignore"})
|
||||
missing = client.get("/api/requests?status=missing").json()
|
||||
assert {r["title"] for r in missing} == {"Band 3", "Band 4"}
|
||||
assert client.post("/api/series/check").json()[0]["new"] == []
|
||||
|
||||
hits = None
|
||||
[w] = client.post("/api/series/check").json()
|
||||
assert w["error"] == "offline"
|
||||
assert client.post("/api/series", json={**watch, "series_id": "s2"}).status_code == 502
|
||||
|
||||
# the daily run only picks up what was checked more than a day ago
|
||||
hits, calls[:] = [], []
|
||||
asyncio.run(series.check_due())
|
||||
assert calls == []
|
||||
with db.SessionLocal() as session:
|
||||
session.get(db.WatchedSeries, w["id"]).checked_at = (
|
||||
datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=2))
|
||||
session.commit()
|
||||
asyncio.run(series.check_due())
|
||||
assert calls == ["s1"]
|
||||
|
||||
assert client.delete(f"/api/series/{w['id']}").status_code == 200
|
||||
assert client.get("/api/series").json() == []
|
||||
|
||||
|
||||
def test_covers_are_fetched_once_and_kept(client, monkeypatch):
|
||||
import httpx
|
||||
calls = []
|
||||
|
||||
async def fake_get(self, url, **kwargs):
|
||||
calls.append(url)
|
||||
if "dead" in url:
|
||||
raise httpx.ConnectError("gone")
|
||||
ctype = "text/html" if "page" in url else "image/png"
|
||||
return httpx.Response(200, headers={"content-type": ctype}, content=b"PNG",
|
||||
request=httpx.Request("GET", url))
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||
from wordarr import config
|
||||
monkeypatch.setattr(config, "CONFIG_DIR", client.tmp_path / "config")
|
||||
lib = client.post("/api/libraries", json={
|
||||
"name": "Covers", "media_type": "ebook", "root_path": str(client.tmp_path)}).json()
|
||||
|
||||
def request(cover_url):
|
||||
return client.post("/api/requests?allow_duplicate=true", json={
|
||||
"library_id": lib["id"], "title": "Buch", "cover_url": cover_url}).json()["id"]
|
||||
|
||||
good = request("https://covers.example/a.png")
|
||||
for _ in range(2):
|
||||
resp = client.get(f"/api/requests/{good}/cover")
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"PNG"
|
||||
assert resp.headers["content-type"] == "image/png"
|
||||
assert calls == ["https://covers.example/a.png"] # the second came from disk
|
||||
assert [p.suffix for p in (client.tmp_path / "config" / "covers").iterdir()] == [".png"]
|
||||
|
||||
for url in ("https://covers.example/page", "https://covers.example/dead", "", "file:///etc/passwd"):
|
||||
assert client.get(f"/api/requests/{request(url)}/cover").status_code == 404
|
||||
+11
-1
@@ -1,11 +1,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config, connect
|
||||
from .. import config, connect, covers
|
||||
from ..db import BookRequest, Library, get_session, record
|
||||
from ..importer import mover, tagger
|
||||
|
||||
@@ -237,6 +238,15 @@ def update_request(request_id: int, data: RequestIn, session: Session = Depends(
|
||||
return _out(req)
|
||||
|
||||
|
||||
@router.get("/{request_id}/cover")
|
||||
async def request_cover(request_id: int, session: Session = Depends(get_session)):
|
||||
req = session.get(BookRequest, request_id)
|
||||
if not req or not (path := await covers.cached(req.cover_url)):
|
||||
raise HTTPException(404, "no cover")
|
||||
# the UI puts the cover url into the query, so a changed cover is a new url
|
||||
return FileResponse(path, headers={"Cache-Control": "max-age=31536000, immutable"})
|
||||
|
||||
|
||||
@router.post("/{request_id}/retag")
|
||||
def retag_request(request_id: int, background: BackgroundTasks,
|
||||
session: Session = Depends(get_session)):
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import db
|
||||
from ..db import BookRequest, Library, SeriesPart, WatchedSeries, get_session
|
||||
from ..metadata import SERIES_PROVIDERS
|
||||
|
||||
router = APIRouter(prefix="/api/series", tags=["series"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PART_FIELDS = ("title", "authors", "narrator", "external_id", "year", "series",
|
||||
"volume", "cover_url")
|
||||
|
||||
|
||||
def _now() -> datetime.datetime:
|
||||
return datetime.datetime.now(datetime.UTC)
|
||||
|
||||
|
||||
def _key(r) -> str:
|
||||
return r.external_id or f"{r.title.strip().lower()}|{r.volume}"
|
||||
|
||||
|
||||
async def check_watch(session: Session, w: WatchedSeries) -> None:
|
||||
"""Look the series up again. The first lookup only records what exists, so
|
||||
just later parts become suggestions; so do parts nobody has requested yet."""
|
||||
try:
|
||||
hits = await SERIES_PROVIDERS[w.media_type](w.series_id, w.title, w.language, w.source)
|
||||
except Exception as exc:
|
||||
log.warning("series %r lookup failed: %s", w.title, exc)
|
||||
w.error, w.checked_at = str(exc), _now()
|
||||
return
|
||||
initial = not w.parts
|
||||
reqs = session.scalars(select(BookRequest).where(BookRequest.media_type == w.media_type)).all()
|
||||
requested = {r.external_id for r in reqs if r.external_id}
|
||||
requested |= {(r.title.strip().lower(), r.volume) for r in reqs}
|
||||
seen = {p.key for p in w.parts}
|
||||
for hit in hits:
|
||||
if (key := _key(hit)) in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
known = initial or hit.external_id in requested or (
|
||||
hit.title.strip().lower(), hit.volume) in requested
|
||||
w.parts.append(SeriesPart(key=key, state="known" if known else "new",
|
||||
**{f: getattr(hit, f) for f in PART_FIELDS}))
|
||||
w.error, w.checked_at = "", _now()
|
||||
|
||||
|
||||
async def check_due() -> None:
|
||||
cutoff = (_now() - datetime.timedelta(days=1)).replace(tzinfo=None) # SQLite stores naive
|
||||
with db.SessionLocal() as session:
|
||||
due = session.scalars(select(WatchedSeries).where(
|
||||
or_(WatchedSeries.checked_at.is_(None), WatchedSeries.checked_at < cutoff))).all()
|
||||
for w in due:
|
||||
await check_watch(session, w)
|
||||
session.commit()
|
||||
if due:
|
||||
log.info("checked %d watched series", len(due))
|
||||
|
||||
|
||||
async def run_daily() -> None:
|
||||
"""Wakes hourly and checks what is a day old, so restarts do not reset the clock."""
|
||||
while True:
|
||||
try:
|
||||
await check_due()
|
||||
except Exception:
|
||||
log.exception("series check failed")
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
|
||||
def _out(w: WatchedSeries) -> dict:
|
||||
checked = w.checked_at and w.checked_at.replace(tzinfo=datetime.UTC).isoformat()
|
||||
return {
|
||||
"id": w.id, "media_type": w.media_type, "library_id": w.library_id,
|
||||
"library_name": w.library.name, "series_id": w.series_id, "source": w.source,
|
||||
"title": w.title, "checked_at": checked, "error": w.error,
|
||||
"new": [{"id": p.id, **{f: getattr(p, f) for f in PART_FIELDS}}
|
||||
for p in w.parts if p.state == "new"],
|
||||
}
|
||||
|
||||
|
||||
def _all(session: Session) -> list[dict]:
|
||||
return [_out(w) for w in session.scalars(select(WatchedSeries).order_by(WatchedSeries.title))]
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_watches(session: Session = Depends(get_session)):
|
||||
return _all(session)
|
||||
|
||||
|
||||
class WatchIn(BaseModel):
|
||||
media_type: str
|
||||
library_id: int
|
||||
series_id: str
|
||||
source: str = ""
|
||||
title: str
|
||||
language: str = ""
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_watch(data: WatchIn, session: Session = Depends(get_session)):
|
||||
if data.media_type not in SERIES_PROVIDERS:
|
||||
raise HTTPException(400, f"no series lookup for media_type: {data.media_type}")
|
||||
lib = session.get(Library, data.library_id)
|
||||
if not lib:
|
||||
raise HTTPException(404, "library not found")
|
||||
if lib.media_type != data.media_type:
|
||||
raise HTTPException(400, "library has a different media type")
|
||||
if session.scalar(select(WatchedSeries).where(
|
||||
WatchedSeries.media_type == data.media_type,
|
||||
WatchedSeries.source == data.source,
|
||||
WatchedSeries.series_id == data.series_id)):
|
||||
raise HTTPException(409, "series is already watched")
|
||||
w = WatchedSeries(**data.model_dump())
|
||||
session.add(w)
|
||||
await check_watch(session, w)
|
||||
if w.error: # without a first lookup every part would turn up as new later
|
||||
session.rollback()
|
||||
raise HTTPException(502, f"metadata provider error: {w.error}")
|
||||
session.commit()
|
||||
return _out(w)
|
||||
|
||||
|
||||
@router.delete("/{watch_id}")
|
||||
def delete_watch(watch_id: int, session: Session = Depends(get_session)):
|
||||
w = session.get(WatchedSeries, watch_id)
|
||||
if not w:
|
||||
raise HTTPException(404, "series not found")
|
||||
session.delete(w)
|
||||
session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/check")
|
||||
async def check_all(session: Session = Depends(get_session)):
|
||||
for w in session.scalars(select(WatchedSeries)).all():
|
||||
await check_watch(session, w)
|
||||
session.commit()
|
||||
return _all(session)
|
||||
|
||||
|
||||
class PartsIn(BaseModel):
|
||||
ids: list[int]
|
||||
action: str # request | ignore
|
||||
|
||||
|
||||
@router.post("/parts")
|
||||
def handle_parts(data: PartsIn, session: Session = Depends(get_session)):
|
||||
if data.action not in ("request", "ignore"):
|
||||
raise HTTPException(400, f"invalid action: {data.action}")
|
||||
parts = session.scalars(select(SeriesPart).where(
|
||||
SeriesPart.id.in_(data.ids), SeriesPart.state == "new")).all()
|
||||
for p in parts:
|
||||
if data.action == "request":
|
||||
session.add(BookRequest(media_type=p.watch.media_type, library_id=p.watch.library_id,
|
||||
**{f: getattr(p, f) for f in PART_FIELDS}))
|
||||
p.state = "requested" if data.action == "request" else "ignored"
|
||||
session.commit()
|
||||
return {"done": len(parts)}
|
||||
@@ -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))
|
||||
+30
-5
@@ -1,9 +1,10 @@
|
||||
"""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
|
||||
webhook gets every row, Audiobookshelf the new files of the libraries holding
|
||||
them. 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
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks
|
||||
@@ -46,15 +47,39 @@ def notify(events: list[dict]) -> None:
|
||||
|
||||
|
||||
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."""
|
||||
"""Tell the libraries whose folders contain a destination about the new
|
||||
files. 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):
|
||||
mine = [d for d in dests
|
||||
if any(f and (d == f or d.startswith(f + "/")) for f in folders)]
|
||||
if not mine:
|
||||
continue
|
||||
if (lib.get("settings") or {}).get("disableWatcher"):
|
||||
# the watcher endpoint drops paths of unwatched libraries
|
||||
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"))
|
||||
continue
|
||||
# a full scan racing the watcher adds the same folder twice, and it
|
||||
# walks the whole library; this queues the files with the watcher
|
||||
for path in _files(mine):
|
||||
client.post(f"{ABS_URL}/api/watcher/update", headers=headers,
|
||||
json={"libraryId": lib.get("id"), "path": path,
|
||||
"type": "add"}).raise_for_status()
|
||||
log.info("audiobookshelf watches new files in library %s", lib.get("name"))
|
||||
|
||||
|
||||
def _files(dests: list[str]) -> list[str]:
|
||||
"""Imports are flat: a destination is a file or a folder of files."""
|
||||
out = []
|
||||
for d in map(Path, dests):
|
||||
if d.is_file():
|
||||
out.append(str(d))
|
||||
elif d.is_dir():
|
||||
out += sorted(str(f) for f in d.iterdir() if f.is_file())
|
||||
return out
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Covers are fetched once and kept under the config dir, so a dead link or a
|
||||
slow host does not take the picture with it."""
|
||||
import hashlib
|
||||
import logging
|
||||
import mimetypes
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from . import config
|
||||
|
||||
MAX_BYTES = 5 * 1024 * 1024
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# shortcut: covers of deleted requests stay on disk, add a sweep if the dir grows
|
||||
async def cached(url: str) -> Path | None:
|
||||
"""The file for this url, downloaded on first use; None if it cannot be had."""
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return None
|
||||
folder = config.CONFIG_DIR / "covers"
|
||||
name = hashlib.sha256(url.encode()).hexdigest()
|
||||
if hit := next(folder.glob(name + ".*"), None):
|
||||
return hit
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
log.warning("cover %s failed: %s", url, exc)
|
||||
return None
|
||||
ctype = resp.headers.get("content-type", "").split(";")[0].strip()
|
||||
if not ctype.startswith("image/") or len(resp.content) > MAX_BYTES:
|
||||
log.warning("cover %s is no usable image (%s, %d bytes)", url, ctype, len(resp.content))
|
||||
return None
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
# written aside and renamed, so a parallel request never serves half a file
|
||||
with tempfile.NamedTemporaryFile(dir=folder, suffix=".tmp", delete=False) as tmp:
|
||||
tmp.write(resp.content)
|
||||
path = folder / (name + (mimetypes.guess_extension(ctype) or ".img"))
|
||||
Path(tmp.name).chmod(0o644) # NamedTemporaryFile creates 0600
|
||||
Path(tmp.name).replace(path)
|
||||
return path
|
||||
@@ -23,6 +23,8 @@ class Library(Base):
|
||||
language: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
requests: Mapped[list["BookRequest"]] = relationship(back_populates="library")
|
||||
watches: Mapped[list["WatchedSeries"]] = relationship(
|
||||
back_populates="library", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class BookRequest(Base):
|
||||
@@ -68,6 +70,47 @@ class History(Base):
|
||||
message: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
|
||||
class WatchedSeries(Base):
|
||||
"""A series looked up again now and then; new parts become suggestions."""
|
||||
|
||||
__tablename__ = "watched_series"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
media_type: Mapped[str] = mapped_column(String)
|
||||
library_id: Mapped[int] = mapped_column(ForeignKey("libraries.id"))
|
||||
series_id: Mapped[str] = mapped_column(String)
|
||||
source: Mapped[str] = mapped_column(String, default="")
|
||||
title: Mapped[str] = mapped_column(String)
|
||||
language: Mapped[str] = mapped_column(String, default="")
|
||||
checked_at: Mapped[datetime.datetime | None] = mapped_column(default=None)
|
||||
error: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
library: Mapped[Library] = relationship(back_populates="watches")
|
||||
parts: Mapped[list["SeriesPart"]] = relationship(
|
||||
back_populates="watch", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class SeriesPart(Base):
|
||||
"""Every part a watch has seen, so a later lookup can tell what is new."""
|
||||
|
||||
__tablename__ = "series_parts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
watch_id: Mapped[int] = mapped_column(ForeignKey("watched_series.id"))
|
||||
key: Mapped[str] = mapped_column(String)
|
||||
state: Mapped[str] = mapped_column(String) # known | new | requested | ignored
|
||||
title: Mapped[str] = mapped_column(String)
|
||||
authors: Mapped[str] = mapped_column(String, default="")
|
||||
narrator: Mapped[str] = mapped_column(String, default="")
|
||||
external_id: Mapped[str] = mapped_column(String, default="")
|
||||
year: Mapped[int | None] = mapped_column(default=None)
|
||||
series: Mapped[str] = mapped_column(String, default="")
|
||||
volume: Mapped[int | None] = mapped_column(default=None)
|
||||
cover_url: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
watch: Mapped[WatchedSeries] = relationship(back_populates="parts")
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
+9
-2
@@ -1,17 +1,22 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import db, version
|
||||
from .api import fs, history, imports, libraries, requests, search
|
||||
from . import db, logs, version
|
||||
from .api import fs, history, imports, libraries, requests, search, series, system
|
||||
|
||||
logs.install()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
db.init_db()
|
||||
watcher = asyncio.create_task(series.run_daily())
|
||||
yield
|
||||
watcher.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title="wordarr", lifespan=lifespan)
|
||||
@@ -22,6 +27,8 @@ 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.include_router(series.router)
|
||||
|
||||
|
||||
@app.get("/api/version")
|
||||
|
||||
Reference in new issue
Block a user