diff --git a/docs/widgets/services/booklore.md b/docs/widgets/services/booklore.md
new file mode 100644
index 000000000..137aa1e52
--- /dev/null
+++ b/docs/widgets/services/booklore.md
@@ -0,0 +1,16 @@
+---
+title: Booklore
+description: Booklore Widget Configuration
+---
+
+Learn more about [Booklore](https://github.com/booklore-app/booklore).
+
+The widget authenticates with your Booklore credentials to surface total libraries, books, and reading progress counts for your account.
+
+```yaml
+widget:
+ type: booklore
+ url: https://booklore.host.or.ip
+ username: username
+ password: password
+```
diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md
index 8ae84ee9e..8110a7fc4 100644
--- a/docs/widgets/services/index.md
+++ b/docs/widgets/services/index.md
@@ -17,6 +17,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Azure DevOps](azuredevops.md)
- [Backrest](backrest.md)
- [Bazarr](bazarr.md)
+- [Booklore](booklore.md)
- [Beszel](beszel.md)
- [Caddy](caddy.md)
- [Calendar](calendar.md)
diff --git a/mkdocs.yml b/mkdocs.yml
index b3865d3c0..2a21cbfc6 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -41,6 +41,7 @@ nav:
- widgets/services/azuredevops.md
- widgets/services/backrest.md
- widgets/services/bazarr.md
+ - widgets/services/booklore.md
- widgets/services/beszel.md
- widgets/services/caddy.md
- widgets/services/calendar.md
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 22d2134f6..7adcd4c12 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -793,6 +793,12 @@
"categories": "Categories",
"series": "Series"
},
+ "booklore": {
+ "libraries": "Libraries",
+ "books": "Books",
+ "reading": "Reading",
+ "finished": "Finished"
+ },
"jdownloader": {
"downloadCount": "Queue",
"downloadBytesRemaining": "Remaining",
diff --git a/src/widgets/booklore/component.jsx b/src/widgets/booklore/component.jsx
new file mode 100644
index 000000000..fc8b53483
--- /dev/null
+++ b/src/widgets/booklore/component.jsx
@@ -0,0 +1,43 @@
+import Block from "components/services/widget/block";
+import Container from "components/services/widget/container";
+import { useTranslation } from "next-i18next";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+
+ const { data: bookloreData, error: bookloreError } = useWidgetAPI(widget);
+
+ if (bookloreError) {
+ return ;
+ }
+
+ if (!bookloreData) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ const stats = {
+ libraries: bookloreData.libraries ?? 0,
+ books: bookloreData.books ?? 0,
+ reading: bookloreData.reading ?? 0,
+ finished: bookloreData.finished ?? 0,
+ };
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/booklore/proxy.js b/src/widgets/booklore/proxy.js
new file mode 100644
index 000000000..ccf2b2b69
--- /dev/null
+++ b/src/widgets/booklore/proxy.js
@@ -0,0 +1,156 @@
+import cache from "memory-cache";
+
+import getServiceWidget from "utils/config/service-helpers";
+import createLogger from "utils/logger";
+import { formatApiCall } from "utils/proxy/api-helpers";
+import { httpProxy } from "utils/proxy/http";
+import widgets from "widgets/widgets";
+
+const proxyName = "bookloreProxyHandler";
+const sessionTokenCacheKey = `${proxyName}__sessionToken`;
+const logger = createLogger(proxyName);
+
+async function login(widget, service) {
+ if (!widget.username || !widget.password) {
+ logger.debug("Missing credentials for Booklore service '%s'", service);
+ return { accessToken: false };
+ }
+
+ const api = widgets?.[widget.type]?.api;
+ const loginUrl = new URL(formatApiCall(api, { ...widget, endpoint: "auth/login" }));
+
+ const [status, , data] = await httpProxy(loginUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ accept: "application/json",
+ },
+ body: JSON.stringify({
+ username: widget.username,
+ password: widget.password,
+ }),
+ });
+
+ if (status !== 200) {
+ logger.debug("Booklore login failed for service '%s' with status %d", service, status);
+ return { accessToken: false };
+ }
+
+ try {
+ const { accessToken } = JSON.parse(data.toString());
+
+ if (accessToken) {
+ // access tokens are valid for ~10 hours; refresh 1 minute early.
+ cache.put(`${sessionTokenCacheKey}.${service}`, accessToken, 10 * 60 * 60 * 1000 - 60 * 1000);
+ return { accessToken };
+ }
+ } catch (e) {
+ logger.error("Unable to login to Booklore API: %s", e);
+ }
+
+ return { accessToken: false };
+}
+
+async function apiCall(widget, endpoint, service) {
+ const cacheKey = `${sessionTokenCacheKey}.${service}`;
+ let accessToken = cache.get(cacheKey);
+
+ if (!accessToken) {
+ ({ accessToken } = await login(widget, service));
+ }
+
+ if (!accessToken) {
+ return { status: 401, data: null };
+ }
+
+ const headers = {
+ accept: "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ };
+
+ const url = new URL(formatApiCall(widgets[widget.type].api, { ...widget, endpoint }));
+ let [status, , data] = await httpProxy(url, {
+ method: "GET",
+ headers,
+ });
+
+ if (status === 401 || status === 403) {
+ logger.debug("Booklore API rejected the request, attempting to obtain new session token");
+ const refreshedToken = (await login(widget, service)).accessToken;
+ if (!refreshedToken) {
+ return { status, data: null };
+ }
+ headers.Authorization = `Bearer ${refreshedToken}`;
+ [status, , data] = await httpProxy(url, {
+ method: "GET",
+ headers,
+ });
+ }
+
+ if (status !== 200) {
+ logger.error("Error getting data from Booklore: %s status %d. Data: %s", url, status, data);
+ return { status, data: null };
+ }
+
+ try {
+ return { status, data: JSON.parse(data.toString()) };
+ } catch (e) {
+ logger.error("Error parsing Booklore response: %s", e);
+ }
+
+ return { status, data: null };
+}
+
+function summarizeStatuses(books = []) {
+ return books.reduce(
+ (accumulator, book) => {
+ const status = (book?.readStatus || "").toString().toUpperCase();
+ if (status === "READING") accumulator.reading += 1;
+ else if (status === "READ") accumulator.finished += 1;
+ return accumulator;
+ },
+ { reading: 0, finished: 0 },
+ );
+}
+
+export default async function bookloreProxyHandler(req, res) {
+ const { group, service, index } = req.query;
+
+ if (!group || !service) {
+ logger.debug("Invalid or missing service '%s' or group '%s'", service, group);
+ return res.status(400).json({ error: "Invalid proxy service type" });
+ }
+
+ const widget = await getServiceWidget(group, service, index);
+
+ if (!widget) {
+ logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
+ return res.status(400).json({ error: "Invalid proxy service type" });
+ }
+
+ if (!widget.username || !widget.password) {
+ logger.debug("Missing credentials for Booklore widget in service '%s'", service);
+ return res.status(400).json({ error: "Missing Booklore credentials" });
+ }
+
+ const { data: librariesData, status: librariesStatus } = await apiCall(widget, "libraries", service);
+
+ if (librariesStatus !== 200 || !Array.isArray(librariesData)) {
+ return res.status(librariesStatus || 500).send(librariesData || { error: "Error fetching libraries" });
+ }
+
+ const { data: booksData, status: booksStatus } = await apiCall(widget, "books", service);
+
+ if (booksStatus !== 200 || !Array.isArray(booksData)) {
+ return res.status(booksStatus || 500).send(booksData || { error: "Error fetching books" });
+ }
+
+ const { reading, finished } = summarizeStatuses(booksData);
+
+ return res.status(200).send({
+ libraries: librariesData.length,
+ books: booksData.length,
+ reading,
+ finished,
+ });
+}
diff --git a/src/widgets/booklore/widget.js b/src/widgets/booklore/widget.js
new file mode 100644
index 000000000..3ff862e31
--- /dev/null
+++ b/src/widgets/booklore/widget.js
@@ -0,0 +1,8 @@
+import bookloreProxyHandler from "./proxy";
+
+const widget = {
+ api: "{url}/api/v1/{endpoint}",
+ proxyHandler: bookloreProxyHandler,
+};
+
+export default widget;
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 911be5fb8..30bafd50a 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -12,6 +12,7 @@ const components = {
backrest: dynamic(() => import("./backrest/component")),
bazarr: dynamic(() => import("./bazarr/component")),
beszel: dynamic(() => import("./beszel/component")),
+ booklore: dynamic(() => import("./booklore/component")),
caddy: dynamic(() => import("./caddy/component")),
calendar: dynamic(() => import("./calendar/component")),
calibreweb: dynamic(() => import("./calibreweb/component")),
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index dcc0ba65e..f7947de78 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -9,6 +9,7 @@ import azuredevops from "./azuredevops/widget";
import backrest from "./backrest/widget";
import bazarr from "./bazarr/widget";
import beszel from "./beszel/widget";
+import booklore from "./booklore/widget";
import caddy from "./caddy/widget";
import calendar from "./calendar/widget";
import calibreweb from "./calibreweb/widget";
@@ -156,6 +157,7 @@ const widgets = {
azuredevops,
backrest,
bazarr,
+ booklore,
beszel,
caddy,
calibreweb,