diff --git a/static/app.js b/static/app.js
index ead96b9..c346d13 100644
--- a/static/app.js
+++ b/static/app.js
@@ -93,7 +93,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", "system"];
+const VIEWS = ["search", "missing", "imported", "import", "series", "activity", "settings", "system"];
function showView() {
const hash = location.hash.slice(1);
@@ -103,6 +103,7 @@ function showView() {
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));
}
@@ -320,6 +321,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);
@@ -366,6 +369,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 =
@@ -411,6 +416,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) {
@@ -701,6 +731,84 @@ 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 = `
${t("watch.empty")}
`;
+ 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) => `
+ | ${p.volume ?? ""} |
+ ${esc(p.title)}${p.year ? ` (${p.year})` : ""} |
+ ${esc(p.authors)} |
+
+
+
+ |
+
`)
+ .join("");
+ return `
+
+ ${esc(w.title)}
+ ${esc(w.library_name)} · ${esc(w.media_type)} · ${t("watch.checked", { at: checked })}
+
+
+ ${w.error ? `
${esc(t("watch.error", { error: w.error }))}
` : ""}
+ ${parts ? `
` : `
${t("watch.nothingNew")}
`}
+
`;
+ })
+ .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 = [];
@@ -1962,6 +2070,7 @@ function onLangChange() {
renderHistory();
renderHealth();
renderLogs();
+ renderWatches();
if (libraries.length) loadLibraries();
const editId = $("#lib-form").dataset.editId;
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
@@ -1987,5 +2096,6 @@ updateLangButton();
endLibraryEdit();
loadLibraries();
refreshMissingBadge();
+loadWatches();
showVersion();
showView();
diff --git a/static/i18n.js b/static/i18n.js
index 98eaf4a..7a62c18 100644
--- a/static/i18n.js
+++ b/static/i18n.js
@@ -47,6 +47,19 @@ 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.",
@@ -309,6 +322,19 @@ 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.",
diff --git a/static/index.html b/static/index.html
index 0209978..3fa76ff 100644
--- a/static/index.html
+++ b/static/index.html
@@ -20,6 +20,10 @@
+