15 Commits
Author SHA1 Message Date
admin 40af29ab15 Merge pull request 'fix(connect): queue new files with the audiobookshelf watcher instead of a full scan' (#10) from bugfix/abs-targeted-scan into main
Build image / image (push) Successful in 1m41s
Reviewed-on: #10
2026-10-27 06:45:44 +01:00
Steppenstreuner 377d2c8557 fix(connect): queue new files with the audiobookshelf watcher instead of a full scan 2026-10-27 06:44:38 +01:00
admin 33d82ab7d4 Merge pull request 'Feature/cache covers' (#9) from feature/cache-covers into main
Build image / image (push) Successful in 53s
Reviewed-on: #9
2026-10-25 20:33:29 +01:00
Steppenstreuner 173e24a362 feat(ui): load request covers through the local cache 2026-10-25 20:32:48 +01:00
Steppenstreuner 98c7214140 feat(covers): keep a local copy of request covers 2026-10-25 20:32:34 +01:00
admin be1da8bbf3 Merge pull request 'Feature/watch series' (#8) from feature/watch-series into main
Build image / image (push) Successful in 53s
Reviewed-on: #8
2026-10-25 20:29:29 +01:00
Steppenstreuner 1487512183 feat(ui): watch a series from its dialog and review new parts 2026-10-25 20:17:28 +01:00
Steppenstreuner 55d7c8bdda feat(series): watch series and suggest new parts once a day 2026-10-25 20:17:13 +01:00
admin 0f48981478 Merge pull request 'Feature/system health' (#7) from feature/system-health into main
Build image / image (push) Successful in 51s
Reviewed-on: #7
2026-10-25 20:09:34 +01:00
admin ebd981cc3c Merge pull request 'fix(ui): center dialogs again after the global margin reset' (#6) from bugfix/center-dialogs into main
Build image / image (push) Successful in 52s
Reviewed-on: #6
2026-10-25 20:05:09 +01:00
admin 0d2ada3901 Merge pull request 'Feature/library adopt' (#5) from feature/library-adopt into main
Build image / image (push) Successful in 51s
Reviewed-on: #5
2026-10-25 19:55:58 +01:00
admin 44bf65b3a5 Merge pull request 'Feature/wanted bulk edit' (#4) from feature/wanted-bulk-edit into main
Build image / image (push) Successful in 50s
Reviewed-on: #4
2026-10-25 19:47:16 +01:00
admin 0f56d13577 Merge pull request 'feat(connect): scan audiobookshelf and call a webhook after changes' (#3) from feature/connections into main
Build image / image (push) Successful in 50s
Reviewed-on: #3
2026-10-25 19:46:53 +01:00
admin 4deaca4f1c Merge pull request 'Feature/activity history' (#2) from feature/activity-history into main
Build image / image (push) Successful in 50s
Reviewed-on: #2
2026-10-25 19:46:27 +01:00
admin 025fedc556 Merge pull request 'Feature/ui routing and states' (#1) from feature/ui-routing-and-states into main
Build image / image (push) Successful in 52s
Reviewed-on: #1
2026-10-25 19:45:50 +01:00
11 changed files with 579 additions and 17 deletions

No files matched your search

+118 -3
View File
@@ -81,6 +81,11 @@ function libOptions(mediaType) {
.join(""); .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 ---- // ---- missing badge ----
async function refreshMissingBadge() { async function refreshMissingBadge() {
try { try {
@@ -93,7 +98,7 @@ async function refreshMissingBadge() {
// ---- 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", "activity", "settings", "system"]; const VIEWS = ["search", "missing", "imported", "import", "series", "activity", "settings", "system"];
function showView() { function showView() {
const hash = location.hash.slice(1); const hash = location.hash.slice(1);
@@ -103,6 +108,7 @@ function showView() {
if (name === "missing" || name === "imported") loadRequests(name); if (name === "missing" || name === "imported") loadRequests(name);
if (name === "activity") loadHistory(); if (name === "activity") loadHistory();
if (name === "system") loadSystem(); if (name === "system") loadSystem();
if (name === "series") loadWatches();
if (name === "settings") loadLibraries().catch((err) => toast(err.message, true)); if (name === "settings") loadLibraries().catch((err) => toast(err.message, true));
} }
@@ -320,6 +326,8 @@ function renderSearchResults() {
// ---- request a whole series ---- // ---- request a whole series ----
let seriesEpisodes = []; let seriesEpisodes = [];
let seriesType = "audiobook"; let seriesType = "audiobook";
let seriesResult = null;
let seriesLanguage = "";
function renderSeriesList() { function renderSeriesList() {
const from = parseInt($("#series-from").value); const from = parseInt($("#series-from").value);
@@ -366,6 +374,8 @@ function updateSeriesCount() {
async function openSeriesDialog(result, type = "audiobook", language = "") { async function openSeriesDialog(result, type = "audiobook", language = "") {
const dlg = $("#series-dialog"); const dlg = $("#series-dialog");
seriesType = type; seriesType = type;
seriesResult = result;
seriesLanguage = language;
const unit = t(type === "ebook" ? "series.unitVolumes" : "series.unitEpisodes"); const unit = t(type === "ebook" ? "series.unitVolumes" : "series.unitEpisodes");
$("#series-title").textContent = result.series || t("series.fallbackTitle"); $("#series-title").textContent = result.series || t("series.fallbackTitle");
$("#series-info").innerHTML = $("#series-info").innerHTML =
@@ -411,6 +421,31 @@ $("#series-none").addEventListener("click", () => {
}); });
$("#series-cancel").addEventListener("click", () => $("#series-dialog").close()); $("#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 () => { $("#series-submit").addEventListener("click", async () => {
const libId = $("#series-library").value; const libId = $("#series-library").value;
if (!libId) { if (!libId) {
@@ -579,7 +614,7 @@ function renderRequests(status) {
box.innerHTML = reqs box.innerHTML = reqs
.map( .map(
(r) => `<div class="card clickable" data-detail="${r.id}" tabindex="0"> (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"> <div class="card-body">
<strong>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""}</strong> <strong>${esc(r.title)}${r.volume != null ? ` · ${t("common.volume")} ${r.volume}` : ""}</strong>
<span>${esc(r.authors)}</span> <span>${esc(r.authors)}</span>
@@ -701,6 +736,84 @@ function renderHistory() {
.join(""); .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 ---- // ---- system ----
let healthIssues = []; let healthIssues = [];
let logRows = []; let logRows = [];
@@ -780,7 +893,7 @@ function openDetail(id, status) {
const img = $("#detail-cover"); const img = $("#detail-cover");
img.hidden = !r.cover_url; img.hidden = !r.cover_url;
$("#detail-nocover").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-retag").hidden = !(r.status === "imported" && r.media_type === "audiobook");
$("#detail-relocate").hidden = r.status !== "imported"; $("#detail-relocate").hidden = r.status !== "imported";
$("#detail-dialog").showModal(); $("#detail-dialog").showModal();
@@ -1962,6 +2075,7 @@ function onLangChange() {
renderHistory(); renderHistory();
renderHealth(); renderHealth();
renderLogs(); renderLogs();
renderWatches();
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();
@@ -1987,5 +2101,6 @@ updateLangButton();
endLibraryEdit(); endLibraryEdit();
loadLibraries(); loadLibraries();
refreshMissingBadge(); refreshMissingBadge();
loadWatches();
showVersion(); showVersion();
showView(); showView();
+26
View File
@@ -47,6 +47,19 @@ const STRINGS = {
"nav.import": "Import", "nav.import": "Import",
"nav.settings": "Libraries", "nav.settings": "Libraries",
"nav.activity": "Aktivität", "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", "nav.system": "System",
"system.health": "Zustand", "system.health": "Zustand",
"system.healthy": "Alles in Ordnung.", "system.healthy": "Alles in Ordnung.",
@@ -309,6 +322,19 @@ const STRINGS = {
"nav.import": "Import", "nav.import": "Import",
"nav.settings": "Libraries", "nav.settings": "Libraries",
"nav.activity": "Activity", "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", "nav.system": "System",
"system.health": "Health", "system.health": "Health",
"system.healthy": "All good.", "system.healthy": "All good.",
+12
View File
@@ -20,6 +20,10 @@
</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="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="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 data-view="system"> <button data-view="system">
@@ -97,6 +101,7 @@
</div> </div>
<div class="row"> <div class="row">
<button type="button" id="series-cancel" class="secondary" data-i18n="common.cancel"></button> <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> <button type="button" id="series-submit" data-i18n="common.request"></button>
</div> </div>
</dialog> </dialog>
@@ -287,6 +292,13 @@
</dialog> </dialog>
</section> </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> <section id="view-activity" hidden>
<div class="table-scroll"> <div class="table-scroll">
<table id="history-table"> <table id="history-table">
+4
View File
@@ -77,6 +77,10 @@ td.state.running { color: var(--muted); }
td.state.warn { color: var(--warn); } td.state.warn { color: var(--warn); }
.badge.warn { background: var(--warn); color: #000; } .badge.warn { background: var(--warn); color: #000; }
.badge.error { background: var(--danger); } .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 { 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 { 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.error { border-left-color: var(--danger); }
+122 -7
View File
@@ -1,4 +1,5 @@
import importlib import importlib
import json
import os import os
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -1228,12 +1229,13 @@ def test_connections_hear_about_committed_rows(client, monkeypatch):
from wordarr import connect from wordarr import connect
calls = [] calls = []
watcher = {"disableWatcher": False}
def handler(request): 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": if request.url.path == "/api/libraries":
return httpx.Response(200, json={"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")}]}, "folders": [{"fullPath": str(client.tmp_path / "library")}]},
{"id": "lib-pod", "name": "Pod", "folders": [{"fullPath": "/podcasts"}]}, {"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, "path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id,
}]}).json()["results"][0]["ok"] }]}).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"), ("POST", "http://hook/in"),
("GET", "http://abs/api/libraries"), ("GET", "http://abs/api/libraries"),
("POST", "http://abs/api/libraries/lib-audio/scan"), ("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 test_bulk_move_delete_and_count(client):
def lib(name, media_type): def lib(name, media_type):
@@ -1359,3 +1374,103 @@ def test_recent_logs_newest_first(client):
rows = client.get("/api/system/logs").json() rows = client.get("/api/system/logs").json()
assert [r["message"] for r in rows[:2]] == ["second", "first"] assert [r["message"] for r in rows[:2]] == ["second", "first"]
assert rows[0]["level"] == "WARNING" 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
View File
@@ -1,11 +1,12 @@
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import config, connect from .. import config, connect, covers
from ..db import BookRequest, Library, get_session, record from ..db import BookRequest, Library, get_session, record
from ..importer import mover, tagger from ..importer import mover, tagger
@@ -237,6 +238,15 @@ def update_request(request_id: int, data: RequestIn, session: Session = Depends(
return _out(req) 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") @router.post("/{request_id}/retag")
def retag_request(request_id: int, background: BackgroundTasks, def retag_request(request_id: int, background: BackgroundTasks,
session: Session = Depends(get_session)): session: Session = Depends(get_session)):
+164
View File
@@ -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)}
+30 -5
View File
@@ -1,9 +1,10 @@
"""Tell other services about history rows once they are committed: a generic """Tell other services about history rows once they are committed: a generic
webhook gets every row, Audiobookshelf a scan of the libraries holding new webhook gets every row, Audiobookshelf the new files of the libraries holding
files. Runs as a background task, so a slow or dead service never fails the them. Runs as a background task, so a slow or dead service never fails the
request that caused it; errors only reach the log.""" request that caused it; errors only reach the log."""
import logging import logging
import os import os
from pathlib import Path
import httpx import httpx
from fastapi import BackgroundTasks from fastapi import BackgroundTasks
@@ -46,15 +47,39 @@ def notify(events: list[dict]) -> None:
def _scan_audiobookshelf(client: httpx.Client, dests: list[str]) -> None: def _scan_audiobookshelf(client: httpx.Client, dests: list[str]) -> None:
"""Scan only the libraries whose folders contain a destination. That needs """Tell the libraries whose folders contain a destination about the new
both containers to mount the media under the same path.""" files. That needs both containers to mount the media under the same path."""
headers = {"Authorization": f"Bearer {ABS_TOKEN}"} headers = {"Authorization": f"Bearer {ABS_TOKEN}"}
resp = client.get(f"{ABS_URL}/api/libraries", headers=headers) resp = client.get(f"{ABS_URL}/api/libraries", headers=headers)
resp.raise_for_status() resp.raise_for_status()
for lib in resp.json().get("libraries") or []: for lib in resp.json().get("libraries") or []:
folders = [str(f.get("fullPath") or "").rstrip("/") folders = [str(f.get("fullPath") or "").rstrip("/")
for f in lib.get("folders") or []] 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", client.post(f"{ABS_URL}/api/libraries/{lib.get('id')}/scan",
headers=headers).raise_for_status() headers=headers).raise_for_status()
log.info("audiobookshelf scans library %s", lib.get("name")) 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
+44
View File
@@ -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
+43
View File
@@ -23,6 +23,8 @@ class Library(Base):
language: Mapped[str] = mapped_column(String, default="") language: Mapped[str] = mapped_column(String, default="")
requests: Mapped[list["BookRequest"]] = relationship(back_populates="library") requests: Mapped[list["BookRequest"]] = relationship(back_populates="library")
watches: Mapped[list["WatchedSeries"]] = relationship(
back_populates="library", cascade="all, delete-orphan")
class BookRequest(Base): class BookRequest(Base):
@@ -68,6 +70,47 @@ class History(Base):
message: Mapped[str] = mapped_column(String, default="") 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: def record(session, event: str, req: BookRequest, **fields: str) -> History:
"""Add a history row; it is saved with the caller's next commit.""" """Add a history row; it is saved with the caller's next commit."""
row = History(event=event, request_id=req.id, title=req.title, row = History(event=event, request_id=req.id, title=req.title,
+5 -1
View File
@@ -1,3 +1,4 @@
import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -5,7 +6,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import db, logs, version from . import db, logs, version
from .api import fs, history, imports, libraries, requests, search, system from .api import fs, history, imports, libraries, requests, search, series, system
logs.install() logs.install()
@@ -13,7 +14,9 @@ logs.install()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
db.init_db() db.init_db()
watcher = asyncio.create_task(series.run_daily())
yield yield
watcher.cancel()
app = FastAPI(title="wordarr", lifespan=lifespan) app = FastAPI(title="wordarr", lifespan=lifespan)
@@ -25,6 +28,7 @@ app.include_router(imports.router)
app.include_router(fs.router) app.include_router(fs.router)
app.include_router(history.router) app.include_router(history.router)
app.include_router(system.router) app.include_router(system.router)
app.include_router(series.router)
@app.get("/api/version") @app.get("/api/version")