feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -1,6 +1,7 @@
import { ParseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -10,12 +11,19 @@ import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-sum
import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from "./adguard-home-types";
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: { Authorization: `Basic ${this.getAuthorizationHeaderValue()}` } };
}
public async getSummaryAsync(): Promise<DnsHoleSummary> {
const statsResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/stats"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
});
const headers = {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
};
const [statsResponse, statusResponse, filteringStatusResponse] = await Promise.all([
fetchWithTrustedCertificatesAsync(this.url("/control/stats"), { headers }),
fetchWithTrustedCertificatesAsync(this.url("/control/status"), { headers }),
fetchWithTrustedCertificatesAsync(this.url("/control/filtering/status"), { headers }),
]);
if (!statsResponse.ok) {
throw new Error(
@@ -23,24 +31,12 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
);
}
const statusResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
});
if (!statusResponse.ok) {
throw new Error(
`Failed to fetch status for ${this.integration.name} (${this.integration.id}): ${statusResponse.statusText}`,
);
}
const filteringStatusResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/filtering/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
});
if (!filteringStatusResponse.ok) {
throw new Error(
`Failed to fetch filtering status for ${this.integration.name} (${this.integration.id}): ${filteringStatusResponse.statusText}`,
@@ -0,0 +1,95 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { AdGuardHomeIntegration } from "../adguard-home-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integration = new AdGuardHomeIntegration({
id: "adguard-1",
name: "AdGuard Home",
url: "https://adguard.example.com",
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: "admin" },
{ kind: "password", value: "secret" },
],
});
describe("AdGuardHomeIntegration", () => {
beforeEach(() => vi.clearAllMocks());
test("starts all summary requests before awaiting any response", async () => {
const requestGates = new Map<string, ReturnType<typeof Promise.withResolvers<Response>>>();
mockFetch.mockImplementation((url) => {
const path = new URL(url.toString()).pathname;
const gate = Promise.withResolvers<Response>();
requestGates.set(path, gate);
return gate.promise;
});
const resultPromise = integration.getSummaryAsync();
await vi.waitFor(() => {
expect([...requestGates.keys()]).toEqual(["/control/stats", "/control/status", "/control/filtering/status"]);
});
requestGates.get("/control/stats")?.resolve(jsonResponse(stats));
requestGates.get("/control/status")?.resolve(jsonResponse(status));
requestGates.get("/control/filtering/status")?.resolve(jsonResponse(filteringStatus));
await expect(resultPromise).resolves.toMatchObject({
status: "enabled",
adsBlockedToday: 20,
dnsQueriesToday: 100,
domainsBeingBlocked: 12,
});
});
});
const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 });
const stats = {
time_units: "hours",
top_queried_domains: [],
top_clients: [],
top_blocked_domains: [],
dns_queries: [40, 60],
blocked_filtering: [5, 15],
replaced_safebrowsing: [],
replaced_parental: [],
num_dns_queries: 100,
num_blocked_filtering: 20,
num_replaced_safebrowsing: 0,
num_replaced_safesearch: 0,
num_replaced_parental: 0,
avg_processing_time: 0.01,
};
const status = {
version: "1.0.0",
language: "en",
dns_addresses: ["0.0.0.0"],
dns_port: 53,
http_port: 3000,
protection_enabled: true,
dhcp_available: false,
running: true,
};
const filteringStatus = {
filters: [
{ url: "https://example.com/filter.txt", name: "Enabled", id: 1, rules_count: 12, enabled: true },
{ url: "https://example.com/disabled.txt", name: "Disabled", id: 2, rules_count: 50, enabled: false },
],
};
@@ -1,6 +1,7 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -26,6 +27,10 @@ const mediaTypeCountField = {
>;
export class AudiobookshelfIntegration extends Integration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: this.getAuthHeaders(), redactValues: [] };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const healthResponse = await input.fetchAsync(this.url("/healthcheck"));
@@ -54,6 +59,7 @@ export class AudiobookshelfIntegration extends Integration {
const statsByLibrary = await Promise.all(
libraries.map(async (library) => ({
library,
mediaType: library.mediaType,
stats: await this.getLibraryStatsAsync(library.id),
})),
@@ -75,6 +81,14 @@ export class AudiobookshelfIntegration extends Integration {
totalPodcasts: counts.totalPodcasts,
totalListeningTimeSeconds: listeningStats.totalTime,
activeSessions,
libraries: statsByLibrary.map(({ library, stats }) => ({
id: library.id,
name: library.name,
mediaType: library.mediaType,
totalItems: stats.totalItems,
totalDurationSeconds: stats.totalDuration ?? null,
totalSizeBytes: stats.totalSize ?? null,
})),
};
}
@@ -30,6 +30,14 @@ export interface AudiobookshelfDashboardData {
totalPodcasts: number;
totalListeningTimeSeconds: number;
activeSessions: number;
libraries?: {
id: string;
name: string;
mediaType: "book" | "podcast";
totalItems: number;
totalDurationSeconds: number | null;
totalSizeBytes: number | null;
}[];
}
export type AudiobookshelfLibrary = z.infer<typeof audiobookshelfLibrarySchema>;
+394 -133
View File
@@ -1,146 +1,407 @@
import type { IntegrationKind } from "@homarr/definitions";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { BeszelIntegration } from "../beszel/beszel-integration";
import { BazarrIntegration } from "../bazarr/bazarr-integration";
import { AnchorIntegration } from "../anchor/anchor-integration";
import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
import { Aria2Integration } from "../download-client/aria2/aria2-integration";
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration";
import { SlskdIntegration } from "../download-client/slskd/slskd-integration";
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
import { EmbyIntegration } from "../emby/emby-integration";
import { GlancesIntegration } from "../glances/glances-integration";
import { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration";
import { GluetunIntegration } from "../gluetun/gluetun-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { ICalIntegration } from "../ical/ical-integration";
import { ImmichIntegration } from "../immich/immich-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration";
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { MockIntegration } from "../mock/mock-integration";
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { GotifyIntegration } from "../gotify/gotify-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration";
import { PatchMonIntegration } from "../patchmon/patchmon-integration";
import { PeaNutIntegration } from "../peanut/peanut-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
import { createTechnitiumDnsIntegrationAsync } from "../technitium/technitium-integration-factory";
import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import { SeerrIntegration } from "../seerr/seerr-integration";
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { TraefikIntegration } from "../traefik/traefik-integration";
import { SynologyIntegration } from "../synology/synology-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UmamiIntegration } from "../umami/umami-integration";
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import { UnraidIntegration } from "../unraid/unraid-integration";
import { WudIntegration } from "../wud/wud-integration";
import type { Integration, IntegrationInput } from "./integration";
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
const logger = createLogger({ module: "integrationFactory" });
// Keep each import path explicit so Node and Turbopack can discover every lazy integration chunk.
const integrationCreators = {
autobrr: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { autobrrStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/autobrr"),
]);
return new StatsIntegration(input, autobrrStatsProvider);
},
jellystat: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { jellystatStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/jellystat"),
]);
return new StatsIntegration(input, jellystatStatsProvider);
},
scrutiny: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { scrutinyStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/scrutiny"),
]);
return new StatsIntegration(input, scrutinyStatsProvider);
},
tubearchivist: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { tubearchivistStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/tubearchivist"),
]);
return new StatsIntegration(input, tubearchivistStatsProvider);
},
frigate: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { frigateStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/frigate"),
]);
return new StatsIntegration(input, frigateStatsProvider);
},
komga: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { komgaStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/komga"),
]);
return new StatsIntegration(input, komgaStatsProvider);
},
netalertx: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { netalertxStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/netalertx"),
]);
return new StatsIntegration(input, netalertxStatsProvider);
},
jackett: async (input: IntegrationInput) =>
new (await import("../jackett/jackett-integration")).JackettIntegration(input),
yourSpotify: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { yourSpotifyStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/your-spotify"),
]);
return new StatsIntegration(input, yourSpotifyStatsProvider);
},
romm: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { rommStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/romm"),
]);
return new StatsIntegration(input, rommStatsProvider);
},
homebox: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { homeboxStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/homebox"),
]);
return new StatsIntegration(input, homeboxStatsProvider);
},
mealie: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { mealieStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/mealie"),
]);
return new StatsIntegration(input, mealieStatsProvider);
},
xteve: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { xteveStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/xteve"),
]);
return new StatsIntegration(input, xteveStatsProvider);
},
unmanic: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { unmanicStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/unmanic"),
]);
return new StatsIntegration(input, unmanicStatsProvider);
},
syncthingRelay: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { syncthingRelayStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/syncthing-relay"),
]);
return new StatsIntegration(input, syncthingRelayStatsProvider);
},
stash: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { stashStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/stash"),
]);
return new StatsIntegration(input, stashStatsProvider);
},
prometheus: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { prometheusStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/prometheus"),
]);
return new StatsIntegration(input, prometheusStatsProvider);
},
plantit: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { plantitStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/plantit"),
]);
return new StatsIntegration(input, plantitStatsProvider);
},
netdata: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { netdataStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/netdata"),
]);
return new StatsIntegration(input, netdataStatsProvider);
},
fileflows: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { fileflowsStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/fileflows"),
]);
return new StatsIntegration(input, fileflowsStatsProvider);
},
trilium: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { triliumStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/trilium"),
]);
return new StatsIntegration(input, triliumStatsProvider);
},
tandoor: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { tandoorStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/tandoor"),
]);
return new StatsIntegration(input, tandoorStatsProvider);
},
spoolman: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { spoolmanStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/spoolman"),
]);
return new StatsIntegration(input, spoolmanStatsProvider);
},
miniflux: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { minifluxStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/miniflux"),
]);
return new StatsIntegration(input, minifluxStatsProvider);
},
maintainerr: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { maintainerrStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/maintainerr"),
]);
return new StatsIntegration(input, maintainerrStatsProvider);
},
linkwarden: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { linkwardenStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/linkwarden"),
]);
return new StatsIntegration(input, linkwardenStatsProvider);
},
karakeep: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { karakeepStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/karakeep"),
]);
return new StatsIntegration(input, karakeepStatsProvider);
},
healthchecks: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { healthchecksStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/healthchecks"),
]);
return new StatsIntegration(input, healthchecksStatsProvider);
},
gatus: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { gatusStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/gatus"),
]);
return new StatsIntegration(input, gatusStatsProvider);
},
changedetection: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { changedetectionStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/changedetection"),
]);
return new StatsIntegration(input, changedetectionStatsProvider);
},
caddy: async (input: IntegrationInput) => {
const [{ StatsIntegration }, { caddyStatsProvider }] = await Promise.all([
import("../stats/stats-integration"),
import("../stats/providers/caddy"),
]);
return new StatsIntegration(input, caddyStatsProvider);
},
anchor: async (input: IntegrationInput) =>
new (await import("../anchor/anchor-integration")).AnchorIntegration(input),
piHole: async (input: IntegrationInput) =>
(await import("../pi-hole/pi-hole-integration-factory")).createPiHoleIntegrationAsync(input),
adGuardHome: async (input: IntegrationInput) =>
new (await import("../adguard-home/adguard-home-integration")).AdGuardHomeIntegration(input),
technitiumDns: async (input: IntegrationInput) =>
(await import("../technitium/technitium-integration-factory")).createTechnitiumDnsIntegrationAsync(input),
homeAssistant: async (input: IntegrationInput) =>
new (await import("../homeassistant/homeassistant-integration")).HomeAssistantIntegration(input),
jellyfin: async (input: IntegrationInput) =>
new (await import("../jellyfin/jellyfin-integration")).JellyfinIntegration(input),
plex: async (input: IntegrationInput) => new (await import("../plex/plex-integration")).PlexIntegration(input),
sonarr: async (input: IntegrationInput) =>
new (await import("../media-organizer/sonarr/sonarr-integration")).SonarrIntegration(input),
radarr: async (input: IntegrationInput) =>
new (await import("../media-organizer/radarr/radarr-integration")).RadarrIntegration(input),
sabNzbd: async (input: IntegrationInput) =>
new (await import("../download-client/sabnzbd/sabnzbd-integration")).SabnzbdIntegration(input),
nzbGet: async (input: IntegrationInput) =>
new (await import("../download-client/nzbget/nzbget-integration")).NzbGetIntegration(input),
qBittorrent: async (input: IntegrationInput) =>
new (await import("../download-client/qbittorrent/qbittorrent-integration")).QBitTorrentIntegration(input),
deluge: async (input: IntegrationInput) =>
new (await import("../download-client/deluge/deluge-integration")).DelugeIntegration(input),
transmission: async (input: IntegrationInput) =>
new (await import("../download-client/transmission/transmission-integration")).TransmissionIntegration(input),
slskd: async (input: IntegrationInput) =>
new (await import("../download-client/slskd/slskd-integration")).SlskdIntegration(input),
aria2: async (input: IntegrationInput) =>
new (await import("../download-client/aria2/aria2-integration")).Aria2Integration(input),
jellyseerr: async (input: IntegrationInput) =>
new (await import("../jellyseerr/jellyseerr-integration")).JellyseerrIntegration(input),
seerr: async (input: IntegrationInput) => new (await import("../seerr/seerr-integration")).SeerrIntegration(input),
overseerr: async (input: IntegrationInput) =>
new (await import("../overseerr/overseerr-integration")).OverseerrIntegration(input),
prowlarr: async (input: IntegrationInput) =>
new (await import("../prowlarr/prowlarr-integration")).ProwlarrIntegration(input),
openmediavault: async (input: IntegrationInput) =>
new (await import("../openmediavault/openmediavault-integration")).OpenMediaVaultIntegration(input),
lidarr: async (input: IntegrationInput) =>
new (await import("../media-organizer/lidarr/lidarr-integration")).LidarrIntegration(input),
readarr: async (input: IntegrationInput) =>
new (await import("../media-organizer/readarr/readarr-integration")).ReadarrIntegration(input),
bindery: async (input: IntegrationInput) =>
new (await import("../media-organizer/bindery/bindery-integration")).BinderyIntegration(input),
dashDot: async (input: IntegrationInput) =>
new (await import("../dashdot/dashdot-integration")).DashDotIntegration(input),
tdarr: async (input: IntegrationInput) =>
new (await import("../media-transcoding/tdarr-integration")).TdarrIntegration(input),
proxmox: async (input: IntegrationInput) =>
new (await import("../proxmox/proxmox-integration")).ProxmoxIntegration(input),
emby: async (input: IntegrationInput) => new (await import("../emby/emby-integration")).EmbyIntegration(input),
nextcloud: async (input: IntegrationInput) =>
new (await import("../nextcloud/nextcloud.integration")).NextcloudIntegration(input),
unifiController: async (input: IntegrationInput) =>
new (await import("../unifi-controller/unifi-controller-integration")).UnifiControllerIntegration(input),
opnsense: async (input: IntegrationInput) =>
new (await import("../opnsense/opnsense-integration")).OPNsenseIntegration(input),
ical: async (input: IntegrationInput) => new (await import("../ical/ical-integration")).ICalIntegration(input),
ntfy: async (input: IntegrationInput) => new (await import("../ntfy/ntfy-integration")).NTFYIntegration(input),
gotify: async (input: IntegrationInput) =>
new (await import("../gotify/gotify-integration")).GotifyIntegration(input),
mock: async (input: IntegrationInput) => new (await import("../mock/mock-integration")).MockIntegration(input),
truenas: async (input: IntegrationInput) =>
new (await import("../truenas/truenas-integration")).TrueNasIntegration(input),
synology: async (input: IntegrationInput) =>
new (await import("../synology/synology-integration")).SynologyIntegration(input),
unraid: async (input: IntegrationInput) =>
new (await import("../unraid/unraid-integration")).UnraidIntegration(input),
coolify: async (input: IntegrationInput) =>
new (await import("../coolify/coolify-integration")).CoolifyIntegration(input),
tracearr: async (input: IntegrationInput) =>
new (await import("../tracearr/tracearr-integration")).TracearrIntegration(input),
glances: async (input: IntegrationInput) =>
new (await import("../glances/glances-integration")).GlancesIntegration(input),
immich: async (input: IntegrationInput) =>
new (await import("../immich/immich-integration")).ImmichIntegration(input),
paperlessNgx: async (input: IntegrationInput) =>
new (await import("../paperless-ngx/paperless-ngx-integration")).PaperlessNgxIntegration(input),
wazuh: async (input: IntegrationInput) => new (await import("../wazuh/wazuh-integration")).WazuhIntegration(input),
patchmon: async (input: IntegrationInput) =>
new (await import("../patchmon/patchmon-integration")).PatchMonIntegration(input),
speedtestTracker: async (input: IntegrationInput) =>
new (await import("../speedtest-tracker/speedtest-tracker-integration")).SpeedtestTrackerIntegration(input),
audiobookshelf: async (input: IntegrationInput) =>
new (await import("../audiobookshelf/audiobookshelf-integration")).AudiobookshelfIntegration(input),
navidrome: async (input: IntegrationInput) =>
new (await import("../navidrome/navidrome-integration")).NavidromeIntegration(input),
umami: async (input: IntegrationInput) => new (await import("../umami/umami-integration")).UmamiIntegration(input),
gluetun: async (input: IntegrationInput) =>
new (await import("../gluetun/gluetun-integration")).GluetunIntegration(input),
archiveTeamWarrior: async (input: IntegrationInput) =>
new (await import("../archive-team-warrior/archive-team-warrior-integration")).ArchiveTeamWarriorIntegration(input),
uptimeKuma: async (input: IntegrationInput) =>
new (await import("../uptime-kuma/uptime-kuma-integration")).UptimeKumaIntegration(input),
peaNut: async (input: IntegrationInput) =>
new (await import("../peanut/peanut-integration")).PeaNutIntegration(input),
beszel: async (input: IntegrationInput) =>
new (await import("../beszel/beszel-integration")).BeszelIntegration(input),
bazarr: async (input: IntegrationInput) =>
new (await import("../bazarr/bazarr-integration")).BazarrIntegration(input),
traefik: async (input: IntegrationInput) =>
new (await import("../traefik/traefik-integration")).TraefikIntegration(input),
wud: async (input: IntegrationInput) => new (await import("../wud/wud-integration")).WudIntegration(input),
llamacpp: async (input: IntegrationInput) =>
new (await import("../llama-cpp/llamacpp-integration")).LlamacppIntegration(input),
} satisfies Record<IntegrationKind, (input: IntegrationInput) => Promise<Integration>>;
type IntegrationCreators = typeof integrationCreators;
export type IntegrationInstanceOfKind<TKind extends keyof IntegrationCreators> = Awaited<
ReturnType<IntegrationCreators[TKind]>
>;
export function createIntegrationAsync<TKind extends keyof IntegrationCreators>(
integration: IntegrationInput & { kind: TKind },
) => {
if (!(integration.kind in integrationCreators)) {
): Promise<IntegrationInstanceOfKind<TKind>>;
export async function createIntegrationAsync(
integration: IntegrationInput & { kind: IntegrationKind },
): Promise<Integration> {
if (!Object.hasOwn(integrationCreators, integration.kind)) {
logger.warn("Integration client creation refused", {
integrationId: integration.id,
integrationKind: integration.kind,
reason: "integration-kind-unregistered",
});
throw new Error(
`Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`,
);
}
const creator = integrationCreators[integration.kind];
const startedAt = Date.now();
logger.debug("Creating integration client", { integrationId: integration.id, integrationKind: integration.kind });
// factories are an array, to differentiate in js between class constructors and functions
if (Array.isArray(creator)) {
return (await creator[0](integration)) as IntegrationInstanceOfKind<TKind>;
try {
const instance = await integrationCreators[integration.kind](integration);
logger.debug("Integration client created", {
integrationId: integration.id,
integrationKind: integration.kind,
durationMs: Date.now() - startedAt,
});
return instance;
} catch (error) {
logger.warn(
new ErrorWithMetadata(
"Integration client creation failed",
{
integrationId: integration.id,
integrationKind: integration.kind,
durationMs: Date.now() - startedAt,
},
{ cause: error },
),
);
throw error;
}
}
return new creator(integration) as IntegrationInstanceOfKind<TKind>;
};
type IntegrationInstance = new (integration: IntegrationInput) => Integration;
// factories are an array, to differentiate in js between class constructors and functions
export const integrationCreators = {
anchor: AnchorIntegration,
piHole: [createPiHoleIntegrationAsync],
adGuardHome: AdGuardHomeIntegration,
technitiumDns: [createTechnitiumDnsIntegrationAsync],
homeAssistant: HomeAssistantIntegration,
jellyfin: JellyfinIntegration,
plex: PlexIntegration,
sonarr: SonarrIntegration,
radarr: RadarrIntegration,
sabNzbd: SabnzbdIntegration,
nzbGet: NzbGetIntegration,
qBittorrent: QBitTorrentIntegration,
deluge: DelugeIntegration,
transmission: TransmissionIntegration,
slskd: SlskdIntegration,
aria2: Aria2Integration,
jellyseerr: JellyseerrIntegration,
seerr: SeerrIntegration,
overseerr: OverseerrIntegration,
prowlarr: ProwlarrIntegration,
openmediavault: OpenMediaVaultIntegration,
lidarr: LidarrIntegration,
readarr: ReadarrIntegration,
dashDot: DashDotIntegration,
tdarr: TdarrIntegration,
proxmox: ProxmoxIntegration,
emby: EmbyIntegration,
nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration,
opnsense: OPNsenseIntegration,
ical: ICalIntegration,
ntfy: NTFYIntegration,
gotify: GotifyIntegration,
mock: MockIntegration,
truenas: TrueNasIntegration,
synology: SynologyIntegration,
unraid: UnraidIntegration,
coolify: CoolifyIntegration,
tracearr: TracearrIntegration,
glances: GlancesIntegration,
immich: ImmichIntegration,
paperlessNgx: PaperlessNgxIntegration,
patchmon: PatchMonIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
umami: UmamiIntegration,
gluetun: GluetunIntegration,
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
bazarr: BazarrIntegration,
traefik: TraefikIntegration,
wud: WudIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
[kind in TKind]: (typeof integrationCreators)[kind] extends [(input: IntegrationInput) => Promise<Integration>]
? Awaited<ReturnType<(typeof integrationCreators)[kind][0]>>
: (typeof integrationCreators)[kind] extends IntegrationInstance
? InstanceType<(typeof integrationCreators)[kind]>
: never;
}[TKind];
export async function getIntegrationHttpAuthenticationAsync(integration: IntegrationInput & { kind: IntegrationKind }) {
const client = await createIntegrationAsync(integration);
return client.getHttpAuthenticationAsync();
}
@@ -1,15 +1,21 @@
import { isFunction } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { Integration } from "../integration";
import type { IIntegrationErrorHandler } from "./handler";
import { integrationFetchHttpErrorHandler } from "./http";
import { IntegrationError } from "./integration-error";
import { IntegrationUnknownError } from "./integration-unknown-error";
import { IntegrationRequestError } from "./http/integration-request-error";
import { IntegrationResponseError } from "./http/integration-response-error";
import { integrationJsonParseErrorHandler, integrationZodParseErrorHandler } from "./parse";
const logger = createLogger({ module: "handleIntegrationErrors" });
const loggedIntegrationErrors = new WeakSet<IntegrationError>();
// ponytail: TypeScript requires `any[]` for mixin constructors (TS2545). The
// decorator returns the same constructor type T, so callers retain exact args.
// eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any
type AbstractConstructor<T = {}> = abstract new (...args: any[]) => T;
@@ -19,6 +25,68 @@ const defaultErrorHandlers: IIntegrationErrorHandler[] = [
integrationFetchHttpErrorHandler,
];
interface IntegrationOperationMetadata {
operation: string;
integrationId: string;
integrationKind: string;
integrationName: string;
}
const safeErrorNameRegex = /^(?:Error|[A-Za-z][A-Za-z0-9]*(?:Error|Err))$/;
const createSafeErrorCause = (error: unknown, depth = 0): Error => {
let options: ErrorOptions | undefined;
if (depth < 5 && error instanceof Error && error.cause !== undefined) {
options = { cause: createSafeErrorCause(error.cause, depth + 1) };
}
const safeError = new Error("Integration operation failed", options);
if (error instanceof Error && safeErrorNameRegex.test(error.name)) {
safeError.name = error.name;
}
if (error instanceof Error && error.stack) {
const originalHeader = Error.prototype.toString.call(error);
if (error.stack.startsWith(originalHeader)) {
safeError.stack = `${safeError.name}: ${safeError.message}${error.stack.slice(originalHeader.length)}`;
}
}
return safeError;
};
const logFailure = (
error: IntegrationError,
failureType: "normalized" | "unknown",
metadata: IntegrationOperationMetadata,
durationMs: number,
) => {
if (loggedIntegrationErrors.has(error)) {
return;
}
loggedIntegrationErrors.add(error);
const errorMetadata: Record<string, unknown> = { errorType: error.name };
if (error instanceof IntegrationRequestError) {
errorMetadata.requestType = error.cause.type;
errorMetadata.requestReason = error.cause.reason;
errorMetadata.requestCode = error.cause.code;
}
if (error instanceof IntegrationResponseError) {
errorMetadata.statusCode = error.cause.statusCode;
}
logger.warn(
new ErrorWithMetadata(
"Integration operation failed",
{
...metadata,
durationMs,
failureType,
...errorMetadata,
},
{ cause: createSafeErrorCause(error) },
),
);
};
export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[]) => {
const combinedErrorHandlers = [...defaultErrorHandlers, ...errorHandlers];
return <T extends AbstractConstructor<Integration>>(IntegrationBaseClass: T): T => {
@@ -44,8 +112,27 @@ export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[
processedProperties.add(propertyKey);
const wrapped = (...methodArgs: unknown[]) => {
const handleError = (error: unknown) => {
const startedAt = Date.now();
const operationMetadata: IntegrationOperationMetadata = {
operation: propertyKey,
integrationId: this.publicIntegration.id,
integrationKind: this.integration.kind ?? "unknown",
integrationName: this.publicIntegration.name,
};
logger.debug("Integration operation started", operationMetadata);
const logCompletion = () => {
logger.debug("Integration operation completed", {
...operationMetadata,
durationMs: Date.now() - startedAt,
});
};
const handleError = (error: unknown): never => {
if (error instanceof IntegrationError) {
const failureType = error instanceof IntegrationUnknownError ? "unknown" : "normalized";
logFailure(error, failureType, operationMetadata, Date.now() - startedAt);
throw error;
}
@@ -53,26 +140,29 @@ export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[
const handledError = handler.handleError(error, this.publicIntegration);
if (!handledError) continue;
logFailure(handledError, "normalized", operationMetadata, Date.now() - startedAt);
throw handledError;
}
// If the error was handled and should be thrown again, throw it
logger.debug("Unhandled error in integration", {
error: error instanceof Error ? `${error.name}: ${error.message}` : undefined,
integrationName: this.publicIntegration.name,
});
throw new IntegrationUnknownError(this.publicIntegration, { cause: error });
const unknownError = new IntegrationUnknownError(this.publicIntegration, { cause: error });
logFailure(unknownError, "unknown", operationMetadata, Date.now() - startedAt);
throw unknownError;
};
try {
const result = original.apply(this, methodArgs);
if (result instanceof Promise) {
return result.catch((error: unknown) => {
handleError(error);
});
return result.then(
(value: unknown) => {
logCompletion();
return value;
},
(error: unknown) => handleError(error),
);
}
logCompletion();
return result;
} catch (error: unknown) {
handleError(error);
+13 -1
View File
@@ -5,8 +5,11 @@ import { fetch as undiciFetch } from "undici";
import { removeTrailingSlash } from "@homarr/common";
import { createAxiosCertificateInstanceAsync, createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { getIntegrationHttpAuthDefinition } from "@homarr/definitions";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { resolveDeclaredHttpAuthentication } from "../http-auth";
import { HandleIntegrationErrors } from "./errors/decorator";
import { TestConnectionError } from "./test-connection/test-connection-error";
import type { TestingResult } from "./test-connection/test-connection-service";
@@ -15,6 +18,7 @@ import type { IntegrationSecret } from "./types";
export interface IntegrationInput {
id: string;
kind?: IntegrationKind;
name: string;
url: string;
externalUrl: string | null;
@@ -35,6 +39,14 @@ export interface IntegrationTestingInput {
export abstract class Integration {
constructor(protected integration: IntegrationInput) {}
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
if (this.integration.kind) {
const definition = getIntegrationHttpAuthDefinition(this.integration.kind);
if (definition.type !== "adapter") return resolveDeclaredHttpAuthentication(this.integration, definition);
}
throw new Error("This integration does not support arbitrary HTTP requests");
}
public get publicIntegration() {
return {
id: this.integration.id,
@@ -0,0 +1,43 @@
import { describe, expect, test } from "vitest";
import { z } from "zod/v4";
import { simulateResponseContractAsync } from "./response-contract";
const parser = async (response: { json: () => Promise<unknown> }) =>
z.object({ count: z.number() }).parse(await response.json());
describe("simulateResponseContractAsync", () => {
test("runs valid, mapped, and rejected payload fixtures", async () => {
const results = await simulateResponseContractAsync(
parser,
[
{ name: "valid response", payload: { count: 2 }, expected: { total: 2 } },
{ name: "invalid response", payload: { count: "two" }, rejects: true },
],
(value) => ({ total: value.count }),
);
expect(results).toEqual([
{ name: "valid response", passed: true, message: undefined },
{ name: "invalid response", passed: true, message: undefined },
]);
});
test("treats a fixture without expected or rejects as a successful parse assertion", async () => {
const results = await simulateResponseContractAsync(parser, [{ name: "parses only", payload: { count: 5 } }]);
expect(results).toEqual([{ name: "parses only", passed: true, message: undefined }]);
});
test("reports actionable mismatches without throwing away later fixtures", async () => {
const results = await simulateResponseContractAsync(parser, [
{ name: "wrong output", payload: { count: 2 }, expected: { count: 3 } },
{ name: "unexpected success", payload: { count: 4 }, rejects: true },
]);
expect(results).toEqual([
{ name: "wrong output", passed: false, message: "Parsed output did not match expected." },
{ name: "unexpected success", passed: false, message: "Expected parser rejection, but parsing succeeded." },
]);
});
});
@@ -0,0 +1,51 @@
export type ResponseContractParser<TResult> = (response: { json: () => Promise<unknown> }) => Promise<TResult>;
export interface ResponseContractFixture<TResult> {
name: string;
payload: unknown;
expected?: TResult;
rejects?: true;
}
/**
* Runs JSON-like fixtures through the same parser used by an integration.
* This keeps contribution fixtures transport-free and deterministic while
* still exercising schema validation and response mapping.
*/
export const simulateResponseContractAsync = async <TParsed, TResult = TParsed>(
parser: ResponseContractParser<TParsed>,
fixtures: readonly ResponseContractFixture<TResult>[],
map: (parsed: TParsed) => TResult = (parsed) => parsed as unknown as TResult,
) => {
const results: { name: string; passed: boolean; message?: string }[] = [];
for (const fixture of fixtures) {
try {
const parsed = await parser({ json: () => Promise.resolve(structuredClone(fixture.payload)) });
if (fixture.rejects) {
results.push({
name: fixture.name,
passed: false,
message: "Expected parser rejection, but parsing succeeded.",
});
continue;
}
const actual = map(parsed);
const passed = "expected" in fixture ? isDeepStrictEqual(actual, fixture.expected) : true;
results.push({
name: fixture.name,
passed,
message: passed ? undefined : "Parsed output did not match expected.",
});
} catch (error) {
results.push({
name: fixture.name,
passed: fixture.rejects === true,
message: fixture.rejects ? undefined : error instanceof Error ? error.message : String(error),
});
}
}
return results;
};
import { isDeepStrictEqual } from "node:util";
@@ -1,15 +1,66 @@
import superjson from "superjson";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createKeyedFingerprint, decryptSecret, encryptSecret, verifyKeyedFingerprint } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createGetSetChannel } from "@homarr/redis";
import { createGetSetChannel, getIntegrationSessionStoreKey } from "@homarr/redis";
import type { IntegrationSecret } from "./types";
const logger = createLogger({ module: "sessionStore" });
const sessionEnvelopeType = "homarr-integration-session";
const sessionEnvelopeVersion = 1;
export const createSessionStore = <TValue>(integration: { id: string }) => {
const channelName = `session-store:${integration.id}`;
interface SessionStoreIntegration {
id: string;
url: string;
decryptedSecrets: readonly IntegrationSecret[];
}
interface SessionEnvelope<TValue> {
type: typeof sessionEnvelopeType;
version: typeof sessionEnvelopeVersion;
configurationFingerprint: string;
value: TValue;
}
const compareStrings = (left: string, right: string) => {
if (left < right) return -1;
if (left > right) return 1;
return 0;
};
const serializeSessionConfiguration = (integration: SessionStoreIntegration) => {
const secrets = integration.decryptedSecrets
.map(({ kind, value }) => [kind, value] as const)
.toSorted(([leftKind, leftValue], [rightKind, rightValue]) => {
const kindComparison = compareStrings(leftKind, rightKind);
if (kindComparison !== 0) return kindComparison;
return compareStrings(leftValue, rightValue);
});
return JSON.stringify({ integrationId: integration.id, url: integration.url, secrets });
};
const isSessionEnvelope = <TValue>(value: unknown): value is SessionEnvelope<TValue> => {
if (typeof value !== "object" || value === null) return false;
if (!("value" in value)) return false;
return (
"type" in value &&
value.type === sessionEnvelopeType &&
"version" in value &&
value.version === sessionEnvelopeVersion &&
"configurationFingerprint" in value &&
typeof value.configurationFingerprint === "string"
);
};
export const createSessionStore = <TValue>(integration: SessionStoreIntegration) => {
const channelName = getIntegrationSessionStoreKey(integration.id);
const channel = createGetSetChannel<`${string}.${string}`>(channelName);
const serializedConfiguration = serializeSessionConfiguration(integration);
const configurationFingerprint = createKeyedFingerprint(serializedConfiguration);
return {
async getAsync() {
@@ -17,7 +68,16 @@ export const createSessionStore = <TValue>(integration: { id: string }) => {
const value = await channel.getAsync();
if (!value) return null;
try {
return superjson.parse<TValue>(decryptSecret(value));
const envelope = superjson.parse<unknown>(decryptSecret(value));
if (!isSessionEnvelope<TValue>(envelope)) {
logger.debug("Ignoring legacy or unsupported session", { store: channelName });
return null;
}
if (!verifyKeyedFingerprint(serializedConfiguration, envelope.configurationFingerprint)) {
logger.debug("Ignoring session for a different integration configuration", { store: channelName });
return null;
}
return envelope.value;
} catch (error) {
logger.warn("Failed to load session", { store: channelName, error });
return null;
@@ -31,7 +91,13 @@ export const createSessionStore = <TValue>(integration: { id: string }) => {
async setAsync(value: TValue, options?: { ttlSeconds?: number }) {
logger.debug("Updating session in store", { store: channelName, ttlSeconds: options?.ttlSeconds });
try {
await channel.setAsync(encryptSecret(superjson.stringify(value)), options);
const envelope: SessionEnvelope<TValue> = {
type: sessionEnvelopeType,
version: sessionEnvelopeVersion,
configurationFingerprint,
value,
};
await channel.setAsync(encryptSecret(superjson.stringify(envelope)), options);
} catch (error) {
logger.error(new ErrorWithMetadata("Failed to save session", { store: channelName }, { cause: error }));
}
@@ -1,3 +1,4 @@
import { isRecord } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
@@ -10,6 +11,7 @@ import type { SessionStore } from "../base/session-store";
import { createSessionStore } from "../base/session-store";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type {
BeszelAlert,
BeszelAlertHistory,
@@ -66,7 +68,7 @@ export const normalizeRealtimeSnapshot = (
const id = `realtime-${receivedAt.getTime()}`;
const events: LiveStatsEvent[] = [];
if (payload.stats && typeof payload.stats === "object" && !Array.isArray(payload.stats)) {
if (isRecord(payload.stats)) {
events.push({
type: "system_stats",
record: { id, system: systemId, stats: payload.stats, type: "1m", created, updated: created },
@@ -131,7 +133,29 @@ export class BeszelIntegration extends Integration {
this.sessionStore = createSessionStore(integration);
}
public override async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
const session = await this.authenticateAsync();
return {
headers: { Authorization: session.token },
redactValues: [session.token],
};
}
private pendingAuthentication: Promise<BeszelSession> | undefined;
private async authenticateAsync(): Promise<BeszelSession> {
if (this.pendingAuthentication) return await this.pendingAuthentication;
const pending = this.loadSessionAsync();
this.pendingAuthentication = pending;
try {
return await pending;
} finally {
this.pendingAuthentication = undefined;
}
}
private async loadSessionAsync(): Promise<BeszelSession> {
const existingSession = await this.sessionStore.getAsync();
if (existingSession && !isSessionExpired(existingSession)) {
logger.debug("Using stored Beszel session", { integrationId: this.integration.id });
@@ -151,6 +175,7 @@ export class BeszelIntegration extends Integration {
const response = await fetchWithTrustedCertificatesAsync(authUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
redirect: "error",
body: JSON.stringify({
identity: this.getSecretValue("username"),
password: this.getSecretValue("password"),
@@ -40,7 +40,7 @@ export interface BeszelSystemInfo {
dp: number;
/** battery [level%, charging state] */
bat?: [number, number];
/** bandwidth — public interface (Mbps, legacy). Use bb when available */
/** bandwidth — public interface (MiB/s, legacy). Use bb when available */
b?: number;
/** bandwidth — public interface (bytes/s, newer format). Preferred over b */
bb?: number;
@@ -94,9 +94,9 @@ export interface BeszelSystemDetails {
*
* Units:
* - CPU/GPU/memory/disk percentages: 0–100
* - Memory/disk absolute values (mu, du, m, d, etc.): GB
* - Disk I/O (dr, dw): MB/s
* - Network (ns, nr): bytes/s — all interfaces (including loopback, docker bridges)
* - Memory/disk absolute values (mu, du, m, d, etc.): GiB
* - Disk I/O (dr, dw): MiB/s
* - Legacy network (ns, nr): MiB/s — all interfaces (including loopback, docker bridges)
* - Bandwidth (b): bytes/s [sent, recv] — public interfaces only. Prefer b over ns/nr for user-facing charts
*/
export interface BeszelSystemStats {
@@ -110,49 +110,49 @@ export interface BeszelSystemStats {
cpus?: number[];
/** load average [1m, 5m, 15m] */
la?: [number, number, number];
/** total memory (GB) */
/** total memory (GiB) */
m: number;
/** memory used (GB) */
/** memory used (GiB) */
mu: number;
/** memory usage (%) */
mp: number;
/** memory buffer/cache (GB) */
/** memory buffer/cache (GiB) */
mb: number;
/** memory max used (GB) */
/** memory max used (GiB) */
mm?: number;
/** memory zfs arc (GB) */
/** memory zfs arc (GiB) */
mz?: number;
/** total swap (GB) */
/** total swap (GiB) */
s: number;
/** swap used (GB) */
/** swap used (GiB) */
su: number;
/** total disk (GB) */
/** total disk (GiB) */
d: number;
/** disk used (GB) */
/** disk used (GiB) */
du: number;
/** disk usage (%) */
dp: number;
/** disk read (MB/s) */
/** disk read (MiB/s) */
dr?: number;
/** disk write (MB/s) */
/** disk write (MiB/s) */
dw?: number;
/** disk read max (MB/s) */
/** disk read max (MiB/s) */
drm?: number;
/** disk write max (MB/s) */
/** disk write max (MiB/s) */
dwm?: number;
/** disk IOPS [read, write] */
/** disk throughput [read bytes/s, write bytes/s]. Prefer over dr/dw */
dio?: [number, number];
/** disk IOPS max [read, write] */
/** disk throughput max [read bytes/s, write bytes/s] */
diom?: [number, number];
/** network sent — all interfaces (bytes/s) */
/** legacy network sent — all interfaces (MiB/s) */
ns?: number;
/** network received — all interfaces (bytes/s) */
/** legacy network received — all interfaces (MiB/s) */
nr?: number;
/** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */
b?: [number, number];
/** network sent max (bytes/s) */
/** legacy network sent max (MiB/s) */
nsm?: number;
/** network received max (bytes/s) */
/** legacy network received max (MiB/s) */
nrm?: number;
/** bandwidth max [sent, recv] (bytes/s) */
bm?: [number, number];
@@ -173,21 +173,21 @@ export interface BeszelExtraFsStats {
d: number;
/** disk used (GiB) */
du: number;
/** read (bytes/s) */
/** legacy disk read (MiB/s) */
r: number;
/** write (bytes/s) */
/** legacy disk write (MiB/s) */
w: number;
/** read max (bytes/s) */
/** legacy disk read max (MiB/s) */
rm?: number;
/** write max (bytes/s) */
/** legacy disk write max (MiB/s) */
wm?: number;
/** read (IOPS) */
/** disk read (bytes/s) */
rb?: number;
/** write (IOPS) */
/** disk write (bytes/s) */
wb?: number;
/** read max (IOPS) */
/** disk read max (bytes/s) */
rbm?: number;
/** write max (IOPS) */
/** disk write max (bytes/s) */
wbm?: number;
}
@@ -236,18 +236,18 @@ export interface BeszelContainer {
* One entry per container in a BeszelContainerStatsRecord.
*
* Network fields: b (bandwidth) is preferred when available; fall back to ns/nr.
* Units: m = MB, c = %, b/ns/nr = bytes/s
* Units: m/ns/nr = MiB or MiB/s, c = %, b = bytes/s
*/
export interface BeszelContainerStats {
/** container name */
n: string;
/** CPU usage (%) */
c: number;
/** memory usage (MB) */
/** memory usage (MiB) */
m: number;
/** network sent (bytes/s) — legacy, use b when available */
/** network sent (MiB/s) — legacy, use b when available */
ns?: number;
/** network received (bytes/s) — legacy, use b when available */
/** network received (MiB/s) — legacy, use b when available */
nr?: number;
/** network bandwidth [sent, recv] (bytes/s) — preferred over ns/nr */
b?: [number, number];
@@ -388,7 +388,7 @@ export interface BeszelSystemRow {
gpu: number;
/** load average [1m, 5m, 15m] */
loadAvg: [number, number, number] | null;
/** public interface bandwidth (bytes/s). From info.bb or info.b * 1M */
/** public interface bandwidth (bytes/s). From info.bb or legacy info.b normalized from MiB/s */
netBytes: number;
/** temperature (°C) */
temp: number | null;
@@ -13,6 +13,7 @@ const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? "";
const closeAgent = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("@homarr/redis", () => ({
getIntegrationSessionStoreKey: (integrationId: string) => `session-store:${integrationId}`,
createGetSetChannel: () => ({
getAsync: vi.fn().mockResolvedValue(null),
setAsync: vi.fn().mockResolvedValue(undefined),
@@ -12,6 +12,7 @@ vi.hoisted(() => {
const store = vi.hoisted(() => new Map<string, { value: string; ttlSeconds?: number }>());
vi.mock("@homarr/redis", () => ({
getIntegrationSessionStoreKey: (integrationId: string) => `session-store:${integrationId}`,
createGetSetChannel: (name: string) => ({
getAsync: () => Promise.resolve(store.get(name)?.value ?? null),
setAsync: (value: string, options?: { ttlSeconds?: number }) => {
@@ -1,6 +1,7 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
@@ -32,6 +33,10 @@ import {
} from "./coolify-types";
export class CoolifyIntegration extends Integration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: this.getAuthHeaders() };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const healthUrl = this.url("/api/health");
const healthResponse = await input.fetchAsync(healthUrl, {});
@@ -26,13 +26,16 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
}
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const info = await this.getInfoAsync();
const cpuLoad = await this.getCurrentCpuLoadAsync();
const memoryLoad = await this.getCurrentMemoryLoadAsync();
const storageLoad = await this.getCurrentStorageLoadAsync();
const networkLoad = await this.getCurrentNetworkLoadAsync();
const gpuLoad = await this.getCurrentGpuLoadAsync();
const [info, cpuLoad, memoryLoad, storageLoad, networkLoad, gpuLoad] = await Promise.all([
this.getInfoAsync(),
this.getCurrentCpuLoadAsync(),
this.getCurrentMemoryLoadAsync(),
this.getCurrentStorageLoadAsync(),
this.getCurrentNetworkLoadAsync(),
this.getCurrentGpuLoadAsync(),
]);
// Read history after the CPU request has persisted its newest sample.
const channel = this.getChannel();
const history = await channel.getSliceUntilTimeAsync(dayjs().subtract(15, "minutes").toDate());
@@ -0,0 +1,114 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createChannelEventHistoryOld } from "../../../../redis/src/lib/channel";
import { DashDotIntegration } from "../dashdot-integration";
vi.mock("@homarr/redis", () => ({}));
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
vi.mock("../../../../redis/src/lib/channel", () => ({ createChannelEventHistoryOld: vi.fn() }));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const mockCreateHistory = vi.mocked(createChannelEventHistoryOld);
const channel = {
pushAsync: vi.fn(),
getSliceUntilTimeAsync: vi.fn(),
};
const integration = new DashDotIntegration({
id: "dashdot-1",
name: "DashDot",
url: "https://dashdot.example.com",
externalUrl: null,
decryptedSecrets: [],
});
describe("DashDotIntegration", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateHistory.mockReturnValue(channel as never);
});
test("starts independent requests together and reads history after the CPU sample is persisted", async () => {
const requestGates = new Map<string, ReturnType<typeof Promise.withResolvers<Response>>>();
const persistenceGate = Promise.withResolvers<void>();
const order: string[] = [];
mockFetch.mockImplementation((url) => {
const path = new URL(url.toString()).pathname;
const gate = Promise.withResolvers<Response>();
requestGates.set(path, gate);
return gate.promise;
});
channel.pushAsync.mockImplementation(async () => {
order.push("push-start");
await persistenceGate.promise;
order.push("push-end");
});
channel.getSliceUntilTimeAsync.mockImplementation(async () => {
order.push("history");
return [cpuLoad];
});
const resultPromise = integration.getSystemInfoAsync();
await vi.waitFor(() => {
expect([...requestGates.keys()]).toEqual([
"/info",
"/load/cpu",
"/load/ram",
"/load/storage",
"/load/network",
"/load/gpu",
]);
});
requestGates.get("/info")?.resolve(jsonResponse(serverInfo));
requestGates.get("/load/cpu")?.resolve(jsonResponse(cpuLoad));
requestGates.get("/load/ram")?.resolve(jsonResponse({ load: 4_000 }));
requestGates.get("/load/storage")?.resolve(jsonResponse([250]));
requestGates.get("/load/network")?.resolve(jsonResponse({ up: 20, down: 10 }));
requestGates.get("/load/gpu")?.resolve(jsonResponse({ layout: [{ load: 30, memory: 40 }] }));
await vi.waitFor(() => expect(channel.pushAsync).toHaveBeenCalledOnce());
expect(channel.getSliceUntilTimeAsync).not.toHaveBeenCalled();
persistenceGate.resolve();
const result = await resultPromise;
expect(order).toEqual(["push-start", "push-end", "history"]);
expect(result).toMatchObject({
cpuUtilization: 15,
memUsedInBytes: 4_000,
memAvailableInBytes: 12_000,
network: { up: 20, down: 10 },
fileSystem: [{ used: "250", available: "750", percentage: 25 }],
gpu: [{ memoryUtilization: 40, processorUtilization: 30 }],
});
});
});
const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 });
const cpuLoad = [
{ load: 10, temp: 40 },
{ load: 20, temp: 50 },
];
const serverInfo = {
os: { distro: "Linux", kernel: "6.0", release: "1", uptime: 60 },
cpu: { brand: "Intel", model: "Test CPU" },
ram: { size: 16_000 },
storage: [{ size: 1_000, disks: [{ device: "sda", brand: "Test", type: "SSD" }] }],
gpu: { layout: [{ brand: "Test GPU" }] },
};
@@ -11,8 +11,19 @@ import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/dow
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
import type { Aria2Download, Aria2GetClient } from "./aria2-types";
import type { IntegrationHttpAuthentication } from "../../http-auth";
export class Aria2Integration extends Integration implements IDownloadClientIntegration {
public override async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
if (!this.hasSecretValue("apiKey")) return { headers: {} };
const token = this.getSecretValue("apiKey");
return {
headers: {},
body: { type: "jsonArrayPrefix", name: "params", value: `token:${token}` },
redactValues: [token, `token:${token}`],
};
}
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const client = this.getClient();
const keys: (keyof Aria2Download)[] = [
@@ -62,7 +73,7 @@ export class Aria2Integration extends Integration implements IDownloadClientInte
downSpeed: Number(download.downloadSpeed),
upSpeed: Number(download.uploadSpeed),
time: this.calculateEta(completedSize, totalSize, Number(download.downloadSpeed)),
state: this.getState(download.status, Boolean(download.bittorrent)),
state: this.getState(download.status, Boolean(download.bittorrent), progress),
category: [],
progress,
};
@@ -147,8 +158,12 @@ export class Aria2Integration extends Integration implements IDownloadClientInte
) as Aria2GetClient;
}
private getState(aria2Status: Aria2Download["status"], isTorrent: boolean): DownloadClientItem["state"] {
return isTorrent ? this.getTorrentState(aria2Status) : this.getNonTorrentState(aria2Status);
private getState(
aria2Status: Aria2Download["status"],
isTorrent: boolean,
progress: number,
): DownloadClientItem["state"] {
return isTorrent ? this.getTorrentState(aria2Status, progress) : this.getNonTorrentState(aria2Status);
}
private getNonTorrentState(aria2Status: Aria2Download["status"]): DownloadClientItem["state"] {
switch (aria2Status) {
@@ -167,10 +182,14 @@ export class Aria2Integration extends Integration implements IDownloadClientInte
return "unknown";
}
}
private getTorrentState(aria2Status: Aria2Download["status"]): DownloadClientItem["state"] {
// aria2 reports an active torrent as "active" whether it is still downloading
// or has finished and is only seeding, so the download state has to come from
// the progress (1 once completedLength reaches totalLength). Without this a
// seeding torrent is indistinguishable from an actively downloading one.
private getTorrentState(aria2Status: Aria2Download["status"], progress: number): DownloadClientItem["state"] {
switch (aria2Status) {
case "active":
return "leeching";
return progress >= 1 ? "seeding" : "leeching";
case "waiting":
return "queued";
case "paused":
@@ -2,7 +2,7 @@ import { Deluge } from "@ctrl/deluge";
import dayjs from "dayjs";
import type { Dispatcher } from "undici";
import { createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { HandleIntegrationErrors } from "../../base/errors/decorator";
import { integrationOFetchHttpErrorHandler } from "../../base/errors/http";
@@ -14,9 +14,34 @@ import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/dow
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
import type { IntegrationHttpAuthentication } from "../../http-auth";
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
export class DelugeIntegration extends Integration implements IDownloadClientIntegration {
public override async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
const password = this.getSecretValue("password");
const response = await fetchWithTrustedCertificatesAsync(this.url("/json"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ method: "auth.login", params: [password], id: 1 }),
redirect: "error",
}).catch(() => {
throw new Error("Deluge authentication failed");
});
if (!response.ok) throw new Error("Deluge authentication failed");
const result = (await response.json().catch(() => null)) as { result?: unknown; error?: unknown } | null;
if (result?.result !== true || result.error) throw new Error("Deluge authentication failed");
const cookies = response.headers
.getSetCookie()
.map((cookie) => cookie.split(";", 1)[0])
.filter((cookie): cookie is string => Boolean(cookie));
if (cookies.length === 0) throw new Error("Deluge authentication failed");
const cookieHeader = cookies.join("; ");
return { headers: { Cookie: cookieHeader }, redactValues: [password, ...cookies] };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.getClientAsync(input.dispatcher);
const isSuccess = await client.login();
@@ -23,9 +23,11 @@ export class NzbGetIntegration extends Integration implements IDownloadClientInt
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "usenet";
const queue = await this.nzbGetApiCallAsync("listgroups");
const history = await this.nzbGetApiCallAsync("history");
const nzbGetStatus = await this.nzbGetApiCallAsync("status");
const [queue, history, nzbGetStatus] = await Promise.all([
this.nzbGetApiCallAsync("listgroups"),
this.nzbGetApiCallAsync("history"),
this.nzbGetApiCallAsync("status"),
]);
const status: DownloadClientStatus = {
paused: nzbGetStatus.DownloadPaused,
rates: { down: nzbGetStatus.DownloadRate },
@@ -0,0 +1,75 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../../base/types";
import { NzbGetIntegration } from "../nzbget-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const secrets: IntegrationSecret[] = [
{ kind: "username", value: "test-user" },
{ kind: "password", value: "test-password" },
];
const createIntegration = () =>
new NzbGetIntegration({
id: "test-nzbget",
name: "Test NZBGet",
url: "https://nzbget.example.com",
externalUrl: null,
decryptedSecrets: secrets,
});
describe("NzbGetIntegration.getClientJobsAndStatusAsync", () => {
beforeEach(() => {
mockFetch.mockReset();
});
test("starts queue, history, and status requests together", async () => {
const releaseRequests = Promise.withResolvers<void>();
const startedMethods: string[] = [];
const results = {
listgroups: [],
history: [],
status: { DownloadPaused: false, DownloadRate: 0 },
} as const;
mockFetch.mockImplementation(async (_url, init) => {
const method = (JSON.parse(String(init?.body)) as { method: keyof typeof results }).method;
startedMethods.push(method);
await releaseRequests.promise;
return new Response(JSON.stringify({ result: results[method] }), {
status: 200,
headers: { "content-type": "application/json" },
});
});
const resultPromise = createIntegration().getClientJobsAndStatusAsync({ limit: 10 });
try {
await vi.waitFor(() => {
expect(startedMethods).toEqual(expect.arrayContaining(["listgroups", "history", "status"]));
expect(startedMethods).toHaveLength(3);
});
} finally {
releaseRequests.resolve();
}
await expect(resultPromise).resolves.toStrictEqual({
status: { paused: false, rates: { down: 0 }, types: ["usenet"] },
items: [],
});
});
});
@@ -25,15 +25,20 @@ export class SabnzbdIntegration extends Integration implements IDownloadClientIn
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "usenet";
const { queue } = await queueSchema.parseAsync(
await this.sabNzbApiCallAsync("queue", { limit: input.limit.toString() }),
);
const { history } = await historySchema.parseAsync(
await this.sabNzbApiCallAsync("history", { limit: input.limit.toString() }),
);
const [queueResult, historyResult] = await Promise.all([
this.sabNzbApiCallAsync("queue", { limit: input.limit.toString() }).then((result) =>
queueSchema.parseAsync(result),
),
this.sabNzbApiCallAsync("history", { limit: input.limit.toString() }).then((result) =>
historySchema.parseAsync(result),
),
]);
const { queue } = queueResult;
const { history } = historyResult;
const status: DownloadClientStatus = {
paused: queue.paused,
rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps ()
// SABnzbd's legacy kbpersec field is kibibytes per second; normalize it to bytes per second.
rates: { down: Math.floor(Number(queue.kbpersec) * 1024) },
types: [type],
};
const items = queue.slots
@@ -0,0 +1,71 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../../base/types";
import { SabnzbdIntegration } from "../sabnzbd-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "test-api-key" }];
const createIntegration = () =>
new SabnzbdIntegration({
id: "test-sabnzbd",
name: "Test SABnzbd",
url: "https://sabnzbd.example.com",
externalUrl: null,
decryptedSecrets: secrets,
});
describe("SabnzbdIntegration.getClientJobsAndStatusAsync", () => {
beforeEach(() => {
mockFetch.mockReset();
});
test("starts queue and history requests together", async () => {
const releaseRequests = Promise.withResolvers<void>();
const startedModes: string[] = [];
const results = {
queue: { queue: { paused: false, kbpersec: "0", slots: [] } },
history: { history: { slots: [] } },
} as const;
mockFetch.mockImplementation(async (url) => {
const mode = new URL(String(url)).searchParams.get("mode") as keyof typeof results;
startedModes.push(mode);
await releaseRequests.promise;
return new Response(JSON.stringify(results[mode]), {
status: 200,
headers: { "content-type": "application/json" },
});
});
const resultPromise = createIntegration().getClientJobsAndStatusAsync({ limit: 10 });
try {
await vi.waitFor(() => {
expect(startedModes).toEqual(expect.arrayContaining(["queue", "history"]));
expect(startedModes).toHaveLength(2);
});
} finally {
releaseRequests.resolve();
}
await expect(resultPromise).resolves.toStrictEqual({
status: { paused: false, rates: { down: 0 }, types: ["usenet"] },
items: [],
});
});
});
@@ -6,6 +6,8 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { apiKeyAuth } from "../../http-auth";
import type { IntegrationTestingInput } from "../../base/integration";
import { Integration } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
@@ -49,6 +51,10 @@ const logger = createLogger({ module: "slskd-integration" });
* Slskd integration cannot pause or stop downloads, it can only list downloads.
*/
export class SlskdIntegration extends Integration implements IDownloadClientIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
// eslint-disable-next-line no-restricted-syntax
public pauseQueueAsync(): Promise<void> {
return Promise.resolve();
@@ -10,9 +10,22 @@ import { TestConnectionError } from "../base/test-connection/test-connection-err
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import { convertJellyfinType } from "../jellyfin/jellyfin-integration";
import { convertJellyfinType, parseLocation, ticksToMs } from "../jellyfin/jellyfin-integration";
import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types";
const transcodingInfoSchema = z.object({
Bitrate: z.number().nullish(),
Framerate: z.number().nullish(),
AudioChannels: z.number().nullish(),
AudioCodec: z.string().nullish(),
VideoCodec: z.string().nullish(),
Container: z.string().nullish(),
IsVideoDirect: z.boolean().nullish(),
IsAudioDirect: z.boolean().nullish(),
Width: z.number().nullish(),
Height: z.number().nullish(),
});
const sessionSchema = z.object({
NowPlayingItem: z
.object({
@@ -20,9 +33,16 @@ const sessionSchema = z.object({
SeriesName: z.string().nullish(),
Name: z.string().nullish(),
SeasonName: z.string().nullish(),
ParentIndexNumber: z.number().nullish(),
EpisodeTitle: z.string().nullish(),
IndexNumber: z.number().nullish(),
Album: z.string().nullish(),
EpisodeCount: z.number().nullish(),
RunTimeTicks: z.number().nullish(),
Width: z.number().nullish(),
Height: z.number().nullish(),
MediaStreams: z.array(z.object({ BitRate: z.number().nullish() })).nullish(),
MediaSources: z.array(z.object({ Bitrate: z.number().nullish() })).nullish(),
})
.optional(),
Id: z.string(),
@@ -31,6 +51,15 @@ const sessionSchema = z.object({
DeviceName: z.string().nullish(),
UserId: z.string().optional(),
UserName: z.string().nullish(),
RemoteEndPoint: z.string().nullish(),
PlayState: z
.object({
IsPaused: z.boolean().nullish(),
PositionTicks: z.number().nullish(),
PlayMethod: z.string().nullish(),
})
.nullish(),
TranscodingInfo: transcodingInfoSchema.nullish(),
});
const itemSchema = z.object({
@@ -110,20 +139,72 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
if (sessionInfo.NowPlayingItem) {
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
const transcodingInfo = sessionInfo.TranscodingInfo;
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
// Emby only reliably fills in MediaSources[].Bitrate for a transcoding session -
// /Sessions has no `fields` selector to force it, so fall back to summing the individual
// media streams' own bitrates (populated from the source file's metadata) for direct play.
const mediaStreamsBitrateBps = sessionInfo.NowPlayingItem.MediaStreams?.reduce(
(sum, stream) => sum + (stream.BitRate ?? 0),
0,
);
const bitrateBps =
transcodingInfo?.Bitrate ??
sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ??
(mediaStreamsBitrateBps || null);
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
state: null,
positionMs: null,
durationMs: null,
state: sessionInfo.PlayState?.IsPaused ? "paused" : "playing",
positionMs,
durationMs,
},
location: parseLocation(sessionInfo.RemoteEndPoint),
metadata: {
video: {
resolution:
sessionInfo.NowPlayingItem.Width && sessionInfo.NowPlayingItem.Height
? {
width: sessionInfo.NowPlayingItem.Width,
height: sessionInfo.NowPlayingItem.Height,
}
: null,
frameRate: transcodingInfo?.Framerate ?? null,
},
audio: {
channelCount: transcodingInfo?.AudioChannels ?? null,
codec: transcodingInfo?.AudioCodec ?? null,
},
transcoding: {
resolution:
transcodingInfo?.Width && transcodingInfo.Height
? {
width: transcodingInfo.Width,
height: transcodingInfo.Height,
}
: null,
target: {
audioCodec: transcodingInfo?.AudioCodec ?? null,
videoCodec: transcodingInfo?.VideoCodec ?? null,
},
container: transcodingInfo?.Container ?? null,
isVideoDirect: transcodingInfo?.IsVideoDirect ?? true,
isAudioDirect: transcodingInfo?.IsAudioDirect ?? true,
containerChanged: sessionInfo.PlayState?.PlayMethod === "DirectStream",
},
bitrateKbps,
},
location: null,
metadata: null,
};
}
+2
View File
@@ -0,0 +1,2 @@
export { createIntegrationAsync, getIntegrationHttpAuthenticationAsync } from "./base/creator";
export type { IntegrationHttpAuthentication } from "./http-auth";
@@ -35,7 +35,11 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
if (session == null) throw new Error("Session was unexpectitly null");
const [stats, cpuTemp] = await Promise.all([this.getAllStatsAsync(), this.getCpuTempAsync()]);
const [stats, cpuTemp, smart] = await Promise.all([
this.getAllStatsAsync(),
this.getCpuTempAsync(),
this.getSmartAsync(),
]);
return {
cpuUtilization: stats.cpu.total,
@@ -57,7 +61,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
rebootRequired: false,
cpuModelName: stats.quicklook?.cpu_name ?? "Unknown",
loadAverage: null,
smart: [],
smart,
cpuTemp,
gpu: stats.gpu.map((gpu) => ({
gpuId: gpu.gpu_id,
@@ -97,6 +101,23 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
}
}
private async getSmartAsync(): Promise<SystemHealthMonitoring["smart"]> {
// The SMART plugin is disabled by default (Glances answers 400), so a missing
// or unexpected /api/4/smart response must not fail getSystemInfoAsync().
try {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/smart"));
if (!response.ok) {
return [];
}
const devices = await smartSchema.parseAsync(await response.json());
return devices.flatMap(mapGlancesSmartDevice);
} catch {
return [];
}
}
private async getAllStatsAsync() {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/all"));
@@ -220,3 +241,74 @@ export const parseGlancesCpuTempFromSensors = (sensors: z.infer<typeof sensorsSc
return undefined;
};
// Glances (pySMART) returns one object per disk: "DeviceName" is "<device> <model>",
// every other object value is a SMART attribute (ATA) or an NVMe health log entry.
const smartAttributeSchema = z.object({
name: z.string().nullable().optional(),
key: z.string().nullable().optional(),
value: z.union([z.string(), z.number()]).nullable().optional(),
raw: z.union([z.string(), z.number()]).nullable().optional(),
when_failed: z.string().nullable().optional(),
});
const smartSchema = z.array(z.object({ DeviceName: z.string() }).catchall(z.unknown()));
const ataTemperatureNames = ["Temperature_Celsius", "Airflow_Temperature_Cel"] as const;
const mapGlancesSmartDevice = (device: z.infer<typeof smartSchema>[number]): SystemHealthMonitoring["smart"] => {
const attributes = Object.values(device).flatMap((value) => {
const result = smartAttributeSchema.safeParse(value);
return result.success ? [result.data] : [];
});
// ATA: when_failed is "-" unless the attribute crossed its threshold. NVMe: critical warning bitmask.
const failedAttributes = attributes
.filter((attribute) => attribute.when_failed && attribute.when_failed !== "-")
.flatMap((attribute) => attribute.name ?? attribute.key ?? []);
const criticalWarningValue = attributes.find((attribute) => attribute.key === "criticalWarning")?.value;
const criticalWarningCount = criticalWarningValue == null ? Number.NaN : Number(criticalWarningValue);
const isNvme = Number.isFinite(criticalWarningCount);
// Glances lists a device even when no health attribute was read (or all are hidden by hide_attributes).
// Without ATA thresholds or an NVMe critical warning there is no verdict, so the widget shows N/A.
const hasAtaThresholds = attributes.some((attribute) => typeof attribute.when_failed === "string");
if (!hasAtaThresholds && !isNvme) return [];
const hasCriticalWarning = isNvme && criticalWarningCount !== 0;
const healthy = failedAttributes.length === 0 && !hasCriticalWarning;
return [
{
deviceName: `/dev/${device.DeviceName.split(" ")[0]}`,
temperature: getSmartTemperature(attributes),
overallStatus: healthy ? "PASSED" : "FAILED",
healthy,
statusReason: getSmartStatusReason(failedAttributes, isNvme, hasCriticalWarning),
},
];
};
const getSmartStatusReason = (
failedAttributes: string[],
isNvme: boolean,
hasCriticalWarning: boolean,
): SystemHealthMonitoring["smart"][number]["statusReason"] => {
if (failedAttributes.length > 0) return { type: "attributesFailed", attributes: failedAttributes };
if (hasCriticalWarning) return { type: "criticalWarning" };
return { type: isNvme ? "noCriticalWarning" : "attributesWithinThresholds" };
};
const getSmartTemperature = (attributes: z.infer<typeof smartAttributeSchema>[]): number | null => {
const nvmeTemperature = attributes.find((attribute) => attribute.key === "_temperature")?.value;
if (typeof nvmeTemperature === "number") return nvmeTemperature;
for (const name of ataTemperatureNames) {
// ATA raw values may carry extra data, e.g. "35 (Min/Max 20/45)".
const raw = attributes.find((attribute) => attribute.name === name)?.raw;
const temperature = Number.parseInt(String(raw ?? ""), 10);
if (!Number.isNaN(temperature)) return temperature;
}
return null;
};
@@ -68,6 +68,14 @@ export class GotifyIntegration extends Integration implements INotificationsInte
}
}
public async deleteNotificationAsync(id: string): Promise<void> {
const response = await fetchWithTrustedCertificatesAsync(this.url(`/message/${encodeURIComponent(id)}`), {
method: "DELETE",
headers: this.getHeaders(),
});
if (!response.ok) throw new ResponseError(response);
}
public async getNotificationsAsync(): Promise<Notification[]> {
const [messagesResponse, applicationsById] = await Promise.all([
fetchWithTrustedCertificatesAsync(this.url("/message", { limit: 100 }), { headers: this.getHeaders() }),
@@ -98,6 +106,7 @@ export class GotifyIntegration extends Integration implements INotificationsInte
time: new Date(message.date),
title: message.title,
body: message.message,
contentType: message.extras?.["client::display"]?.contentType,
href: this.getMessagesUrl(message.appid),
source: application
? {
@@ -8,6 +8,12 @@ export const gotifyMessageSchema = z.object({
title: z.string(),
message: z.string(),
priority: z.number().optional(),
extras: z
.object({
"client::display": z.object({ contentType: z.string().optional() }).optional(),
})
.nullish()
.catch(undefined),
});
export const gotifyMessagesResponseSchema = z.object({
@@ -5,6 +5,7 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -17,6 +18,22 @@ import { calendarEventSchema, calendarsSchema, entityStateSchema } from "./homea
const logger = createLogger({ module: "homeAssistantIntegration" });
export class HomeAssistantIntegration extends Integration implements ISmartHomeIntegration, ICalendarIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: this.getAuthHeaders() };
}
public async getEntityStatsAsync() {
const response = await this.getAsync("/api/states");
if (!response.ok) throw new ResponseError(response);
const entities = z.array(z.object({ entity_id: z.string(), state: z.string() })).parse(await response.json());
return {
entities: entities.length,
unavailable: entities.filter((entity) => entity.state === "unavailable").length,
lightsOn: entities.filter((entity) => entity.entity_id.startsWith("light.") && entity.state === "on").length,
peopleHome: entities.filter((entity) => entity.entity_id.startsWith("person.") && entity.state === "home").length,
};
}
public async getEntityStateAsync(entityId: string) {
try {
const response = await this.getAsync(`/api/states/${entityId}`);
+74
View File
@@ -0,0 +1,74 @@
import { Buffer } from "node:buffer";
import type {
IntegrationHttpAuthDefinition,
IntegrationHttpBodyAuth,
IntegrationSecretKind,
} from "@homarr/definitions";
import type { IntegrationInput } from "./base/integration";
/** Server-only credentials supplied by the integration's native adapter. */
export interface IntegrationHttpAuthentication {
headers: Record<string, string>;
query?: Record<string, string>;
body?: IntegrationHttpBodyAuth;
redactValues?: string[];
}
function secret(input: IntegrationInput, kind: IntegrationSecretKind) {
const value = input.decryptedSecrets.find((entry) => entry.kind === kind)?.value;
if (!value) throw new Error(`Integration credential ${kind} is missing`);
return value;
}
export function apiKeyAuth(input: IntegrationInput): IntegrationHttpAuthentication {
return { headers: { "X-API-Key": secret(input, "apiKey") } };
}
export function resolveDeclaredHttpAuthentication(
input: IntegrationInput,
definition: IntegrationHttpAuthDefinition,
): IntegrationHttpAuthentication {
if (definition.type === "modes") {
const savedKinds = new Set(input.decryptedSecrets.map(({ kind }) => kind));
const mode = definition.modes.find(
({ when }) => when.length === savedKinds.size && when.every((kind) => savedKinds.has(kind)),
);
if (!mode) throw new Error("Saved credentials do not support generic HTTP requests");
return resolveDeclaredHttpAuthentication(input, mode.auth);
}
switch (definition.type) {
case "none":
return { headers: {} };
case "bearer":
return { headers: { Authorization: `Bearer ${secret(input, definition.secretKind ?? "apiKey")}` } };
case "apiKeyHeader":
return {
headers: { [definition.name]: `${definition.prefix ?? ""}${secret(input, definition.secretKind ?? "apiKey")}` },
};
case "apiKeyQuery":
return { headers: {}, query: { [definition.name]: secret(input, definition.secretKind ?? "apiKey") } };
case "basic":
if (definition.username !== undefined) {
const credentials = Buffer.from(
`${definition.username}:${secret(input, definition.passwordKind ?? "password")}`,
).toString("base64");
return { headers: { Authorization: `Basic ${credentials}` }, redactValues: [credentials] };
}
return basicAuth(definition.usernameKind, definition.passwordKind)(input);
case "adapter":
throw new Error("This integration requires adapter-owned HTTP authentication");
}
}
export const basicAuth =
(
username: IntegrationSecretKind = "username",
password: IntegrationSecretKind = "password",
headers: Record<string, string> = {},
) =>
(input: IntegrationInput): IntegrationHttpAuthentication => {
const credentials = Buffer.from(`${secret(input, username)}:${secret(input, password)}`).toString("base64");
return { headers: { ...headers, Authorization: `Basic ${credentials}` }, redactValues: [credentials] };
};
@@ -1,41 +1,140 @@
import ICAL from "ical.js";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ICalendarIntegration } from "../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../interfaces/calendar/calendar-types";
const logger = createLogger({ module: "icalIntegration" });
// Caps the walk so a high frequency rule on an untrusted feed can't hang the request.
const MAX_OCCURRENCE_ITERATIONS = 100000;
const toCalendarEvent = (event: ICAL.Event, startDate: Date, endDate: Date): CalendarEvent => ({
title: event.summary,
subTitle: null,
description: event.description,
startDate,
endDate,
image: null,
location: event.location,
indicatorColor: "red",
links: [],
});
export class ICalIntegration extends Integration implements ICalendarIntegration {
public override async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
throw new Error("iCal stores a private feed URL, which cannot authenticate arbitrary HTTP requests");
}
async getCalendarEventsAsync(start: Date, end: Date): Promise<CalendarEvent[]> {
const response = await fetchWithTrustedCertificatesAsync(super.getSecretValue("url"));
const result = await response.text();
const jcal = ICAL.parse(result) as unknown[];
const comp = new ICAL.Component(jcal);
return comp.getAllSubcomponents("vevent").reduce((prev, vevent) => {
const event = new ICAL.Event(vevent);
const startDate = event.startDate.toJSDate();
const endDate = event.endDate.toJSDate();
const vevents = comp.getAllSubcomponents("vevent");
const masterVevents: ICAL.Component[] = [];
const exceptionVevents: ICAL.Component[] = [];
if (startDate > end) return prev;
if (endDate < start) return prev;
for (const vevent of vevents) {
const event = new ICAL.Event(vevent, { exceptions: [] });
if (event.isRecurrenceException()) {
exceptionVevents.push(vevent);
} else {
masterVevents.push(vevent);
}
}
return prev.concat({
title: event.summary,
subTitle: null,
description: event.description,
startDate,
endDate,
image: null,
location: event.location,
indicatorColor: "red",
links: [],
});
}, [] as CalendarEvent[]);
const events: CalendarEvent[] = [];
const emittedExceptionKeys = new Set<string>();
const emittedExceptionComponents = new Set<ICAL.Component>();
for (const masterVevent of masterVevents) {
// exceptions: [] so an unrelated series sharing no uid is never auto-attached; uids are matched by hand below.
const event = new ICAL.Event(masterVevent, { exceptions: [] });
for (const exceptionVevent of exceptionVevents) {
const exceptionEvent = new ICAL.Event(exceptionVevent, { exceptions: [] });
if (exceptionEvent.uid !== event.uid) continue;
event.relateException(exceptionVevent);
}
if (!event.isRecurring()) {
const startDate = event.startDate.toJSDate();
const endDate = event.endDate.toJSDate();
if (startDate > end) continue;
if (endDate < start) continue;
events.push(toCalendarEvent(event, startDate, endDate));
continue;
}
const iterator = event.iterator();
let next: ICAL.Time | undefined;
let iterations = 0;
let cappedByIterationLimit = false;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while ((next = iterator.next())) {
if (iterations >= MAX_OCCURRENCE_ITERATIONS) {
cappedByIterationLimit = true;
break;
}
iterations++;
if (next.toJSDate() > end) break;
const details = event.getOccurrenceDetails(next);
const startDate = details.startDate.toJSDate();
const endDate = details.endDate.toJSDate();
if (startDate > end) continue;
if (endDate < start) continue;
if (details.item.isRecurrenceException()) {
// Keyed on details.recurrenceId, not details.item.recurrenceId: for a RANGE=THISANDFUTURE
// override those collapse to the same value for every later occurrence, which would drop them.
const exceptionKey = `${details.item.uid}:${details.recurrenceId.toString()}`;
if (emittedExceptionKeys.has(exceptionKey)) continue;
emittedExceptionKeys.add(exceptionKey);
emittedExceptionComponents.add(details.item.component);
}
events.push(toCalendarEvent(details.item, startDate, endDate));
}
if (cappedByIterationLimit) {
logger.warn(
`iCal integration "${this.integration.id}": stopped expanding a recurring event after ${MAX_OCCURRENCE_ITERATIONS} occurrences (MAX_OCCURRENCE_ITERATIONS); some occurrences in the requested window may be missing`,
);
}
}
for (const exceptionVevent of exceptionVevents) {
if (emittedExceptionComponents.has(exceptionVevent)) continue;
const exceptionEvent = new ICAL.Event(exceptionVevent, { exceptions: [] });
const key = `${exceptionEvent.uid}:${exceptionEvent.recurrenceId.toString()}`;
if (emittedExceptionKeys.has(key)) continue;
const startDate = exceptionEvent.startDate.toJSDate();
const endDate = exceptionEvent.endDate.toJSDate();
if (startDate > end) continue;
if (endDate < start) continue;
events.push(toCalendarEvent(exceptionEvent, startDate, endDate));
}
return events;
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
@@ -6,17 +6,18 @@ import {
getAllAlbums,
getMyUser,
getServerStatistics,
init,
searchAssets,
searchRandom,
searchUsers,
} from "@immich/sdk";
import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
import type { AlbumResponseDto, AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ImageProxy } from "@homarr/image-proxy";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { apiKeyAuth } from "../http-auth";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
@@ -36,7 +37,6 @@ export interface ImmichAlbum {
export interface ImmichAsset {
id: string;
originalPath: string;
thumbhash: string | null;
fileModifiedAt: string;
fileCreatedAt: string;
@@ -46,15 +46,18 @@ export interface ImmichAsset {
}
const logger = createLogger({ module: "immich-integration" });
const randomPhotoCount = 50;
const carouselPhotoCount = 50;
export class ImmichIntegration extends Integration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
constructor(integration: IntegrationInput) {
super(integration);
}
public async getServerStatsAsync(): Promise<ImmichServerStats> {
this.initClient();
const [statistics, users] = await Promise.all([
getServerStatistics(this.getRequestOptions()),
searchUsers(this.getRequestOptions()),
@@ -68,14 +71,13 @@ export class ImmichIntegration extends Integration {
}
public async getAlbumAsync(albumId?: string): Promise<ImmichAlbum> {
this.initClient();
const requestOptions = this.getRequestOptions();
if (!albumId) {
const assets = await searchRandom(
{
randomSearchDto: {
size: randomPhotoCount,
size: carouselPhotoCount,
type: AssetTypeEnum.Image,
visibility: AssetVisibility.Timeline,
},
@@ -90,10 +92,8 @@ export class ImmichIntegration extends Integration {
};
}
const [album, albumAssets] = await Promise.all([
getAlbumInfo({ id: albumId }, requestOptions),
this.searchAlbumAssetsAsync(albumId),
]);
const albumPromise = getAlbumInfo({ id: albumId }, requestOptions);
const [album, albumAssets] = await Promise.all([albumPromise, this.searchAlbumAssetsAsync(albumId, albumPromise)]);
return {
albumName: album.albumName,
assets: await this.createAssetsAsync(albumAssets),
@@ -101,6 +101,30 @@ export class ImmichIntegration extends Integration {
};
}
public async getAlbumPreviewAsync(albumId: string, randomizePhotos: boolean): Promise<ImmichAlbum> {
const requestOptions = this.getRequestOptions();
let assets: AssetResponseDto[] = [];
if (randomizePhotos) {
try {
assets = await searchRandom(
{ randomSearchDto: { albumIds: [albumId], size: 1, type: AssetTypeEnum.Image } },
requestOptions,
);
} catch {
// Older Immich instances may reject album-scoped random search.
// The first metadata page is still a valid preview for this album.
}
}
if (assets.length === 0) {
const result = await searchAssets(
{ metadataSearchDto: { albumIds: [albumId], type: AssetTypeEnum.Image, size: 1, withExif: false } },
requestOptions,
);
assets = result.assets.items;
}
return { id: albumId, albumName: "", assets: await this.createAssetsAsync(assets.slice(0, 1)) };
}
public async getAlbumsAsync(): Promise<
{
id: string;
@@ -108,75 +132,93 @@ export class ImmichIntegration extends Integration {
assetCount: number;
}[]
> {
this.initClient();
const albums = await getAllAlbums({}, this.getRequestOptions());
return albums.map((album) => ({ id: album.id, albumName: album.albumName, assetCount: album.assetCount }));
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
this.initClient();
const user = await getMyUser(this.getRequestOptions(input.fetchAsync));
logger.debug(`Logged in as ${user.name} (${user.id})`);
return { success: true };
}
private async searchAlbumAssetsAsync(albumId: string): Promise<AssetResponseDto[]> {
private async searchAlbumAssetsAsync(
albumId: string,
albumPromise: Promise<AlbumResponseDto>,
): Promise<AssetResponseDto[]> {
const requestOptions = this.getRequestOptions();
const pageSize = 1000;
const metadataSearchDto: MetadataSearchDto = {
albumIds: [albumId],
type: AssetTypeEnum.Image,
size: pageSize,
withExif: false,
};
const pages = new Map<number, ReturnType<typeof searchAssets>>();
const getPage = (page: number) => {
const existing = pages.get(page);
if (existing) return existing;
const pending = searchAssets({ metadataSearchDto: { ...metadataSearchDto, page } }, requestOptions);
pages.set(page, pending);
// A speculative page can fail before pagination needs to await it.
void pending.catch(() => undefined);
return pending;
};
getPage(1);
// The count includes videos, so it is only a hint. Start at most four pages
// together, then follow actual nextPage values to preserve the full album.
void albumPromise
.then(
(album) => {
const initialPages = Math.min(4, Math.ceil(album.assetCount / pageSize));
for (let page = 2; page <= initialPages; page++) getPage(page);
},
() => undefined,
)
.catch(() => undefined);
const assets: AssetResponseDto[] = [];
let nextPage: string | null = null;
let page: number | null = 1;
do {
const metadataSearchDto: MetadataSearchDto = { albumIds: [albumId] };
if (nextPage !== null) {
metadataSearchDto.page = Number(nextPage);
}
const searchResult = await searchAssets({ metadataSearchDto }, requestOptions);
assets.push(...searchResult.assets.items);
nextPage = searchResult.assets.nextPage;
} while (nextPage !== null);
const result = await getPage(page);
assets.push(...result.assets.items);
if (result.assets.nextPage === null) break;
const nextPage = Number(result.assets.nextPage);
if (!Number.isInteger(nextPage) || nextPage <= page) throw new Error("Immich returned invalid album pagination");
page = nextPage;
} while (page !== null);
return assets;
}
private async createAssetsAsync(assets: AssetResponseDto[]): Promise<ImmichAsset[]> {
const imageProxy = new ImageProxy();
return await Promise.all(
assets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
// This is the URL generated by the SDK's viewAsset call. Preview images are
// browser-compatible while avoiding the large original asset download.
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
const publicLinks = await imageProxy.createImagesAsync(
assets.map((asset) => this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString()),
{ "x-api-key": this.getSecretValue("apiKey") },
);
return assets.map((asset, index) => {
const publicLink = publicLinks[index];
if (!publicLink) throw new Error("Image proxy registration returned no link for an asset");
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
updatedAt: asset.updatedAt,
publicLink,
};
});
}
private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) {
return {
// Every parallel integration call owns its endpoint and credentials.
// SDK-wide defaults can be overwritten by another Immich instance.
baseUrl: this.url("/api").toString(),
headers: { "x-api-key": this.getSecretValue("apiKey") },
// Undici and node types are not the same
fetch: fetchAsync as unknown as typeof fetch,
};
}
/**
* Sets the credentials and prepares the client for calls.
* Must be called before any other functions.
* @private
*/
private initClient() {
init({ baseUrl: this.url("/api").toString(), apiKey: this.getSecretValue("apiKey") });
}
}
@@ -0,0 +1,127 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { IntegrationSecret } from "../../base/types";
import { ImmichIntegration } from "../immich-integration";
const mocks = vi.hoisted(() => ({
getAlbumInfo: vi.fn(),
searchAssets: vi.fn(),
searchRandom: vi.fn(),
createImagesAsync: vi.fn<(urls: string[]) => Promise<string[]>>(),
}));
vi.mock("@immich/sdk", () => ({
AssetMediaSize: { Preview: "preview" },
AssetTypeEnum: { Image: "IMAGE" },
AssetVisibility: { Timeline: "timeline" },
getAlbumInfo: mocks.getAlbumInfo,
getAllAlbums: vi.fn(),
getMyUser: vi.fn(),
getServerStatistics: vi.fn(),
init: vi.fn(),
searchAssets: mocks.searchAssets,
searchRandom: mocks.searchRandom,
searchUsers: vi.fn(),
}));
vi.mock("@homarr/image-proxy", () => ({
ImageProxy: class {
createImagesAsync = mocks.createImagesAsync;
},
}));
const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "immich-token" }];
const integration = new ImmichIntegration({
id: "test-immich",
name: "Test Immich",
url: "https://immich.example.com",
externalUrl: null,
decryptedSecrets: secrets,
});
const createAsset = (index: number) => ({
id: `asset-${index}`,
type: "IMAGE",
thumbhash: null,
fileCreatedAt: "2026-01-01T00:00:00.000Z",
fileModifiedAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
describe("ImmichIntegration.getAlbumAsync", () => {
beforeEach(() => {
mocks.getAlbumInfo.mockReset();
mocks.searchAssets.mockReset();
mocks.searchRandom.mockReset();
mocks.createImagesAsync.mockReset();
});
test("keeps every image across paginated album results", async () => {
mocks.getAlbumInfo.mockResolvedValue({ id: "album-id", albumName: "Large album" });
mocks.searchAssets
.mockResolvedValueOnce({
assets: {
items: Array.from({ length: 6 }, (_, index) => createAsset(index)),
nextPage: "2",
},
})
.mockResolvedValueOnce({
assets: {
items: Array.from({ length: 6 }, (_, index) => createAsset(index + 6)),
nextPage: null,
},
});
mocks.createImagesAsync.mockImplementation(async (urls) => urls.map((url) => `proxied:${url}`));
const album = await integration.getAlbumAsync("album-id");
expect(mocks.searchAssets).toHaveBeenCalledTimes(2);
expect(mocks.searchAssets.mock.calls.map(([input]) => input)).toStrictEqual([
{ metadataSearchDto: { albumIds: ["album-id"], type: "IMAGE", size: 1000, withExif: false, page: 1 } },
{ metadataSearchDto: { albumIds: ["album-id"], type: "IMAGE", size: 1000, withExif: false, page: 2 } },
]);
expect(album.assets.map(({ id }) => id)).toStrictEqual(Array.from({ length: 12 }, (_, index) => `asset-${index}`));
expect(mocks.createImagesAsync).toHaveBeenCalledTimes(1);
expect(mocks.createImagesAsync.mock.calls[0]?.[0]).toHaveLength(12);
});
test("selects a single random photo from the requested album for its preview", async () => {
mocks.searchRandom.mockResolvedValue([createAsset(7)]);
mocks.createImagesAsync.mockImplementation(async (urls) => urls.map((url) => `proxied:${url}`));
const preview = await integration.getAlbumPreviewAsync("album-id", true);
expect(mocks.searchRandom.mock.calls[0]?.[0]).toStrictEqual({
randomSearchDto: { albumIds: ["album-id"], size: 1, type: "IMAGE" },
});
expect(mocks.searchAssets).not.toHaveBeenCalled();
expect(preview.assets.map(({ id }) => id)).toStrictEqual(["asset-7"]);
expect(mocks.createImagesAsync.mock.calls[0]?.[0]).toHaveLength(1);
});
test("falls back to the album's first metadata photo when random search is unavailable", async () => {
mocks.searchRandom.mockRejectedValue(new Error("Random search unavailable"));
mocks.searchAssets.mockResolvedValue({ assets: { items: [createAsset(3)], nextPage: null } });
mocks.createImagesAsync.mockImplementation(async (urls) => urls.map((url) => `proxied:${url}`));
const preview = await integration.getAlbumPreviewAsync("album-id", true);
expect(mocks.searchAssets.mock.calls[0]?.[0]).toStrictEqual({
metadataSearchDto: { albumIds: ["album-id"], type: "IMAGE", size: 1, withExif: false },
});
expect(preview.assets.map(({ id }) => id)).toStrictEqual(["asset-3"]);
});
test("keeps the first album photo when randomization is off", async () => {
mocks.searchAssets.mockResolvedValue({ assets: { items: [createAsset(2)], nextPage: "2" } });
mocks.createImagesAsync.mockImplementation(async (urls) => urls.map((url) => `proxied:${url}`));
const preview = await integration.getAlbumPreviewAsync("album-id", false);
expect(mocks.searchRandom).not.toHaveBeenCalled();
expect(preview.assets.map(({ id }) => id)).toStrictEqual(["asset-2"]);
expect(mocks.searchAssets).toHaveBeenCalledTimes(1);
});
});
+11 -2
View File
@@ -1,6 +1,7 @@
// General integrations
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
export { WudIntegration } from "./wud/wud-integration";
export { LlamacppIntegration } from "./llama-cpp/llamacpp-integration";
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
export { AnchorIntegration } from "./anchor/anchor-integration";
@@ -14,6 +15,7 @@ export { TransmissionIntegration } from "./download-client/transmission/transmis
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
export { BinderyIntegration } from "./media-organizer/bindery/bindery-integration";
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration";
export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration";
@@ -29,6 +31,7 @@ export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5";
export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { JackettIntegration } from "./jackett/jackett-integration";
export { TrueNasIntegration } from "./truenas/truenas-integration";
export { SynologyIntegration } from "./synology/synology-integration";
export { UnraidIntegration } from "./unraid/unraid-integration";
@@ -45,6 +48,7 @@ export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
export { BazarrIntegration } from "./bazarr/bazarr-integration";
export { TraefikIntegration } from "./traefik/traefik-integration";
export { mockWidgetData } from "./mock/widget-data";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -62,6 +66,7 @@ export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/heal
export { UpstreamMediaRequestStatus } from "./interfaces/media-requests/media-request-types";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types";
export type { StreamSession } from "./interfaces/media-server/media-server-types";
export type { EntityState } from "./interfaces/smart-home/smart-home-types";
export type {
TdarrQueue,
TdarrPieSegment,
@@ -72,6 +77,8 @@ export type { Notification } from "./interfaces/notifications/notification-types
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
export type { PaperlessNgxStats } from "./paperless-ngx/paperless-ngx-types";
export type { PatchMonStats, PatchMonOsDistributionEntry } from "./patchmon/patchmon-types";
export { toWazuhPublicError } from "./wazuh/wazuh-errors";
export type { WazuhErrorReason, WazuhPublicError } from "./wazuh/wazuh-errors";
export type {
AnchorNote,
AnchorNotePermission,
@@ -93,11 +100,13 @@ export type {
ArchiveTeamWarriorStatus,
} from "./archive-team-warrior/archive-team-warrior-types";
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
export type { LlamacppStats, LlamacppModel } from "./llama-cpp/llamacpp-types";
// Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
export { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
// Helpers
export { createIntegrationAsync } from "./base/creator";
export { simulateResponseContractAsync } from "./base/response-contract";
export type { ResponseContractFixture, ResponseContractParser } from "./base/response-contract";
@@ -30,6 +30,12 @@ export interface SystemHealthMonitoring {
temperature: number | null;
overallStatus: string;
healthy: boolean;
// What the health verdict is based on, shown in the widget's status tooltip.
statusReason?:
| { type: "attributesWithinThresholds" }
| { type: "attributesFailed"; attributes: string[] }
| { type: "noCriticalWarning" }
| { type: "criticalWarning" };
}[];
gpu: {
gpuId: string;
@@ -1,12 +1,12 @@
export interface Indexer {
id: number;
id: number | string;
name: string;
url: string;
/**
* Enabled: when the user enable / disable the indexer.
* Status: when there is an error with the indexer site.
* If one of the options are false the indexer is off.
* A null status means the provider does not expose health.
*/
enabled: boolean;
status: boolean;
status: boolean | null;
}
@@ -1,7 +1,7 @@
export interface MissingMediaItem {
id: number;
title: string;
type: "movie" | "episode";
type: "movie" | "episode" | "book";
year?: number;
seasonNumber?: number;
episodeNumber?: number;
@@ -13,7 +13,7 @@ export interface MissingMediaItem {
export interface QueuedMediaItem {
id: number;
title: string;
type: "movie" | "episode";
type: "movie" | "episode" | "book";
status: string;
timeLeft: string | null;
percentComplete: number;
@@ -40,6 +40,8 @@ export interface MediaRelease {
subtitle?: string;
description?: string;
releaseDate: Date;
/** The source supplied a calendar date without a time or timezone. */
releaseDateIsDateOnly?: boolean;
imageUrls: {
poster: string | undefined;
backdrop: string | undefined;
@@ -10,7 +10,9 @@ export interface StreamSession {
type: "audio" | "video" | "tv" | "movie";
name: string;
seasonName: string | undefined;
seasonNumber?: number | null;
episodeName?: string | null;
episodeNumber?: number | null;
albumName?: string | null;
episodeCount?: number | null;
playback: {
@@ -3,6 +3,7 @@ export interface Notification {
time: Date;
title: string;
body: string;
contentType?: string;
href?: string;
source?: {
name: string;
@@ -1,4 +1,4 @@
interface EntityState {
export interface EntityState {
attributes: Record<string, string | number | boolean | null | (string | number)[]>;
entity_id: string;
last_changed: Date;
@@ -0,0 +1,86 @@
import { z } from "zod/v4";
import { parseStringPromise } from "xml2js";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IIndexerManagerIntegration } from "../interfaces/indexer-manager/indexer-manager-integration";
import type { Indexer } from "../interfaces/indexer-manager/indexer-manager-types";
const indexerSchema = z.object({
$: z.object({ id: z.string().min(1), configured: z.enum(["true", "false"]) }),
title: z.array(z.string()).min(1),
link: z.array(z.string()).optional(),
});
const indexersSchema = z.object({
indexers: z.union([z.literal(""), z.object({ indexer: z.array(indexerSchema).optional() })]),
});
const capsSchema = z.object({ caps: z.object({}).passthrough() });
const searchSchema = z.object({ rss: z.object({ channel: z.array(z.object({}).passthrough()).min(1) }) });
export class JackettIntegration extends Integration implements IIndexerManagerIntegration {
private torznabUrl(id: string, action: string) {
return this.url(`/api/v2.0/indexers/${encodeURIComponent(id)}/results/torznab/api`, {
apikey: this.getSecretValue("apiKey"),
t: action,
});
}
private async getConfiguredIndexersAsync() {
const url = this.torznabUrl("all", "indexers");
url.searchParams.set("configured", "true");
const response = await fetchWithTrustedCertificatesAsync(url, {
signal: AbortSignal.timeout(30_000),
redirect: "error",
});
if (!response.ok) throw new Error(`Jackett indexers request failed (${response.status})`);
const parsed = indexersSchema.parse(await parseStringPromise(await response.text(), { explicitArray: true }));
if (parsed.indexers === "") return [];
return (parsed.indexers.indexer ?? []).filter((indexer) => indexer.$.configured === "true");
}
public async getIndexersAsync(): Promise<Indexer[]> {
const indexers = await this.getConfiguredIndexersAsync();
return indexers.map((indexer) => ({
id: indexer.$.id,
name: indexer.title[0] ?? indexer.$.id,
url: indexer.link?.[0] ?? "",
enabled: true,
// Jackett's Torznab catalog does not expose health or last-test results.
status: null,
}));
}
public async getStatsAsync() {
return { configured: (await this.getConfiguredIndexersAsync()).length };
}
public async testAllAsync(): Promise<void> {
const indexers = await this.getConfiguredIndexersAsync();
// Torznab search exercises the upstream indexer; caps only returns metadata.
// Limit concurrency so a large catalog does not flood every tracker at once.
for (let offset = 0; offset < indexers.length; offset += 4) {
await Promise.all(
indexers.slice(offset, offset + 4).map(async (indexer) => {
const response = await fetchWithTrustedCertificatesAsync(this.torznabUrl(indexer.$.id, "search"), {
signal: AbortSignal.timeout(30_000),
redirect: "error",
});
if (!response.ok) throw new Error(`Jackett indexer test failed (${response.status})`);
// A Torznab <error> can use HTTP 200; only a valid RSS result is success.
searchSchema.parse(await parseStringPromise(await response.text(), { explicitArray: true }));
}),
);
}
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.torznabUrl("all", "caps"), { redirect: "error" });
if (!response.ok) return TestConnectionError.StatusResult(response);
capsSchema.parse(await parseStringPromise(await response.text(), { explicitArray: true }));
return { success: true };
}
}
@@ -0,0 +1,65 @@
import { describe, expect, test } from "vitest";
import { extractHost, parseLocation } from "./jellyfin-integration";
describe("extractHost", () => {
test("strips the port from an IPv4 endpoint", () => {
expect(extractHost("192.168.1.100:54321")).toBe("192.168.1.100");
});
test("returns an IPv4 address unchanged when there's no port", () => {
expect(extractHost("8.8.8.8")).toBe("8.8.8.8");
});
test("strips brackets and the port from a bracketed IPv6 endpoint", () => {
expect(extractHost("[::1]:8096")).toBe("::1");
});
test("returns a bare IPv6 address unchanged when there's no port", () => {
expect(extractHost("::1")).toBe("::1");
});
});
describe("parseLocation", () => {
test("returns null when there's no endpoint to classify", () => {
expect(parseLocation(null)).toBeNull();
expect(parseLocation(undefined)).toBeNull();
expect(parseLocation("")).toBeNull();
});
test("returns null for a value that isn't an IP", () => {
expect(parseLocation("not-an-ip:1234")).toBeNull();
});
test("classifies private IPv4 ranges as lan", () => {
expect(parseLocation("192.168.1.100:54321")).toBe("lan");
expect(parseLocation("10.0.0.5:1234")).toBe("lan");
expect(parseLocation("172.16.5.5:1234")).toBe("lan");
expect(parseLocation("127.0.0.1:8096")).toBe("lan");
});
test("classifies a public IPv4 address as wan", () => {
expect(parseLocation("8.8.8.8:1234")).toBe("wan");
});
test("classifies IPv6 loopback and unique-local addresses as lan", () => {
expect(parseLocation("[::1]:8096")).toBe("lan");
expect(parseLocation("[fd12:3456:789a::1]:8096")).toBe("lan");
});
test("classifies a public IPv6 address as wan", () => {
expect(parseLocation("[2606:4700:4700::1111]:8096")).toBe("wan");
});
test("unwraps an IPv4-mapped IPv6 address before classifying it", () => {
expect(parseLocation("[::ffff:192.168.1.5]:8096")).toBe("lan");
expect(parseLocation("[::ffff:8.8.8.8]:8096")).toBe("wan");
});
test("classifies link-local addresses as lan", () => {
// Link-local traffic can't traverse a router, so it's always LAN even though
// it isn't a private range in the traditional (RFC 1918 / ULA) sense.
expect(parseLocation("169.254.1.5:8096")).toBe("lan");
expect(parseLocation("[fe80::1]:8096")).toBe("lan");
});
});
@@ -1,3 +1,4 @@
import { BlockList, isIP } from "node:net";
import { Jellyfin } from "@jellyfin/sdk";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
@@ -6,23 +7,108 @@ import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api/user-library-api";
import type { AxiosInstance } from "axios";
import { createAxiosCertificateInstanceAsync } from "@homarr/core/infrastructure/http";
import {
createAxiosCertificateInstanceAsync,
fetchWithTrustedCertificatesAsync,
} from "@homarr/core/infrastructure/http";
import { createKeyedFingerprint } from "@homarr/common/server";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import { integrationAxiosHttpErrorHandler } from "../base/errors/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types";
function ticksToMs(ticks: number | null | undefined): number | null {
return ticks ? Math.round(ticks / 10_000) : null;
export function ticksToMs(ticks: number | null | undefined): number | null {
return ticks == null ? null : Math.round(ticks / 10_000);
}
// Unlike Plex, Jellyfin doesn't report a session's network location directly - only the
// client's RemoteEndPoint (an IP, optionally with a port). Classify it ourselves.
const privateAddresses = new BlockList();
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
privateAddresses.addSubnet("127.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("169.254.0.0", 16, "ipv4");
privateAddresses.addSubnet("fc00::", 7, "ipv6");
privateAddresses.addSubnet("fe80::", 10, "ipv6");
privateAddresses.addAddress("::1", "ipv6");
export function extractHost(remoteEndPoint: string): string {
if (remoteEndPoint.startsWith("[")) {
const end = remoteEndPoint.indexOf("]");
return end === -1 ? remoteEndPoint : remoteEndPoint.slice(1, end);
}
// A bare IPv6 address (no brackets, no port) has more than one colon.
const isIPv4WithPort = remoteEndPoint.split(":").length === 2;
return isIPv4WithPort ? remoteEndPoint.slice(0, remoteEndPoint.lastIndexOf(":")) : remoteEndPoint;
}
export function parseLocation(remoteEndPoint: string | null | undefined): "lan" | "wan" | null {
if (!remoteEndPoint) return null;
let host = extractHost(remoteEndPoint);
let family = isIP(host);
// A dual-stack server can report an IPv4 client as an IPv4-mapped IPv6 address
// (e.g. "::ffff:192.168.1.5") - unwrap it so the IPv4 private ranges still apply.
if (family === 6 && host.toLowerCase().startsWith("::ffff:") && isIP(host.slice(7)) === 4) {
host = host.slice(7);
family = 4;
}
if (!family) return null;
return privateAddresses.check(host, family === 4 ? "ipv4" : "ipv6") ? "lan" : "wan";
}
const authorizationPrefix = 'MediaBrowser Client="Homarr", Device="Homarr", DeviceId="homarr", Version="0.0.1"';
const pendingAuthentications = new Map<string, Promise<string>>();
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public override async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
const accessToken = await this.getAccessTokenAsync();
return {
headers: { Authorization: `${authorizationPrefix}, Token="${accessToken}"` },
redactValues: [accessToken],
};
}
private async getAccessTokenAsync(): Promise<string> {
if (this.hasSecretValue("apiKey")) return this.getSecretValue("apiKey");
const username = this.getSecretValue("username");
const password = this.getSecretValue("password");
const identity = createKeyedFingerprint(JSON.stringify([this.url("/").toString(), username, password]));
const existing = pendingAuthentications.get(identity);
if (existing) return await existing;
const authenticate = async () => {
const response = await fetchWithTrustedCertificatesAsync(this.url("/Users/AuthenticateByName"), {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: authorizationPrefix },
body: JSON.stringify({ Username: username, Pw: password }),
redirect: "error",
}).catch(() => {
throw new Error("Jellyfin authentication failed");
});
if (!response.ok) throw new Error("Jellyfin authentication failed");
const result = (await response.json().catch(() => null)) as { AccessToken?: unknown } | null;
if (typeof result?.AccessToken !== "string" || result.AccessToken.length === 0)
throw new Error("Jellyfin authentication failed");
return result.AccessToken;
};
const pending = authenticate();
pendingAuthentications.set(identity, pending);
try {
return await pending;
} finally {
pendingAuthentications.delete(identity);
}
}
private readonly jellyfin: Jellyfin = new Jellyfin({
clientInfo: {
name: "Homarr",
@@ -56,15 +142,27 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
if (sessionInfo.NowPlayingItem) {
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
// Jellyfin only reliably fills in MediaSources[].Bitrate for a transcoding session -
// /Sessions has no `fields` selector to force it, so fall back to summing the individual
// media streams' own bitrates (populated from the source file's metadata) for direct play.
const mediaStreamsBitrateBps = sessionInfo.NowPlayingItem.MediaStreams?.reduce(
(sum, stream) => sum + (stream.BitRate ?? 0),
0,
);
const bitrateBps =
sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null;
sessionInfo.TranscodingInfo?.Bitrate ??
sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ??
(mediaStreamsBitrateBps || null);
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: {
@@ -72,7 +170,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
positionMs,
durationMs,
},
location: null,
location: parseLocation(sessionInfo.RemoteEndPoint),
metadata: {
video: {
resolution:
@@ -187,18 +285,20 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
* @returns An instance of Api that has been authenticated
*/
private async getApiAsync(fallbackInstance?: AxiosInstance) {
const axiosInstance = fallbackInstance ?? (await createAxiosCertificateInstanceAsync());
if (this.hasSecretValue("apiKey")) {
const apiKey = this.getSecretValue("apiKey");
return this.jellyfin.createApi(this.url("/").toString(), apiKey, axiosInstance);
// Testing supplies a confined Axios client; keep all its traffic on that
// client. Normal query work shares only the in-flight authentication token.
if (fallbackInstance) {
if (this.hasSecretValue("apiKey"))
return this.jellyfin.createApi(this.url("/").toString(), this.getSecretValue("apiKey"), fallbackInstance);
const api = this.jellyfin.createApi(this.url("/").toString(), undefined, fallbackInstance);
await api.authenticateUserByName(this.getSecretValue("username"), this.getSecretValue("password"));
return api;
}
const apiClient = this.jellyfin.createApi(this.url("/").toString(), undefined, axiosInstance);
// Authentication state is stored internally in the Api class, so now
// requests that require authentication can be made normally.
// see https://typescript-sdk.jellyfin.org/#usage
await apiClient.authenticateUserByName(this.getSecretValue("username"), this.getSecretValue("password"));
return apiClient;
const [axiosInstance, accessToken] = await Promise.all([
createAxiosCertificateInstanceAsync(),
this.getAccessTokenAsync(),
]);
return this.jellyfin.createApi(this.url("/").toString(), accessToken, axiosInstance);
}
}
@@ -0,0 +1,83 @@
import { ParseError, ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { LlamacppStats } from "./llamacpp-types";
import {
mapContextUsage,
mapLlamacppModel,
mapLlamacppPerRequest,
mapLlamacppStats,
mapSlotsSummary,
parseLlamacppHealthAsync,
parseLlamacppMetricsAsync,
parseLlamacppModelsAsync,
parseLlamacppSlotsAsync,
} from "./llamacpp-types";
const LLMACPP_REQUEST_TIMEOUT_MS = 10_000;
export class LlamacppIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/health"), {
signal: AbortSignal.timeout(LLMACPP_REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
// Validate the health payload shape, not just that it is parseable JSON: an
// unrelated JSON body must not pass connection testing.
try {
await parseLlamacppHealthAsync(response);
} catch (error) {
if (error instanceof ParseError) {
return TestConnectionError.ParseResult(error);
}
return TestConnectionError.UnknownResult(error);
}
return { success: true };
}
public async getStatsAsync(): Promise<LlamacppStats> {
const [healthResponse, modelsResponse, metricsResponse, slotsResponse] = await Promise.all([
fetchWithTrustedCertificatesAsync(this.url("/health"), { timeout: LLMACPP_REQUEST_TIMEOUT_MS }),
fetchWithTrustedCertificatesAsync(this.url("/v1/models"), { timeout: LLMACPP_REQUEST_TIMEOUT_MS }),
fetchWithTrustedCertificatesAsync(this.url("/metrics"), { timeout: LLMACPP_REQUEST_TIMEOUT_MS }),
// /slots is not present on every build; a missing endpoint degrades to no context usage.
fetchWithTrustedCertificatesAsync(this.url("/slots"), { timeout: LLMACPP_REQUEST_TIMEOUT_MS }).catch(() => null),
]);
if (!healthResponse.ok) {
throw new ResponseError(healthResponse);
}
if (!modelsResponse.ok) {
throw new ResponseError(modelsResponse);
}
// /metrics only exists when llama-server was started with --metrics; a non-OK
// response (typically 501 Not Implemented) degrades to no rate counters instead
// of dropping the health and model data that the other endpoints still provide.
const metricsAvailable = metricsResponse.ok;
const [health, models, metrics, slots] = await Promise.all([
parseLlamacppHealthAsync(healthResponse),
parseLlamacppModelsAsync(modelsResponse),
metricsAvailable ? parseLlamacppMetricsAsync(metricsResponse) : Promise.resolve([]),
slotsResponse?.ok ? parseLlamacppSlotsAsync(slotsResponse) : Promise.resolve(null),
]);
const firstModel = models.data[0] ? mapLlamacppModel(models.data[0]) : null;
const contextUsage = mapContextUsage(slots);
const slotsSummary = mapSlotsSummary(slots);
const perRequest = mapLlamacppPerRequest(slots);
return mapLlamacppStats(health, firstModel, metrics, contextUsage, slotsSummary, perRequest);
}
}
@@ -0,0 +1,381 @@
import { ParseError } from "@homarr/common/server";
import { z } from "zod/v4";
export const LLMACPP_HEALTH_PARSE_ERROR_MESSAGE = "Invalid llama.cpp /health response";
export const LLMACPP_MODELS_PARSE_ERROR_MESSAGE = "Invalid llama.cpp /v1/models response";
export const parsePrometheusMetrics = (text: string): { name: string; value: number }[] => {
const metrics: { name: string; value: number }[] = [];
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (line.length === 0 || line.startsWith("#")) {
continue;
}
const match = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{[^}]*\})?\s+(-?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|NaN)$/.exec(
line,
);
if (!match) {
continue;
}
const name = match[1];
const rawValue = match[2];
if (!name || rawValue === undefined) {
continue;
}
const value = Number.parseFloat(rawValue);
if (!Number.isFinite(value)) {
continue;
}
metrics.push({ name, value });
}
return metrics;
};
export const llamacppHealthSchema = z.object({
status: z.string(),
});
const llamacppModelMetaSchema = z
.object({
n_ctx: z.number().optional(),
n_params: z.number().optional(),
size: z.number().optional(),
ftype: z.string().optional(),
})
.optional();
const llamacppModelSchema = z.object({
id: z.string(),
meta: llamacppModelMetaSchema,
});
export const llamacppModelsSchema = z.object({
data: z.array(llamacppModelSchema),
});
export interface LlamacppModel {
id: string;
name: string;
contextSize: number | null;
parameterCount: number | null;
fileSizeBytes: number | null;
quantization: string | null;
}
export interface LlamacppPerRequest {
taskId: number | null;
decodedTokens: number | null;
}
export interface LlamacppSlotsSummary {
total: number;
processing: number;
decoding: number;
speculative: boolean | null;
}
export interface LlamacppStats {
health: string;
model: LlamacppModel | null;
contextUsage: LlamacppContextUsage | null;
slots: LlamacppSlotsSummary | null;
metrics: {
generationSpeedTps: number | null;
promptSpeedTps: number | null;
avgGenerationSpeedTps: number | null;
avgPromptSpeedTps: number | null;
requestsProcessing: number | null;
requestsDeferred: number | null;
tokensProcessed: number | null;
tokensGenerated: number | null;
promptCacheHitRate: number | null;
requestDecodedTokens: number | null;
taskId: number | null;
specDraftTokens: number | null;
specAcceptedTokens: number | null;
specDrafts: number | null;
};
}
const shortModelName = (id: string): string =>
id
.split("/")
.pop()
?.replace(/\.gguf$/i, "") ?? id;
export const mapLlamacppModel = (model: z.infer<typeof llamacppModelSchema>): LlamacppModel => ({
id: model.id,
name: shortModelName(model.id),
contextSize: model.meta?.n_ctx ?? null,
parameterCount: model.meta?.n_params ?? null,
fileSizeBytes: model.meta?.size ?? null,
quantization: model.meta?.ftype ?? null,
});
export const getMetricValue = (metrics: readonly { name: string; value: number }[], name: string): number | null =>
metrics.find((metric) => metric.name === name)?.value ?? null;
/**
* Averages cumulative counters (tokens ÷ seconds) so a stable number is available while
* the server is idle, mirroring how llama-monitor derives its always-on speeds.
*/
export const avgTokensPerSecond = (tokens: number | null, seconds: number | null): number | null =>
tokens !== null && seconds !== null && seconds > 0 ? tokens / seconds : null;
interface LlamacppSlot {
n_ctx?: number;
n_prompt_tokens?: number;
id_task?: number;
is_processing?: boolean;
speculative?: boolean;
next_token?: { n_decoded?: number }[];
}
export interface LlamacppContextUsage {
usedTokens: number;
contextSize: number;
percent: number;
}
const clampPercent = (value: number): number => Math.min(100, Math.max(0, value));
/**
* Computes context (KV) usage from the /slots endpoint. The /metrics endpoint has no
* KV metrics, so the largest used prompt tokens over the slot context size is used.
*/
export const mapContextUsage = (slots: readonly unknown[] | null): LlamacppContextUsage | null => {
if (!Array.isArray(slots)) {
return null;
}
let largest: LlamacppContextUsage | null = null;
for (const slot of slots) {
if (typeof slot !== "object" || slot === null) {
continue;
}
const record = slot as Partial<LlamacppSlot>;
const contextSize = record.n_ctx;
const usedTokens = record.n_prompt_tokens;
if (typeof contextSize === "number" && contextSize > 0 && typeof usedTokens === "number" && usedTokens >= 0) {
const usage: LlamacppContextUsage = {
usedTokens,
contextSize,
percent: clampPercent(Math.round((usedTokens / contextSize) * 1000) / 10),
};
if (largest === null || usage.usedTokens > largest.usedTokens) {
largest = usage;
}
}
}
return largest;
};
/**
* Summarizes the /slots endpoint: total slot count, how many are currently
* processing, how many are actively decoding, and whether any slot has
* speculative decoding enabled. Null when /slots is unavailable.
*/
export const mapSlotsSummary = (slots: readonly unknown[] | null): LlamacppSlotsSummary | null => {
if (!Array.isArray(slots) || slots.length === 0) {
return null;
}
let total = 0;
let processing = 0;
let decoding = 0;
let speculative: boolean | null = null;
for (const slot of slots) {
if (typeof slot !== "object" || slot === null) {
continue;
}
total += 1;
const record = slot as Partial<LlamacppSlot>;
if (record.is_processing === true) {
processing += 1;
if (Array.isArray(record.next_token) && record.next_token.length > 0) {
decoding += 1;
}
}
if (record.speculative === true) {
speculative = true;
} else if (typeof record.speculative === "boolean" && speculative !== true) {
speculative = record.speculative;
}
}
return total > 0
? {
total,
processing,
decoding,
speculative,
}
: null;
};
/**
* Extracts the active request's identity and generated-token count from /slots. `id_task`
* identifies the in-flight request (llama.cpp task IDs are monotonically increasing and
* never reused) and `next_token[0].n_decoded` is `stats.n_gen`, the tokens this request has
* generated so far, which the server resets to 0 whenever a new generation starts. The widget
* takes deltas between polls to derive the per-request average speed.
*/
export const mapLlamacppPerRequest = (slots: readonly unknown[] | null): LlamacppPerRequest => {
if (!Array.isArray(slots)) {
return { taskId: null, decodedTokens: null };
}
for (const slot of slots) {
if (typeof slot !== "object" || slot === null) {
continue;
}
const record = slot as LlamacppSlot;
if (record.is_processing !== true) {
continue;
}
const taskId = typeof record.id_task === "number" ? record.id_task : null;
const nextToken = record.next_token?.[0];
const decodedTokens = typeof nextToken?.n_decoded === "number" ? nextToken.n_decoded : null;
return { taskId, decodedTokens };
}
return { taskId: null, decodedTokens: null };
};
export const requestSpeedTps = (tokens: number | null, elapsedSeconds: number | null): number | null => {
if (tokens === null || elapsedSeconds === null || elapsedSeconds <= 0) {
return null;
}
const speed = tokens / elapsedSeconds;
return Number.isFinite(speed) && speed > 0 ? speed : null;
};
export const parseLlamacppSlotsAsync = async (response: {
json: () => Promise<unknown>;
}): Promise<unknown[] | null> => {
let json: unknown;
try {
json = await response.json();
} catch (error) {
throw new ParseError("Invalid llama.cpp /slots response", {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
return Array.isArray(json) ? json : null;
};
export const mapLlamacppStats = (
health: z.infer<typeof llamacppHealthSchema>,
model: LlamacppModel | null,
metrics: readonly { name: string; value: number }[],
contextUsage: LlamacppContextUsage | null,
slotsSummary: LlamacppSlotsSummary | null,
perRequest: LlamacppPerRequest,
): LlamacppStats => {
const promptTokens = getMetricValue(metrics, "llamacpp:prompt_tokens_total");
const cachedTokens = getMetricValue(metrics, "llamacpp:prompt_tokens_cached_total");
const promptCacheHitRate =
promptTokens !== null && cachedTokens !== null && promptTokens + cachedTokens > 0
? Math.round((cachedTokens / (promptTokens + cachedTokens)) * 100)
: null;
return {
health: health.status,
model,
contextUsage,
slots: slotsSummary,
metrics: {
generationSpeedTps: getMetricValue(metrics, "llamacpp:predicted_tokens_seconds"),
promptSpeedTps: getMetricValue(metrics, "llamacpp:prompt_tokens_seconds"),
avgGenerationSpeedTps: avgTokensPerSecond(
getMetricValue(metrics, "llamacpp:tokens_predicted_total"),
getMetricValue(metrics, "llamacpp:tokens_predicted_seconds_total"),
),
avgPromptSpeedTps: avgTokensPerSecond(
getMetricValue(metrics, "llamacpp:prompt_tokens_total"),
getMetricValue(metrics, "llamacpp:prompt_seconds_total"),
),
requestsProcessing: getMetricValue(metrics, "llamacpp:requests_processing"),
requestsDeferred: getMetricValue(metrics, "llamacpp:requests_deferred"),
tokensProcessed: getMetricValue(metrics, "llamacpp:prompt_tokens_total"),
tokensGenerated: getMetricValue(metrics, "llamacpp:tokens_predicted_total"),
promptCacheHitRate,
requestDecodedTokens: perRequest.decodedTokens,
taskId: perRequest.taskId,
specDraftTokens: getMetricValue(metrics, "llamacpp:spec_decode_num_draft_tokens_total"),
specAcceptedTokens: getMetricValue(metrics, "llamacpp:spec_decode_num_accepted_tokens_total"),
specDrafts: getMetricValue(metrics, "llamacpp:spec_decode_num_drafts_total"),
},
};
};
export const parseLlamacppHealthAsync = async (response: {
json: () => Promise<unknown>;
}): Promise<z.infer<typeof llamacppHealthSchema>> => {
let json: unknown;
try {
json = await response.json();
} catch (error) {
throw new ParseError(LLMACPP_HEALTH_PARSE_ERROR_MESSAGE, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
const result = await llamacppHealthSchema.safeParseAsync(json);
if (!result.success) {
throw new ParseError(LLMACPP_HEALTH_PARSE_ERROR_MESSAGE, { cause: result.error });
}
return result.data;
};
export const parseLlamacppModelsAsync = async (response: {
json: () => Promise<unknown>;
}): Promise<z.infer<typeof llamacppModelsSchema>> => {
let json: unknown;
try {
json = await response.json();
} catch (error) {
throw new ParseError(LLMACPP_MODELS_PARSE_ERROR_MESSAGE, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
const result = await llamacppModelsSchema.safeParseAsync(json);
if (!result.success) {
throw new ParseError(LLMACPP_MODELS_PARSE_ERROR_MESSAGE, { cause: result.error });
}
return result.data;
};
export const parseLlamacppMetricsAsync = async (response: {
text: () => Promise<string>;
}): Promise<{ name: string; value: number }[]> => {
let text: string;
try {
text = await response.text();
} catch (error) {
throw new ParseError("Invalid llama.cpp /metrics response", {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
return parsePrometheusMetrics(text);
};
@@ -0,0 +1,489 @@
// @vitest-environment node
import { ParseError } from "@homarr/common/server";
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../base/types";
import { IntegrationParseError } from "../../base/errors/parse/integration-parse-error";
import { LlamacppIntegration } from "../llamacpp-integration";
import {
avgTokensPerSecond,
mapContextUsage,
mapLlamacppModel,
mapLlamacppPerRequest,
mapSlotsSummary,
parsePrometheusMetrics,
requestSpeedTps,
} from "../llamacpp-types";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "http://llamacpp.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const sampleModel = {
id: "/root/models/unsloth/Qwen3.8-27B-UD-Q4_K_XL.gguf",
meta: {
n_ctx: 131072,
n_params: 27320697856,
size: 17912397824,
ftype: "Q4_K - Small",
},
};
const sampleModelsResponse = {
data: [sampleModel],
};
const sampleMetricsText = [
"# HELP llamacpp:prompt_tokens_total Total number of tokens processed",
"# TYPE llamacpp:prompt_tokens_total counter",
"llamacpp:prompt_tokens_total 69834",
"llamacpp:prompt_seconds_total 23278",
"llamacpp:prompt_tokens_cached_total 12000",
"llamacpp:tokens_predicted_total 12161",
"llamacpp:tokens_predicted_seconds_total 380.03125",
"llamacpp:requests_processing 1",
"llamacpp:requests_deferred 0",
"llamacpp:predicted_tokens_seconds 32.8472",
"llamacpp:prompt_tokens_seconds 1500.5",
"llamacpp:spec_decode_num_draft_tokens_total 128",
"llamacpp:spec_decode_num_accepted_tokens_total 96",
"llamacpp:spec_decode_num_drafts_total 16",
].join("\n");
const sampleSlotsResponse = [
{
id: 0,
n_ctx: 131072,
n_prompt_tokens: 101076,
is_processing: true,
speculative: false,
id_task: 7,
next_token: [{ n_decoded: 42, n_remain: 100, has_next_token: true, has_new_line: false }],
},
];
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
const textResponse = (body: string, status = 200) =>
new Response(body, {
status,
headers: { "content-type": "text/plain" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
new LlamacppIntegration({
id: "test-llamacpp",
name: "Test llama.cpp",
url: TEST_URL,
externalUrl: null,
decryptedSecrets,
});
const mockAllEndpoints = () => {
mockFetch.mockReset();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/v1/models")) {
return jsonResponse(sampleModelsResponse);
}
if (url.includes("/metrics")) {
return textResponse(sampleMetricsText);
}
if (url.includes("/slots")) {
return jsonResponse(sampleSlotsResponse);
}
return textResponse("not found", 404);
}) as typeof fetchWithTrustedCertificatesAsync);
};
beforeEach(() => {
mockFetch.mockReset();
});
describe("parsePrometheusMetrics", () => {
test("parses metric lines and skips comments and HELP/TYPE lines", () => {
const metrics = parsePrometheusMetrics(sampleMetricsText);
expect(metrics).toStrictEqual([
{ name: "llamacpp:prompt_tokens_total", value: 69834 },
{ name: "llamacpp:prompt_seconds_total", value: 23278 },
{ name: "llamacpp:prompt_tokens_cached_total", value: 12000 },
{ name: "llamacpp:tokens_predicted_total", value: 12161 },
{ name: "llamacpp:tokens_predicted_seconds_total", value: 380.03125 },
{ name: "llamacpp:requests_processing", value: 1 },
{ name: "llamacpp:requests_deferred", value: 0 },
{ name: "llamacpp:predicted_tokens_seconds", value: 32.8472 },
{ name: "llamacpp:prompt_tokens_seconds", value: 1500.5 },
{ name: "llamacpp:spec_decode_num_draft_tokens_total", value: 128 },
{ name: "llamacpp:spec_decode_num_accepted_tokens_total", value: 96 },
{ name: "llamacpp:spec_decode_num_drafts_total", value: 16 },
]);
});
test("parses metric lines with labels and skips non-numeric values", () => {
const metrics = parsePrometheusMetrics(
[
'llamacpp:requests_by_status{status="ok"} 5',
"llamacpp:uptime_seconds +Inf",
"llamacpp:nan_metric NaN",
"",
].join("\n"),
);
expect(metrics).toStrictEqual([{ name: "llamacpp:requests_by_status", value: 5 }]);
});
test("returns an empty array for empty input", () => {
expect(parsePrometheusMetrics("")).toStrictEqual([]);
});
});
describe("mapLlamacppModel", () => {
test("extracts a short display name and metadata fields", () => {
const model = mapLlamacppModel(sampleModel);
expect(model).toStrictEqual({
id: "/root/models/unsloth/Qwen3.8-27B-UD-Q4_K_XL.gguf",
name: "Qwen3.8-27B-UD-Q4_K_XL",
contextSize: 131072,
parameterCount: 27320697856,
fileSizeBytes: 17912397824,
quantization: "Q4_K - Small",
});
});
test("returns null metadata when meta is missing", () => {
const model = mapLlamacppModel({ id: "models/foo.gguf" });
expect(model).toStrictEqual({
id: "models/foo.gguf",
name: "foo",
contextSize: null,
parameterCount: null,
fileSizeBytes: null,
quantization: null,
});
});
});
describe("mapContextUsage", () => {
test("computes percent from a slot with valid n_ctx and n_prompt_tokens", () => {
expect(mapContextUsage(sampleSlotsResponse)).toStrictEqual({
usedTokens: 101076,
contextSize: 131072,
percent: 77.1,
});
});
test("returns the slot with the greatest used tokens, not the first valid slot", () => {
expect(
mapContextUsage([
{ n_ctx: 1000, n_prompt_tokens: 100 },
{ n_ctx: 8192, n_prompt_tokens: 6000 },
{ n_ctx: 4096, n_prompt_tokens: 3000 },
]),
).toStrictEqual({
usedTokens: 6000,
contextSize: 8192,
percent: 73.2,
});
});
test("returns null for missing, non-array, or malformed slots", () => {
expect(mapContextUsage(null)).toBeNull();
expect(mapContextUsage([{ n_ctx: 0, n_prompt_tokens: 10 }])).toBeNull();
expect(mapContextUsage([{ n_prompt_tokens: 10 }])).toBeNull();
expect(mapContextUsage(["not-an-object", null])).toBeNull();
});
test("clamps the percentage to 100", () => {
expect(mapContextUsage([{ n_ctx: 10, n_prompt_tokens: 50 }])).toStrictEqual({
usedTokens: 50,
contextSize: 10,
percent: 100,
});
});
});
describe("avgTokensPerSecond", () => {
test("divides cumulative counters", () => {
expect(avgTokensPerSecond(12161, 380.03125)).toBe(32);
});
test("returns null when either counter is missing or seconds are zero", () => {
expect(avgTokensPerSecond(null, 5)).toBeNull();
expect(avgTokensPerSecond(10, null)).toBeNull();
expect(avgTokensPerSecond(10, 0)).toBeNull();
});
});
describe("mapLlamacppPerRequest", () => {
test("returns the active request id and decoded tokens from the processing slot", () => {
expect(mapLlamacppPerRequest(sampleSlotsResponse)).toStrictEqual({ taskId: 7, decodedTokens: 42 });
});
test("skips idle slots and returns nulls", () => {
expect(
mapLlamacppPerRequest([{ is_processing: false, id_task: 3, next_token: [{ n_decoded: 10 }] }]),
).toStrictEqual({ taskId: null, decodedTokens: null });
});
test("returns nulls for missing, empty, or malformed slots", () => {
expect(mapLlamacppPerRequest(null)).toStrictEqual({ taskId: null, decodedTokens: null });
expect(mapLlamacppPerRequest([])).toStrictEqual({ taskId: null, decodedTokens: null });
expect(mapLlamacppPerRequest(["nope", null])).toStrictEqual({ taskId: null, decodedTokens: null });
});
test("handles a processing slot missing the per-request fields", () => {
expect(mapLlamacppPerRequest([{ is_processing: true }])).toStrictEqual({
taskId: null,
decodedTokens: null,
});
});
});
describe("mapSlotsSummary", () => {
test("counts slots, processing slots, decoding slots and speculative flags", () => {
expect(
mapSlotsSummary([
{ is_processing: true, next_token: [{ n_decoded: 4 }], speculative: false },
{ is_processing: false, speculative: true },
{ is_processing: true, next_token: [], speculative: false },
]),
).toStrictEqual({ total: 3, processing: 2, decoding: 1, speculative: true });
});
test("treats an empty next_token list as not decoding", () => {
expect(mapSlotsSummary([{ is_processing: true, next_token: [] }, { is_processing: false }])).toStrictEqual({
total: 2,
processing: 1,
decoding: 0,
speculative: null,
});
});
test("returns null for missing, empty, or malformed slot lists", () => {
expect(mapSlotsSummary(null)).toBeNull();
expect(mapSlotsSummary([])).toBeNull();
expect(mapSlotsSummary(["not-an-object", null])).toBeNull();
});
});
describe("requestSpeedTps", () => {
test("divides token delta by elapsed seconds", () => {
expect(requestSpeedTps(128, 4)).toBe(32);
});
test("returns null for missing values, non-positive, or negative elapsed", () => {
expect(requestSpeedTps(null, 4)).toBeNull();
expect(requestSpeedTps(10, null)).toBeNull();
expect(requestSpeedTps(10, 0)).toBeNull();
expect(requestSpeedTps(10, -2)).toBeNull();
});
});
describe("LlamacppIntegration getStatsAsync", () => {
test("fetches health, models, metrics and slots and maps them into stats", async () => {
mockAllEndpoints();
const stats = await createIntegration().getStatsAsync();
expect(stats).toStrictEqual({
health: "ok",
model: {
id: "/root/models/unsloth/Qwen3.8-27B-UD-Q4_K_XL.gguf",
name: "Qwen3.8-27B-UD-Q4_K_XL",
contextSize: 131072,
parameterCount: 27320697856,
fileSizeBytes: 17912397824,
quantization: "Q4_K - Small",
},
contextUsage: {
usedTokens: 101076,
contextSize: 131072,
percent: 77.1,
},
slots: { total: 1, processing: 1, decoding: 1, speculative: false },
metrics: {
generationSpeedTps: 32.8472,
promptSpeedTps: 1500.5,
avgGenerationSpeedTps: 32,
avgPromptSpeedTps: 3,
requestsProcessing: 1,
requestsDeferred: 0,
tokensProcessed: 69834,
tokensGenerated: 12161,
promptCacheHitRate: 15,
requestDecodedTokens: 42,
taskId: 7,
specDraftTokens: 128,
specAcceptedTokens: 96,
specDrafts: 16,
},
});
const requestedUrls = mockFetch.mock.calls.map((call) => String(call[0])).toSorted();
expect(requestedUrls).toStrictEqual(
[`${TEST_URL}/health`, `${TEST_URL}/metrics`, `${TEST_URL}/slots`, `${TEST_URL}/v1/models`].toSorted(),
);
});
test("returns null model when the server has no models loaded", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/v1/models")) {
return jsonResponse({ data: [] });
}
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/slots")) {
return jsonResponse([]);
}
return textResponse(sampleMetricsText);
}) as typeof fetchWithTrustedCertificatesAsync);
const stats = await createIntegration().getStatsAsync();
expect(stats.model).toBeNull();
expect(stats.metrics.generationSpeedTps).toBe(32.8472);
});
test("returns null for metrics that are absent from /metrics", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/v1/models")) {
return jsonResponse({ data: [] });
}
if (url.includes("/slots")) {
return jsonResponse([]);
}
return textResponse("");
}) as typeof fetchWithTrustedCertificatesAsync);
const stats = await createIntegration().getStatsAsync();
expect(stats.metrics).toStrictEqual({
generationSpeedTps: null,
promptSpeedTps: null,
avgGenerationSpeedTps: null,
avgPromptSpeedTps: null,
requestsProcessing: null,
requestsDeferred: null,
tokensProcessed: null,
tokensGenerated: null,
promptCacheHitRate: null,
requestDecodedTokens: null,
taskId: null,
specDraftTokens: null,
specAcceptedTokens: null,
specDrafts: null,
});
});
test("throws when the /health endpoint returns an error status", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/health")) {
return textResponse("Service Unavailable", 503);
}
return jsonResponse({ status: "ok" });
}) as typeof fetchWithTrustedCertificatesAsync);
await expect(createIntegration().getStatsAsync()).rejects.toThrow();
});
test("degrades to null metrics when /metrics is not available (501) but keeps health and model data", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/metrics")) {
return textResponse("Not Implemented", 501);
}
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/v1/models")) {
return jsonResponse(sampleModelsResponse);
}
if (url.includes("/slots")) {
return jsonResponse(sampleSlotsResponse);
}
return textResponse("not found", 404);
}) as typeof fetchWithTrustedCertificatesAsync);
const stats = await createIntegration().getStatsAsync();
expect(stats.health).toBe("ok");
expect(stats.model?.name).toBe("Qwen3.8-27B-UD-Q4_K_XL");
expect(stats.contextUsage).toStrictEqual({
usedTokens: 101076,
contextSize: 131072,
percent: 77.1,
});
expect(stats.slots).toStrictEqual({ total: 1, processing: 1, decoding: 1, speculative: false });
expect(stats.metrics).toStrictEqual({
generationSpeedTps: null,
promptSpeedTps: null,
avgGenerationSpeedTps: null,
avgPromptSpeedTps: null,
requestsProcessing: null,
requestsDeferred: null,
tokensProcessed: null,
tokensGenerated: null,
promptCacheHitRate: null,
requestDecodedTokens: 42,
taskId: 7,
specDraftTokens: null,
specAcceptedTokens: null,
specDrafts: null,
});
});
test("throws a parse error when /v1/models is not valid JSON", async () => {
mockAllEndpoints();
mockFetch.mockImplementation((async (input) => {
const url = String(input);
if (url.includes("/v1/models")) {
return textResponse("not-json");
}
if (url.includes("/health")) {
return jsonResponse({ status: "ok" });
}
if (url.includes("/slots")) {
return jsonResponse([]);
}
return textResponse(sampleMetricsText);
}) as typeof fetchWithTrustedCertificatesAsync);
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
if (!(error instanceof IntegrationParseError)) return false;
const cause = error.cause;
return cause instanceof ParseError && cause.message.includes("Invalid llama.cpp /v1/models response");
});
});
});
@@ -0,0 +1,131 @@
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { IMediaOrganizerIntegration } from "../../interfaces/media-organizer/media-organizer-integration";
import type { MissingMediaItem, QueuedMediaItem } from "../../interfaces/media-organizer/media-organizer-types";
/**
* Bindery (https://github.com/vavallee/bindery) is a Sonarr/Radarr-style
* manager for ebooks and audiobooks. Unlike Sonarr/Radarr it does not expose
* server-side pagination on its missing/queue endpoints, so both methods below
* fetch the full response and page it client-side.
*/
export class BinderyIntegration extends Integration implements IMediaOrganizerIntegration {
async getMissingAsync(pageSize = 10): Promise<{ items: MissingMediaItem[]; totalCount: number }> {
const url = this.url("/api/v1/wanted/missing");
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
// Bindery returns a bare array here (no {items,total} wrapper and no
// pagination query params), unlike /api/v1/book which is paginated.
const books = await z.array(binderyMissingBookSchema).parseAsync(await response.json());
return {
totalCount: books.length,
items: books.slice(0, pageSize).map(
(book): MissingMediaItem => ({
id: book.id,
title: book.title,
type: "book",
year: book.releaseDate ? new Date(book.releaseDate).getUTCFullYear() : undefined,
seriesTitle: book.author?.authorName,
// This endpoint returns a direct, unauthenticated provider image URL
// (e.g. assets.hardcover.app). /api/v1/book's imageUrl is instead a
// Bindery-proxied path that requires auth and would 401 in the
// browser, so only pass through absolute URLs from here.
imageUrl: book.imageUrl?.startsWith("http") ? book.imageUrl : null,
link: this.externalUrl(`/book/${book.id}`).toString(),
}),
),
};
}
async getMediaQueueAsync(pageSize = 10): Promise<{ items: QueuedMediaItem[]; totalCount: number }> {
const url = this.url("/api/v1/queue");
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
const data = await z
.union([z.array(binderyQueueItemSchema), z.object({ items: z.array(binderyQueueItemSchema) })])
.parseAsync(await response.json());
const queueItems = Array.isArray(data) ? data : data.items;
// Bindery's queue endpoint returns its entire historical log rather than
// just in-flight downloads (observed live: 522 of 555 rows were already
// "imported"). Exclude those so the widget shows what's actually active.
const activeItems = queueItems.filter((item) => item.status !== "imported");
return {
totalCount: activeItems.length,
items: activeItems.slice(0, pageSize).map((item): QueuedMediaItem => {
const percentComplete = item.percentage ? Math.round(Number.parseFloat(item.percentage)) : 0;
// Observed live: a completed-but-blocked item reported percentage
// "100.0" alongside a stale timeLeft ("721h 20m") left over from
// whatever chunked-progress state produced it. A 100%-complete item
// has no meaningful time remaining, so don't surface that value.
const timeLeft = percentComplete >= 100 ? null : (item.timeLeft ?? null);
return {
id: item.id,
title: item.book?.title ?? item.title,
type: "book",
status: item.status,
timeLeft,
percentComplete,
seriesTitle: item.book?.authorName,
imageUrl: null, // the queue endpoint does not return cover art
link: item.book?.id
? this.externalUrl(`/book/${item.book.id}`).toString()
: this.externalUrl("/queue").toString(),
};
}),
};
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/v1/system/status"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
if (!response.ok) return TestConnectionError.StatusResult(response);
await response.json();
return { success: true };
}
}
const binderyMissingBookSchema = z.object({
id: z.number(),
title: z.string(),
// Observed live: books with no known release date return releaseDate: null
// (not just an omitted key), e.g. newly-added books awaiting metadata.
releaseDate: z.string().optional().nullable(),
imageUrl: z.string().optional().nullable(),
author: z
.object({
authorName: z.string(),
})
.optional(),
});
const binderyQueueItemSchema = z.object({
id: z.number(),
title: z.string(),
status: z.string(),
percentage: z.string().optional(),
timeLeft: z.string().optional().nullable(),
book: z
.object({
id: z.number().optional(),
title: z.string(),
authorName: z.string().optional(),
})
.optional(),
});
@@ -3,6 +3,8 @@ import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { apiKeyAuth } from "../../http-auth";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
@@ -14,6 +16,19 @@ import { mediaOrganizerPriorities } from "../media-organizer";
const logger = createLogger({ module: "lidarrIntegration" });
export class LidarrIntegration extends Integration implements ICalendarIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
async getLibraryStatsAsync() {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/artist"), {
headers: (await this.getHttpAuthenticationAsync()).headers,
});
if (!response.ok) throw new Error(`Lidarr library request failed (${response.status})`);
const library = z.array(z.object({ monitored: z.boolean() })).parse(await response.json());
return { artists: library.length, monitored: library.filter((artist) => artist.monitored).length };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
@@ -3,6 +3,8 @@ import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { apiKeyAuth } from "../../http-auth";
import type { IntegrationTestingInput } from "../../base/integration";
import { Integration } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
@@ -17,6 +19,32 @@ import { mediaOrganizerPriorities } from "../media-organizer";
const logger = createLogger({ module: "radarrIntegration" });
export class RadarrIntegration extends Integration implements ICalendarIntegration, IMediaOrganizerIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
async getLibraryStatsAsync() {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v3/movie"), {
headers: (await this.getHttpAuthenticationAsync()).headers,
});
if (!response.ok) throw new Error(`Radarr library request failed (${response.status})`);
const library = z
.array(
z.object({
monitored: z.boolean(),
hasFile: z.boolean(),
sizeOnDisk: z.number().nonnegative(),
}),
)
.parse(await response.json());
return {
movies: library.length,
monitored: library.filter((movie) => movie.monitored).length,
downloaded: library.filter((movie) => movie.hasFile).length,
storage: library.reduce((sum, movie) => sum + movie.sizeOnDisk, 0),
};
}
/**
* Gets the events in the Radarr calendar between two dates.
* @param start The start date
@@ -3,6 +3,8 @@ import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { apiKeyAuth } from "../../http-auth";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
@@ -14,6 +16,19 @@ import { mediaOrganizerPriorities } from "../media-organizer";
const logger = createLogger({ module: "readarrIntegration" });
export class ReadarrIntegration extends Integration implements ICalendarIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
async getLibraryStatsAsync() {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/book"), {
headers: (await this.getHttpAuthenticationAsync()).headers,
});
if (!response.ok) throw new Error(`Readarr library request failed (${response.status})`);
const library = z.array(z.object({ monitored: z.boolean() })).parse(await response.json());
return { books: library.length, monitored: library.filter((book) => book.monitored).length };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
@@ -3,6 +3,8 @@ import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { apiKeyAuth } from "../../http-auth";
import { Integration } from "../../base/integration";
import type { IntegrationTestingInput } from "../../base/integration";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
@@ -16,6 +18,38 @@ import { mediaOrganizerPriorities } from "../media-organizer";
const logger = createLogger({ module: "sonarrIntegration" });
export class SonarrIntegration extends Integration implements ICalendarIntegration, IMediaOrganizerIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
async getLibraryStatsAsync() {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v3/series"), {
headers: (await this.getHttpAuthenticationAsync()).headers,
});
if (!response.ok) throw new Error(`Sonarr library request failed (${response.status})`);
const library = z
.array(
z.object({
monitored: z.boolean(),
statistics: z
.object({
totalEpisodeCount: z.number().nonnegative(),
episodeFileCount: z.number().nonnegative(),
sizeOnDisk: z.number().nonnegative(),
})
.nullish(),
}),
)
.parse(await response.json());
return {
shows: library.length,
monitored: library.filter((series) => series.monitored).length,
episodes: library.reduce((sum, series) => sum + (series.statistics?.totalEpisodeCount ?? 0), 0),
downloaded: library.reduce((sum, series) => sum + (series.statistics?.episodeFileCount ?? 0), 0),
storage: library.reduce((sum, series) => sum + (series.statistics?.sizeOnDisk ?? 0), 0),
};
}
/**
* Gets the events in the Sonarr calendar between two dates.
* @param start The start date
+35 -31
View File
@@ -10,8 +10,9 @@ import type {
LiveStatsEvent,
} from "../../beszel/beszel-types";
const GB = 1024 * 1024 * 1024;
const KB = 1024;
const KIBIBYTE = 1024;
const MEBIBYTE = 1024 * KIBIBYTE;
const GIBIBYTE = 1024 * MEBIBYTE;
interface MockSystemDef {
name: string;
@@ -34,7 +35,7 @@ const mockSystems: MockSystemDef[] = [
os: "Ubuntu 24.04",
cpu: "AMD Ryzen 9 7950X",
cores: 16,
mem: 64 * GB,
mem: 64 * GIBIBYTE,
arch: "amd64",
baseCpu: 35,
baseMem: 55,
@@ -47,7 +48,7 @@ const mockSystems: MockSystemDef[] = [
os: "Debian 12",
cpu: "Intel Xeon E-2388G",
cores: 8,
mem: 32 * GB,
mem: 32 * GIBIBYTE,
arch: "amd64",
baseCpu: 12,
baseMem: 70,
@@ -60,7 +61,7 @@ const mockSystems: MockSystemDef[] = [
os: "Raspberry Pi OS",
cpu: "ARM Cortex-A76",
cores: 4,
mem: 8 * GB,
mem: 8 * GIBIBYTE,
arch: "aarch64",
baseCpu: 45,
baseMem: 82,
@@ -73,7 +74,7 @@ const mockSystems: MockSystemDef[] = [
os: "Proxmox VE 8.2",
cpu: "Intel i7-13700K",
cores: 16,
mem: 128 * GB,
mem: 128 * GIBIBYTE,
arch: "amd64",
baseCpu: 22,
baseMem: 48,
@@ -86,7 +87,7 @@ const mockSystems: MockSystemDef[] = [
os: "TrueNAS SCALE",
cpu: "Intel Xeon E5-2680",
cores: 12,
mem: 48 * GB,
mem: 48 * GIBIBYTE,
arch: "amd64",
baseCpu: 8,
baseMem: 30,
@@ -95,15 +96,15 @@ const mockSystems: MockSystemDef[] = [
},
];
const containerProfiles: { name: string; cpuBase: number; memMB: number; netKB: number }[] = [
{ name: "nginx", cpuBase: 2, memMB: 120, netKB: 800 },
{ name: "postgres", cpuBase: 8, memMB: 450, netKB: 200 },
{ name: "redis", cpuBase: 3, memMB: 180, netKB: 500 },
{ name: "grafana", cpuBase: 5, memMB: 280, netKB: 150 },
{ name: "prometheus", cpuBase: 6, memMB: 350, netKB: 300 },
{ name: "traefik", cpuBase: 4, memMB: 90, netKB: 1200 },
{ name: "minio", cpuBase: 3, memMB: 200, netKB: 600 },
{ name: "gitea", cpuBase: 4, memMB: 250, netKB: 100 },
const containerProfiles: { name: string; cpuBase: number; memMiB: number; netKiB: number }[] = [
{ name: "nginx", cpuBase: 2, memMiB: 120, netKiB: 800 },
{ name: "postgres", cpuBase: 8, memMiB: 450, netKiB: 200 },
{ name: "redis", cpuBase: 3, memMiB: 180, netKiB: 500 },
{ name: "grafana", cpuBase: 5, memMiB: 280, netKiB: 150 },
{ name: "prometheus", cpuBase: 6, memMiB: 350, netKiB: 300 },
{ name: "traefik", cpuBase: 4, memMiB: 90, netKiB: 1200 },
{ name: "minio", cpuBase: 3, memMiB: 200, netKiB: 600 },
{ name: "gitea", cpuBase: 4, memMiB: 250, netKiB: 100 },
];
const rand = (min: number, max: number) => min + Math.random() * (max - min);
@@ -144,30 +145,30 @@ function generateSystemStatsSeries(count: number, sys: MockSystemDef): BeszelSys
const netSendWalk = smoothWalk(500, 200, count, 10, 8000);
const netRecvWalk = smoothWalk(800, 300, count, 10, 12000);
const diskTotal = rand(200, 2000) * GB;
const diskTotal = rand(200, 2000) * GIBIBYTE;
return cpuWalk.map((cpu, i) => {
const memPct = memPctWalk[i] ?? sys.baseMem;
const diskPct = diskPctWalk[i] ?? sys.baseDisk;
const memTotalGB = sys.mem / GB;
const diskTotalGB = diskTotal / GB;
const memTotalGiB = sys.mem / GIBIBYTE;
const diskTotalGiB = diskTotal / GIBIBYTE;
return {
cpu,
la: [cpu / 20 + rand(0, 0.5), cpu / 25 + rand(0, 0.3), cpu / 30 + rand(0, 0.2)] as [number, number, number],
m: memTotalGB,
mu: (memTotalGB * memPct) / 100,
m: memTotalGiB,
mu: (memTotalGiB * memPct) / 100,
mp: memPct,
mb: rand(0.5, 4),
s: 4,
su: rand(0, 2),
d: diskTotalGB,
du: (diskTotalGB * diskPct) / 100,
d: diskTotalGiB,
du: (diskTotalGiB * diskPct) / 100,
dp: diskPct,
dr: diskReadWalk[i] ?? 5,
dw: diskWriteWalk[i] ?? 3,
ns: (netSendWalk[i] ?? 500) * KB,
nr: (netRecvWalk[i] ?? 800) * KB,
b: [(netSendWalk[i] ?? 500) * KB * 0.7, (netRecvWalk[i] ?? 800) * KB * 0.7] as [number, number],
ns: ((netSendWalk[i] ?? 500) * KIBIBYTE) / MEBIBYTE,
nr: ((netRecvWalk[i] ?? 800) * KIBIBYTE) / MEBIBYTE,
b: [(netSendWalk[i] ?? 500) * KIBIBYTE * 0.7, (netRecvWalk[i] ?? 800) * KIBIBYTE * 0.7] as [number, number],
};
});
}
@@ -176,16 +177,19 @@ function generateContainerStatsSeries(count: number, profiles: typeof containerP
const walks = profiles.map((p) => ({
profile: p,
cpuWalk: smoothWalk(p.cpuBase, 1.5, count, 0, 50),
memWalk: smoothWalk(p.memMB, p.memMB * 0.1, count, p.memMB * 0.5, p.memMB * 2),
netWalk: smoothWalk(p.netKB, p.netKB * 0.3, count, 0, p.netKB * 4),
memWalk: smoothWalk(p.memMiB, p.memMiB * 0.1, count, p.memMiB * 0.5, p.memMiB * 2),
netWalk: smoothWalk(p.netKiB, p.netKiB * 0.3, count, 0, p.netKiB * 4),
}));
return Array.from({ length: count }, (_, i) =>
walks.map(({ profile, cpuWalk, memWalk, netWalk }) => ({
n: profile.name,
c: cpuWalk[i] ?? profile.cpuBase,
m: memWalk[i] ?? profile.memMB,
b: [(netWalk[i] ?? profile.netKB) * KB * 0.4, (netWalk[i] ?? profile.netKB) * KB * 0.6] as [number, number],
m: memWalk[i] ?? profile.memMiB,
b: [(netWalk[i] ?? profile.netKiB) * KIBIBYTE * 0.4, (netWalk[i] ?? profile.netKiB) * KIBIBYTE * 0.6] as [
number,
number,
],
})),
);
}
@@ -227,7 +231,7 @@ export class BeszelMockService {
mp: clamp(sys.baseMem + rand(-5, 5), 0, 100),
dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100),
efs: i === 0 ? { "/mnt/sda": 86.9, "/mnt/sdb": 70.5, "/mnt/sdc": 42.7 } : undefined,
bb: rand(200, 3000) * KB,
bb: rand(200, 3000) * KIBIBYTE,
v: "0.8.2",
g: i === 3 ? rand(15, 45) : 0,
dt: rand(32, 58),
@@ -48,6 +48,17 @@ const missingItems: MissingMediaItem[] = [
imageUrl: "https://image.tmdb.org/t/p/w300/2cxhvwyEwRlysAmRH4iodkvo0z5.jpg",
link: "https://www.imdb.com/title/tt9218128/",
},
{
// "book" (added for Bindery) has no season/episode fields; the widget
// falls back to a themed icon when, as here, there is no cover image.
id: 6,
title: "The Will of the Many",
type: "book",
year: 2023,
seriesTitle: "James Islington",
imageUrl: null,
link: "https://openlibrary.org/works/OL27448W",
},
];
const queuedItems: QueuedMediaItem[] = [
@@ -97,6 +108,17 @@ const queuedItems: QueuedMediaItem[] = [
imageUrl: "https://image.tmdb.org/t/p/w300/z2yahl2uefxDCl0nogcRBstwruJ.jpg",
link: "https://www.imdb.com/title/tt11198330/",
},
{
id: 105,
title: "The Will of the Many",
type: "book",
status: "downloading",
timeLeft: "12m",
percentComplete: 54,
seriesTitle: "James Islington",
imageUrl: null,
link: "https://openlibrary.org/works/OL27448W",
},
];
export class MediaOrganizerMockService implements IMediaOrganizerIntegration {
@@ -26,7 +26,9 @@ const sessions: StreamSession[] = [
type: "video",
name: "Breaking Bad",
seasonName: "Season 5",
seasonNumber: 5,
episodeName: "Ozymandias",
episodeNumber: 14,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
@@ -58,7 +60,9 @@ const sessions: StreamSession[] = [
type: "video",
name: "Severance",
seasonName: "Season 2",
seasonNumber: 2,
episodeName: "The We We Are",
episodeNumber: 10,
albumName: null,
episodeCount: null,
playback: { state: null, positionMs: null, durationMs: null },
@@ -0,0 +1,387 @@
import type { AnchorNote, AnchorNoteSummary } from "../anchor/anchor-types";
import type { ArchiveTeamWarriorStatus } from "../archive-team-warrior/archive-team-warrior-types";
import type { BazarrBadges } from "../bazarr/bazarr-types";
import type { CoolifyInstanceInfo } from "../coolify/coolify-types";
import type { ImmichAlbum, ImmichServerStats } from "../immich/immich-integration";
import type { NavidromeDashboardData } from "../navidrome/navidrome-types";
import type { PaperlessNgxStats } from "../paperless-ngx/paperless-ngx-types";
import type { PatchMonStats } from "../patchmon/patchmon-types";
import type { SpeedtestTrackerDashboardData } from "../speedtest-tracker/speedtest-tracker-types";
import type { TracearrDashboardData } from "../tracearr/tracearr-types";
import type { TraefikDashboardData } from "../traefik/traefik-types";
import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats, UmamiWebsite } from "../umami/umami-types";
import type { UptimeKumaDashboardData } from "../uptime-kuma/uptime-kuma-types";
import type { LlamacppStats } from "../llama-cpp/llamacpp-types";
import type { WudStats } from "../wud/wud-types";
import { createWazuhMockData } from "../wazuh/wazuh-mock";
import type {
FirewallCpuSummary,
FirewallInterfacesSummary,
FirewallMemorySummary,
FirewallVersionSummary,
} from "../interfaces/firewall-summary/firewall-summary-types";
import type { VpnSummary } from "../interfaces/vpn-summary/vpn-summary-types";
const demoTimestamp = "2026-08-24T12:00:00.000Z";
const anchorNote: AnchorNote = {
id: "homarr-demo-note",
title: "Homarr demo runbook",
content: JSON.stringify({
ops: [
{ insert: "Welcome to the complete Homarr widget gallery.\n", attributes: { bold: true } },
{ insert: "Every integration-backed card below is powered by safe mock data.\n" },
],
}),
createdAt: demoTimestamp,
updatedAt: demoTimestamp,
isPinned: true,
isArchived: false,
tagIds: ["demo"],
permission: "editor",
background: null,
userId: "demo",
};
export const mockWidgetData = {
timestamp: demoTimestamp,
anchorNote,
anchorNotes: [
{
id: anchorNote.id,
title: anchorNote.title,
updatedAt: anchorNote.updatedAt,
isPinned: anchorNote.isPinned,
tagIds: anchorNote.tagIds,
permission: anchorNote.permission,
},
] satisfies AnchorNoteSummary[],
archiveTeamWarrior: {
status: "running",
runnerStatus: "running",
project: { id: "urlteam2", title: "URLTeam 2" },
selectedProject: "urlteam2",
broadcastMessage: "Archiving the open web, one URL at a time.",
bandwidth: {
received: 8_942_000_000,
sent: 1_284_000_000,
receiving: 4_800_000,
sending: 720_000,
session_id: "homarr-demo-session",
},
items: [
{ id: "demo-1", name: "urls-20260824-01", status: "running", project: "urlteam2", startTime: 1_777_000_000 },
{ id: "demo-2", name: "urls-20260824-00", status: "completed", project: "urlteam2" },
],
counts: { running: 1, completed: 284, failed: 2, canceled: 0 },
updatedAt: demoTimestamp,
} satisfies ArchiveTeamWarriorStatus,
audioStats: { artistCount: 842, albumCount: 1_976, songCount: 24_681 } satisfies NavidromeDashboardData,
bazarr: {
episodes: 12,
movies: 4,
providers: 6,
status: 0,
sonarr_signalr: "connected",
radarr_signalr: "connected",
announcements: 1,
} satisfies BazarrBadges,
coolify: {
version: "4.0.0-beta.420",
servers: [
{
id: 1,
uuid: "homarr-demo-edge",
name: "Paris edge",
ip: "10.0.0.20",
is_reachable: true,
is_usable: true,
settings: { server_id: 1, is_build_server: true, is_reachable: true, is_usable: true },
},
],
applications: [
{
id: 1,
uuid: "homarr-demo-app",
name: "Homarr",
status: "running:healthy",
fqdn: "https://homarr.dev",
projectName: "Home cloud",
environmentName: "Production",
},
{
id: 2,
uuid: "media-demo-app",
name: "Media stack",
status: "running:healthy",
fqdn: null,
projectName: "Home cloud",
environmentName: "Production",
},
],
services: [
{
id: 3,
uuid: "postgres-demo-service",
name: "Postgres",
status: "running:healthy",
fqdn: null,
applications: [],
projectName: "Home cloud",
environmentName: "Production",
},
],
} satisfies CoolifyInstanceInfo,
firewallCpu: { total: 23 } satisfies FirewallCpuSummary,
firewallInterfaces: [
{
data: [
{ name: "WAN", receive: 18_400_000, transmit: 4_200_000 },
{ name: "LAN", receive: 46_700_000, transmit: 21_300_000 },
],
timestamp: new Date(demoTimestamp),
},
] satisfies FirewallInterfacesSummary[],
firewallMemory: { used: 3_220_000_000, total: 8_590_000_000, percent: 37.5 } satisfies FirewallMemorySummary,
firewallVersion: { version: "24.7.11" } satisfies FirewallVersionSummary,
immichStats: {
userCount: 4,
photoCount: 48_392,
videoCount: 2_418,
totalLibraryUsageInBytes: 684_510_412_800,
} satisfies ImmichServerStats,
immichAlbums: [
{ id: "demo-paris", albumName: "Paris weekend", assetCount: 128 },
{ id: "demo-mountains", albumName: "Alpine trails", assetCount: 84 },
],
immichAlbum: {
id: "demo-paris",
albumName: "Paris weekend",
assets: [
{
id: "demo-photo-1",
thumbhash: null,
fileModifiedAt: demoTimestamp,
fileCreatedAt: demoTimestamp,
updatedAt: demoTimestamp,
type: "IMAGE",
publicLink: "https://images.unsplash.com/photo-1502602898657-3e91760cbb34?auto=format&fit=crop&w=1200&q=80",
},
{
id: "demo-photo-2",
thumbhash: null,
fileModifiedAt: demoTimestamp,
fileCreatedAt: demoTimestamp,
updatedAt: demoTimestamp,
type: "IMAGE",
publicLink: "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=1200&q=80",
},
],
} satisfies ImmichAlbum,
paperlessNgx: {
documentsTotal: 2_847,
documentsInbox: 14,
correspondentsCount: 96,
tagsCount: 38,
documentTypesCount: 12,
} satisfies PaperlessNgxStats,
patchmon: {
totalHosts: 18,
hostsNeedingUpdates: 5,
securityUpdates: 7,
upToDateHosts: 13,
hostsWithSecurityUpdates: 3,
recentUpdates24h: 11,
totalOutdatedPackages: 26,
totalRepos: 42,
lastUpdated: demoTimestamp,
osDistribution: [
{ name: "Ubuntu 24.04", count: 9, osType: "linux", osVersion: "24.04" },
{ name: "Debian 13", count: 6, osType: "linux", osVersion: "13" },
{ name: "Fedora 42", count: 3, osType: "linux", osVersion: "42" },
],
} satisfies PatchMonStats,
...createWazuhMockData(demoTimestamp),
speedtestTracker: {
latestResult: {
id: 42,
ping: 8.4,
download_bits: 936_000_000,
upload_bits: 487_000_000,
healthy: true,
created_at: new Date(demoTimestamp),
},
stats: {
ping: { avg: 9.1, min: 6.8, max: 14.2 },
download: { avg: 901, avg_bits: 901_000_000, min: 822, max: 948 },
upload: { avg: 472, avg_bits: 472_000_000, min: 431, max: 495 },
total_results: 128,
},
recentResults: [
{
id: 42,
ping: 8.4,
download_bits: 936_000_000,
upload_bits: 487_000_000,
healthy: true,
created_at: new Date(demoTimestamp),
},
],
} satisfies SpeedtestTrackerDashboardData,
tracearr: {
stats: { activeStreams: 2, totalUsers: 14, totalSessions: 1_248, recentViolations: 1, timestamp: demoTimestamp },
streams: {
data: [],
summary: {
total: 2,
transcodes: 1,
directStreams: 0,
directPlays: 1,
totalBitrate: "24.8 Mbps",
byServer: [
{
serverId: "demo",
serverName: "Homarr Media",
total: 2,
transcodes: 1,
directStreams: 0,
directPlays: 1,
totalBitrate: "24.8 Mbps",
},
],
},
},
violations: { data: [], meta: { total: 1, page: 1, pageSize: 10 } },
recentActivity: { data: [], meta: { total: 1_248, page: 1, pageSize: 10 } },
} satisfies TracearrDashboardData,
traefik: {
version: "3.5.0",
entryPoints: ["web", "websecure", "metrics"],
failedEndpoints: [],
resources: [
{ protocol: "http", type: "router", name: "homarr@docker", provider: "docker", status: "enabled" },
{ protocol: "http", type: "service", name: "homarr@docker", provider: "docker", status: "enabled" },
{ protocol: "http", type: "middleware", name: "secure-headers@file", provider: "file", status: "enabled" },
],
http: {
routers: { total: 12, enabled: 12, warnings: 0, errors: 0 },
services: { total: 10, enabled: 10, warnings: 0, errors: 0 },
middlewares: { total: 8, enabled: 8, warnings: 0, errors: 0 },
},
tcp: {
routers: { total: 2, enabled: 2, warnings: 0, errors: 0 },
services: { total: 2, enabled: 2, warnings: 0, errors: 0 },
middlewares: { total: 0, enabled: 0, warnings: 0, errors: 0 },
},
udp: {
routers: { total: 1, enabled: 1, warnings: 0, errors: 0 },
services: { total: 1, enabled: 1, warnings: 0, errors: 0 },
},
} satisfies TraefikDashboardData,
umamiWebsites: [{ id: "homarr-demo", name: "Homarr demo", domain: "demo.homarr.dev" }] satisfies UmamiWebsite[],
umamiVisitorStats: {
domain: "demo.homarr.dev",
websiteId: "homarr-demo",
websiteName: "Homarr demo",
totalVisitors: 8_429,
totalPageviews: 21_384,
totalVisits: 11_207,
bounceRate: 32.4,
avgDuration: 284,
timeFrame: "24h",
dataPoints: [
{ timestamp: "2026-08-24T08:00:00.000Z", visitors: 280, events: 0 },
{ timestamp: "2026-08-24T10:00:00.000Z", visitors: 410, events: 0 },
{ timestamp: "2026-08-24T12:00:00.000Z", visitors: 536, events: 0 },
],
eventCount: 0,
} satisfies UmamiVisitorStats,
umamiEventNames: ["dashboard_opened", "widget_added", "board_shared"],
umamiTopPages: [
{ x: "/", y: 8_240 },
{ x: "/boards/home", y: 5_812 },
{ x: "/manage/integrations", y: 1_942 },
] satisfies UmamiMetricItem[],
umamiTopReferrers: [
{ x: "github.com", y: 3_842 },
{ x: "google.com", y: 2_106 },
{ x: "docs.homarr.dev", y: 1_284 },
] satisfies UmamiMetricItem[],
umamiEventSeries: [
{
eventName: "dashboard_opened",
dataPoints: [
{ x: "2026-08-24T08:00:00.000Z", y: 92 },
{ x: "2026-08-24T10:00:00.000Z", y: 134 },
{ x: "2026-08-24T12:00:00.000Z", y: 188 },
],
},
] satisfies UmamiEventSeries[],
uptimeKuma: {
totalMonitors: 6,
upCount: 5,
downCount: 1,
pausedCount: 0,
averageUptimePercent: 99.72,
monitors: [
{ id: 1, name: "Homarr", status: "up", uptimePercent24h: 100 },
{ id: 2, name: "Media server", status: "up", uptimePercent24h: 99.98 },
{ id: 3, name: "Home cloud", status: "up", uptimePercent24h: 99.91 },
{ id: 4, name: "Backups", status: "up", uptimePercent24h: 99.62 },
{ id: 5, name: "VPN", status: "up", uptimePercent24h: 99.8 },
{ id: 6, name: "Lab service", status: "down", uptimePercent24h: 98.99 },
],
} satisfies UptimeKumaDashboardData,
vpn: {
vpnStatus: "running",
dnsStatus: "running",
publicIp: "198.51.100.42",
country: "France",
city: "Paris",
vpnProvider: { protocol: "wireguard", provider: "Mullvad" },
} satisfies VpnSummary,
wud: {
totalContainers: 24,
updatesAvailable: 3,
updates: [
{
id: "homarr",
name: "Homarr",
currentVersion: "v1.35.0",
newVersion: "v1.36.0",
link: "https://github.com/homarr-labs/homarr",
},
{ id: "postgres", name: "Postgres", currentVersion: "17.5", newVersion: "17.6", link: null },
{ id: "redis", name: "Redis", currentVersion: "8.0.2", newVersion: "8.0.3", link: null },
],
} satisfies WudStats,
llamacpp: {
health: "ok",
model: {
id: "unsloth/Qwen3.8-27B-UD-Q4_K_XL.gguf",
name: "Qwen3.8-27B-UD-Q4_K_XL",
contextSize: 32768,
parameterCount: 27_347_098_624,
fileSizeBytes: 16_842_776_576,
quantization: "Q4_K_XL",
},
contextUsage: { usedTokens: 8192, contextSize: 32768, percent: 25 },
slots: { total: 2, processing: 1, decoding: 1, speculative: false },
metrics: {
generationSpeedTps: 42.5,
promptSpeedTps: 1840.2,
avgGenerationSpeedTps: 39.8,
avgPromptSpeedTps: 2104.6,
requestsProcessing: 1,
requestsDeferred: 0,
tokensProcessed: 12_483,
tokensGenerated: 1_024,
promptCacheHitRate: 62,
requestDecodedTokens: 96,
taskId: 1042,
specDraftTokens: 48,
specAcceptedTokens: 12,
specDrafts: 8,
},
} satisfies LlamacppStats,
} as const;
@@ -6,6 +6,7 @@ import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { NavidromeDashboardData, NavidromeNowPlayingEntry, SubsonicResponseBody } from "./navidrome-types";
@@ -25,6 +26,15 @@ const asArray = <TValue>(value: TValue | TValue[] | undefined): TValue[] =>
[value].flat().filter((item): item is TValue => item !== undefined);
export class NavidromeIntegration extends Integration implements IMediaServerIntegration {
public override async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
const query = this.getAuthParams();
return {
headers: {},
query,
};
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const url = this.url("/rest/ping.view", this.getAuthParams());
const response = await input.fetchAsync(url);
@@ -0,0 +1,13 @@
export const createNextcloudCalendarServerUrl = (integrationUrl: string) => {
const calendarServerUrl = new URL(integrationUrl);
const integrationPath = calendarServerUrl.pathname.replace(/\/+$/, "");
const davPath = "/remote.php/dav";
calendarServerUrl.pathname = integrationPath.endsWith(davPath)
? `${integrationPath}/`
: `${integrationPath}${davPath}/`;
calendarServerUrl.search = "";
calendarServerUrl.hash = "";
return calendarServerUrl.toString();
};
@@ -12,6 +12,8 @@ import { ResponseError } from "@homarr/common/server";
import { createHttpsAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { basicAuth } from "../http-auth";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import { integrationTsdavHttpErrorHandler } from "../base/errors/http";
import type { IntegrationTestingInput } from "../base/integration";
@@ -21,6 +23,7 @@ import type { ICalendarIntegration } from "../interfaces/calendar/calendar-integ
import type { CalendarEvent } from "../interfaces/calendar/calendar-types";
import type { Notification } from "../interfaces/notifications/notification-types";
import type { INotificationsIntegration } from "../interfaces/notifications/notifications-integration";
import { createNextcloudCalendarServerUrl } from "./nextcloud-url";
// Register all existing timezones
if (ICAL.TimezoneService.count === 0) {
@@ -56,6 +59,10 @@ dayjs.extend(timezone);
@HandleIntegrationErrors([integrationTsdavHttpErrorHandler])
export class NextcloudIntegration extends Integration implements ICalendarIntegration, INotificationsIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return basicAuth("username", "password", { "OCS-APIRequest": "true" })(this.integration);
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.createCalendarClientAsync(await createHttpsAgentAsync(input.options));
await client.login();
@@ -189,7 +196,7 @@ export class NextcloudIntegration extends Integration implements ICalendarIntegr
private async createCalendarClientAsync(agent?: Agent) {
return new DAVClient({
serverUrl: this.integration.url,
serverUrl: createNextcloudCalendarServerUrl(this.integration.url),
credentials: {
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
@@ -0,0 +1,38 @@
import type { Agent } from "node:https";
import { DAVClient } from "tsdav";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { NextcloudIntegration } from "../nextcloud.integration";
vi.mock("tsdav", () => ({ DAVClient: vi.fn() }));
interface NextcloudCalendarClientFactory {
createCalendarClientAsync(agent: Agent): Promise<unknown>;
}
describe("NextcloudIntegration", () => {
beforeEach(() => {
vi.mocked(DAVClient).mockClear();
});
test("uses a calendar server URL that preserves the integration subpath", async () => {
const integration = new NextcloudIntegration({
id: "integration-id",
name: "Nextcloud",
url: "https://example.com/nextcloud",
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: "admin" },
{ kind: "password", value: "password" },
],
});
await (integration as unknown as NextcloudCalendarClientFactory).createCalendarClientAsync({} as Agent);
expect(DAVClient).toHaveBeenCalledWith(
expect.objectContaining({
serverUrl: "https://example.com/nextcloud/remote.php/dav/",
}),
);
});
});
@@ -0,0 +1,29 @@
import { describe, expect, test } from "vitest";
import { createNextcloudCalendarServerUrl } from "../nextcloud-url";
describe("createNextcloudCalendarServerUrl", () => {
test("uses the DAV endpoint for a root installation", () => {
expect(createNextcloudCalendarServerUrl("https://cloud.example.com")).toBe(
"https://cloud.example.com/remote.php/dav/",
);
});
test("preserves the base path for a subpath installation", () => {
expect(createNextcloudCalendarServerUrl("https://example.com/nextcloud")).toBe(
"https://example.com/nextcloud/remote.php/dav/",
);
});
test("does not duplicate a trailing slash", () => {
expect(createNextcloudCalendarServerUrl("https://example.com/nextcloud/")).toBe(
"https://example.com/nextcloud/remote.php/dav/",
);
});
test("does not duplicate an existing DAV endpoint", () => {
expect(createNextcloudCalendarServerUrl("https://example.com/nextcloud/remote.php/dav")).toBe(
"https://example.com/nextcloud/remote.php/dav/",
);
});
});
@@ -6,6 +6,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { SessionStore } from "../base/session-store";
import { createSessionStore } from "../base/session-store";
import type { TestingResult } from "../base/test-connection/test-connection-service";
@@ -27,38 +28,35 @@ export class OpenMediaVaultIntegration extends Integration implements ISystemHea
this.sessionStore = createSessionStore(integration);
}
public override async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
const session = await this.getSessionAsync();
if (session.type === "cookie") {
const cookie = `${session.loginToken};${session.sessionId}`;
return { headers: { Cookie: cookie }, redactValues: [cookie, session.loginToken, session.sessionId] };
}
return {
headers: { "X-OPENMEDIAVAULT-SESSIONID": session.sessionId },
redactValues: [session.sessionId],
};
}
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const systemResponses = await this.makeAuthenticatedRpcCallAsync("system", "getInformation");
const fileSystemResponse = await this.makeAuthenticatedRpcCallAsync(
"filesystemmgmt",
"enumerateMountedFilesystems",
{ includeroot: true },
);
const fileSystem = await this.getFileSystemsAsync();
const smartResponse = await this.makeAuthenticatedRpcCallAsync("smart", "enumerateDevices");
const cpuTempResponse = await this.makeAuthenticatedRpcCallAsync("cputemp", "get");
const systemResult = systemInformationSchema.safeParse(await systemResponses.json());
const fileSystemResult = fileSystemSchema.safeParse(await fileSystemResponse.json());
const smartResult = smartSchema.safeParse(await smartResponse.json());
const cpuTempResult = cpuTempSchema.safeParse(await cpuTempResponse.json());
if (!systemResult.success) {
throw new Error("Invalid system information response");
}
if (!fileSystemResult.success) {
throw new Error("Invalid file system response");
}
if (!smartResult.success) {
throw new Error("Invalid SMART information response");
}
const fileSystem = fileSystemResult.data.response.map((fileSystem) => ({
deviceName: fileSystem.devicename,
used: fileSystem.used,
available: fileSystem.available.toString(),
percentage: fileSystem.percentage,
}));
const smart = smartResult.data.response.map((smart) => ({
deviceName: smart.devicename,
temperature: smart.temperature,
@@ -90,11 +88,39 @@ export class OpenMediaVaultIntegration extends Integration implements ISystemHea
};
}
public async listStorageVolumesAsync(): Promise<{ value: string; label: string }[]> {
const fileSystem = await this.getFileSystemsAsync();
const { id: integrationId, name: integrationName } = this.integration;
return fileSystem.map(({ deviceName }) => ({
value: `${integrationId}:${deviceName}`,
label: `${deviceName} (${integrationName})`,
}));
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await this.getSessionAsync(input.fetchAsync);
return { success: true };
}
private async getFileSystemsAsync(): Promise<SystemHealthMonitoring["fileSystem"]> {
const response = await this.makeAuthenticatedRpcCallAsync("filesystemmgmt", "enumerateMountedFilesystems", {
includeroot: true,
});
const result = fileSystemSchema.safeParse(await response.json());
if (!result.success) {
throw new Error("Invalid file system response");
}
return result.data.response.map((fileSystem) => ({
deviceName: fileSystem.devicename,
used: fileSystem.used,
available: fileSystem.available.toString(),
percentage: fileSystem.percentage,
}));
}
private async makeAuthenticatedRpcCallAsync(
serviceName: string,
method: string,
@@ -0,0 +1,39 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { OverseerrIntegration } from "./overseerr-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integration = new OverseerrIntegration({
id: "overseerr-1",
name: "Overseerr",
url: "https://overseerr.example.com",
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: "test-key" }],
});
describe("Overseerr media request actions", () => {
beforeEach(() => vi.clearAllMocks());
it.each([
["approve", () => integration.approveRequestAsync(42)],
["decline", () => integration.declineRequestAsync(42)],
])("rejects %s when Overseerr returns a non-success response", async (_action, run) => {
mockFetch.mockResolvedValue(new Response(null, { status: 500 }));
await expect(run()).rejects.toMatchObject({
name: "IntegrationResponseError",
message: "Response from integration did not indicate success",
cause: {
name: "ResponseError",
message: "Response did not indicate success",
statusCode: 500,
},
});
expect(mockFetch).toHaveBeenCalledOnce();
});
});
@@ -1,9 +1,12 @@
import type { Dispatcher } from "undici";
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { ResponseError } from "@homarr/common/server";
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { apiKeyAuth } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { ISearchableIntegration } from "../base/searchable-integration";
@@ -40,6 +43,10 @@ export class OverseerrIntegration
extends Integration
implements IMediaRequestIntegration, ISearchableIntegration<OverseerrSearchResult>
{
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
public async searchAsync(query: string) {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), {
headers: {
@@ -123,77 +130,84 @@ export class OverseerrIntegration
}
public async getRequestsAsync(): Promise<MediaRequest[]> {
const pendingRequests = await fetchWithTrustedCertificatesAsync(
this.url("/api/v1/request", { take: 20, filter: "pending" }),
{
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
},
);
// One load shares certificate setup, sockets and TLS sessions. A later load
// still reads current certificate settings, and no dispatcher is retained.
const dispatcher = await createCertificateAgentAsync();
try {
const fetchRequests = async (parameters: { take: number; filter?: string; sort?: string }) => {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request", parameters), {
headers: { "X-Api-Key": this.getSecretValue("apiKey") },
dispatcher,
});
return (await getRequestsSchema.parseAsync(await response.json())).results;
};
const [pendingResults, allResults] = await Promise.all([
fetchRequests({ take: 20, filter: "pending" }),
fetchRequests({ take: 20, sort: "modified" }),
]);
const allRequests = await fetchWithTrustedCertificatesAsync(
this.url("/api/v1/request", { take: 20, sort: "modified" }),
{
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
},
);
//Concat the 2 lists while remove any duplicate pending from the all items list
let requests;
const pendingResults = (await getRequestsSchema.parseAsync(await pendingRequests.json())).results;
const allResults = (await getRequestsSchema.parseAsync(await allRequests.json())).results;
if (pendingResults.length > 0 && allResults.length > 0) {
requests = pendingResults.concat(
allResults.filter(({ status }) => status !== UpstreamMediaRequestStatus.PendingApproval),
);
} else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults;
else return [];
//Concat the 2 lists while remove any duplicate pending from the all items list
let requests;
// Requests for different users can reference the same movie or series. Share
// its in-flight lookup within this load, without retaining stale metadata.
const informationByMedia = new Map<string, Promise<MediaInformation>>();
const settled = await Promise.allSettled(
requests.map(async (request): Promise<MediaRequest> => {
const mediaKey = `${request.type}:${request.media.tmdbId}`;
let informationPromise = informationByMedia.get(mediaKey);
if (!informationPromise) {
informationPromise = this.getItemInformationAsync(request.media.tmdbId, request.type, dispatcher);
informationByMedia.set(mediaKey, informationPromise);
}
const information = await informationPromise;
if (pendingResults.length > 0 && allResults.length > 0) {
requests = pendingResults.concat(
allResults.filter(({ status }) => status !== UpstreamMediaRequestStatus.PendingApproval),
// See https://github.com/seerr-team/seerr/blob/af083a3cd5c3e3d5d7917fdf4fdd67fe3f39c46b/src/components/StatusBadge/index.tsx#L40
const inProgress = (request.media.downloadStatus ?? []).length >= 1;
return {
id: request.id,
name: information.name,
status: this.mapRequestStatus(request.status),
availability: this.mapAvailability(request.media.status, inProgress),
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
type: request.type,
createdAt: request.createdAt,
airDate: new Date(information.airDate),
requestedBy: request.requestedBy
? ({
...request.requestedBy,
displayName: request.requestedBy.displayName,
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
} satisfies Omit<RequestUser, "requestCount">)
: undefined,
};
}),
);
} else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults;
else return [];
const settled = await Promise.allSettled(
requests.map(async (request): Promise<MediaRequest> => {
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
const fulfilled = settled
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
.map((result) => result.value);
// See https://github.com/seerr-team/seerr/blob/af083a3cd5c3e3d5d7917fdf4fdd67fe3f39c46b/src/components/StatusBadge/index.tsx#L40
const inProgress = (request.media.downloadStatus ?? []).length >= 1;
if (fulfilled.length === 0) {
throw new Error("Failed to resolve any media request information");
}
return {
id: request.id,
name: information.name,
status: this.mapRequestStatus(request.status),
availability: this.mapAvailability(request.media.status, inProgress),
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
type: request.type,
createdAt: request.createdAt,
airDate: new Date(information.airDate),
requestedBy: request.requestedBy
? ({
...request.requestedBy,
displayName: request.requestedBy.displayName,
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
} satisfies Omit<RequestUser, "requestCount">)
: undefined,
};
}),
);
const fulfilled = settled
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
.map((result) => result.value);
if (fulfilled.length === 0) {
throw new Error("Failed to resolve any media request information");
return fulfilled;
} finally {
await dispatcher.close();
}
return fulfilled;
}
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
@@ -259,59 +273,43 @@ export class OverseerrIntegration
public async approveRequestAsync(requestId: number): Promise<void> {
logger.info("Approving media request", { requestId, integration: this.integration.name });
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
}).then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to approve media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
});
if (!response.ok) throw new ResponseError(response);
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
}
public async declineRequestAsync(requestId: number): Promise<void> {
logger.info("Declining media request", { requestId, integration: this.integration.name });
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
}).then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to decline media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
});
if (!response.ok) throw new ResponseError(response);
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
}
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
private async getItemInformationAsync(
id: number,
type: MediaRequest["type"],
dispatcher: Dispatcher,
): Promise<MediaInformation> {
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/${type}/${id}`), {
dispatcher,
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
throw new Error(`Failed to fetch ${type} information for id ${id}: ${response.status} ${response.statusText}`);
}
@@ -52,9 +52,9 @@ const mapOsDistributionEntry = (
osVersion: entry.os_version,
});
export const parsePatchMonStatsResponseAsync = async (
response: { json: () => Promise<unknown> },
): Promise<z.infer<typeof patchmonStatsResponseSchema>> => {
export const parsePatchMonStatsResponseAsync = async (response: {
json: () => Promise<unknown>;
}): Promise<z.infer<typeof patchmonStatsResponseSchema>> => {
let json: unknown;
try {
json = await response.json();
@@ -82,7 +82,5 @@ export const mapPatchMonStats = (data: z.infer<typeof patchmonStatsResponseSchem
totalOutdatedPackages: data.total_outdated_packages,
totalRepos: data.total_repos,
lastUpdated: data.last_updated,
osDistribution: [...data.os_distribution]
.map(mapOsDistributionEntry)
.sort((a, b) => b.count - a.count),
osDistribution: [...data.os_distribution].map(mapOsDistributionEntry).toSorted((a, b) => b.count - a.count),
});
@@ -1,8 +1,10 @@
import { isRecord } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../../base/integration";
import { Integration } from "../../base/integration";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { TestConnectionError } from "../../base/test-connection/test-connection-error";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { DnsHoleSummaryIntegration } from "../../interfaces/dns-hole-summary/dns-hole-summary-integration";
@@ -10,6 +12,11 @@ import type { DnsHoleSummary } from "../../interfaces/dns-hole-summary/dns-hole-
import { summaryResponseSchema } from "./pi-hole-schemas-v5";
export class PiHoleIntegrationV5 extends Integration implements DnsHoleSummaryIntegration {
public override async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
if (!this.hasSecretValue("apiKey")) return { headers: {} };
return { headers: {}, query: { auth: this.getSecretValue("apiKey") } };
}
public async getSummaryAsync(): Promise<DnsHoleSummary> {
const apiKey = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?summaryRaw", { auth: apiKey }));
@@ -38,7 +45,7 @@ export class PiHoleIntegrationV5 extends Integration implements DnsHoleSummaryIn
const data = await response.json();
// Pi-hole v5 returned an empty array if the API key is wrong
if (typeof data !== "object" || Array.isArray(data)) {
if (!isRecord(data)) {
return TestConnectionError.UnauthorizedResult(401);
}
@@ -7,6 +7,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationInput, IntegrationTestingInput } from "../../base/integration";
import { Integration } from "../../base/integration";
import type { IntegrationHttpAuthentication } from "../../http-auth";
import type { SessionStore } from "../../base/session-store";
import { createSessionStore } from "../../base/session-store";
import type { TestingResult } from "../../base/test-connection/test-connection-service";
@@ -24,6 +25,13 @@ export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIn
this.sessionStore = createSessionStore(integration);
}
public override async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
if (!this.hasSecretValue("apiKey")) return { headers: {} };
const sessionId = await this.getSessionAsync();
if (!sessionId) throw new Error("Pi-hole did not return an authenticated session");
return { headers: { sid: sessionId }, redactValues: [sessionId] };
}
public async getDnsBlockingStatusAsync(): Promise<z.infer<typeof dnsBlockingGetSchema>> {
const response = await this.withAuthAsync(async (sessionId) => {
return await fetchWithTrustedCertificatesAsync(this.url("/api/dns/blocking"), {
+2 -1
View File
@@ -80,8 +80,9 @@ interface Session {
$: {
grandparentTitle?: string;
parentTitle?: string;
parentIndex?: string;
title?: string;
index?: number;
index?: string;
type: string;
live?: string;
viewOffset?: string;
@@ -1,8 +1,10 @@
import { parseStringPromise } from "xml2js";
import { z } from "zod/v4";
import { ParseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { ParseError, ResponseError } from "@homarr/common/server";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ImageProxy } from "@homarr/image-proxy";
@@ -43,6 +45,111 @@ function parseResolution(
return parsedWidth !== null && parsedHeight !== null ? { width: parsedWidth, height: parsedHeight } : null;
}
// Seasons in /library/recentlyAdded use "/library/metadata/{ratingKey}/children" as key, episodes reference their
// season through parentKey "/library/metadata/{ratingKey}". /library/recentlyAdded can return either of them for the
// same content, so both are mapped to the season they belong to.
function seasonKeyOf(item: RecentlyAddedItem): string | null {
if (item.type === "season") return item.key.replace(/\/children$/, "");
if (item.type === "episode") return item.parentKey ?? null;
return null;
}
function formatEpisodeSubtitle(episode: RecentlyAddedEpisode): string | undefined {
if (episode.parentIndex === undefined || episode.index === undefined) return undefined;
const seasonEpisode = `S${String(episode.parentIndex).padStart(2, "0")}E${String(episode.index).padStart(2, "0")}`;
return episode.title ? `${seasonEpisode} \u2013 ${episode.title}` : seasonEpisode;
}
function episodeImages(episode: RecentlyAddedEpisode): RecentlyAddedItem["Image"] {
const images: NonNullable<RecentlyAddedItem["Image"]> = [];
const poster = episode.grandparentThumb ?? episode.parentThumb ?? episode.thumb;
const backdrop = episode.art ?? episode.grandparentArt;
if (poster) images.push({ type: "coverPoster", url: poster });
if (backdrop) images.push({ type: "background", url: backdrop });
return images.length > 0 ? images : undefined;
}
function parseReleaseDate(item: RecentlyAddedItem): Date {
const airDate = item.originallyAvailableAt;
if (!airDate) return new Date(item.addedAt * 1000);
return new Date(airDate);
}
/**
* `/library/recentlyAdded` lists seasons with the `addedAt` of the season itself, so episodes added to an existing
* season never surface. Merges the most recently added episode of every season into the list: entries that received
* newer episodes are bumped to the episode's `addedAt`, seasons that are no longer part of `/library/recentlyAdded`
* are re-created from the episode metadata. Entries whose show or season is already listed are never duplicated.
*/
function mergeRecentlyAddedEpisodes(items: RecentlyAddedItem[], episodes: RecentlyAddedEpisode[]): MergedItem[] {
if (episodes.length === 0) return items;
// Episodes are sorted by addedAt descending, so the first one per season or show is the newest
const newestEpisodeBySeasonKey = new Map<string, RecentlyAddedEpisode>();
const newestEpisodeByShowKey = new Map<string, RecentlyAddedEpisode>();
for (const episode of episodes) {
if (episode.parentKey && !newestEpisodeBySeasonKey.has(episode.parentKey)) {
newestEpisodeBySeasonKey.set(episode.parentKey, episode);
}
if (episode.grandparentKey && !newestEpisodeByShowKey.has(episode.grandparentKey)) {
newestEpisodeByShowKey.set(episode.grandparentKey, episode);
}
}
const bumpedItems = items.map((item): MergedItem => {
const seasonKey = seasonKeyOf(item);
let episode: RecentlyAddedEpisode | undefined;
if (item.type === "show") episode = newestEpisodeByShowKey.get(item.key);
else if (seasonKey) episode = newestEpisodeBySeasonKey.get(seasonKey);
if (!episode || (item.type !== "show" && episode.addedAt <= item.addedAt)) return item;
if (item.type === "episode") {
return {
...item,
key: episode.key,
Media: undefined,
title: episode.title ?? item.title,
parentTitle: episode.parentTitle ?? item.parentTitle,
grandparentTitle: episode.grandparentTitle ?? item.grandparentTitle,
Image: episodeImages(episode) ?? item.Image,
summary: episode.summary,
duration: episode.duration,
addedAt: episode.addedAt,
originallyAvailableAt: episode.originallyAvailableAt,
latestEpisode: episode,
};
}
return {
...item,
addedAt: Math.max(item.addedAt, episode.addedAt),
originallyAvailableAt: episode.originallyAvailableAt ?? item.originallyAvailableAt,
latestEpisode: episode,
};
});
const listedKeys = new Set(
items.flatMap((item) => (item.type === "show" ? [item.key] : [seasonKeyOf(item)])).filter((key) => key !== null),
);
const missingSeasons = [...newestEpisodeBySeasonKey.entries()]
.filter(([seasonKey, episode]) => !listedKeys.has(seasonKey) && !listedKeys.has(episode.grandparentKey ?? ""))
.map(([seasonKey, episode]): MergedItem => {
return {
key: `${seasonKey}/children`,
type: "season",
title: episode.parentTitle ?? (episode.parentIndex !== undefined ? `Season ${episode.parentIndex}` : "Season"),
parentTitle: episode.grandparentTitle,
addedAt: episode.addedAt,
originallyAvailableAt: episode.originallyAvailableAt,
latestEpisode: episode,
Image: episodeImages(episode),
};
});
return [...bumpedItems, ...missingSeasons].toSorted((itemA, itemB) => itemB.addedAt - itemA.addedAt);
}
function isStreamDirect(decision: string | undefined): boolean {
// Plex reports "transcode" when a stream is re-encoded. "copy" (remuxed
// without re-encoding) and an absent decision both count as direct.
@@ -97,8 +204,8 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
const isAudioDirect = isStreamDirect(transcodeElement?.$.audioDecision);
const containerChanged = Boolean(
transcodeElement?.$.container &&
mediaInfoElement?.$.container &&
transcodeElement.$.container !== mediaInfoElement.$.container,
mediaInfoElement?.$.container &&
transcodeElement.$.container !== mediaInfoElement.$.container,
);
const bitrateKbps =
parseOptionalNumber(sessionElement?.$.bandwidth) ?? parseOptionalNumber(mediaInfoElement?.$.bitrate);
@@ -115,9 +222,11 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type),
name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown",
seasonName: mediaElement.$.parentTitle,
episodeName: mediaElement.$.title ?? null,
seasonNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.parentIndex) : null,
episodeName: mediaElement.$.type === "episode" ? (mediaElement.$.title ?? null) : null,
episodeNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.index) : null,
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
episodeCount: mediaElement.$.index ?? null,
episodeCount: parseOptionalNumber(mediaElement.$.index),
playback: {
state: playbackState,
positionMs,
@@ -156,20 +265,34 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
const token = super.getSecretValue("apiKey");
const machineIdentifier = await this.getMachineIdentifierAsync();
const response = await fetchWithTrustedCertificatesAsync(super.url("/library/recentlyAdded"), {
headers: {
"X-Plex-Token": token,
Accept: "application/json",
},
});
// A single dispatcher for all requests of this call, so the trusted certificates are read once and the
// connection to Plex is reused instead of opening a new TLS connection per library.
const dispatcher = await createCertificateAgentAsync(undefined, { bodyTimeout: REQUEST_TIMEOUT_MS });
const json = await response.json();
const data = await recentlyAddedSchema.parseAsync(json);
try {
return await this.getMediaReleasesWithDispatcherAsync(token, dispatcher);
} finally {
await dispatcher.close();
}
}
private async getMediaReleasesWithDispatcherAsync(token: string, dispatcher: UndiciHttpAgent) {
// The episode requests are an addition to /library/recentlyAdded and run alongside it instead of after it
const [machineIdentifier, data, episodes] = await Promise.all([
this.getMachineIdentifierAsync(dispatcher),
this.fetchJsonAsync("/library/recentlyAdded", dispatcher).then(
async (json) => await recentlyAddedSchema.parseAsync(json),
),
this.getRecentlyAddedEpisodesAsync(dispatcher),
]);
const recentlyAddedItems = mergeRecentlyAddedEpisodes(data.MediaContainer.Metadata ?? [], episodes)
.filter((item) => item.Image)
.slice(0, MEDIA_RELEASES_LIMIT);
const imageProxy = new ImageProxy();
const images =
data.MediaContainer.Metadata?.filter((item) => item.Image)
recentlyAddedItems
.filter((item) => item.Image)
.flatMap((item) => [
{
mediaKey: item.key,
@@ -207,8 +330,14 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
}),
);
const media =
data.MediaContainer.Metadata?.filter((item) => item.Image).map((item) => {
const media = recentlyAddedItems
.filter((item) => item.Image)
.map((item) => {
let latestEpisodeSubtitle: string | undefined;
if (item.latestEpisode) {
latestEpisodeSubtitle = formatEpisodeSubtitle(item.latestEpisode);
if (!latestEpisodeSubtitle && item.type === "show") latestEpisodeSubtitle = item.latestEpisode.title;
}
const title =
item.type === "episode"
? (item.grandparentTitle ?? item.title)
@@ -220,11 +349,10 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
id: item.Media?.at(0)?.id.toString() ?? item.key,
type: mapType(item.type),
title,
subtitle: title === item.title ? item.tagline : item.title,
subtitle: latestEpisodeSubtitle ?? (title === item.title ? item.tagline : item.title),
description: item.summary,
releaseDate: item.originallyAvailableAt
? new Date(item.originallyAvailableAt)
: new Date(item.addedAt * 1000),
releaseDate: parseReleaseDate(item),
releaseDateIsDateOnly: /^\d{4}-\d{2}-\d{2}$/.test(item.originallyAvailableAt ?? ""),
imageUrls: {
poster: proxiedImages.find((image) => image.mediaKey === item.key && image.type === "poster")?.url,
backdrop: proxiedImages.find((image) => image.mediaKey === item.key && image.type === "backdrop")?.url,
@@ -240,20 +368,84 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
.toString(),
length: item.duration ? Math.round(item.duration / 1000) : undefined,
};
}) ?? [];
});
return media;
}
private async getMachineIdentifierAsync(): Promise<string> {
/**
* Most recently added episodes of every show library, newest first. Never rejects: a library that fails or times
* out is skipped so the remaining libraries still contribute, and an empty result keeps the seasons only list.
*/
private async getRecentlyAddedEpisodesAsync(dispatcher: UndiciHttpAgent): Promise<RecentlyAddedEpisode[]> {
const showSections = await this.getShowSectionsAsync(dispatcher);
const episodesPerSection = await Promise.allSettled(
showSections.map(async (section) => {
const data = await recentlyAddedEpisodesSchema.parseAsync(
await this.fetchJsonAsync(
`/library/sections/${encodeURIComponent(section.key)}/all?type=4&sort=addedAt:desc&X-Plex-Container-Start=0&X-Plex-Container-Size=${EPISODES_PAGE_SIZE}`,
dispatcher,
),
);
return data.MediaContainer.Metadata ?? [];
}),
);
return episodesPerSection
.flatMap((result, index) => {
if (result.status === "fulfilled") return result.value;
logger.warn(
new ErrorWithMetadata(
"Failed to fetch recently added episodes of Plex library",
{ section: showSections[index]?.key ?? "unknown" },
{ cause: result.reason },
),
);
return [];
})
.toSorted((episodeA, episodeB) => episodeB.addedAt - episodeA.addedAt);
}
private async getShowSectionsAsync(dispatcher: UndiciHttpAgent) {
try {
const sections = await librarySectionsSchema.parseAsync(
await this.fetchJsonAsync("/library/sections", dispatcher),
);
return sections.MediaContainer.Directory?.filter((section) => section.type === "show") ?? [];
} catch (error) {
logger.warn(
new Error("Failed to list Plex libraries, media releases fall back to seasons only", { cause: error }),
);
return [];
}
}
/**
* Wraps fetchWithTrustedCertificatesAsync with the Plex token and JSON accept header used by all JSON endpoints.
* The dispatcher carries the body timeout, `timeout` covers the response headers.
*/
private async fetchJsonAsync(path: `/${string}`, dispatcher: UndiciHttpAgent): Promise<unknown> {
const token = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(super.url("/identity"), {
const response = await fetchWithTrustedCertificatesAsync(super.url(path), {
dispatcher,
timeout: REQUEST_TIMEOUT_MS,
headers: {
"X-Plex-Token": token,
Accept: "application/json",
},
});
const data = await identitySchema.parseAsync(await response.json());
if (!response.ok) {
throw new ResponseError(response);
}
return await response.json();
}
private async getMachineIdentifierAsync(dispatcher: UndiciHttpAgent): Promise<string> {
const data = await identitySchema.parseAsync(await this.fetchJsonAsync("/identity", dispatcher));
return data.MediaContainer.machineIdentifier;
}
@@ -301,54 +493,112 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
}
}
// Same limit as the Jellyfin and Emby integrations
const MEDIA_RELEASES_LIMIT = 100;
// Default page size of /library/recentlyAdded, enough to cover the newest episode of every recently updated season
const EPISODES_PAGE_SIZE = 50;
// A stalled Plex request must not block the merge, the fallback or the widget
const REQUEST_TIMEOUT_MS = 10_000;
const recentlyAddedItemSchema = z.object({
key: z.string(),
parentKey: z.string().optional(), // season of an episode, for example "/library/metadata/123"
studio: z.string().optional(),
type: z.string(), // For example "movie", "album"
title: z.string(),
parentTitle: z.string().optional(),
grandparentTitle: z.string().optional(),
summary: z.string().optional(),
duration: z.number().optional(),
addedAt: z.number(),
rating: z.number().optional(),
tagline: z.string().optional(),
originallyAvailableAt: z.string().optional(),
Media: z
.array(
z.object({
id: z.number(),
}),
)
.optional(),
Image: z
.array(
z
.object({
type: z.string(), // for example "coverPoster" or "background"
url: z.string(),
})
.optional(),
)
.optional(),
Genre: z
.array(
z.object({
tag: z.string(),
}),
)
.optional(),
});
type RecentlyAddedItem = z.infer<typeof recentlyAddedItemSchema>;
/**
* A recently added item together with the newest episode that put it at its position, if any.
*/
interface MergedItem extends RecentlyAddedItem {
latestEpisode?: RecentlyAddedEpisode;
}
// https://plexapi.dev/api-reference/library/get-recently-added
const recentlyAddedSchema = z.object({
MediaContainer: z.object({
Metadata: z
Metadata: z.array(recentlyAddedItemSchema).optional(),
}),
});
// https://plexapi.dev/api-reference/library/get-all-libraries
const librarySectionsSchema = z.object({
MediaContainer: z.object({
Directory: z
.array(
z.object({
key: z.string(),
studio: z.string().optional(),
type: z.string(), // For example "movie", "album"
title: z.string(),
parentTitle: z.string().optional(),
grandparentTitle: z.string().optional(),
summary: z.string().optional(),
duration: z.number().optional(),
addedAt: z.number(),
rating: z.number().optional(),
tagline: z.string().optional(),
originallyAvailableAt: z.string().optional(),
Media: z
.array(
z.object({
id: z.number(),
}),
)
.optional(),
Image: z
.array(
z
.object({
type: z.string(), // for example "coverPoster" or "background"
url: z.string(),
})
.optional(),
)
.optional(),
Genre: z
.array(
z.object({
tag: z.string(),
}),
)
.optional(),
type: z.string(), // For example "movie", "show", "artist"
}),
)
.optional(),
}),
});
// https://plexapi.dev/api-reference/library/get-library-items with type=4 (episodes)
const recentlyAddedEpisodeSchema = z.object({
key: z.string(),
title: z.string().optional(), // episode title
summary: z.string().optional(),
duration: z.number().optional(),
index: z.number().optional(), // episode number
parentKey: z.string().optional(), // season, for example "/library/metadata/123"
parentIndex: z.number().optional(), // season number
parentTitle: z.string().optional(), // season title, for example "Season 2"
parentThumb: z.string().optional(),
thumb: z.string().optional(),
grandparentKey: z.string().optional(), // show, for example "/library/metadata/122"
grandparentTitle: z.string().optional(), // show title
grandparentThumb: z.string().optional(),
grandparentArt: z.string().optional(),
art: z.string().optional(),
addedAt: z.number(),
originallyAvailableAt: z.string().optional(),
});
type RecentlyAddedEpisode = z.infer<typeof recentlyAddedEpisodeSchema>;
const recentlyAddedEpisodesSchema = z.object({
MediaContainer: z.object({
Metadata: z.array(recentlyAddedEpisodeSchema).optional(),
}),
});
// https://plexapi.dev/api-reference/server/get-server-identity
const identitySchema = z.object({
MediaContainer: z.object({
@@ -11,8 +11,11 @@ const imageProxyMocks = vi.hoisted(() => ({
createImageAsync: vi.fn((url: string) => Promise.resolve(`proxied:${url}`)),
}));
const dispatcherMocks = vi.hoisted(() => ({ close: vi.fn(() => Promise.resolve()) }));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
createCertificateAgentAsync: vi.fn(() => Promise.resolve(dispatcherMocks)),
}));
vi.mock("@homarr/image-proxy", () => ({
@@ -49,6 +52,19 @@ const createMetadataItem = (key: string, overrides: Record<string, unknown>) =>
...overrides,
});
const createEpisode = (key: string, overrides: Record<string, unknown>) => ({
key: `/library/metadata/${key}`,
type: "episode",
title: "Episode",
addedAt: 1_735_689_600,
...overrides,
});
const respond = (body: unknown, status = 200) =>
Promise.resolve(new Response(JSON.stringify(body), { status })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
beforeEach(() => {
mockFetch.mockReset();
imageProxyMocks.createImageAsync.mockClear();
@@ -137,4 +153,218 @@ describe("PlexIntegration.getMediaReleasesAsync", () => {
},
]);
});
});
});
describe("PlexIntegration.getMediaReleasesAsync with recently added episodes", () => {
const mockPlex = (
recentlyAdded: Record<string, unknown>[],
episodesBySection: Record<string, Record<string, unknown>[] | "error">,
) => {
mockFetch.mockImplementation((url) => {
const urlString = String(url);
if (urlString.endsWith("/identity")) {
return respond({ MediaContainer: { machineIdentifier: "server-id" } });
}
if (urlString.endsWith("/library/recentlyAdded")) {
return respond({ MediaContainer: { Metadata: recentlyAdded } });
}
if (urlString.endsWith("/library/sections")) {
return respond({
MediaContainer: {
Directory: [
{ key: "2", type: "movie" },
...Object.keys(episodesBySection).map((key) => ({ key, type: "show" })),
],
},
});
}
const sectionMatch = /\/library\/sections\/(\d+)\/all\?type=4&sort=addedAt:desc/.exec(urlString);
if (sectionMatch?.[1] && sectionMatch[1] in episodesBySection) {
const episodes = episodesBySection[sectionMatch[1]];
return episodes === "error"
? respond({ error: "boom" }, 500)
: respond({ MediaContainer: { Metadata: episodes } });
}
throw new Error(`Unexpected Plex request: ${urlString}`);
});
};
test("bumps seasons that received new episodes and re-creates seasons missing from recentlyAdded", async () => {
mockPlex(
[
createMetadataItem("movie", { type: "movie", title: "Inception", addedAt: 300 }),
createMetadataItem("season-1/children", {
type: "season",
title: "Season 1",
parentTitle: "Breaking Bad",
addedAt: 200,
originallyAvailableAt: "2008-01-20",
}),
createMetadataItem("season-2/children", {
type: "season",
title: "Season 2",
parentTitle: "Breaking Bad",
addedAt: 100,
}),
],
{
"1": [
createEpisode("ep-new", {
parentKey: "/library/metadata/season-1",
addedAt: 500,
originallyAvailableAt: "2026-09-14",
}),
createEpisode("ep-old", { parentKey: "/library/metadata/season-1", addedAt: 50 }),
],
"7": [
createEpisode("ep-missing", {
parentKey: "/library/metadata/season-99",
parentTitle: "Season 11",
grandparentTitle: "Futurama",
grandparentThumb: "/library/metadata/show-99/thumb",
art: "/library/metadata/ep-missing/art",
addedAt: 400,
originallyAvailableAt: "2026-09-13",
}),
],
},
);
const releases = await createIntegration().getMediaReleasesAsync();
expect(releases.map((release) => [release.title, release.subtitle])).toEqual([
["Breaking Bad", "Season 1"],
["Futurama", "Season 11"],
["Inception", undefined],
["Breaking Bad", "Season 2"],
]);
// Plex air dates are represented as UTC dates and rendered as calendar dates by the widget
expect(releases[0]?.releaseDate).toEqual(new Date("2026-09-14"));
expect(releases[0]?.releaseDateIsDateOnly).toBe(true);
expect(releases[1]).toMatchObject({
type: "tv",
releaseDate: new Date("2026-09-13"),
releaseDateIsDateOnly: true,
imageUrls: {
poster: `proxied:${TEST_URL}/library/metadata/show-99/thumb`,
backdrop: `proxied:${TEST_URL}/library/metadata/ep-missing/art`,
},
href: `${TEST_URL}/web/index.html#!/server/server-id/details?key=${encodeURIComponent("/library/metadata/season-99")}`,
});
});
test("falls back to recentlyAdded when episodes cannot be fetched", async () => {
mockFetch.mockImplementation((url) => {
const urlString = String(url);
if (urlString.endsWith("/identity")) {
return Promise.resolve(
new Response(JSON.stringify({ MediaContainer: { machineIdentifier: "server-id" } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlString.endsWith("/library/recentlyAdded")) {
return Promise.resolve(
new Response(
JSON.stringify({
MediaContainer: {
Metadata: [
createMetadataItem("season/children", {
type: "season",
title: "Season 1",
parentTitle: "Breaking Bad",
}),
],
},
}),
{ status: 200 },
),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response("Not found", { status: 404 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const releases = await createIntegration().getMediaReleasesAsync();
expect(releases).toMatchObject([{ title: "Breaking Bad", subtitle: "Season 1" }]);
});
test("does not duplicate content that recentlyAdded already lists as an episode or a show", async () => {
mockPlex(
[
createMetadataItem("episode", {
type: "episode",
title: "Older episode",
parentKey: "/library/metadata/season-11",
grandparentTitle: "Futurama",
addedAt: 100,
}),
createMetadataItem("show-42", { type: "show", title: "Severance", addedAt: 90 }),
],
{
"1": [
createEpisode("ep-futurama", {
title: "Oceans Three",
index: 5,
parentIndex: 11,
parentKey: "/library/metadata/season-11",
grandparentKey: "/library/metadata/show-futurama",
grandparentTitle: "Futurama",
addedAt: 500,
}),
createEpisode("ep-severance", {
title: "Cold Harbor",
index: 10,
parentIndex: 2,
parentKey: "/library/metadata/season-sev-2",
grandparentKey: "/library/metadata/show-42",
grandparentTitle: "Severance",
addedAt: 400,
}),
],
},
);
const releases = await createIntegration().getMediaReleasesAsync();
// one card per show: the episode and show entries are both labelled with their newest episodes
expect(releases.map((release) => [release.title, release.subtitle])).toEqual([
["Futurama", "S11E05 \u2013 Oceans Three"],
["Severance", "S02E10 \u2013 Cold Harbor"],
]);
expect(releases[0]?.href).toContain(encodeURIComponent("/library/metadata/ep-futurama"));
});
test("keeps the episodes of the libraries that answered when one library fails", async () => {
mockPlex([createMetadataItem("movie", { type: "movie", title: "Inception", addedAt: 100 })], {
"1": "error",
"7": [
createEpisode("ep-ok", {
parentKey: "/library/metadata/season-7",
parentTitle: "Season 3",
grandparentTitle: "Andor",
grandparentThumb: "/library/metadata/show-andor/thumb",
addedAt: 400,
}),
],
});
const releases = await createIntegration().getMediaReleasesAsync();
expect(releases.map((release) => release.title)).toEqual(["Andor", "Inception"]);
});
test("throws a response error instead of a parse error when Plex rejects the request", async () => {
mockFetch.mockImplementation(() => respond({ error: "unauthorized" }, 401));
await expect(createIntegration().getMediaReleasesAsync()).rejects.toThrow();
});
});
@@ -2,6 +2,8 @@ import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { apiKeyAuth } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -11,6 +13,10 @@ import type { Indexer } from "../interfaces/indexer-manager/indexer-manager-type
import { indexerResponseSchema, statusResponseSchema } from "./prowlarr-types";
export class ProwlarrIntegration extends Integration implements IIndexerManagerIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
public async getIndexersAsync(): Promise<Indexer[]> {
const apiKey = super.getSecretValue("apiKey");
@@ -7,6 +7,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IClusterHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
import { ProxmoxApiErrorHandler } from "./proxmox-error-handler";
@@ -23,6 +24,12 @@ const logger = createLogger({ module: "proxmoxIntegration" });
@HandleIntegrationErrors([new ProxmoxApiErrorHandler()])
export class ProxmoxIntegration extends Integration implements IClusterHealthMonitoringIntegration {
public override async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
const tokenId = `${this.getSecretValue("username")}@${this.getSecretValue("realm")}!${this.getSecretValue("tokenId")}`;
const tokenSecret = this.getSecretValue("apiKey");
return { headers: { Authorization: `PVEAPIToken=${tokenId}=${tokenSecret}` } };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const proxmox = this.getPromoxApi(input.fetchAsync);
await proxmox.nodes.$get();
@@ -1,6 +1,7 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
@@ -18,6 +19,10 @@ import type {
} from "./speedtest-tracker-types";
export class SpeedtestTrackerIntegration extends Integration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return { headers: this.getAuthHeaders(), redactValues: [] };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const url = this.url("/api/v1/results/latest");
const response = await input.fetchAsync(url, {
+427
View File
@@ -0,0 +1,427 @@
import { withHttpRequestSignalAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationInstanceOfKind } from "../base/creator";
import type { IntegrationKind } from "@homarr/definitions";
import type { IntegrationInput } from "../base/integration";
import { createIntegrationAsync } from "../factory";
import type { StatsMetric, StatsUnit, StatsValue } from "./types";
export interface ExistingStatsProvider {
metrics: StatsMetric[];
fetchAsync: (input: IntegrationInput, signal: AbortSignal) => Promise<Record<string, StatsValue>>;
}
const metric = (key: string, label: string, unit: StatsUnit = "count"): StatsMetric => ({ key, label, unit });
const adapter = <TKind extends IntegrationKind, TData>(
kind: TKind,
metrics: StatsMetric[],
fetchAsync: (client: IntegrationInstanceOfKind<TKind>) => Promise<TData>,
pick: (data: TData) => Record<string, StatsValue>,
): ExistingStatsProvider => ({
metrics,
async fetchAsync(input, signal) {
return await withHttpRequestSignalAsync(signal, async () => {
const result = await fetchAsync(await createIntegrationAsync({ ...input, kind }));
signal.throwIfAborted();
return pick(result);
});
},
});
export const existingStatsProviders: Partial<Record<IntegrationKind, ExistingStatsProvider>> = {
jackett: adapter(
"jackett",
[metric("configured", "Configured indexers")],
(client) => client.getStatsAsync(),
(data) => data,
),
paperlessNgx: adapter(
"paperlessNgx",
[
metric("documentsTotal", "Documents"),
metric("documentsInbox", "Inbox"),
metric("tagsCount", "Tags"),
metric("correspondentsCount", "Correspondents"),
metric("documentTypesCount", "Document types"),
],
(client) => client.getStatsAsync(),
(data) => ({ ...data }),
),
navidrome: adapter(
"navidrome",
[metric("artistCount", "Artists"), metric("albumCount", "Albums"), metric("songCount", "Songs")],
(client) => client.getDashboardDataAsync(),
(data) => ({ ...data }),
),
audiobookshelf: adapter(
"audiobookshelf",
[
metric("libraryCount", "Libraries"),
metric("totalAudiobooks", "Audiobooks"),
metric("totalPodcasts", "Podcasts"),
metric("totalListeningTimeSeconds", "Listening time", "seconds"),
metric("activeSessions", "Active sessions"),
],
(client) => client.getDashboardDataAsync(),
({ libraries: _libraries, ...data }) => data,
),
immich: adapter(
"immich",
[
metric("userCount", "Users"),
metric("photoCount", "Photos"),
metric("videoCount", "Videos"),
metric("totalLibraryUsageInBytes", "Storage", "bytes"),
],
(client) => client.getServerStatsAsync(),
(data) => ({ ...data }),
),
bazarr: adapter(
"bazarr",
[
metric("episodes", "Missing episode subtitles"),
metric("movies", "Missing movie subtitles"),
metric("providers", "Providers"),
],
(client) => client.getBadgesAsync(),
({ episodes, movies, providers }) => ({ episodes, movies, providers }),
),
wud: adapter(
"wud",
[metric("totalContainers", "Containers"), metric("updatesAvailable", "Updates available")],
(client) => client.getStatsAsync(),
({ totalContainers, updatesAvailable }) => ({ totalContainers, updatesAvailable }),
),
patchmon: adapter(
"patchmon",
[
metric("totalHosts", "Hosts"),
metric("hostsNeedingUpdates", "Hosts needing updates"),
metric("securityUpdates", "Security updates"),
metric("totalOutdatedPackages", "Outdated packages"),
],
(client) => client.getStatsAsync(),
({ totalHosts, hostsNeedingUpdates, securityUpdates, totalOutdatedPackages }) => ({
totalHosts,
hostsNeedingUpdates,
securityUpdates,
totalOutdatedPackages,
}),
),
traefik: adapter(
"traefik",
[metric("routers", "Routers"), metric("services", "Services"), metric("middlewares", "Middleware")],
(client) => client.getDashboardDataAsync(),
({ http, tcp, udp }) => ({
routers: http.routers.total + tcp.routers.total + udp.routers.total,
services: http.services.total + tcp.services.total + udp.services.total,
middlewares: http.middlewares.total + tcp.middlewares.total,
}),
),
speedtestTracker: adapter(
"speedtestTracker",
[
metric("ping", "Latency", "milliseconds"),
metric("download", "Download", "bytesPerSecond"),
metric("upload", "Upload", "bytesPerSecond"),
],
(client) => client.getLatestResultAsync(),
(data) => ({
ping: data?.ping ?? null,
download: data?.download_bits == null ? null : data.download_bits / 8,
upload: data?.upload_bits == null ? null : data.upload_bits / 8,
}),
),
};
for (const kind of ["piHole", "adGuardHome", "technitiumDns"] as const) {
existingStatsProviders[kind] = adapter(
kind,
[
metric("dnsQueriesToday", "Queries today"),
metric("adsBlockedToday", "Blocked today"),
metric("adsBlockedTodayPercentage", "Blocked", "percent"),
metric("domainsBeingBlocked", "Blocked domains"),
],
(client) => client.getSummaryAsync(),
({ dnsQueriesToday, adsBlockedToday, adsBlockedTodayPercentage, domainsBeingBlocked }) => ({
dnsQueriesToday,
adsBlockedToday,
adsBlockedTodayPercentage,
domainsBeingBlocked,
}),
);
}
for (const kind of ["dashDot", "glances", "openmediavault", "synology", "truenas", "unraid"] as const) {
existingStatsProviders[kind] = adapter(
kind,
[
metric("cpu", "CPU", "percent"),
metric("memoryUsed", "Memory used", "bytes"),
metric("memoryAvailable", "Memory available", "bytes"),
metric("uptime", "Uptime", "seconds"),
],
(client) => client.getSystemInfoAsync(),
(data) => ({
cpu: data.cpuUtilization,
memoryUsed: data.memUsedInBytes,
memoryAvailable: data.memAvailableInBytes,
uptime: data.uptime,
}),
);
}
for (const kind of ["jellyseerr", "seerr", "overseerr"] as const) {
existingStatsProviders[kind] = adapter(
kind,
[
metric("pending", "Pending requests"),
metric("approved", "Approved requests"),
metric("available", "Available requests"),
metric("processing", "Processing requests"),
],
(client) => client.getStatsAsync(),
({ pending, approved, available, processing }) => ({ pending, approved, available, processing }),
);
}
for (const kind of ["sonarr", "radarr"] as const) {
const libraryMetric = metric("movies", "Movies");
if (kind === "sonarr") {
libraryMetric.key = "shows";
libraryMetric.label = "Shows";
}
const metrics = [
libraryMetric,
metric("monitored", "Monitored"),
metric("downloaded", "Downloaded movies"),
metric("storage", "Storage", "bytes"),
metric("missing", "Missing"),
metric("queued", "Queued"),
];
if (kind === "sonarr") {
metrics[2] = metric("downloaded", "Downloaded episodes");
metrics.push(metric("episodes", "Episodes"));
}
existingStatsProviders[kind] = adapter(
kind,
metrics,
async (client) => {
const [library, missing, queued] = await Promise.all([
client.getLibraryStatsAsync(),
client.getMissingAsync(1),
client.getMediaQueueAsync(1),
]);
return { ...library, missing: missing.totalCount, queued: queued.totalCount };
},
(data) => data,
);
}
for (const kind of ["plex", "jellyfin", "emby"] as const) {
existingStatsProviders[kind] = adapter(
kind,
[metric("sessions", "Playback sessions")],
(client) => client.getCurrentSessionsAsync({ showOnlyPlaying: false }),
(data) => ({ sessions: data.length }),
);
}
for (const kind of ["ntfy", "gotify"] as const) {
existingStatsProviders[kind] = adapter(
kind,
[metric("messages", "Retrieved notifications")],
(client) => client.getNotificationsAsync(),
(data) => ({ messages: data.length }),
);
}
for (const kind of ["sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission", "slskd", "aria2"] as const) {
existingStatsProviders[kind] = adapter(
kind,
[
metric("download", "Download", "bytesPerSecond"),
metric("upload", "Upload", "bytesPerSecond"),
metric("paused", "Paused", "text"),
],
(client) => client.getClientJobsAndStatusAsync({ limit: 1 }),
({ status }) => ({ download: status.rates.down, upload: status.rates.up ?? null, paused: status.paused }),
);
}
existingStatsProviders.nextcloud = adapter(
"nextcloud",
[metric("messages", "Retrieved notifications")],
(client) => client.getNotificationsAsync(),
(data) => ({ messages: data.length }),
);
existingStatsProviders.prowlarr = adapter(
"prowlarr",
[metric("indexers", "Indexers")],
(client) => client.getIndexersAsync(),
(data) => ({ indexers: data.length }),
);
existingStatsProviders.gluetun = adapter(
"gluetun",
[
metric("status", "VPN status", "text"),
metric("publicIp", "Public IP", "text"),
metric("country", "Country", "text"),
],
(client) => client.getSummaryAsync(),
(data) => ({ status: data.vpnStatus, publicIp: data.publicIp, country: data.country }),
);
existingStatsProviders.unifiController = adapter(
"unifiController",
[metric("wifi", "Wi-Fi clients"), metric("lan", "Wired clients"), metric("latency", "WAN latency", "milliseconds")],
(client) => client.getNetworkSummaryAsync(),
(data) => ({
wifi: data.wifi.users + data.wifi.guests,
lan: data.lan.users + data.lan.guests,
latency: data.www.latency,
}),
);
existingStatsProviders.peaNut = adapter(
"peaNut",
[metric("devices", "UPS devices"), metric("onBattery", "On battery"), metric("lowBattery", "Low battery")],
(client) => client.getUpsSummariesAsync(),
(data) => ({
devices: data.length,
onBattery: data.filter((item) => item.status === "onBattery").length,
lowBattery: data.filter((item) => item.status === "lowBattery").length,
}),
);
existingStatsProviders.uptimeKuma = adapter(
"uptimeKuma",
[
metric("totalMonitors", "Monitors"),
metric("upCount", "Up"),
metric("downCount", "Down"),
metric("averageUptimePercent", "Average uptime", "percent"),
],
(client) => client.getDashboardDataAsync(),
({ totalMonitors, upCount, downCount, averageUptimePercent }) => ({
totalMonitors,
upCount,
downCount,
averageUptimePercent,
}),
);
existingStatsProviders.tdarr = adapter(
"tdarr",
[
metric("totalFileCount", "Files"),
metric("totalTranscodeCount", "Transcodes"),
metric("failedTranscodeCount", "Failed transcodes"),
],
(client) => client.getStatisticsAsync(),
({ totalFileCount, totalTranscodeCount, failedTranscodeCount }) => ({
totalFileCount,
totalTranscodeCount,
failedTranscodeCount,
}),
);
existingStatsProviders.coolify = adapter(
"coolify",
[metric("applications", "Applications"), metric("services", "Services"), metric("servers", "Servers")],
(client) => client.getInstanceInfoAsync(),
(data) => ({ applications: data.applications.length, services: data.services.length, servers: data.servers.length }),
);
existingStatsProviders.proxmox = adapter(
"proxmox",
[metric("nodes", "Nodes"), metric("vms", "Virtual machines"), metric("lxcs", "Containers")],
(client) => client.getClusterInfoAsync(),
(data) => ({ nodes: data.nodes.length, vms: data.vms.length, lxcs: data.lxcs.length }),
);
existingStatsProviders.beszel = adapter(
"beszel",
[metric("systems", "Systems")],
(client) => client.getSystemsAsync(),
(data) => ({ systems: data.length }),
);
existingStatsProviders.umami = adapter(
"umami",
[metric("websites", "Websites")],
(client) => client.getWebsitesAsync(),
(data) => ({ websites: data.length }),
);
existingStatsProviders.opnsense = adapter(
"opnsense",
[metric("cpu", "CPU", "percent")],
(client) => client.getFirewallCpuAsync(),
(data) => ({ cpu: data.total }),
);
existingStatsProviders.archiveTeamWarrior = adapter(
"archiveTeamWarrior",
[metric("running", "Running"), metric("completed", "Completed"), metric("failed", "Failed")],
(client) => client.getStatusAsync(),
({ counts }) => ({ running: counts.running, completed: counts.completed, failed: counts.failed }),
);
existingStatsProviders.llamacpp = adapter(
"llamacpp",
[
metric("health", "Health", "text"),
metric("tokensGenerated", "Tokens generated"),
metric("requestsProcessing", "Requests processing"),
],
(client) => client.getStatsAsync(),
(data) => ({
health: data.health,
tokensGenerated: data.metrics.tokensGenerated,
requestsProcessing: data.metrics.requestsProcessing,
}),
);
existingStatsProviders.tracearr = adapter(
"tracearr",
[
metric("activeStreams", "Active streams"),
metric("totalUsers", "Users"),
metric("totalSessions", "Sessions"),
metric("recentViolations", "Recent violations"),
],
(client) => client.getStatsAsync(),
({ activeStreams, totalUsers, totalSessions, recentViolations }) => ({
activeStreams,
totalUsers,
totalSessions,
recentViolations,
}),
);
existingStatsProviders.homeAssistant = adapter(
"homeAssistant",
[
metric("entities", "Entities"),
metric("unavailable", "Unavailable entities"),
metric("lightsOn", "Lights on"),
metric("peopleHome", "People home"),
],
(client) => client.getEntityStatsAsync(),
(data) => data,
);
existingStatsProviders.anchor = adapter(
"anchor",
[metric("notes", "Retrieved notes")],
(client) => client.listNotesAsync(),
(data) => ({ notes: data.length }),
);
for (const kind of ["lidarr", "readarr"] as const) {
const libraryMetric = metric("books", "Books");
if (kind === "lidarr") {
libraryMetric.key = "artists";
libraryMetric.label = "Artists";
}
existingStatsProviders[kind] = adapter(
kind,
[libraryMetric, metric("monitored", "Monitored"), metric("upcoming", "Events in the next 7 days")],
async (client) => {
const [library, events] = await Promise.all([
client.getLibraryStatsAsync(),
client.getCalendarEventsAsync(new Date(), new Date(Date.now() + 7 * 86_400_000)),
]);
return { ...library, upcoming: events.length };
},
(data) => data,
);
}
existingStatsProviders.mock = {
metrics: [metric("documents", "Documents"), metric("songs", "Songs"), metric("storage", "Storage", "bytes")],
async fetchAsync() {
return { documents: 1234, songs: 5678, storage: 12345678900 };
},
};
+35
View File
@@ -0,0 +1,35 @@
import type { IntegrationKind } from "@homarr/definitions";
import type { IntegrationInput } from "../base/integration";
import { existingStatsProviders } from "./existing";
import { statsProviders } from "./providers";
import { StatsIntegration } from "./stats-integration";
import type { StatsProvider } from "./types";
export type { StatsMetric, StatsUnit, StatsValue } from "./types";
export const getStatsMetrics = (kind: IntegrationKind) => {
const provider = (statsProviders as Partial<Record<IntegrationKind, StatsProvider>>)[kind];
return provider?.metrics ?? existingStatsProviders[kind]?.metrics ?? [];
};
// Do not launch repeated legacy calls after a caller's deadline expires. Legacy
// SDKs may ignore transport cancellation; retain this guard until their work settles.
const legacyPending = new Set<string>();
const maxLegacyPending = 128;
export const fetchStatsAsync = async (input: IntegrationInput & { kind: IntegrationKind }, signal: AbortSignal) => {
const provider = (statsProviders as Partial<Record<IntegrationKind, StatsProvider>>)[input.kind];
if (provider) return await new StatsIntegration(input, provider).getStatsAsync(signal);
const existing = existingStatsProviders[input.kind];
if (!existing) throw new Error("Statistics are not supported by this integration");
signal.throwIfAborted();
if (legacyPending.has(input.id)) throw new Error("Previous statistics request is still running");
if (legacyPending.size >= maxLegacyPending) throw new Error("Legacy statistics request limit reached");
legacyPending.add(input.id);
try {
return await existing.fetchAsync(input, signal);
} finally {
legacyPending.delete(input.id);
}
};
@@ -0,0 +1,64 @@
import { autobrrStatsProvider } from "./providers/autobrr";
import type { StatsProvider } from "./types";
import { caddyStatsProvider } from "./providers/caddy";
import { changedetectionStatsProvider } from "./providers/changedetection";
import { fileflowsStatsProvider } from "./providers/fileflows";
import { gatusStatsProvider } from "./providers/gatus";
import { healthchecksStatsProvider } from "./providers/healthchecks";
import { homeboxStatsProvider } from "./providers/homebox";
import { jellystatStatsProvider } from "./providers/jellystat";
import { karakeepStatsProvider } from "./providers/karakeep";
import { komgaStatsProvider } from "./providers/komga";
import { linkwardenStatsProvider } from "./providers/linkwarden";
import { maintainerrStatsProvider } from "./providers/maintainerr";
import { mealieStatsProvider } from "./providers/mealie";
import { minifluxStatsProvider } from "./providers/miniflux";
import { netdataStatsProvider } from "./providers/netdata";
import { netalertxStatsProvider } from "./providers/netalertx";
import { frigateStatsProvider } from "./providers/frigate";
import { scrutinyStatsProvider } from "./providers/scrutiny";
import { plantitStatsProvider } from "./providers/plantit";
import { prometheusStatsProvider } from "./providers/prometheus";
import { rommStatsProvider } from "./providers/romm";
import { spoolmanStatsProvider } from "./providers/spoolman";
import { stashStatsProvider } from "./providers/stash";
import { syncthingRelayStatsProvider } from "./providers/syncthing-relay";
import { tandoorStatsProvider } from "./providers/tandoor";
import { triliumStatsProvider } from "./providers/trilium";
import { tubearchivistStatsProvider } from "./providers/tubearchivist";
import { unmanicStatsProvider } from "./providers/unmanic";
import { xteveStatsProvider } from "./providers/xteve";
import { yourSpotifyStatsProvider } from "./providers/your-spotify";
export const statsProviders = {
autobrr: autobrrStatsProvider,
caddy: caddyStatsProvider,
changedetection: changedetectionStatsProvider,
fileflows: fileflowsStatsProvider,
gatus: gatusStatsProvider,
healthchecks: healthchecksStatsProvider,
homebox: homeboxStatsProvider,
jellystat: jellystatStatsProvider,
karakeep: karakeepStatsProvider,
komga: komgaStatsProvider,
linkwarden: linkwardenStatsProvider,
maintainerr: maintainerrStatsProvider,
mealie: mealieStatsProvider,
miniflux: minifluxStatsProvider,
netdata: netdataStatsProvider,
netalertx: netalertxStatsProvider,
frigate: frigateStatsProvider,
scrutiny: scrutinyStatsProvider,
plantit: plantitStatsProvider,
prometheus: prometheusStatsProvider,
romm: rommStatsProvider,
spoolman: spoolmanStatsProvider,
stash: stashStatsProvider,
syncthingRelay: syncthingRelayStatsProvider,
tandoor: tandoorStatsProvider,
trilium: triliumStatsProvider,
tubearchivist: tubearchivistStatsProvider,
unmanic: unmanicStatsProvider,
xteve: xteveStatsProvider,
yourSpotify: yourSpotifyStatsProvider,
} satisfies Record<string, StatsProvider>;
@@ -0,0 +1,30 @@
import { z } from "zod";
import type { StatsProvider } from "../types";
const count = z.number().finite().int().nonnegative();
const statsSchema = z.object({ push_approved_count: count, push_rejected_count: count }).passthrough();
const listSchema = z.array(z.unknown());
export const autobrrStatsProvider = {
metrics: [
{ key: "approvedPushes", label: "Approved pushes", unit: "count" },
{ key: "rejectedPushes", label: "Rejected pushes", unit: "count" },
{ key: "filters", label: "Filters", unit: "count" },
{ key: "indexers", label: "Indexers", unit: "count" },
],
async fetchAsync(context) {
const [stats, filters, indexers] = await Promise.all([
context.requestAsync("/api/release/stats", { signal: context.signal }),
context.requestAsync("/api/filters", { signal: context.signal }),
context.requestAsync("/api/release/indexers", { signal: context.signal }),
]);
const parsedStats = statsSchema.parse(stats);
return {
approvedPushes: parsedStats.push_approved_count,
rejectedPushes: parsedStats.push_rejected_count,
filters: listSchema.parse(filters).length,
indexers: listSchema.parse(indexers).length,
};
},
} satisfies StatsProvider;
@@ -0,0 +1,43 @@
import { z } from "zod";
import type { StatsProvider } from "../types";
const countSchema = z.number().finite().int().nonnegative();
const upstreamSchema = z
.object({
num_requests: countSchema,
fails: countSchema,
})
.passthrough();
const upstreamsSchema = z.array(upstreamSchema);
const statsSchema = z.object({
upstreams: countSchema,
requests: countSchema,
requestsFailed: countSchema,
});
export const caddyStatsProvider = {
getHttpAuthentication: () => ({ headers: {} }),
transport: "axios",
metrics: [
{ key: "upstreams", label: "Upstreams", unit: "count" },
{ key: "requests", label: "Active requests", unit: "count" },
{ key: "requestsFailed", label: "Failed requests", unit: "count" },
],
async fetchAsync(context) {
const response = await context.requestAsync("/reverse_proxy/upstreams", {
method: "GET",
signal: context.signal,
});
const upstreams = upstreamsSchema.parse(response);
return statsSchema.parse({
upstreams: upstreams.length,
requests: upstreams.reduce((total, upstream) => total + upstream.num_requests, 0),
requestsFailed: upstreams.reduce((total, upstream) => total + upstream.fails, 0),
});
},
} satisfies StatsProvider;
@@ -0,0 +1,36 @@
import { z } from "zod";
import type { StatsAuthenticationContext, StatsProvider } from "../types";
const watchSchema = z
.object({
last_changed: z.number().finite(),
viewed: z.boolean(),
})
.passthrough();
const watchesSchema = z.record(z.string(), watchSchema);
const getHttpAuthentication = (context: StatsAuthenticationContext) => ({
headers: { "x-api-key": context.secret("apiKey") },
});
export const changedetectionStatsProvider = {
getHttpAuthentication,
metrics: [
{ key: "diffsDetected", label: "Diffs detected", unit: "count" },
{ key: "totalObserved", label: "Total observed", unit: "count" },
],
async fetchAsync(context) {
const response = await context.requestAsync("/api/v1/watch", {
headers: getHttpAuthentication(context).headers,
signal: context.signal,
});
const watches = watchesSchema.parse(response);
return {
diffsDetected: Object.values(watches).filter((watch) => watch.last_changed > 0 && !watch.viewed).length,
totalObserved: Object.keys(watches).length,
};
},
} satisfies StatsProvider;
@@ -0,0 +1,27 @@
import { z } from "zod";
import type { StatsProvider } from "../types";
const fileflowsStatusSchema = z.object({
queue: z.number().finite().int().nonnegative(),
processing: z.number().finite().int().nonnegative(),
processed: z.number().finite().int().nonnegative(),
time: z
.string()
.nullish()
.transform((value) => value ?? null),
});
export const fileflowsStatsProvider = {
getHttpAuthentication: () => ({ headers: {} }),
metrics: [
{ key: "queue", label: "Queued", unit: "count" },
{ key: "processing", label: "Processing", unit: "count" },
{ key: "processed", label: "Processed", unit: "count" },
{ key: "time", label: "Processing time", unit: "text" },
],
async fetchAsync(context) {
const response = await context.requestAsync("/api/status", { signal: context.signal });
return fileflowsStatusSchema.parse(response);
},
} satisfies StatsProvider;
@@ -0,0 +1,23 @@
import { z } from "zod";
import type { StatsProvider } from "../types";
const statsSchema = z
.object({
cameras: z.record(z.string(), z.unknown()).default({}),
service: z.object({ uptime: z.number().finite().nonnegative(), version: z.string() }).passthrough(),
})
.passthrough();
export const frigateStatsProvider = {
getHttpAuthentication: () => ({ headers: {} }),
metrics: [
{ key: "cameras", label: "Cameras", unit: "count" },
{ key: "uptime", label: "Uptime", unit: "seconds" },
{ key: "version", label: "Version", unit: "text" },
],
async fetchAsync(context) {
const stats = statsSchema.parse(await context.requestAsync("/api/stats", { signal: context.signal }));
return { cameras: Object.keys(stats.cameras).length, uptime: stats.service.uptime, version: stats.service.version };
},
} satisfies StatsProvider;
@@ -0,0 +1,43 @@
import { z } from "zod";
import type { StatsProvider } from "../types";
const endpointResultSchema = z
.object({
success: z.boolean(),
})
.passthrough();
const endpointSchema = z
.object({
results: z.array(endpointResultSchema),
})
.passthrough();
const endpointsSchema = z.union([z.array(endpointSchema), z.record(z.string(), endpointSchema)]);
export const gatusStatsProvider = {
getHttpAuthentication: () => ({ headers: {} }),
metrics: [
{ key: "up", label: "Up", unit: "count" },
{ key: "down", label: "Down", unit: "count" },
{ key: "unknown", label: "Unknown", unit: "count" },
{ key: "total", label: "Total", unit: "count" },
],
async fetchAsync(context) {
const response = await context.requestAsync("/api/v1/endpoints/statuses", {
signal: context.signal,
});
const endpoints = Object.values(endpointsSchema.parse(response));
const up = endpoints.filter((endpoint) => endpoint.results.at(-1)?.success === true).length;
const down = endpoints.filter((endpoint) => endpoint.results.at(-1)?.success === false).length;
return {
up,
down,
unknown: endpoints.length - up - down,
total: endpoints.length,
};
},
} satisfies StatsProvider;
@@ -0,0 +1,45 @@
import { z } from "zod";
import type { StatsAuthenticationContext, StatsProvider } from "../types";
const checkSchema = z
.object({
status: z.enum(["new", "up", "grace", "down", "paused"]),
})
.passthrough();
const checksResponseSchema = z
.object({
checks: z.array(checkSchema),
})
.passthrough();
const countSchema = z.number().finite().int().nonnegative();
const getHttpAuthentication = (context: StatsAuthenticationContext) => ({
headers: { "X-Api-Key": context.secret("apiKey") },
});
export const healthchecksStatsProvider = {
getHttpAuthentication,
metrics: [
{ key: "checksUp", label: "Up checks", unit: "count" },
{ key: "checksDown", label: "Down checks", unit: "count" },
{ key: "checksGrace", label: "Checks in grace", unit: "count" },
{ key: "checksNew", label: "New checks", unit: "count" },
],
async fetchAsync(context) {
const response = await context.requestAsync("/api/v3/checks/", {
headers: getHttpAuthentication(context).headers,
signal: context.signal,
});
const checks = checksResponseSchema.parse(response).checks;
return {
checksUp: countSchema.parse(checks.filter((check) => check.status === "up").length),
checksDown: countSchema.parse(checks.filter((check) => check.status === "down").length),
checksGrace: countSchema.parse(checks.filter((check) => check.status === "grace").length),
checksNew: countSchema.parse(checks.filter((check) => check.status === "new").length),
};
},
} satisfies StatsProvider;
@@ -0,0 +1,77 @@
import { z } from "zod";
import type { StatsFetchContext, StatsProvider } from "../types";
const countSchema = z.number().finite().nonnegative().int();
const groupStatsResponseSchema = z
.object({
totalItems: countSchema,
totalLocations: countSchema,
totalLabels: countSchema.optional(),
totalTags: countSchema.optional(),
totalWithWarranty: countSchema,
totalItemPrice: z.number().finite().nonnegative(),
totalUsers: countSchema,
})
.passthrough()
.refine((stats) => stats.totalLabels !== undefined || stats.totalTags !== undefined, {
message: "Expected a labels or tags count",
});
export const homeboxStatsProvider = {
metrics: [
{ key: "items", label: "Items", unit: "count" },
{ key: "locations", label: "Locations", unit: "count" },
{ key: "labels", label: "Labels", unit: "count" },
{ key: "itemsWithWarranty", label: "Items with warranty", unit: "count" },
{ key: "totalValue", label: "Total value", unit: "text" },
{ key: "users", label: "Users", unit: "count" },
],
getHttpAuthenticationAsync,
async fetchAsync(context) {
const authentication = await getHttpAuthenticationAsync(context);
const response = await context.requestAsync("/api/v1/groups/statistics", {
headers: authentication.headers,
signal: context.signal,
});
const stats = groupStatsResponseSchema.parse(response);
const group = z.object({ currency: z.string().min(1) }).parse(
await context.requestAsync("/api/v1/groups", {
headers: authentication.headers,
signal: context.signal,
}),
);
return {
items: stats.totalItems,
locations: stats.totalLocations,
labels: stats.totalLabels ?? stats.totalTags ?? 0,
itemsWithWarranty: stats.totalWithWarranty,
totalValue: `${group.currency} ${stats.totalItemPrice}`,
users: stats.totalUsers,
};
},
} satisfies StatsProvider;
async function getHttpAuthenticationAsync(context: StatsFetchContext) {
try {
const loginResponse = await context.requestAsync("/api/v1/users/login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
username: context.secret("username"),
password: context.secret("password"),
}).toString(),
signal: context.signal,
});
const login = z.object({ token: z.string().min(1) }).safeParse(loginResponse);
if (!login.success) throw new Error("Homebox authentication failed");
return { headers: { Authorization: login.data.token }, redactValues: [login.data.token] };
} catch {
throw new Error("Homebox authentication failed");
}
}
@@ -0,0 +1,42 @@
import { z } from "zod";
import type { StatsAuthenticationContext, StatsProvider } from "../types";
const countSchema = z.number().finite().nonnegative();
const viewsResponseSchema = z
.object({
Audio: countSchema,
Movie: countSchema,
Series: countSchema,
Other: countSchema,
})
.passthrough();
const getHttpAuthentication = (context: StatsAuthenticationContext) => ({
headers: { "X-API-Token": context.secret("apiKey") },
});
export const jellystatStatsProvider = {
getHttpAuthentication,
metrics: [
{ key: "songs", label: "Song plays (30 days)", unit: "count" },
{ key: "movies", label: "Movie plays (30 days)", unit: "count" },
{ key: "episodes", label: "Episode plays (30 days)", unit: "count" },
{ key: "other", label: "Other plays (30 days)", unit: "count" },
],
async fetchAsync(context) {
const params = new URLSearchParams({ days: "30" });
const response = await context.requestAsync(`/stats/getViewsByLibraryType?${params.toString()}`, {
headers: getHttpAuthentication(context).headers,
signal: context.signal,
});
const views = viewsResponseSchema.parse(response);
return {
songs: views.Audio,
movies: views.Movie,
episodes: views.Series,
other: views.Other,
};
},
} satisfies StatsProvider;
@@ -0,0 +1,47 @@
import { z } from "zod";
import type { StatsAuthenticationContext, StatsProvider } from "../types";
const karakeepStatsResponseSchema = z
.object({
numBookmarks: z.number().finite().nonnegative(),
numFavorites: z.number().finite().nonnegative(),
numArchived: z.number().finite().nonnegative(),
numHighlights: z.number().finite().nonnegative(),
numLists: z.number().finite().nonnegative(),
numTags: z.number().finite().nonnegative(),
})
.passthrough();
const getHttpAuthentication = (context: StatsAuthenticationContext) => ({
headers: { Authorization: `Bearer ${context.secret("apiKey")}` },
});
export const karakeepStatsProvider = {
getHttpAuthentication,
metrics: [
{ key: "bookmarks", label: "Bookmarks", unit: "count" },
{ key: "favorites", label: "Favorites", unit: "count" },
{ key: "archived", label: "Archived", unit: "count" },
{ key: "highlights", label: "Highlights", unit: "count" },
{ key: "lists", label: "Lists", unit: "count" },
{ key: "tags", label: "Tags", unit: "count" },
],
async fetchAsync(context) {
const response = await context.requestAsync("/api/v1/users/me/stats", {
headers: getHttpAuthentication(context).headers,
signal: context.signal,
});
const stats = karakeepStatsResponseSchema.parse(response);
return {
bookmarks: stats.numBookmarks,
favorites: stats.numFavorites,
archived: stats.numArchived,
highlights: stats.numHighlights,
lists: stats.numLists,
tags: stats.numTags,
};
},
} satisfies StatsProvider;
@@ -0,0 +1,35 @@
import { z } from "zod";
import type { StatsProvider } from "../types";
const countSchema = z.number().finite().nonnegative().int();
const librarySchema = z.object({ unavailable: z.boolean().optional() }).passthrough();
const pageSchema = z.object({ totalElements: countSchema }).passthrough();
export const komgaStatsProvider = {
metrics: [
{ key: "libraries", label: "Libraries", unit: "count" },
{ key: "series", label: "Series", unit: "count" },
{ key: "books", label: "Books", unit: "count" },
],
async fetchAsync(context) {
const headers = {
Accept: "application/json",
"Content-Type": "application/json",
};
const [librariesResponse, seriesResponse, booksResponse] = await Promise.all([
context.requestAsync("/api/v1/libraries", { headers, signal: context.signal }),
context.requestAsync("/api/v1/series/list", { method: "POST", headers, body: "{}", signal: context.signal }),
context.requestAsync("/api/v1/books/list", { method: "POST", headers, body: "{}", signal: context.signal }),
]);
const libraries = z.array(librarySchema).parse(librariesResponse);
const series = pageSchema.parse(seriesResponse);
const books = pageSchema.parse(booksResponse);
return {
libraries: libraries.filter((library) => !library.unavailable).length,
series: series.totalElements,
books: books.totalElements,
};
},
} satisfies StatsProvider;
@@ -0,0 +1,82 @@
import { z } from "zod";
import type { StatsProvider } from "../types";
const countSchema = z.number().finite().int().nonnegative();
const collectionSchema = z.object({
_count: z.object({
links: countSchema,
}),
});
const collectionEnvelopeSchema = z.union([
z.array(collectionSchema),
z.object({ response: z.array(collectionSchema) }),
z.object({ data: z.object({ collections: z.array(collectionSchema) }) }),
]);
const tagSchema = z.record(z.string(), z.unknown());
const tagEnvelopeSchema = z.union([
// Prefer the paginated envelope when v1 also includes a legacy response field.
z.object({ data: z.object({ tags: z.array(tagSchema), nextCursor: z.union([countSchema, z.string()]).nullish() }) }),
z.object({ response: z.array(tagSchema) }),
z.array(tagSchema),
]);
const statsSchema = z.object({
links: countSchema,
collections: countSchema,
tags: countSchema,
});
function collectionList(value: unknown) {
const parsed = collectionEnvelopeSchema.parse(value);
if (Array.isArray(parsed)) return parsed;
if ("response" in parsed) return parsed.response;
return parsed.data.collections;
}
function tagPage(value: unknown) {
const parsed = tagEnvelopeSchema.parse(value);
if (Array.isArray(parsed)) return { tags: parsed, nextCursor: null };
if ("data" in parsed) return parsed.data;
return { tags: parsed.response, nextCursor: null };
}
export const linkwardenStatsProvider = {
metrics: [
{ key: "links", label: "Links", unit: "count" },
{ key: "collections", label: "Collections", unit: "count" },
{ key: "tags", label: "Tags", unit: "count" },
],
async fetchAsync(context) {
const [collectionsResponse, tagsResponse] = await Promise.all([
context.requestAsync("/api/v1/collections", { signal: context.signal }),
context.requestAsync("/api/v1/tags", { signal: context.signal }),
]);
const collections = collectionList(collectionsResponse);
let page = tagPage(tagsResponse);
let tagCount = page.tags.length;
const visited = new Set<string>();
while (page.nextCursor != null) {
context.signal.throwIfAborted();
const cursor = String(page.nextCursor);
if (visited.has(cursor) || visited.size >= 1000)
throw new Error("Invalid or excessive Linkwarden tag pagination");
visited.add(cursor);
page = tagPage(
await context.requestAsync(`/api/v1/tags?cursor=${encodeURIComponent(cursor)}`, {
signal: context.signal,
}),
);
tagCount += page.tags.length;
}
return statsSchema.parse({
links: collections.reduce((total, collection) => total + collection._count.links, 0),
collections: collections.length,
tags: tagCount,
});
},
} satisfies StatsProvider;
@@ -0,0 +1,38 @@
import { z } from "zod";
import type { StatsProvider } from "../types";
const nonNegativeFiniteNumber = z.number().finite().nonnegative();
const handledCount = nonNegativeFiniteNumber.int();
const storageMetricsSchema = z.object({
cleanupTotals: z.object({
itemsHandled: handledCount,
episodesHandled: handledCount,
moviesHandled: handledCount,
}),
collectionSummary: z.object({
activeSizeBytes: nonNegativeFiniteNumber,
}),
});
export const maintainerrStatsProvider = {
getHttpAuthentication: () => ({ headers: {} }),
metrics: [
{ key: "itemsHandled", label: "Items handled", unit: "count" },
{ key: "episodesHandled", label: "Episodes handled", unit: "count" },
{ key: "moviesHandled", label: "Movies handled", unit: "count" },
{ key: "reclaimable", label: "Reclaimable storage", unit: "bytes" },
],
async fetchAsync(context) {
const response = await context.requestAsync("/api/storage-metrics", { signal: context.signal });
const metrics = storageMetricsSchema.parse(response);
return {
itemsHandled: metrics.cleanupTotals.itemsHandled,
episodesHandled: metrics.cleanupTotals.episodesHandled,
moviesHandled: metrics.cleanupTotals.moviesHandled,
reclaimable: metrics.collectionSummary.activeSizeBytes,
};
},
} satisfies StatsProvider;
@@ -0,0 +1,41 @@
import { z } from "zod/v4";
import type { StatsAuthenticationContext, StatsProvider } from "../types";
const mealieStatsResponseSchema = z
.object({
totalRecipes: z.number().finite().int().nonnegative(),
totalUsers: z.number().finite().int().nonnegative(),
totalCategories: z.number().finite().int().nonnegative(),
totalTags: z.number().finite().int().nonnegative(),
})
.passthrough();
const getHttpAuthentication = (context: StatsAuthenticationContext) => ({
headers: { Authorization: `Bearer ${context.secret("apiKey")}` },
});
export const mealieStatsProvider = {
getHttpAuthentication,
metrics: [
{ key: "recipes", label: "Recipes", unit: "count" },
{ key: "users", label: "Users", unit: "count" },
{ key: "categories", label: "Categories", unit: "count" },
{ key: "tags", label: "Tags", unit: "count" },
],
async fetchAsync(context) {
const response = await context.requestAsync("/api/households/statistics", {
headers: getHttpAuthentication(context).headers,
signal: context.signal,
});
const stats = mealieStatsResponseSchema.parse(response);
return {
recipes: stats.totalRecipes,
users: stats.totalUsers,
categories: stats.totalCategories,
tags: stats.totalTags,
};
},
} satisfies StatsProvider;
@@ -0,0 +1,36 @@
import { z } from "zod";
import type { StatsAuthenticationContext, StatsProvider } from "../types";
const countSchema = z.number().finite().int().nonnegative();
const minifluxCountersSchema = z.object({
reads: z.record(z.string(), countSchema),
unreads: z.record(z.string(), countSchema),
});
const sumValues = (values: Record<string, number>) => Object.values(values).reduce((total, value) => total + value, 0);
const getHttpAuthentication = (context: StatsAuthenticationContext) => ({
headers: { "X-Auth-Token": context.secret("apiKey") },
});
export const minifluxStatsProvider = {
getHttpAuthentication,
metrics: [
{ key: "read", label: "Read", unit: "count" },
{ key: "unread", label: "Unread", unit: "count" },
],
async fetchAsync(context) {
const response = await context.requestAsync("/v1/feeds/counters", {
headers: getHttpAuthentication(context).headers,
signal: context.signal,
});
const counters = minifluxCountersSchema.parse(response);
return {
read: sumValues(counters.reads),
unread: sumValues(counters.unreads),
};
},
} satisfies StatsProvider;
@@ -0,0 +1,28 @@
import { z } from "zod";
import type { StatsAuthenticationContext, StatsProvider } from "../types";
const totalSchema = z.union([z.number().finite().int().nonnegative(), z.string().regex(/^\d+$/)]).transform(Number);
const totalsSchema = z.tuple([totalSchema, totalSchema, totalSchema, totalSchema, totalSchema]).rest(totalSchema);
const getHttpAuthentication = (context: StatsAuthenticationContext) => ({
headers: { Authorization: `Bearer ${context.secret("apiKey")}` },
});
export const netalertxStatsProvider = {
getHttpAuthentication,
metrics: [
{ key: "total", label: "Total devices", unit: "count" },
{ key: "connected", label: "Connected devices", unit: "count" },
{ key: "newDevices", label: "New devices", unit: "count" },
{ key: "downAlerts", label: "Down alerts", unit: "count" },
],
async fetchAsync(context) {
const response = await context.requestAsync("/devices/totals", {
headers: getHttpAuthentication(context).headers,
signal: context.signal,
});
const totals = totalsSchema.parse(response);
return { total: totals[0], connected: totals[1], newDevices: totals[3], downAlerts: totals[4] };
},
} satisfies StatsProvider;
@@ -0,0 +1,35 @@
import { z } from "zod";
import type { StatsProvider } from "../types";
const countSchema = z.number().finite().int().nonnegative();
const infoSchema = z
.object({
alarms: z
.object({
warning: countSchema,
critical: countSchema,
})
.passthrough(),
})
.passthrough();
export const netdataStatsProvider = {
getHttpAuthentication: () => ({ headers: {} }),
metrics: [
{ key: "warnings", label: "Warnings", unit: "count" },
{ key: "criticals", label: "Criticals", unit: "count" },
],
async fetchAsync(context) {
const response = await context.requestAsync("/api/v1/info", {
signal: context.signal,
});
const info = infoSchema.parse(response);
return {
warnings: info.alarms.warning,
criticals: info.alarms.critical,
};
},
} satisfies StatsProvider;
@@ -0,0 +1,63 @@
import { z } from "zod";
import type { StatsFetchContext, StatsProvider } from "../types";
const plantitStatsResponseSchema = z
.object({
diaryEntryCount: z.number().finite().int().nonnegative(),
plantCount: z.number().finite().int().nonnegative(),
imageCount: z.number().finite().int().nonnegative(),
botanicalInfoCount: z.number().finite().int().nonnegative(),
})
.passthrough();
const plantitLoginResponseSchema = z.object({
jwt: z.object({ value: z.string().min(1) }),
});
export const plantitStatsProvider = {
metrics: [
{ key: "plants", label: "Plants", unit: "count" },
{ key: "species", label: "Species", unit: "count" },
{ key: "photos", label: "Photos", unit: "count" },
{ key: "events", label: "Events", unit: "count" },
],
getHttpAuthenticationAsync,
async fetchAsync(context) {
const authentication = await getHttpAuthenticationAsync(context);
const response = await context.requestAsync("/api/stats", {
headers: authentication.headers,
signal: context.signal,
});
const stats = plantitStatsResponseSchema.parse(response);
return {
plants: stats.plantCount,
species: stats.botanicalInfoCount,
photos: stats.imageCount,
events: stats.diaryEntryCount,
};
},
} satisfies StatsProvider;
async function getHttpAuthenticationAsync(context: StatsFetchContext) {
try {
const loginResponse = await context.requestAsync("/api/authentication/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
username: context.secret("username"),
password: context.secret("password"),
}),
signal: context.signal,
});
const login = plantitLoginResponseSchema.safeParse(loginResponse);
if (!login.success) throw new Error("Plant-it authentication failed");
const token = login.data.jwt.value;
return { headers: { Authorization: `Bearer ${token}` }, redactValues: [token] };
} catch {
throw new Error("Plant-it authentication failed");
}
}
@@ -0,0 +1,56 @@
import { z } from "zod";
import type { StatsAuthenticationContext, StatsProvider } from "../types";
const countSchema = z.number().finite().int().nonnegative();
const targetsResponseSchema = z
.object({
status: z.literal("success"),
data: z
.object({
activeTargets: z.array(
z
.object({
health: z.string(),
})
.passthrough(),
),
droppedTargets: z.array(z.unknown()),
})
.passthrough(),
})
.passthrough();
const getHttpAuthentication = (context: StatsAuthenticationContext) => {
const headers: Record<string, string> = {};
if (context.hasSecret("username") && context.hasSecret("password")) {
headers.Authorization = `Basic ${Buffer.from(`${context.secret("username")}:${context.secret("password")}`, "utf8").toString("base64")}`;
}
return { headers };
};
export const prometheusStatsProvider = {
getHttpAuthentication,
metrics: [
{ key: "total", label: "Targets", unit: "count" },
{ key: "up", label: "Up", unit: "count" },
{ key: "down", label: "Down", unit: "count" },
],
async fetchAsync(context) {
const { headers } = getHttpAuthentication(context);
const response = await context.requestAsync("/api/v1/targets", {
headers,
signal: context.signal,
});
const targets = targetsResponseSchema.parse(response).data.activeTargets;
return {
total: countSchema.parse(targets.length),
up: countSchema.parse(targets.filter((target) => target.health === "up").length),
down: countSchema.parse(targets.filter((target) => target.health === "down").length),
};
},
} satisfies StatsProvider;
@@ -0,0 +1,48 @@
import { z } from "zod";
import type { StatsProvider } from "../types";
const countSchema = z.number().finite().int().nonnegative();
const bytesSchema = z.number().finite().nonnegative();
const statsResponseSchema = z
.object({
PLATFORMS: countSchema,
ROMS: countSchema,
SAVES: countSchema,
STATES: countSchema,
SCREENSHOTS: countSchema,
FILESIZE: bytesSchema.optional(),
TOTAL_FILESIZE_BYTES: bytesSchema.optional(),
})
.passthrough()
.refine((stats) => stats.FILESIZE !== undefined || stats.TOTAL_FILESIZE_BYTES !== undefined, {
message: "Expected FILESIZE or TOTAL_FILESIZE_BYTES",
});
export const rommStatsProvider = {
getHttpAuthentication: () => ({ headers: {} }),
metrics: [
{ key: "platforms", label: "Platforms", unit: "count" },
{ key: "roms", label: "ROMs", unit: "count" },
{ key: "saves", label: "Saves", unit: "count" },
{ key: "states", label: "States", unit: "count" },
{ key: "screenshots", label: "Screenshots", unit: "count" },
{ key: "totalFileSize", label: "Total file size", unit: "bytes" },
],
async fetchAsync(context) {
const response = await context.requestAsync("/api/stats", { signal: context.signal });
const stats = statsResponseSchema.parse(response);
const totalFileSize = stats.FILESIZE ?? stats.TOTAL_FILESIZE_BYTES;
if (totalFileSize === undefined) throw new Error("RomM stats did not include a file size");
return {
platforms: stats.PLATFORMS,
roms: stats.ROMS,
saves: stats.SAVES,
states: stats.STATES,
screenshots: stats.SCREENSHOTS,
totalFileSize,
};
},
} satisfies StatsProvider;
@@ -0,0 +1,56 @@
import { z } from "zod";
import type { StatsProvider } from "../types";
const countSchema = z.number().finite().int().nonnegative();
const summarySchema = z
.object({
data: z.object({
summary: z.record(
z.string(),
z.object({
device: z.object({ archived: z.boolean(), device_status: z.number().int() }).passthrough(),
}),
),
}),
})
.passthrough();
const settingsSchema = z
.object({
settings: z.object({ metrics: z.object({ status_threshold: z.number().int() }).passthrough() }).passthrough(),
})
.passthrough();
const status = { passed: 0, failedSmart: 1, failedScrutiny: 2, failedBoth: 3 } as const;
export const scrutinyStatsProvider = {
getHttpAuthentication: () => ({ headers: {} }),
metrics: [
{ key: "passed", label: "Passed devices", unit: "count" },
{ key: "failed", label: "Failed devices", unit: "count" },
{ key: "unknown", label: "Unknown devices", unit: "count" },
],
async fetchAsync(context) {
const [summaryResponse, settingsResponse] = await Promise.all([
context.requestAsync("/api/summary", { signal: context.signal }),
context.requestAsync("/api/settings", { signal: context.signal }),
]);
const summary = summarySchema.parse(summaryResponse);
const threshold = settingsSchema.parse(settingsResponse).settings.metrics.status_threshold;
const devices = Object.values(summary.data.summary).filter(({ device }) => !device.archived);
const failed = devices.filter(({ device }) => {
if (threshold === status.failedBoth)
return device.device_status > status.passed && device.device_status <= status.failedBoth;
return device.device_status === threshold || device.device_status === status.failedBoth;
}).length;
const unknown = devices.filter(
({ device }) => device.device_status < status.passed || device.device_status > status.failedBoth,
).length;
return {
passed: countSchema.parse(devices.length - failed - unknown),
failed: countSchema.parse(failed),
unknown: countSchema.parse(unknown),
};
},
} satisfies StatsProvider;
@@ -0,0 +1,38 @@
import { z } from "zod";
import type { StatsProvider } from "../types";
const spoolSchema = z.object({
id: z.number().finite().int().nonnegative(),
remaining_weight: z.number().finite().nonnegative().nullish(),
archived: z.boolean(),
});
const spoolListSchema = z.array(spoolSchema);
export const spoolmanStatsProvider = {
getHttpAuthentication: () => ({ headers: {} }),
metrics: [
{ key: "spools", label: "Spools", unit: "count" },
{ key: "remainingWeight", label: "Remaining weight", unit: "grams" },
],
async fetchAsync(context) {
const response = await context.requestAsync("/api/v1/spool", { signal: context.signal });
const spools = spoolListSchema.parse(response).filter((spool) => !spool.archived);
let remainingWeight: number | null = 0;
for (const spool of spools) {
if (spool.remaining_weight == null) {
remainingWeight = null;
break;
}
remainingWeight += spool.remaining_weight;
}
return {
spools: spools.length,
remainingWeight,
};
},
} satisfies StatsProvider;
@@ -0,0 +1,81 @@
import { z } from "zod";
import type { StatsAuthenticationContext, StatsProvider } from "../types";
const countSchema = z.number().finite().int().nonnegative();
const measurementSchema = z.number().finite().nonnegative();
const statsSchema = z
.object({
scene_count: countSchema,
scenes_size: measurementSchema,
scenes_duration: measurementSchema,
image_count: countSchema,
images_size: measurementSchema,
gallery_count: countSchema,
performer_count: countSchema,
studio_count: countSchema,
tag_count: countSchema,
})
.strict();
const responseSchema = z
.object({
data: z.object({ stats: statsSchema }).strict(),
errors: z.never().optional(),
})
.strict();
const query = `query {
stats {
scene_count
scenes_size
scenes_duration
image_count
images_size
gallery_count
performer_count
studio_count
tag_count
}
}`;
const getHttpAuthentication = (context: StatsAuthenticationContext) => ({
headers: { ApiKey: context.secret("apiKey") },
});
export const stashStatsProvider = {
getHttpAuthentication,
metrics: [
{ key: "scenes", label: "Scenes", unit: "count" },
{ key: "sceneBytes", label: "Scene size", unit: "bytes" },
{ key: "sceneDuration", label: "Scene duration", unit: "seconds" },
{ key: "images", label: "Images", unit: "count" },
{ key: "imageBytes", label: "Image size", unit: "bytes" },
{ key: "galleries", label: "Galleries", unit: "count" },
{ key: "performers", label: "Performers", unit: "count" },
{ key: "studios", label: "Studios", unit: "count" },
{ key: "tags", label: "Tags", unit: "count" },
],
async fetchAsync(context) {
const response = await context.requestAsync("/graphql", {
method: "POST",
headers: { "content-type": "application/json", ...getHttpAuthentication(context).headers },
body: JSON.stringify({ query }),
signal: context.signal,
});
const stats = responseSchema.parse(response).data.stats;
return {
scenes: stats.scene_count,
sceneBytes: stats.scenes_size,
sceneDuration: stats.scenes_duration,
images: stats.image_count,
imageBytes: stats.images_size,
galleries: stats.gallery_count,
performers: stats.performer_count,
studios: stats.studio_count,
tags: stats.tag_count,
};
},
} satisfies StatsProvider;
Loaded 100 of 3286 files, more files were not shown because too many files have changed in this diff. Show more