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,