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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"), {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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
Reference in new issue
Block a user