Compare commits

..

16 Commits

Author SHA1 Message Date
shamoon
9bcbb94523 Update index.md 2025-05-09 01:22:06 -07:00
shamoon
b118fa1204 Update http.js 2025-05-09 01:16:32 -07:00
shamoon
b430a6f515 rename var 2025-05-09 01:15:49 -07:00
shamoon
8ba3cae921 Log 2025-05-09 01:14:02 -07:00
shamoon
8f25bf5427 fix request 2025-05-09 01:12:41 -07:00
shamoon
da823ad7e8 Enhancement: support running behind proxy 2025-05-09 01:11:20 -07:00
Garrett
6c82883fa9 Enhancement: respect search engine order from config (#5250) 2025-05-08 00:40:51 -07:00
Matheus Vellone
3c6f99d5ae Chore: change to ical.js for ical parsing (#5241)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-05-07 08:20:04 -07:00
shamoon
b28cc0b7f6 Fix: ensure https protocol with docker tls (#5248) 2025-05-06 07:54:45 -07:00
InsertDisc
2509d8c235 Enhancement: add optional token parameter for gamedig (#5245)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-05-05 09:02:47 -07:00
Zlendy
7e8752243c Feature: Jellystat widget (#5185)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-05-03 08:26:56 -07:00
Tersius Kuhne
41dc2e77cb Documentation: update Gateway API HttpRoute documentation (#5239)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-05-02 19:03:31 -07:00
dependabot[bot]
8b50296dad Chore(deps-dev): Bump eslint-plugin-prettier from 5.2.3 to 5.2.6 (#5232)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-01 21:45:20 +00:00
dependabot[bot]
61a669c85d Chore(deps): Bump next from 15.2.4 to 15.3.1 (#5231)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-01 14:44:41 -07:00
dependabot[bot]
af7803fd04 Chore(deps-dev): Bump eslint from 9.21.0 to 9.25.1 (#5230)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-01 14:43:39 -07:00
dependabot[bot]
937cecf24e Chore(deps): Bump recharts from 2.15.1 to 2.15.3 (#5234)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-01 14:42:27 -07:00
18 changed files with 573 additions and 385 deletions

View File

@@ -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).
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
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`.

View File

@@ -81,3 +81,15 @@ services:
sysctls:
- 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
```

View File

@@ -14,4 +14,5 @@ widget:
type: gamedig
serverType: csgo # see https://github.com/gamedig/node-gamedig#games-list
url: udp://server.host.or.ip:port
gameToken: # optional, a token used by gamedig with certain games
```

View 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
```

View File

@@ -13,19 +13,21 @@
"dependencies": {
"@headlessui/react": "^1.7.19",
"@kubernetes/client-node": "^1.0.0",
"cal-parser": "^1.0.2",
"classnames": "^2.5.1",
"compare-versions": "^6.1.1",
"dockerode": "^4.0.4",
"follow-redirects": "^1.15.9",
"gamedig": "^5.2.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"i18next": "^24.2.3",
"ical.js": "^2.1.0",
"js-yaml": "^4.1.0",
"json-rpc-2.0": "^1.7.0",
"luxon": "^3.5.0",
"memory-cache": "^0.2.0",
"minecraftstatuspinger": "^1.2.2",
"next": "^15.2.4",
"next": "^15.3.1",
"next-i18next": "^12.1.0",
"ping": "^0.4.4",
"pretty-bytes": "^6.1.1",
@@ -34,8 +36,7 @@
"react-dom": "^18.3.1",
"react-i18next": "^11.18.6",
"react-icons": "^5.4.0",
"recharts": "^2.15.1",
"rrule": "^2.8.1",
"recharts": "^2.15.3",
"swr": "^2.3.3",
"systeminformation": "^5.25.11",
"tough-cookie": "^5.1.2",
@@ -46,12 +47,12 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.0.9",
"eslint": "^9.21.0",
"eslint": "^9.25.1",
"eslint-config-next": "^15.2.4",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-import": "^2.31.0",
"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-hooks": "^5.1.0",
"postcss": "^8.5.3",

627
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -856,7 +856,8 @@
"physicalRelease": "Physical release",
"digitalRelease": "Digital release",
"noEventsToday": "No events for today!",
"noEventsFound": "No events found"
"noEventsFound": "No events found",
"errorWhenLoadingData": "Error when loading calendar data"
},
"romm": {
"platforms": "Platforms",
@@ -1042,5 +1043,11 @@
"downloads": "Downloads",
"uploads": "Uploads",
"sharedFiles": "Files"
},
"jellystat": {
"songs": "Songs",
"movies": "Movies",
"episodes": "Episodes",
"other": "Other"
}
}

View File

@@ -49,7 +49,7 @@ export const searchProviders = {
function getAvailableProviderIds(options) {
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]) {
return [options.provider];

View File

@@ -40,6 +40,7 @@ export default function getDockerArguments(server) {
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.key = readFileSync(path.join(CONF_DIR, servers[server].tls.keyFile));
res.conn.protocol = "https";
}
return res;

View File

@@ -304,6 +304,9 @@ export function cleanServiceGroups(groups) {
// frigate
enableRecentEvents,
// gamedig
gameToken,
// beszel, glances, immich, komga, mealie, pihole, pfsense, speedtest
version,
@@ -331,6 +334,9 @@ export function cleanServiceGroups(groups) {
referrerPolicy,
src,
// jellystat
days,
// kopia
snapshotHost,
snapshotPath,
@@ -484,6 +490,9 @@ export function cleanServiceGroups(groups) {
if (["diskstation", "qnap"].includes(type)) {
if (volume) widget.volume = volume;
}
if (type === "gamedig") {
if (gameToken) widget.gameToken = gameToken;
}
if (type === "kopia") {
if (snapshotHost) widget.snapshotHost = snapshotHost;
if (snapshotPath) widget.snapshotPath = snapshotPath;
@@ -563,6 +572,9 @@ export function cleanServiceGroups(groups) {
if (type === "spoolman") {
if (spoolIds !== undefined) widget.spoolIds = spoolIds;
}
if (type === "jellystat") {
if (days !== undefined) widget.days = parseInt(days, 10);
}
return widget;
});
return cleanedService;

View File

@@ -65,7 +65,7 @@ export default async function credentialedProxyHandler(req, res, map) {
} else if (widget.type === "proxmoxbackupserver") {
delete headers["Content-Type"];
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}`;
} else if (widget.type === "tubearchivist") {
headers.Authorization = `Token ${widget.key}`;

View File

@@ -110,21 +110,24 @@ export async function cachedRequest(url, duration = 5, ua = "homepage") {
export async function httpProxy(url, params = {}) {
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 agentOptions = disableIpv6 ? { family: 4, autoSelectFamily: false } : {};
let request = null;
if (constructedUrl.protocol === "https:") {
request = httpsRequest(constructedUrl, {
agent: new https.Agent({ ...agentOptions, rejectUnauthorized: false }),
...params,
});
let agent;
if (proxyUrl) {
agent = constructedUrl.protocol === "https:" ? new HttpsProxyAgent(proxyUrl) : new HttpProxyAgent(proxyUrl);
logger.debug("Using proxy for request to %s: %s", constructedUrl.href, proxyUrl);
} else {
request = httpRequest(constructedUrl, {
agent: new http.Agent(agentOptions),
...params,
});
agent =
constructedUrl.protocol === "https:"
? new https.Agent({ ...agentOptions, rejectUnauthorized: false })
: new http.Agent(agentOptions);
}
const request =
constructedUrl.protocol === "https:"
? httpsRequest(constructedUrl, { agent, ...params })
: httpRequest(constructedUrl, { agent, ...params });
try {
const [status, contentType, data, responseHeaders] = await request;

View File

@@ -1,21 +1,20 @@
import { parseString } from "cal-parser";
import ICAL from "ical.js";
import { DateTime } from "luxon";
import { useTranslation } from "next-i18next";
import { useEffect } from "react";
import { RRule } from "rrule";
import Error from "../../../components/services/widget/error";
import useWidgetAPI from "../../../utils/proxy/use-widget-api";
// https://gist.github.com/jlevy/c246006675becc446360a798e2b2d781
function simpleHash(str) {
/* eslint-disable no-plusplus, no-bitwise */
let hash = 0;
const prime = 31;
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 }) {
@@ -25,11 +24,49 @@ export default function Integration({ config, params, setEvents, hideErrors, tim
});
useEffect(() => {
let parsedIcal;
const { showName = false } = config?.params || {};
let events = [];
if (!icalError && icalData && !icalData.error) {
parsedIcal = parseString(icalData.data);
if (parsedIcal.events.length === 0) {
if (!icalData.data) {
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")}` };
}
}
@@ -37,72 +74,67 @@ export default function Integration({ config, params, setEvents, hideErrors, tim
const startDate = DateTime.fromISO(params.start);
const endDate = DateTime.fromISO(params.end);
if (icalError || !parsedIcal || !startDate.isValid || !endDate.isValid) {
if (icalError || events.length === 0 || !startDate.isValid || !endDate.isValid) {
return;
}
const eventsToAdd = {};
const events = parsedIcal?.getEventsBetweenDates(startDate.toJSDate(), endDate.toJSDate());
const now = timezone ? DateTime.now().setZone(timezone) : DateTime.now();
const rangeStart = ICAL.Time.fromJSDate(startDate.toJSDate());
const rangeEnd = ICAL.Time.fromJSDate(endDate.toJSDate());
events?.forEach((event) => {
let title = `${event?.summary?.value}`;
if (config?.params?.showName) {
title = `${config.name}: ${title}`;
}
// '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",
};
const getOcurrencesFromRange = (event) => {
if (!event.rrule) {
if (event.dtstart.compare(rangeStart) >= 0 && event.dtend.compare(rangeEnd) <= 0) {
return [event.dtstart];
}
};
let recurrenceOptions = event?.recurrenceRule?.origOptions;
// 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;
return [];
}
if (recurrenceOptions && Object.keys(recurrenceOptions).length !== 0) {
try {
const rule = new RRule(recurrenceOptions);
const recurringEvents = rule.between(startDate.toJSDate(), endDate.toJSDate());
const iterator = event.rrule.iterator(event.dtstart);
recurringEvents.forEach((date, i) => {
let eventDate = date;
if (event.dtstart?.params?.tzid) {
// date is in UTC but parsed as if it is in current timezone, so we need to adjust it
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);
const occurrences = [];
for (let next = iterator.next(); next && next.compare(rangeEnd) < 0; next = iterator.next()) {
if (next.compare(rangeStart) < 0) {
continue;
}
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 }));

View File

@@ -59,6 +59,7 @@ const components = {
jdownloader: dynamic(() => import("./jdownloader/component")),
jellyfin: dynamic(() => import("./emby/component")),
jellyseerr: dynamic(() => import("./jellyseerr/component")),
jellystat: dynamic(() => import("./jellystat/component")),
kavita: dynamic(() => import("./kavita/component")),
komga: dynamic(() => import("./komga/component")),
kopia: dynamic(() => import("./kopia/component")),

View File

@@ -12,13 +12,19 @@ export default async function gamedigProxyHandler(req, res) {
const url = new URL(serviceWidget.url);
try {
const serverData = await GameDig.query({
const gamedigOptions = {
type: serviceWidget.serverType,
host: url.hostname,
port: url.port,
givenPortOnly: true,
checkOldIDs: true,
});
};
if (serviceWidget.gameToken) {
gamedigOptions.token = serviceWidget.gameToken;
}
const serverData = await GameDig.query(gamedigOptions);
res.status(200).send({
online: true,

View 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>
);
}

View 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;

View File

@@ -49,6 +49,7 @@ import immich from "./immich/widget";
import jackett from "./jackett/widget";
import jdownloader from "./jdownloader/widget";
import jellyseerr from "./jellyseerr/widget";
import jellystat from "./jellystat/widget";
import karakeep from "./karakeep/widget";
import kavita from "./kavita/widget";
import komga from "./komga/widget";
@@ -190,6 +191,7 @@ const widgets = {
jdownloader,
jellyfin: emby,
jellyseerr,
jellystat,
kavita,
komga,
kopia,