mirror of
https://github.com/gethomepage/homepage.git
synced 2026-01-02 13:02:11 +08:00
Compare commits
16 Commits
v1.2.0
...
feature/ht
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bcbb94523 | ||
|
|
b118fa1204 | ||
|
|
b430a6f515 | ||
|
|
8ba3cae921 | ||
|
|
8f25bf5427 | ||
|
|
da823ad7e8 | ||
|
|
6c82883fa9 | ||
|
|
3c6f99d5ae | ||
|
|
b28cc0b7f6 | ||
|
|
2509d8c235 | ||
|
|
7e8752243c | ||
|
|
41dc2e77cb | ||
|
|
8b50296dad | ||
|
|
61a669c85d | ||
|
|
af7803fd04 | ||
|
|
937cecf24e |
@@ -163,6 +163,18 @@ If the `href` attribute is not present, Homepage will ignore the specific Ingres
|
|||||||
|
|
||||||
Homepage also features automatic service discovery for Gateway API. Service definitions are read by annotating the HttpRoute custom resource definition and are indentical to the Ingress example as defined in [Automatic Service Discovery](#automatic-service-discovery).
|
Homepage also features automatic service discovery for Gateway API. Service definitions are read by annotating the HttpRoute custom resource definition and are indentical to the Ingress example as defined in [Automatic Service Discovery](#automatic-service-discovery).
|
||||||
|
|
||||||
|
To enable Gateway API HttpRoute update `kubernetes.yaml` to include:
|
||||||
|
|
||||||
|
```
|
||||||
|
gateway: true # enable gateway-api
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using the unoffocial helm chart?
|
||||||
|
|
||||||
|
If you are using the unofficial helm chart ensure that the `ClusterRole` has required permissions for `gateway.networking.k8s.io`.
|
||||||
|
|
||||||
|
See [ClusterRole and ClusterRoleBinding](../installation/k8s.md#clusterrole-and-clusterrolebinding)
|
||||||
|
|
||||||
## Caveats
|
## Caveats
|
||||||
|
|
||||||
Similarly to Docker service discovery, there currently is no rigid ordering to discovered services and discovered services will be displayed above those specified in the `services.yaml`.
|
Similarly to Docker service discovery, there currently is no rigid ordering to discovered services and discovered services will be displayed above those specified in the `services.yaml`.
|
||||||
|
|||||||
@@ -81,3 +81,15 @@ services:
|
|||||||
sysctls:
|
sysctls:
|
||||||
- net.ipv6.conf.all.disable_ipv6=1
|
- net.ipv6.conf.all.disable_ipv6=1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Running homepage behind a proxy
|
||||||
|
|
||||||
|
If you are running homepage behind e.g. squid proxy, you can set the environment variable `HOMEPAGE_HTTP_PROXY` to the URL of your proxy. This will allow homepage to use the proxy for all outgoing requests.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
homepage:
|
||||||
|
...
|
||||||
|
environment:
|
||||||
|
- HOMEPAGE_HTTP_PROXY=http://proxy.local:3128
|
||||||
|
```
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ widget:
|
|||||||
type: gamedig
|
type: gamedig
|
||||||
serverType: csgo # see https://github.com/gamedig/node-gamedig#games-list
|
serverType: csgo # see https://github.com/gamedig/node-gamedig#games-list
|
||||||
url: udp://server.host.or.ip:port
|
url: udp://server.host.or.ip:port
|
||||||
|
gameToken: # optional, a token used by gamedig with certain games
|
||||||
```
|
```
|
||||||
|
|||||||
18
docs/widgets/services/jellystat.md
Normal file
18
docs/widgets/services/jellystat.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
title: Jellystat
|
||||||
|
description: Jellystat Widget Configuration
|
||||||
|
---
|
||||||
|
|
||||||
|
Learn more about [Jellystat](https://github.com/CyferShepard/Jellystat). The widget supports (at least) Jellystat version 1.1.6
|
||||||
|
|
||||||
|
You can create an API key from inside Jellystat at `Settings > API Key`.
|
||||||
|
|
||||||
|
Allowed fields: `["songs", "movies", "episodes", "other"]`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
widget:
|
||||||
|
type: jellystat
|
||||||
|
url: http://jellystat.host.or.ip
|
||||||
|
key: apikeyapikeyapikeyapikeyapikey
|
||||||
|
days: 30 # optional, defaults to 30
|
||||||
|
```
|
||||||
13
package.json
13
package.json
@@ -13,19 +13,21 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.7.19",
|
"@headlessui/react": "^1.7.19",
|
||||||
"@kubernetes/client-node": "^1.0.0",
|
"@kubernetes/client-node": "^1.0.0",
|
||||||
"cal-parser": "^1.0.2",
|
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"compare-versions": "^6.1.1",
|
"compare-versions": "^6.1.1",
|
||||||
"dockerode": "^4.0.4",
|
"dockerode": "^4.0.4",
|
||||||
"follow-redirects": "^1.15.9",
|
"follow-redirects": "^1.15.9",
|
||||||
"gamedig": "^5.2.0",
|
"gamedig": "^5.2.0",
|
||||||
|
"http-proxy-agent": "^7.0.2",
|
||||||
|
"https-proxy-agent": "^7.0.6",
|
||||||
"i18next": "^24.2.3",
|
"i18next": "^24.2.3",
|
||||||
|
"ical.js": "^2.1.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"json-rpc-2.0": "^1.7.0",
|
"json-rpc-2.0": "^1.7.0",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"minecraftstatuspinger": "^1.2.2",
|
"minecraftstatuspinger": "^1.2.2",
|
||||||
"next": "^15.2.4",
|
"next": "^15.3.1",
|
||||||
"next-i18next": "^12.1.0",
|
"next-i18next": "^12.1.0",
|
||||||
"ping": "^0.4.4",
|
"ping": "^0.4.4",
|
||||||
"pretty-bytes": "^6.1.1",
|
"pretty-bytes": "^6.1.1",
|
||||||
@@ -34,8 +36,7 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^11.18.6",
|
"react-i18next": "^11.18.6",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"recharts": "^2.15.1",
|
"recharts": "^2.15.3",
|
||||||
"rrule": "^2.8.1",
|
|
||||||
"swr": "^2.3.3",
|
"swr": "^2.3.3",
|
||||||
"systeminformation": "^5.25.11",
|
"systeminformation": "^5.25.11",
|
||||||
"tough-cookie": "^5.1.2",
|
"tough-cookie": "^5.1.2",
|
||||||
@@ -46,12 +47,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.0.9",
|
"@tailwindcss/postcss": "^4.0.9",
|
||||||
"eslint": "^9.21.0",
|
"eslint": "^9.25.1",
|
||||||
"eslint-config-next": "^15.2.4",
|
"eslint-config-next": "^15.2.4",
|
||||||
"eslint-config-prettier": "^10.1.1",
|
"eslint-config-prettier": "^10.1.1",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-prettier": "^5.2.3",
|
"eslint-plugin-prettier": "^5.2.6",
|
||||||
"eslint-plugin-react": "^7.37.4",
|
"eslint-plugin-react": "^7.37.4",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
|
|||||||
627
pnpm-lock.yaml
generated
627
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -856,7 +856,8 @@
|
|||||||
"physicalRelease": "Physical release",
|
"physicalRelease": "Physical release",
|
||||||
"digitalRelease": "Digital release",
|
"digitalRelease": "Digital release",
|
||||||
"noEventsToday": "No events for today!",
|
"noEventsToday": "No events for today!",
|
||||||
"noEventsFound": "No events found"
|
"noEventsFound": "No events found",
|
||||||
|
"errorWhenLoadingData": "Error when loading calendar data"
|
||||||
},
|
},
|
||||||
"romm": {
|
"romm": {
|
||||||
"platforms": "Platforms",
|
"platforms": "Platforms",
|
||||||
@@ -1042,5 +1043,11 @@
|
|||||||
"downloads": "Downloads",
|
"downloads": "Downloads",
|
||||||
"uploads": "Uploads",
|
"uploads": "Uploads",
|
||||||
"sharedFiles": "Files"
|
"sharedFiles": "Files"
|
||||||
|
},
|
||||||
|
"jellystat": {
|
||||||
|
"songs": "Songs",
|
||||||
|
"movies": "Movies",
|
||||||
|
"episodes": "Episodes",
|
||||||
|
"other": "Other"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const searchProviders = {
|
|||||||
|
|
||||||
function getAvailableProviderIds(options) {
|
function getAvailableProviderIds(options) {
|
||||||
if (options.provider && Array.isArray(options.provider)) {
|
if (options.provider && Array.isArray(options.provider)) {
|
||||||
return Object.keys(searchProviders).filter((value) => options.provider.includes(value));
|
return options.provider.filter((value) => searchProviders.hasOwnProperty(value));
|
||||||
}
|
}
|
||||||
if (options.provider && searchProviders[options.provider]) {
|
if (options.provider && searchProviders[options.provider]) {
|
||||||
return [options.provider];
|
return [options.provider];
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export default function getDockerArguments(server) {
|
|||||||
res.conn.ca = readFileSync(path.join(CONF_DIR, servers[server].tls.caFile));
|
res.conn.ca = readFileSync(path.join(CONF_DIR, servers[server].tls.caFile));
|
||||||
res.conn.cert = readFileSync(path.join(CONF_DIR, servers[server].tls.certFile));
|
res.conn.cert = readFileSync(path.join(CONF_DIR, servers[server].tls.certFile));
|
||||||
res.conn.key = readFileSync(path.join(CONF_DIR, servers[server].tls.keyFile));
|
res.conn.key = readFileSync(path.join(CONF_DIR, servers[server].tls.keyFile));
|
||||||
|
res.conn.protocol = "https";
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
|
|||||||
@@ -304,6 +304,9 @@ export function cleanServiceGroups(groups) {
|
|||||||
// frigate
|
// frigate
|
||||||
enableRecentEvents,
|
enableRecentEvents,
|
||||||
|
|
||||||
|
// gamedig
|
||||||
|
gameToken,
|
||||||
|
|
||||||
// beszel, glances, immich, komga, mealie, pihole, pfsense, speedtest
|
// beszel, glances, immich, komga, mealie, pihole, pfsense, speedtest
|
||||||
version,
|
version,
|
||||||
|
|
||||||
@@ -331,6 +334,9 @@ export function cleanServiceGroups(groups) {
|
|||||||
referrerPolicy,
|
referrerPolicy,
|
||||||
src,
|
src,
|
||||||
|
|
||||||
|
// jellystat
|
||||||
|
days,
|
||||||
|
|
||||||
// kopia
|
// kopia
|
||||||
snapshotHost,
|
snapshotHost,
|
||||||
snapshotPath,
|
snapshotPath,
|
||||||
@@ -484,6 +490,9 @@ export function cleanServiceGroups(groups) {
|
|||||||
if (["diskstation", "qnap"].includes(type)) {
|
if (["diskstation", "qnap"].includes(type)) {
|
||||||
if (volume) widget.volume = volume;
|
if (volume) widget.volume = volume;
|
||||||
}
|
}
|
||||||
|
if (type === "gamedig") {
|
||||||
|
if (gameToken) widget.gameToken = gameToken;
|
||||||
|
}
|
||||||
if (type === "kopia") {
|
if (type === "kopia") {
|
||||||
if (snapshotHost) widget.snapshotHost = snapshotHost;
|
if (snapshotHost) widget.snapshotHost = snapshotHost;
|
||||||
if (snapshotPath) widget.snapshotPath = snapshotPath;
|
if (snapshotPath) widget.snapshotPath = snapshotPath;
|
||||||
@@ -563,6 +572,9 @@ export function cleanServiceGroups(groups) {
|
|||||||
if (type === "spoolman") {
|
if (type === "spoolman") {
|
||||||
if (spoolIds !== undefined) widget.spoolIds = spoolIds;
|
if (spoolIds !== undefined) widget.spoolIds = spoolIds;
|
||||||
}
|
}
|
||||||
|
if (type === "jellystat") {
|
||||||
|
if (days !== undefined) widget.days = parseInt(days, 10);
|
||||||
|
}
|
||||||
return widget;
|
return widget;
|
||||||
});
|
});
|
||||||
return cleanedService;
|
return cleanedService;
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default async function credentialedProxyHandler(req, res, map) {
|
|||||||
} else if (widget.type === "proxmoxbackupserver") {
|
} else if (widget.type === "proxmoxbackupserver") {
|
||||||
delete headers["Content-Type"];
|
delete headers["Content-Type"];
|
||||||
headers.Authorization = `PBSAPIToken=${widget.username}:${widget.password}`;
|
headers.Authorization = `PBSAPIToken=${widget.username}:${widget.password}`;
|
||||||
} else if (widget.type === "autobrr") {
|
} else if (["autobrr", "jellystat"].includes(widget.type)) {
|
||||||
headers["X-API-Token"] = `${widget.key}`;
|
headers["X-API-Token"] = `${widget.key}`;
|
||||||
} else if (widget.type === "tubearchivist") {
|
} else if (widget.type === "tubearchivist") {
|
||||||
headers.Authorization = `Token ${widget.key}`;
|
headers.Authorization = `Token ${widget.key}`;
|
||||||
|
|||||||
@@ -110,21 +110,24 @@ export async function cachedRequest(url, duration = 5, ua = "homepage") {
|
|||||||
|
|
||||||
export async function httpProxy(url, params = {}) {
|
export async function httpProxy(url, params = {}) {
|
||||||
const constructedUrl = new URL(url);
|
const constructedUrl = new URL(url);
|
||||||
|
const proxyUrl = process.env.HOMEPAGE_HTTP_PROXY; // e.g. http://proxy.local:3128
|
||||||
const disableIpv6 = process.env.HOMEPAGE_PROXY_DISABLE_IPV6 === "true";
|
const disableIpv6 = process.env.HOMEPAGE_PROXY_DISABLE_IPV6 === "true";
|
||||||
const agentOptions = disableIpv6 ? { family: 4, autoSelectFamily: false } : {};
|
const agentOptions = disableIpv6 ? { family: 4, autoSelectFamily: false } : {};
|
||||||
|
|
||||||
let request = null;
|
let agent;
|
||||||
if (constructedUrl.protocol === "https:") {
|
if (proxyUrl) {
|
||||||
request = httpsRequest(constructedUrl, {
|
agent = constructedUrl.protocol === "https:" ? new HttpsProxyAgent(proxyUrl) : new HttpProxyAgent(proxyUrl);
|
||||||
agent: new https.Agent({ ...agentOptions, rejectUnauthorized: false }),
|
logger.debug("Using proxy for request to %s: %s", constructedUrl.href, proxyUrl);
|
||||||
...params,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
request = httpRequest(constructedUrl, {
|
agent =
|
||||||
agent: new http.Agent(agentOptions),
|
constructedUrl.protocol === "https:"
|
||||||
...params,
|
? new https.Agent({ ...agentOptions, rejectUnauthorized: false })
|
||||||
});
|
: new http.Agent(agentOptions);
|
||||||
}
|
}
|
||||||
|
const request =
|
||||||
|
constructedUrl.protocol === "https:"
|
||||||
|
? httpsRequest(constructedUrl, { agent, ...params })
|
||||||
|
: httpRequest(constructedUrl, { agent, ...params });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [status, contentType, data, responseHeaders] = await request;
|
const [status, contentType, data, responseHeaders] = await request;
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
import { parseString } from "cal-parser";
|
import ICAL from "ical.js";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { RRule } from "rrule";
|
|
||||||
|
|
||||||
import Error from "../../../components/services/widget/error";
|
import Error from "../../../components/services/widget/error";
|
||||||
import useWidgetAPI from "../../../utils/proxy/use-widget-api";
|
import useWidgetAPI from "../../../utils/proxy/use-widget-api";
|
||||||
|
|
||||||
// https://gist.github.com/jlevy/c246006675becc446360a798e2b2d781
|
|
||||||
function simpleHash(str) {
|
function simpleHash(str) {
|
||||||
/* eslint-disable no-plusplus, no-bitwise */
|
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
|
const prime = 31;
|
||||||
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
hash = (hash * prime + str.charCodeAt(i)) % 2_147_483_647;
|
||||||
}
|
}
|
||||||
return (hash >>> 0).toString(36);
|
|
||||||
/* eslint-disable no-plusplus, no-bitwise */
|
return Math.abs(hash).toString(36);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Integration({ config, params, setEvents, hideErrors, timezone }) {
|
export default function Integration({ config, params, setEvents, hideErrors, timezone }) {
|
||||||
@@ -25,11 +24,49 @@ export default function Integration({ config, params, setEvents, hideErrors, tim
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let parsedIcal;
|
const { showName = false } = config?.params || {};
|
||||||
|
let events = [];
|
||||||
|
|
||||||
if (!icalError && icalData && !icalData.error) {
|
if (!icalError && icalData && !icalData.error) {
|
||||||
parsedIcal = parseString(icalData.data);
|
if (!icalData.data) {
|
||||||
if (parsedIcal.events.length === 0) {
|
icalData.error = { message: `'${config.name}': ${t("calendar.errorWhenLoadingData")}` };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jCal = ICAL.parse(icalData.data);
|
||||||
|
const vCalendar = new ICAL.Component(jCal);
|
||||||
|
|
||||||
|
const buildEvent = (event, type) => {
|
||||||
|
return {
|
||||||
|
id: event.getFirstPropertyValue("uid"),
|
||||||
|
type,
|
||||||
|
title: event.getFirstPropertyValue("summary"),
|
||||||
|
rrule: event.getFirstPropertyValue("rrule"),
|
||||||
|
dtstart:
|
||||||
|
event.getFirstPropertyValue("dtstart") ||
|
||||||
|
event.getFirstPropertyValue("due") ||
|
||||||
|
event.getFirstPropertyValue("completed") ||
|
||||||
|
ICAL.Time.now(), // handles events without a date
|
||||||
|
dtend:
|
||||||
|
event.getFirstPropertyValue("dtend") ||
|
||||||
|
event.getFirstPropertyValue("due") ||
|
||||||
|
event.getFirstPropertyValue("completed") ||
|
||||||
|
ICAL.Time.now(), // handles events without a date
|
||||||
|
location: event.getFirstPropertyValue("location"),
|
||||||
|
status: event.getFirstPropertyValue("status"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEvents = () => {
|
||||||
|
const vEvents = vCalendar.getAllSubcomponents("vevent").map((event) => buildEvent(event, "vevent"));
|
||||||
|
|
||||||
|
const vTodos = vCalendar.getAllSubcomponents("vtodo").map((todo) => buildEvent(todo, "vtodo"));
|
||||||
|
|
||||||
|
return [...vEvents, ...vTodos];
|
||||||
|
};
|
||||||
|
|
||||||
|
events = getEvents();
|
||||||
|
if (events.length === 0) {
|
||||||
icalData.error = { message: `'${config.name}': ${t("calendar.noEventsFound")}` };
|
icalData.error = { message: `'${config.name}': ${t("calendar.noEventsFound")}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,72 +74,67 @@ export default function Integration({ config, params, setEvents, hideErrors, tim
|
|||||||
const startDate = DateTime.fromISO(params.start);
|
const startDate = DateTime.fromISO(params.start);
|
||||||
const endDate = DateTime.fromISO(params.end);
|
const endDate = DateTime.fromISO(params.end);
|
||||||
|
|
||||||
if (icalError || !parsedIcal || !startDate.isValid || !endDate.isValid) {
|
if (icalError || events.length === 0 || !startDate.isValid || !endDate.isValid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventsToAdd = {};
|
const rangeStart = ICAL.Time.fromJSDate(startDate.toJSDate());
|
||||||
const events = parsedIcal?.getEventsBetweenDates(startDate.toJSDate(), endDate.toJSDate());
|
const rangeEnd = ICAL.Time.fromJSDate(endDate.toJSDate());
|
||||||
const now = timezone ? DateTime.now().setZone(timezone) : DateTime.now();
|
|
||||||
|
|
||||||
events?.forEach((event) => {
|
const getOcurrencesFromRange = (event) => {
|
||||||
let title = `${event?.summary?.value}`;
|
if (!event.rrule) {
|
||||||
if (config?.params?.showName) {
|
if (event.dtstart.compare(rangeStart) >= 0 && event.dtend.compare(rangeEnd) <= 0) {
|
||||||
title = `${config.name}: ${title}`;
|
return [event.dtstart];
|
||||||
}
|
|
||||||
|
|
||||||
// 'dtend' is null for all-day events
|
|
||||||
const { dtstart, dtend = { value: 0 } } = event;
|
|
||||||
|
|
||||||
const eventToAdd = (date, i, type) => {
|
|
||||||
const days = dtend.value === 0 ? 1 : (dtend.value - dtstart.value) / (1000 * 60 * 60 * 24);
|
|
||||||
const eventDate = timezone ? DateTime.fromJSDate(date, { zone: timezone }) : DateTime.fromJSDate(date);
|
|
||||||
|
|
||||||
for (let j = 0; j < days; j += 1) {
|
|
||||||
// See https://github.com/gethomepage/homepage/issues/2753 uid is not stable
|
|
||||||
// assumption is that the event is the same if the start, end and title are all the same
|
|
||||||
const hash = simpleHash(`${dtstart?.value}${dtend?.value}${title}${i}${j}${type}}`);
|
|
||||||
eventsToAdd[hash] = {
|
|
||||||
title,
|
|
||||||
date: eventDate.plus({ days: j }),
|
|
||||||
color: config?.color ?? "zinc",
|
|
||||||
isCompleted: eventDate < now,
|
|
||||||
additional: event.location?.value,
|
|
||||||
type: "ical",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let recurrenceOptions = event?.recurrenceRule?.origOptions;
|
return [];
|
||||||
// RRuleSet does not have dtstart, add it manually
|
|
||||||
if (event?.recurrenceRule && event.recurrenceRule.rrules && event.recurrenceRule.rrules()?.[0]?.origOptions) {
|
|
||||||
recurrenceOptions = event.recurrenceRule.rrules()[0].origOptions;
|
|
||||||
recurrenceOptions.dtstart = dtstart.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recurrenceOptions && Object.keys(recurrenceOptions).length !== 0) {
|
const iterator = event.rrule.iterator(event.dtstart);
|
||||||
try {
|
|
||||||
const rule = new RRule(recurrenceOptions);
|
|
||||||
const recurringEvents = rule.between(startDate.toJSDate(), endDate.toJSDate());
|
|
||||||
|
|
||||||
recurringEvents.forEach((date, i) => {
|
const occurrences = [];
|
||||||
let eventDate = date;
|
for (let next = iterator.next(); next && next.compare(rangeEnd) < 0; next = iterator.next()) {
|
||||||
if (event.dtstart?.params?.tzid) {
|
if (next.compare(rangeStart) < 0) {
|
||||||
// date is in UTC but parsed as if it is in current timezone, so we need to adjust it
|
continue;
|
||||||
const dateInUTC = DateTime.fromJSDate(date).setZone("UTC");
|
|
||||||
const offset = dateInUTC.offset - DateTime.fromJSDate(date, { zone: event.dtstart.params.tzid }).offset;
|
|
||||||
eventDate = dateInUTC.plus({ minutes: offset }).toJSDate();
|
|
||||||
}
|
|
||||||
eventToAdd(eventDate, i, "recurring");
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error("Unable to parse recurring events from iCal: %s", e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
occurrences.push(next.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
event.matchingDates.forEach((date, i) => eventToAdd(date, i, "single"));
|
return occurrences;
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventsToAdd = [];
|
||||||
|
events.forEach((event, index) => {
|
||||||
|
const occurrences = getOcurrencesFromRange(event);
|
||||||
|
|
||||||
|
occurrences.forEach((icalDate) => {
|
||||||
|
const date = icalDate.toJSDate();
|
||||||
|
|
||||||
|
const hash = simpleHash(`${event.id}-${event.title}-${index}-${date.toString()}`);
|
||||||
|
|
||||||
|
let title = event.title;
|
||||||
|
if (showName) {
|
||||||
|
title = `${config.name}: ${title}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIsCompleted = () => {
|
||||||
|
if (event.type === "vtodo") {
|
||||||
|
return event.status === "COMPLETED";
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.fromJSDate(date) < DateTime.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
eventsToAdd[hash] = {
|
||||||
|
title,
|
||||||
|
date: DateTime.fromJSDate(date),
|
||||||
|
color: config?.color ?? "zinc",
|
||||||
|
isCompleted: getIsCompleted(),
|
||||||
|
additional: event.location,
|
||||||
|
type: "ical",
|
||||||
|
};
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd }));
|
setEvents((prevEvents) => ({ ...prevEvents, ...eventsToAdd }));
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const components = {
|
|||||||
jdownloader: dynamic(() => import("./jdownloader/component")),
|
jdownloader: dynamic(() => import("./jdownloader/component")),
|
||||||
jellyfin: dynamic(() => import("./emby/component")),
|
jellyfin: dynamic(() => import("./emby/component")),
|
||||||
jellyseerr: dynamic(() => import("./jellyseerr/component")),
|
jellyseerr: dynamic(() => import("./jellyseerr/component")),
|
||||||
|
jellystat: dynamic(() => import("./jellystat/component")),
|
||||||
kavita: dynamic(() => import("./kavita/component")),
|
kavita: dynamic(() => import("./kavita/component")),
|
||||||
komga: dynamic(() => import("./komga/component")),
|
komga: dynamic(() => import("./komga/component")),
|
||||||
kopia: dynamic(() => import("./kopia/component")),
|
kopia: dynamic(() => import("./kopia/component")),
|
||||||
|
|||||||
@@ -12,13 +12,19 @@ export default async function gamedigProxyHandler(req, res) {
|
|||||||
const url = new URL(serviceWidget.url);
|
const url = new URL(serviceWidget.url);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const serverData = await GameDig.query({
|
const gamedigOptions = {
|
||||||
type: serviceWidget.serverType,
|
type: serviceWidget.serverType,
|
||||||
host: url.hostname,
|
host: url.hostname,
|
||||||
port: url.port,
|
port: url.port,
|
||||||
givenPortOnly: true,
|
givenPortOnly: true,
|
||||||
checkOldIDs: true,
|
checkOldIDs: true,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (serviceWidget.gameToken) {
|
||||||
|
gamedigOptions.token = serviceWidget.gameToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverData = await GameDig.query(gamedigOptions);
|
||||||
|
|
||||||
res.status(200).send({
|
res.status(200).send({
|
||||||
online: true,
|
online: true,
|
||||||
|
|||||||
38
src/widgets/jellystat/component.jsx
Normal file
38
src/widgets/jellystat/component.jsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Block from "components/services/widget/block";
|
||||||
|
import Container from "components/services/widget/container";
|
||||||
|
|
||||||
|
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||||
|
|
||||||
|
export default function Component({ service }) {
|
||||||
|
const { widget } = service;
|
||||||
|
|
||||||
|
// Days validation
|
||||||
|
if (!(Number.isInteger(widget.days) && 0 < widget.days)) widget.days = 30;
|
||||||
|
|
||||||
|
const { data: viewsData, error: viewsError } = useWidgetAPI(widget, "getViewsByLibraryType", { days: widget.days });
|
||||||
|
|
||||||
|
const error = viewsError || viewsData?.message;
|
||||||
|
if (error) {
|
||||||
|
return <Container service={service} error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!viewsData) {
|
||||||
|
return (
|
||||||
|
<Container service={service}>
|
||||||
|
<Block label="jellystat.songs" />
|
||||||
|
<Block label="jellystat.movies" />
|
||||||
|
<Block label="jellystat.episodes" />
|
||||||
|
<Block label="jellystat.other" />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container service={service}>
|
||||||
|
<Block label="jellystat.songs" value={viewsData.Audio} />
|
||||||
|
<Block label="jellystat.movies" value={viewsData.Movie} />
|
||||||
|
<Block label="jellystat.episodes" value={viewsData.Series} />
|
||||||
|
<Block label="jellystat.other" value={viewsData.Other} />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/widgets/jellystat/widget.js
Normal file
15
src/widgets/jellystat/widget.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
|
||||||
|
|
||||||
|
const widget = {
|
||||||
|
api: "{url}/{endpoint}",
|
||||||
|
proxyHandler: credentialedProxyHandler,
|
||||||
|
|
||||||
|
mappings: {
|
||||||
|
getViewsByLibraryType: {
|
||||||
|
endpoint: "stats/getViewsByLibraryType",
|
||||||
|
params: ["days"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default widget;
|
||||||
@@ -49,6 +49,7 @@ import immich from "./immich/widget";
|
|||||||
import jackett from "./jackett/widget";
|
import jackett from "./jackett/widget";
|
||||||
import jdownloader from "./jdownloader/widget";
|
import jdownloader from "./jdownloader/widget";
|
||||||
import jellyseerr from "./jellyseerr/widget";
|
import jellyseerr from "./jellyseerr/widget";
|
||||||
|
import jellystat from "./jellystat/widget";
|
||||||
import karakeep from "./karakeep/widget";
|
import karakeep from "./karakeep/widget";
|
||||||
import kavita from "./kavita/widget";
|
import kavita from "./kavita/widget";
|
||||||
import komga from "./komga/widget";
|
import komga from "./komga/widget";
|
||||||
@@ -190,6 +191,7 @@ const widgets = {
|
|||||||
jdownloader,
|
jdownloader,
|
||||||
jellyfin: emby,
|
jellyfin: emby,
|
||||||
jellyseerr,
|
jellyseerr,
|
||||||
|
jellystat,
|
||||||
kavita,
|
kavita,
|
||||||
komga,
|
komga,
|
||||||
kopia,
|
kopia,
|
||||||
|
|||||||
Reference in New Issue
Block a user