suwayomi widget

This commit is contained in:
Robonau
2024-11-10 21:40:30 +00:00
committed by shamoon
parent 1a22065c3a
commit aa427d1947
7 changed files with 395 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
---
title: Suwayomi
description: Suwayomi Widget Configuration
---
Learn more about [Suwayomi](https://github.com/Suwayomi/Suwayomi-Server).
all supported fields shown in example yaml, though a max of 4 will show at one time.
username and password are available if you have basic auth setup for Suwayomi.
```yaml
widget:
icon: https://raw.githubusercontent.com/Suwayomi/Suwayomi-Server/refs/heads/master/server/src/main/resources/icon/faviconlogo-128.png
widget:
type: suwayomi
url: http://suwayomi.host.or.ip
username: username # if u have basic auth setup
password: password # if u have basic auth setup
category: 0 # to use a given categoryID defaults to all categories
fields:
- download
- nondownload
- read
- unread
- downloadedRead
- downloadedunread
- nondownloadedread
- nondownloadedunread
```

View File

@@ -309,6 +309,16 @@
"stopped": "Stopped",
"total": "Total"
},
"suwayomi": {
"download": "Downloaded",
"nondownload": "Non-Downloaded",
"read": "Read",
"unread": "Unread",
"downloadedread": "Downloaded & Read",
"downloadedunread": "Downloaded & Unread",
"nondownloadedread": "Non-Downloaded & Read",
"nondownloadedunread": "Non-Downloaded & Unread"
},
"tailscale": {
"address": "Address",
"expires": "Expires",

View File

@@ -114,6 +114,7 @@ const components = {
stocks: dynamic(() => import("./stocks/component")),
strelaysrv: dynamic(() => import("./strelaysrv/component")),
swagdashboard: dynamic(() => import("./swagdashboard/component")),
suwayomi: dynamic(() => import("./suwayomi/component")),
tailscale: dynamic(() => import("./tailscale/component")),
tandoor: dynamic(() => import("./tandoor/component")),
tautulli: dynamic(() => import("./tautulli/component")),

View File

@@ -0,0 +1,41 @@
import { useTranslation } from "next-i18next";
import Container from "components/services/widget/container";
import Block from "components/services/widget/block";
import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) {
const { t } = useTranslation();
/** @type {{widget: { fields: string[] }}} */
const { widget } = service;
const { data: suwayomiData, error: suwayomiError } = useWidgetAPI(widget);
if (suwayomiError) {
return <Container service={service} error={suwayomiError} />;
}
if (!suwayomiData) {
widget.fields.length = 4;
return (
<Container service={service}>
{widget.fields.map((Field) => {
const field = Field.toLowerCase();
return <Block key={field} label={`suwayomi.${field}`} />;
})}
</Container>
);
}
// i would like to be able to do something like this but i guess not
// widget.service_name += suwayomiData.name ? `-${suwayomiData.name}` : "";
return (
<Container service={service}>
{suwayomiData.map((data) => (
<Block key={data.label} label={data.label} value={t("common.number", { value: data.count })} />
))}
</Container>
);
}

View File

@@ -0,0 +1,303 @@
import { httpProxy } from "utils/proxy/http";
import { formatApiCall } from "utils/proxy/api-helpers";
import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger";
import widgets from "widgets/widgets";
const proxyName = "suwayomiProxyHandler";
const logger = createLogger(proxyName);
/**
* @typedef totalCount
* @type {object}
* @property {string} totalCount - count
*/
/**
* @typedef ResponseJSON
* @type {{
* data: {
* download: totalCount,
* nondownload: totalCount,
* read: totalCount,
* unread: totalCount,
* downloadedRead: totalCount,
* downloadedunread: totalCount,
* nondownloadedread: totalCount,
* nondownloadedunread: totalCount,
* }
* }}
*/
/**
* @typedef ResponseJSONcategory
* @type {{
* data: {
* category: {
* mangas: {
* nodes: {
* chapters: {
* nodes: {
* isRead: boolean,
* isDownloaded: boolean
* }
* }
* }
* }
* }
* }
* }}
*/
/**
* Makes a GraphQL query body based on the provided fieldsSet and category.
*
* @param {Set} fieldsSet - Set of fields to include in the query.
* @param {string|number|undefined} [category="all"] - Category ID or "all" for general counts.
* @returns {string} - The JSON stringified query body.
*/
function makeBody(fieldsSet, category = "all") {
if (Number.isNaN(Number(category))) {
return JSON.stringify({
operationName: "Counts",
query: `
query Counts {
${
fieldsSet.has("download")
? `
download: chapters(
condition: {isDownloaded: true}
filter: {inLibrary: {equalTo: true}}
) {
totalCount
}`
: ""
}
${
fieldsSet.has("nondownload")
? `
nondownload: chapters(
condition: {isDownloaded: true}
filter: {inLibrary: {equalTo: true}}
) {
totalCount
}
`
: ""
}
${
fieldsSet.has("read")
? `
read: chapters(
condition: {isRead: true}
filter: {inLibrary: {equalTo: true}}
) {
totalCount
}
`
: ""
}
${
fieldsSet.has("unread")
? `
unread: chapters(
condition: {isRead: false}
filter: {inLibrary: {equalTo: true}}
) {
totalCount
}
`
: ""
}
${
fieldsSet.has("downloadedread")
? `
downloadedread: chapters(
condition: {isDownloaded: true, isRead: true}
filter: {inLibrary: {equalTo: true}}
) {
totalCount
}
`
: ""
}
${
fieldsSet.has("downloadedunread")
? `
downloadedunread: chapters(
condition: {isDownloaded: true, isRead: false}
filter: {inLibrary: {equalTo: true}}
) {
totalCount
}
`
: ""
}
${
fieldsSet.has("nondownloadedread")
? `
nondownloadedread: chapters(
condition: {isDownloaded: false, isRead: true}
filter: {inLibrary: {equalTo: true}}
) {
totalCount
}
`
: ""
}
${
fieldsSet.has("nondownloadedunread")
? `
nondownloadedunread: chapters(
condition: {isDownloaded: false, isRead: false}
filter: {inLibrary: {equalTo: true}}
) {
totalCount
}
`
: ""
}
}`,
});
}
return JSON.stringify({
operationName: "category",
query: `
query category($id: Int!) {
category(id: $id) {
name
mangas {
nodes {
title
chapters {
nodes {
isRead
isDownloaded
}
}
}
}
}
}`,
variables: {
id: Number(category),
},
});
}
/**
* Makes a Basic Authentication token encoded in base64.
*
* @param {string|undefined} username - The username for authentication.
* @param {string|undefined} password - The password for authentication.
* @returns {string|null} A Basic Authentication token, or null if username or password is missing.
*/
function makeAuth(username, password) {
if (username && password) {
// Combine username and password, and encode them in base64
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
}
// Return null if either username or password is not provided
return null;
}
/**
* Extracts count data from the response JSON and appends it to the returnData array.
*
* @param {ResponseJSON|ResponseJSONcategory} responseJSON - The JSON response containing the data.
* @param {keyof ResponseJSON["data"]} fieldName - The name of the field to extract.
* @param {Function} condition - A function to compare and determine the count condition.
* @returns {{ count: number, label: string }} - An object containing the count and label.
*/
function extractCounts(responseJSON, fieldName, condition) {
if (fieldName in responseJSON.data) {
return {
count: responseJSON.data[fieldName].totalCount,
label: `suwayomi.${fieldName}`,
};
}
return {
count: responseJSON.data.category.mangas.nodes.reduce(
(aa, cc) =>
cc.chapters.nodes.reduce((a, c) => {
if (condition(c)) {
return a + 1;
}
return a;
}, 0) + aa,
0,
),
label: `suwayomi.${fieldName}`,
};
}
export default async function suwayomiProxyHandler(req, res) {
const { group, service, endpoint } = 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" });
}
/** @type {{ fields: string[],category: string|number|undefined, type: keyof typeof widgets }} */
const widget = await getServiceWidget(group, service);
widget.fields.length = 4;
widget.fields = widget.fields.map((f) => f.toLowerCase());
/** @type {Set<string>} */
const fieldsSet = new Set(widget.fields);
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" });
}
const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget }));
const body = makeBody(fieldsSet, widget.category);
const [status, contentType, data] = await httpProxy(url, {
method: "POST",
body,
headers: {
"content-type": "application/json",
Authorization: makeAuth(widget.username, widget.password),
},
});
if (status === 401) {
logger.error("unauthorized username or password for Suwayomi is incorrect.");
return res
.status(401)
.send({ error: { message: "401: unauthorized username or password for Suwayomi is incorrect." } });
}
if (status !== 200) {
logger.error("Error getting data from Suwayomi: %d. Data: %s", status, data);
return res.status(status).send({ error: { message: "Error getting data from Suwayomi", body, data } });
}
/** @type {ResponseJSON|ResponseJSONcategory} */
const responseJSON = JSON.parse(data);
const countsToExtract = {
download: (c) => c.isDownloaded,
nondownload: (c) => !c.isDownloaded,
read: (c) => c.isRead,
unread: (c) => !c.isRead,
downloadedread: (c) => c.isDownloaded && c.isRead,
downloadedunread: (c) => c.isDownloaded && !c.isRead,
nondownloadedread: (c) => !c.isDownloaded && c.isRead,
nondownloadedunread: (c) => !c.isDownloaded && !c.isRead,
};
const returnData = widget.fields.map((name) => extractCounts(responseJSON, name, countsToExtract[name]));
// if ("category" in responseJSON.data){
// returnData.name = responseJSON.data.category.name
// // i would like to be able to do something like this but i guess not
// // widget.service_name += `-${responseJSON.data.category.name}`;
// }
if (contentType) res.setHeader("Content-Type", contentType);
return res.status(status).send(returnData);
}

View File

@@ -0,0 +1,9 @@
// import genericProxyHandler from "utils/proxy/handlers/generic";
import suwayomiProxyHandler from "./proxy";
const widget = {
api: "{url}/api/graphql",
proxyHandler: suwayomiProxyHandler,
};
export default widget;

View File

@@ -105,6 +105,7 @@ import stash from "./stash/widget";
import stocks from "./stocks/widget";
import strelaysrv from "./strelaysrv/widget";
import swagdashboard from "./swagdashboard/widget";
import suwayomi from "./suwayomi/widget";
import tailscale from "./tailscale/widget";
import tandoor from "./tandoor/widget";
import tautulli from "./tautulli/widget";
@@ -238,6 +239,7 @@ const widgets = {
stocks,
strelaysrv,
swagdashboard,
suwayomi,
tailscale,
tandoor,
tautulli,