Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f48981478 | ||
|
|
ebd981cc3c | ||
|
|
0d2ada3901 | ||
|
|
44bf65b3a5 | ||
|
|
0f56d13577 | ||
|
|
4deaca4f1c | ||
|
|
025fedc556 |
No files matched your search
+1
-111
@@ -93,7 +93,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", "series", "activity", "settings", "system"];
|
const VIEWS = ["search", "missing", "imported", "import", "activity", "settings", "system"];
|
||||||
|
|
||||||
function showView() {
|
function showView() {
|
||||||
const hash = location.hash.slice(1);
|
const hash = location.hash.slice(1);
|
||||||
@@ -103,7 +103,6 @@ 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,8 +320,6 @@ 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);
|
||||||
@@ -369,8 +366,6 @@ 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 =
|
||||||
@@ -416,31 +411,6 @@ $("#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) {
|
||||||
@@ -731,84 +701,6 @@ 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 = [];
|
||||||
@@ -2070,7 +1962,6 @@ 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();
|
||||||
@@ -2096,6 +1987,5 @@ updateLangButton();
|
|||||||
endLibraryEdit();
|
endLibraryEdit();
|
||||||
loadLibraries();
|
loadLibraries();
|
||||||
refreshMissingBadge();
|
refreshMissingBadge();
|
||||||
loadWatches();
|
|
||||||
showVersion();
|
showVersion();
|
||||||
showView();
|
showView();
|
||||||
@@ -47,19 +47,6 @@ 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.",
|
||||||
@@ -322,19 +309,6 @@ 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.",
|
||||||
|
|||||||
@@ -20,10 +20,6 @@
|
|||||||
</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">
|
||||||
@@ -101,7 +97,6 @@
|
|||||||
</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>
|
||||||
@@ -292,13 +287,6 @@
|
|||||||
</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">
|
||||||
|
|||||||
@@ -77,10 +77,6 @@ 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); }
|
||||||
|
|||||||
@@ -1359,68 +1359,3 @@ 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() == []
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
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)}
|
|
||||||
@@ -23,8 +23,6 @@ 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):
|
||||||
@@ -70,47 +68,6 @@ 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,
|
||||||
|
|||||||
+1
-5
@@ -1,4 +1,3 @@
|
|||||||
import asyncio
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -6,7 +5,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, series, system
|
from .api import fs, history, imports, libraries, requests, search, system
|
||||||
|
|
||||||
logs.install()
|
logs.install()
|
||||||
|
|
||||||
@@ -14,9 +13,7 @@ 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)
|
||||||
@@ -28,7 +25,6 @@ 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")
|
||||||
|
|||||||
Reference in new issue
Block a user