Replace entire codebase with homarr-labs/homarr
This commit is contained in:
4
packages/integrations/eslint.config.js
Normal file
4
packages/integrations/eslint.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [...baseConfig];
|
||||
1
packages/integrations/index.ts
Normal file
1
packages/integrations/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
62
packages/integrations/package.json
Normal file
62
packages/integrations/package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@homarr/integrations",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./test-connection": "./src/base/test-connection/index.ts",
|
||||
"./client": "./src/client.ts",
|
||||
"./types": "./src/types.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"@ctrl/deluge": "^7.5.0",
|
||||
"@ctrl/qbittorrent": "^9.11.0",
|
||||
"@ctrl/transmission": "^7.4.0",
|
||||
"@gitbeaker/rest": "^43.8.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/image-proxy": "workspace:^0.1.0",
|
||||
"@homarr/node-unifi": "^2.6.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@jellyfin/sdk": "^0.13.0",
|
||||
"@octokit/auth-app": "^8.1.2",
|
||||
"ical.js": "^2.2.1",
|
||||
"maria2": "^0.4.1",
|
||||
"node-ical": "^0.22.1",
|
||||
"octokit": "^5.0.5",
|
||||
"proxmox-api": "1.1.1",
|
||||
"tsdav": "^2.1.6",
|
||||
"undici": "7.16.0",
|
||||
"xml2js": "^0.6.2",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-unifi": "^2.5.1",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"eslint": "^9.39.2",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { ParseError } 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 { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
|
||||
import type { DnsHoleSummary } from "../interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||
import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from "./adguard-home-types";
|
||||
|
||||
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
|
||||
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||
const statsResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/stats"), {
|
||||
headers: {
|
||||
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!statsResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch stats for ${this.integration.name} (${this.integration.id}): ${statsResponse.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
const stats = statsResponseSchema.safeParse(await statsResponse.json());
|
||||
const status = statusResponseSchema.safeParse(await statusResponse.json());
|
||||
const filteringStatus = filteringStatusSchema.safeParse(await filteringStatusResponse.json());
|
||||
|
||||
const errorMessages: string[] = [];
|
||||
if (!stats.success) {
|
||||
errorMessages.push(`Stats parsing error: ${stats.error.message}`);
|
||||
}
|
||||
if (!status.success) {
|
||||
errorMessages.push(`Status parsing error: ${status.error.message}`);
|
||||
}
|
||||
if (!filteringStatus.success) {
|
||||
errorMessages.push(`Filtering status parsing error: ${filteringStatus.error.message}`);
|
||||
}
|
||||
if (!stats.success || !status.success || !filteringStatus.success) {
|
||||
throw new Error(
|
||||
`Failed to parse summary for ${this.integration.name} (${this.integration.id}):\n${errorMessages.join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const blockedQueriesToday =
|
||||
stats.data.time_units === "days"
|
||||
? (stats.data.blocked_filtering[stats.data.blocked_filtering.length - 1] ?? 0)
|
||||
: stats.data.blocked_filtering.reduce((prev, sum) => prev + sum, 0);
|
||||
const queriesToday =
|
||||
stats.data.time_units === "days"
|
||||
? (stats.data.dns_queries[stats.data.dns_queries.length - 1] ?? 0)
|
||||
: stats.data.dns_queries.reduce((prev, sum) => prev + sum, 0);
|
||||
const countFilteredDomains = filteringStatus.data.filters
|
||||
.filter((filter) => filter.enabled)
|
||||
.reduce((sum, filter) => filter.rules_count + sum, 0);
|
||||
|
||||
return {
|
||||
status: status.data.protection_enabled ? ("enabled" as const) : ("disabled" as const),
|
||||
adsBlockedToday: blockedQueriesToday,
|
||||
adsBlockedTodayPercentage: blockedQueriesToday > 0 ? (queriesToday / blockedQueriesToday) * 100 : 0,
|
||||
domainsBeingBlocked: countFilteredDomains,
|
||||
dnsQueriesToday: queriesToday,
|
||||
};
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/control/status"), {
|
||||
headers: {
|
||||
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
const result = await response.json();
|
||||
if (typeof result === "object" && result !== null) return { success: true };
|
||||
|
||||
return TestConnectionError.ParseResult(new ParseError("Expected object data"));
|
||||
}
|
||||
|
||||
public async enableAsync(): Promise<void> {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/control/protection"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to enable AdGuard Home for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async disableAsync(duration = 0): Promise<void> {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/control/protection"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: false,
|
||||
duration: duration * 1000,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to disable AdGuard Home for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getAuthorizationHeaderValue() {
|
||||
const username = super.getSecretValue("username");
|
||||
const password = super.getSecretValue("password");
|
||||
return Buffer.from(`${username}:${password}`).toString("base64");
|
||||
}
|
||||
}
|
||||
42
packages/integrations/src/adguard-home/adguard-home-types.ts
Normal file
42
packages/integrations/src/adguard-home/adguard-home-types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const statsResponseSchema = z.object({
|
||||
time_units: z.enum(["hours", "days"]),
|
||||
top_queried_domains: z.array(z.record(z.string(), z.number())),
|
||||
top_clients: z.array(z.record(z.string(), z.number())),
|
||||
top_blocked_domains: z.array(z.record(z.string(), z.number())),
|
||||
dns_queries: z.array(z.number()),
|
||||
blocked_filtering: z.array(z.number()),
|
||||
replaced_safebrowsing: z.array(z.number()),
|
||||
replaced_parental: z.array(z.number()),
|
||||
num_dns_queries: z.number().min(0),
|
||||
num_blocked_filtering: z.number().min(0),
|
||||
num_replaced_safebrowsing: z.number().min(0),
|
||||
num_replaced_safesearch: z.number().min(0),
|
||||
num_replaced_parental: z.number().min(0),
|
||||
avg_processing_time: z.number().min(0),
|
||||
});
|
||||
|
||||
export const statusResponseSchema = z.object({
|
||||
version: z.string(),
|
||||
language: z.string(),
|
||||
dns_addresses: z.array(z.string()),
|
||||
dns_port: z.number().positive(),
|
||||
http_port: z.number().positive(),
|
||||
protection_enabled: z.boolean(),
|
||||
dhcp_available: z.boolean(),
|
||||
running: z.boolean(),
|
||||
});
|
||||
|
||||
export const filteringStatusSchema = z.object({
|
||||
filters: z.array(
|
||||
z.object({
|
||||
url: z.string(),
|
||||
name: z.string(),
|
||||
last_updated: z.string().optional(),
|
||||
id: z.number().nonnegative(),
|
||||
rules_count: z.number().nonnegative(),
|
||||
enabled: z.boolean(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
114
packages/integrations/src/base/creator.ts
Normal file
114
packages/integrations/src/base/creator.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
||||
import { DashDotIntegration } from "../dashdot/dashdot-integration";
|
||||
import { DockerHubIntegration } from "../docker-hub/docker-hub-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 { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
|
||||
import { EmbyIntegration } from "../emby/emby-integration";
|
||||
import { GitHubContainerRegistryIntegration } from "../github-container-registry/github-container-registry-integration";
|
||||
import { GithubIntegration } from "../github/github-integration";
|
||||
import { GitlabIntegration } from "../gitlab/gitlab-integration";
|
||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||
import { ICalIntegration } from "../ical/ical-integration";
|
||||
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
||||
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
|
||||
import { LinuxServerIOIntegration } from "../linuxserverio/linuxserverio-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 { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
|
||||
import { NPMIntegration } from "../npm/npm-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 { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
|
||||
import { PlexIntegration } from "../plex/plex-integration";
|
||||
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
|
||||
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
|
||||
import { QuayIntegration } from "../quay/quay-integration";
|
||||
import { TrueNasIntegration } from "../truenas/truenas-integration";
|
||||
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
|
||||
import { UnraidIntegration } from "../unraid/unraid-integration";
|
||||
import type { Integration, IntegrationInput } from "./integration";
|
||||
|
||||
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
|
||||
integration: IntegrationInput & { kind: TKind },
|
||||
) => {
|
||||
if (!(integration.kind in integrationCreators)) {
|
||||
throw new Error(
|
||||
`Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`,
|
||||
);
|
||||
}
|
||||
|
||||
const creator = integrationCreators[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>;
|
||||
}
|
||||
|
||||
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 = {
|
||||
piHole: [createPiHoleIntegrationAsync],
|
||||
adGuardHome: AdGuardHomeIntegration,
|
||||
homeAssistant: HomeAssistantIntegration,
|
||||
jellyfin: JellyfinIntegration,
|
||||
plex: PlexIntegration,
|
||||
sonarr: SonarrIntegration,
|
||||
radarr: RadarrIntegration,
|
||||
sabNzbd: SabnzbdIntegration,
|
||||
nzbGet: NzbGetIntegration,
|
||||
qBittorrent: QBitTorrentIntegration,
|
||||
deluge: DelugeIntegration,
|
||||
transmission: TransmissionIntegration,
|
||||
aria2: Aria2Integration,
|
||||
jellyseerr: JellyseerrIntegration,
|
||||
overseerr: OverseerrIntegration,
|
||||
prowlarr: ProwlarrIntegration,
|
||||
openmediavault: OpenMediaVaultIntegration,
|
||||
lidarr: LidarrIntegration,
|
||||
readarr: ReadarrIntegration,
|
||||
dashDot: DashDotIntegration,
|
||||
tdarr: TdarrIntegration,
|
||||
proxmox: ProxmoxIntegration,
|
||||
emby: EmbyIntegration,
|
||||
nextcloud: NextcloudIntegration,
|
||||
unifiController: UnifiControllerIntegration,
|
||||
opnsense: OPNsenseIntegration,
|
||||
github: GithubIntegration,
|
||||
dockerHub: DockerHubIntegration,
|
||||
gitlab: GitlabIntegration,
|
||||
npm: NPMIntegration,
|
||||
codeberg: CodebergIntegration,
|
||||
linuxServerIO: LinuxServerIOIntegration,
|
||||
gitHubContainerRegistry: GitHubContainerRegistryIntegration,
|
||||
ical: ICalIntegration,
|
||||
quay: QuayIntegration,
|
||||
ntfy: NTFYIntegration,
|
||||
mock: MockIntegration,
|
||||
truenas: TrueNasIntegration,
|
||||
unraid: UnraidIntegration,
|
||||
} 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];
|
||||
95
packages/integrations/src/base/errors/decorator.ts
Normal file
95
packages/integrations/src/base/errors/decorator.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { isFunction } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
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 { integrationJsonParseErrorHandler, integrationZodParseErrorHandler } from "./parse";
|
||||
|
||||
const logger = createLogger({ module: "handleIntegrationErrors" });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any
|
||||
type AbstractConstructor<T = {}> = abstract new (...args: any[]) => T;
|
||||
|
||||
const defaultErrorHandlers: IIntegrationErrorHandler[] = [
|
||||
integrationZodParseErrorHandler,
|
||||
integrationJsonParseErrorHandler,
|
||||
integrationFetchHttpErrorHandler,
|
||||
];
|
||||
|
||||
export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[]) => {
|
||||
const combinedErrorHandlers = [...defaultErrorHandlers, ...errorHandlers];
|
||||
return <T extends AbstractConstructor<Integration>>(IntegrationBaseClass: T): T => {
|
||||
abstract class ErrorHandledIntegration extends IntegrationBaseClass {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
constructor(...args: any[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
super(...args);
|
||||
const processedProperties = new Set<string>();
|
||||
|
||||
let currentProto: unknown = Object.getPrototypeOf(this);
|
||||
|
||||
while (currentProto && currentProto !== Object.prototype) {
|
||||
for (const propertyKey of Object.getOwnPropertyNames(currentProto)) {
|
||||
if (propertyKey === "constructor" || processedProperties.has(propertyKey)) continue;
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(currentProto, propertyKey);
|
||||
|
||||
if (!descriptor) continue;
|
||||
const original: unknown = descriptor.value;
|
||||
if (!isFunction(original)) continue;
|
||||
|
||||
processedProperties.add(propertyKey);
|
||||
|
||||
const wrapped = (...methodArgs: unknown[]) => {
|
||||
const handleError = (error: unknown) => {
|
||||
if (error instanceof IntegrationError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const handler of combinedErrorHandlers) {
|
||||
const handledError = handler.handleError(error, this.publicIntegration);
|
||||
if (!handledError) continue;
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
try {
|
||||
const result = original.apply(this, methodArgs);
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.catch((error: unknown) => {
|
||||
handleError(error);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
Object.defineProperty(this, propertyKey, {
|
||||
...descriptor,
|
||||
value: wrapped,
|
||||
});
|
||||
}
|
||||
|
||||
currentProto = Object.getPrototypeOf(currentProto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ErrorHandledIntegration;
|
||||
};
|
||||
};
|
||||
5
packages/integrations/src/base/errors/handler.ts
Normal file
5
packages/integrations/src/base/errors/handler.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { IntegrationError, IntegrationErrorData } from "./integration-error";
|
||||
|
||||
export interface IIntegrationErrorHandler {
|
||||
handleError(error: unknown, integration: IntegrationErrorData): IntegrationError | undefined;
|
||||
}
|
||||
15
packages/integrations/src/base/errors/http/index.ts
Normal file
15
packages/integrations/src/base/errors/http/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
AxiosHttpErrorHandler,
|
||||
FetchHttpErrorHandler,
|
||||
OctokitHttpErrorHandler,
|
||||
OFetchHttpErrorHandler,
|
||||
TsdavHttpErrorHandler,
|
||||
} from "@homarr/common/server";
|
||||
|
||||
import { IntegrationHttpErrorHandler } from "./integration-http-error-handler";
|
||||
|
||||
export const integrationFetchHttpErrorHandler = new IntegrationHttpErrorHandler(new FetchHttpErrorHandler());
|
||||
export const integrationOFetchHttpErrorHandler = new IntegrationHttpErrorHandler(new OFetchHttpErrorHandler());
|
||||
export const integrationAxiosHttpErrorHandler = new IntegrationHttpErrorHandler(new AxiosHttpErrorHandler());
|
||||
export const integrationTsdavHttpErrorHandler = new IntegrationHttpErrorHandler(new TsdavHttpErrorHandler());
|
||||
export const integrationOctokitHttpErrorHandler = new IntegrationHttpErrorHandler(new OctokitHttpErrorHandler());
|
||||
@@ -0,0 +1,26 @@
|
||||
import { RequestError, ResponseError } from "@homarr/common/server";
|
||||
import type { HttpErrorHandler } from "@homarr/common/server";
|
||||
|
||||
import type { IIntegrationErrorHandler } from "../handler";
|
||||
import type { IntegrationError, IntegrationErrorData } from "../integration-error";
|
||||
import { IntegrationRequestError } from "./integration-request-error";
|
||||
import { IntegrationResponseError } from "./integration-response-error";
|
||||
|
||||
export class IntegrationHttpErrorHandler implements IIntegrationErrorHandler {
|
||||
private readonly httpErrorHandler: HttpErrorHandler;
|
||||
|
||||
constructor(httpErrorHandler: HttpErrorHandler) {
|
||||
this.httpErrorHandler = httpErrorHandler;
|
||||
}
|
||||
|
||||
handleError(error: unknown, integration: IntegrationErrorData): IntegrationError | undefined {
|
||||
if (error instanceof RequestError) return new IntegrationRequestError(integration, { cause: error });
|
||||
if (error instanceof ResponseError) return new IntegrationResponseError(integration, { cause: error });
|
||||
|
||||
const requestError = this.httpErrorHandler.handleRequestError(error);
|
||||
if (requestError) return new IntegrationRequestError(integration, { cause: requestError });
|
||||
const responseError = this.httpErrorHandler.handleResponseError(error);
|
||||
if (responseError) return new IntegrationResponseError(integration, { cause: responseError });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AnyRequestError, RequestError, RequestErrorType } from "@homarr/common/server";
|
||||
|
||||
import type { IntegrationErrorData } from "../integration-error";
|
||||
import { IntegrationError } from "../integration-error";
|
||||
|
||||
export type IntegrationRequestErrorOfType<TType extends RequestErrorType> = IntegrationRequestError & {
|
||||
cause: RequestError<TType>;
|
||||
};
|
||||
|
||||
export class IntegrationRequestError extends IntegrationError {
|
||||
constructor(integration: IntegrationErrorData, { cause }: { cause: AnyRequestError }) {
|
||||
super(integration, "Request to integration failed", { cause });
|
||||
this.name = IntegrationRequestError.name;
|
||||
}
|
||||
|
||||
get cause(): AnyRequestError {
|
||||
return super.cause as AnyRequestError;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ResponseError } from "@homarr/common/server";
|
||||
|
||||
import type { IntegrationErrorData } from "../integration-error";
|
||||
import { IntegrationError } from "../integration-error";
|
||||
|
||||
export class IntegrationResponseError extends IntegrationError {
|
||||
constructor(integration: IntegrationErrorData, { cause }: { cause: ResponseError }) {
|
||||
super(integration, "Response from integration did not indicate success", { cause });
|
||||
this.name = IntegrationResponseError.name;
|
||||
}
|
||||
|
||||
get cause(): ResponseError {
|
||||
return super.cause as ResponseError;
|
||||
}
|
||||
}
|
||||
18
packages/integrations/src/base/errors/integration-error.ts
Normal file
18
packages/integrations/src/base/errors/integration-error.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface IntegrationErrorData {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export abstract class IntegrationError extends Error {
|
||||
public readonly integrationId: string;
|
||||
public readonly integrationName: string;
|
||||
public readonly integrationUrl: string;
|
||||
|
||||
constructor(integration: IntegrationErrorData, message: string, { cause }: ErrorOptions) {
|
||||
super(message, { cause });
|
||||
this.integrationId = integration.id;
|
||||
this.integrationName = integration.name;
|
||||
this.integrationUrl = integration.url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { IntegrationErrorData } from "./integration-error";
|
||||
import { IntegrationError } from "./integration-error";
|
||||
|
||||
export class IntegrationUnknownError extends IntegrationError {
|
||||
constructor(integration: IntegrationErrorData, { cause }: ErrorOptions) {
|
||||
super(integration, "An unknown error occured while executing Integration method", { cause });
|
||||
}
|
||||
}
|
||||
6
packages/integrations/src/base/errors/parse/index.ts
Normal file
6
packages/integrations/src/base/errors/parse/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { JsonParseErrorHandler, ZodParseErrorHandler } from "@homarr/common/server";
|
||||
|
||||
import { IntegrationParseErrorHandler } from "./integration-parse-error-handler";
|
||||
|
||||
export const integrationZodParseErrorHandler = new IntegrationParseErrorHandler(new ZodParseErrorHandler());
|
||||
export const integrationJsonParseErrorHandler = new IntegrationParseErrorHandler(new JsonParseErrorHandler());
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import type { ParseErrorHandler } from "@homarr/common/server";
|
||||
|
||||
import type { IIntegrationErrorHandler } from "../handler";
|
||||
import type { IntegrationError, IntegrationErrorData } from "../integration-error";
|
||||
import { IntegrationParseError } from "./integration-parse-error";
|
||||
|
||||
export class IntegrationParseErrorHandler implements IIntegrationErrorHandler {
|
||||
private readonly parseErrorHandler: ParseErrorHandler;
|
||||
|
||||
constructor(parseErrorHandler: ParseErrorHandler) {
|
||||
this.parseErrorHandler = parseErrorHandler;
|
||||
}
|
||||
|
||||
handleError(error: unknown, integration: IntegrationErrorData): IntegrationError | undefined {
|
||||
if (error instanceof ParseError) return new IntegrationParseError(integration, { cause: error });
|
||||
const parseError = this.parseErrorHandler.handleParseError(error);
|
||||
if (parseError) return new IntegrationParseError(integration, { cause: parseError });
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ParseError } from "@homarr/common/server";
|
||||
|
||||
import type { IntegrationErrorData } from "../integration-error";
|
||||
import { IntegrationError } from "../integration-error";
|
||||
|
||||
export class IntegrationParseError extends IntegrationError {
|
||||
constructor(integration: IntegrationErrorData, { cause }: { cause: ParseError }) {
|
||||
super(integration, "Failed to parse integration data", { cause });
|
||||
this.name = IntegrationParseError.name;
|
||||
}
|
||||
|
||||
get cause(): ParseError {
|
||||
return super.cause as ParseError;
|
||||
}
|
||||
}
|
||||
121
packages/integrations/src/base/integration.ts
Normal file
121
packages/integrations/src/base/integration.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type tls from "node:tls";
|
||||
import type { AxiosInstance } from "axios";
|
||||
import type { Dispatcher } from "undici";
|
||||
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 { HandleIntegrationErrors } from "./errors/decorator";
|
||||
import { TestConnectionError } from "./test-connection/test-connection-error";
|
||||
import type { TestingResult } from "./test-connection/test-connection-service";
|
||||
import { TestConnectionService } from "./test-connection/test-connection-service";
|
||||
import type { IntegrationSecret } from "./types";
|
||||
|
||||
export interface IntegrationInput {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
externalUrl: string | null;
|
||||
decryptedSecrets: IntegrationSecret[];
|
||||
}
|
||||
|
||||
export interface IntegrationTestingInput {
|
||||
fetchAsync: typeof undiciFetch;
|
||||
dispatcher: Dispatcher;
|
||||
axiosInstance: AxiosInstance;
|
||||
options: {
|
||||
ca: string[] | string;
|
||||
checkServerIdentity: typeof tls.checkServerIdentity;
|
||||
};
|
||||
}
|
||||
|
||||
@HandleIntegrationErrors([])
|
||||
export abstract class Integration {
|
||||
constructor(protected integration: IntegrationInput) {}
|
||||
|
||||
public get publicIntegration() {
|
||||
return {
|
||||
id: this.integration.id,
|
||||
name: this.integration.name,
|
||||
url: this.integration.url,
|
||||
};
|
||||
}
|
||||
|
||||
protected getSecretValue(kind: IntegrationSecretKind) {
|
||||
const secret = this.integration.decryptedSecrets.find((secret) => secret.kind === kind);
|
||||
if (!secret) {
|
||||
throw new Error(`No secret of kind ${kind} was found`);
|
||||
}
|
||||
return secret.value;
|
||||
}
|
||||
|
||||
protected hasSecretValue(kind: IntegrationSecretKind) {
|
||||
return this.integration.decryptedSecrets.some((secret) => secret.kind === kind);
|
||||
}
|
||||
|
||||
private createUrl(
|
||||
inputUrl: string,
|
||||
path: `/${string}`,
|
||||
queryParams?: Record<string, string | Date | number | boolean>,
|
||||
) {
|
||||
const baseUrl = removeTrailingSlash(inputUrl);
|
||||
const url = new URL(`${baseUrl}${path}`);
|
||||
|
||||
if (queryParams) {
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
url.searchParams.set(key, value instanceof Date ? value.toISOString() : value.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
protected url(path: `/${string}`, queryParams?: Record<string, string | Date | number | boolean>) {
|
||||
return this.createUrl(this.integration.url, path, queryParams);
|
||||
}
|
||||
|
||||
protected externalUrl(path: `/${string}`, queryParams?: Record<string, string | Date | number | boolean>) {
|
||||
return this.createUrl(this.integration.externalUrl ?? this.integration.url, path, queryParams);
|
||||
}
|
||||
|
||||
public async testConnectionAsync(): Promise<TestingResult> {
|
||||
try {
|
||||
const url = new URL(this.integration.url);
|
||||
return await new TestConnectionService(url).handleAsync(async ({ ca, checkServerIdentity }) => {
|
||||
const fetchDispatcher = await createCertificateAgentAsync({
|
||||
ca,
|
||||
checkServerIdentity,
|
||||
});
|
||||
|
||||
const axiosInstance = await createAxiosCertificateInstanceAsync({
|
||||
ca,
|
||||
checkServerIdentity,
|
||||
});
|
||||
|
||||
const testingAsync: typeof this.testingAsync = this.testingAsync.bind(this);
|
||||
return await testingAsync({
|
||||
dispatcher: fetchDispatcher,
|
||||
fetchAsync: async (url, options) => await undiciFetch(url, { ...options, dispatcher: fetchDispatcher }),
|
||||
axiosInstance,
|
||||
options: {
|
||||
ca,
|
||||
checkServerIdentity,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof TestConnectionError)) {
|
||||
return TestConnectionError.UnknownResult(error);
|
||||
}
|
||||
|
||||
return error.toResult();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection to the integration
|
||||
* @returns {Promise<TestingResult>}
|
||||
*/
|
||||
protected abstract testingAsync(input: IntegrationTestingInput): Promise<TestingResult>;
|
||||
}
|
||||
3
packages/integrations/src/base/searchable-integration.ts
Normal file
3
packages/integrations/src/base/searchable-integration.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface ISearchableIntegration<TResult extends { image?: string; name: string; link: string }> {
|
||||
searchAsync(query: string): Promise<TResult[]>;
|
||||
}
|
||||
41
packages/integrations/src/base/session-store.ts
Normal file
41
packages/integrations/src/base/session-store.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import superjson from "superjson";
|
||||
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createGetSetChannel } from "@homarr/redis";
|
||||
|
||||
const logger = createLogger({ module: "sessionStore" });
|
||||
|
||||
export const createSessionStore = <TValue>(integration: { id: string }) => {
|
||||
const channelName = `session-store:${integration.id}`;
|
||||
const channel = createGetSetChannel<`${string}.${string}`>(channelName);
|
||||
|
||||
return {
|
||||
async getAsync() {
|
||||
logger.debug("Getting session from store", { store: channelName });
|
||||
const value = await channel.getAsync();
|
||||
if (!value) return null;
|
||||
try {
|
||||
return superjson.parse<TValue>(decryptSecret(value));
|
||||
} catch (error) {
|
||||
logger.warn("Failed to load session", { store: channelName, error });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async setAsync(value: TValue) {
|
||||
logger.debug("Updating session in store", { store: channelName });
|
||||
try {
|
||||
await channel.setAsync(encryptSecret(superjson.stringify(value)));
|
||||
} catch (error) {
|
||||
logger.error(new ErrorWithMetadata("Failed to save session", { store: channelName }, { cause: error }));
|
||||
}
|
||||
},
|
||||
async clearAsync() {
|
||||
logger.debug("Cleared session in store", { store: channelName });
|
||||
await channel.removeAsync();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export type SessionStore<TValue> = ReturnType<typeof createSessionStore<TValue>>;
|
||||
6
packages/integrations/src/base/test-connection/index.ts
Normal file
6
packages/integrations/src/base/test-connection/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
TestConnectionError,
|
||||
AnyTestConnectionError,
|
||||
TestConnectionErrorDataOfType,
|
||||
TestConnectionErrorType,
|
||||
} from "./test-connection-error";
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { X509Certificate } from "node:crypto";
|
||||
|
||||
import type { AnyRequestError, ParseError, RequestError } from "@homarr/common/server";
|
||||
|
||||
import { IntegrationRequestError } from "../errors/http/integration-request-error";
|
||||
import { IntegrationResponseError } from "../errors/http/integration-response-error";
|
||||
import type { IntegrationError } from "../errors/integration-error";
|
||||
import { IntegrationUnknownError } from "../errors/integration-unknown-error";
|
||||
import { IntegrationParseError } from "../errors/parse/integration-parse-error";
|
||||
|
||||
export type TestConnectionErrorType = keyof TestConnectionErrorMap;
|
||||
export type AnyTestConnectionError = {
|
||||
[TType in TestConnectionErrorType]: TestConnectionError<TType>;
|
||||
}[TestConnectionErrorType];
|
||||
export type TestConnectionErrorDataOfType<TType extends TestConnectionErrorType> = TestConnectionErrorMap[TType];
|
||||
|
||||
export class TestConnectionError<TType extends TestConnectionErrorType> extends Error {
|
||||
public readonly type: TType;
|
||||
public readonly data: TestConnectionErrorMap[TType];
|
||||
|
||||
private constructor(type: TType, data: TestConnectionErrorMap[TType], options?: { cause: Error }) {
|
||||
super("Unable to connect to the integration", options);
|
||||
this.name = TestConnectionError.name;
|
||||
this.type = type;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
get cause(): Error | undefined {
|
||||
return super.cause as Error | undefined;
|
||||
}
|
||||
|
||||
public toResult() {
|
||||
return {
|
||||
success: false,
|
||||
error: this,
|
||||
} as const;
|
||||
}
|
||||
|
||||
private static Unknown(cause: unknown) {
|
||||
return new TestConnectionError(
|
||||
"unknown",
|
||||
undefined,
|
||||
cause instanceof Error
|
||||
? {
|
||||
cause,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
public static UnknownResult(cause: unknown) {
|
||||
return this.Unknown(cause).toResult();
|
||||
}
|
||||
|
||||
private static Certificate(requestError: RequestError<"certificate">, certificate: X509Certificate) {
|
||||
return new TestConnectionError(
|
||||
"certificate",
|
||||
{
|
||||
requestError,
|
||||
certificate,
|
||||
},
|
||||
{
|
||||
cause: requestError,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public static CertificateResult(requestError: RequestError<"certificate">, certificate: X509Certificate) {
|
||||
return this.Certificate(requestError, certificate).toResult();
|
||||
}
|
||||
|
||||
private static Authorization(statusCode: number) {
|
||||
return new TestConnectionError("authorization", {
|
||||
statusCode,
|
||||
reason: statusCode === 403 ? "forbidden" : "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
public static UnauthorizedResult(statusCode: number) {
|
||||
return this.Authorization(statusCode).toResult();
|
||||
}
|
||||
|
||||
private static Status(input: { status: number; url: string }) {
|
||||
if (input.status === 401 || input.status === 403) return this.Authorization(input.status);
|
||||
|
||||
// We don't want to leak the query parameters in the error message
|
||||
const urlWithoutQuery = new URL(input.url);
|
||||
urlWithoutQuery.search = "";
|
||||
|
||||
return new TestConnectionError("statusCode", {
|
||||
statusCode: input.status,
|
||||
reason: input.status in statusCodeMap ? statusCodeMap[input.status as keyof typeof statusCodeMap] : "other",
|
||||
url: urlWithoutQuery.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
public static StatusResult(input: { status: number; url: string }) {
|
||||
return this.Status(input).toResult();
|
||||
}
|
||||
|
||||
private static Request(requestError: Exclude<AnyRequestError, RequestError<"certificate">>) {
|
||||
return new TestConnectionError(
|
||||
"request",
|
||||
{ requestError },
|
||||
{
|
||||
cause: requestError,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public static RequestResult(requestError: Exclude<AnyRequestError, RequestError<"certificate">>) {
|
||||
return this.Request(requestError).toResult();
|
||||
}
|
||||
|
||||
private static Parse(cause: ParseError) {
|
||||
return new TestConnectionError("parse", undefined, { cause });
|
||||
}
|
||||
|
||||
public static ParseResult(cause: ParseError) {
|
||||
return this.Parse(cause).toResult();
|
||||
}
|
||||
|
||||
static FromIntegrationError(error: IntegrationError): AnyTestConnectionError {
|
||||
if (error instanceof IntegrationUnknownError) {
|
||||
return this.Unknown(error.cause);
|
||||
}
|
||||
if (error instanceof IntegrationRequestError) {
|
||||
if (error.cause.type === "certificate") {
|
||||
return this.Unknown(new Error("FromIntegrationError can not be used for certificate errors", { cause: error }));
|
||||
}
|
||||
|
||||
return this.Request(error.cause);
|
||||
}
|
||||
if (error instanceof IntegrationResponseError) {
|
||||
return this.Status({
|
||||
status: error.cause.statusCode,
|
||||
url: error.cause.url ?? "?",
|
||||
});
|
||||
}
|
||||
if (error instanceof IntegrationParseError) {
|
||||
return this.Parse(error.cause);
|
||||
}
|
||||
|
||||
return this.Unknown(new Error("FromIntegrationError received unknown IntegrationError", { cause: error }));
|
||||
}
|
||||
}
|
||||
|
||||
const statusCodeMap = {
|
||||
400: "badRequest",
|
||||
404: "notFound",
|
||||
429: "tooManyRequests",
|
||||
500: "internalServerError",
|
||||
503: "serviceUnavailable",
|
||||
504: "gatewayTimeout",
|
||||
} as const;
|
||||
|
||||
interface TestConnectionErrorMap {
|
||||
unknown: undefined;
|
||||
parse: undefined;
|
||||
authorization: {
|
||||
statusCode: number;
|
||||
reason: "unauthorized" | "forbidden";
|
||||
};
|
||||
statusCode: {
|
||||
statusCode: number;
|
||||
reason: (typeof statusCodeMap)[keyof typeof statusCodeMap] | "other";
|
||||
url: string;
|
||||
};
|
||||
certificate: {
|
||||
requestError: RequestError<"certificate">;
|
||||
certificate: X509Certificate;
|
||||
};
|
||||
request: {
|
||||
requestError: Exclude<AnyRequestError, RequestError<"certificate">>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { X509Certificate } from "node:crypto";
|
||||
import tls from "node:tls";
|
||||
|
||||
import { getPortFromUrl } from "@homarr/common";
|
||||
import {
|
||||
getAllTrustedCertificatesAsync,
|
||||
getTrustedCertificateHostnamesAsync,
|
||||
} from "@homarr/core/infrastructure/certificates";
|
||||
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { IntegrationRequestErrorOfType } from "../errors/http/integration-request-error";
|
||||
import { IntegrationRequestError } from "../errors/http/integration-request-error";
|
||||
import { IntegrationError } from "../errors/integration-error";
|
||||
import type { AnyTestConnectionError } from "./test-connection-error";
|
||||
import { TestConnectionError } from "./test-connection-error";
|
||||
|
||||
const logger = createLogger({
|
||||
module: "testConnectionService",
|
||||
});
|
||||
|
||||
export type TestingResult =
|
||||
| {
|
||||
success: true;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: AnyTestConnectionError;
|
||||
};
|
||||
type AsyncTestingCallback = (input: {
|
||||
ca: string[] | string;
|
||||
checkServerIdentity: typeof tls.checkServerIdentity;
|
||||
}) => Promise<TestingResult>;
|
||||
|
||||
export class TestConnectionService {
|
||||
constructor(private url: URL) {}
|
||||
|
||||
public async handleAsync(testingCallbackAsync: AsyncTestingCallback) {
|
||||
logger.debug("Testing connection", {
|
||||
url: this.url.toString(),
|
||||
});
|
||||
|
||||
const testingResult = await testingCallbackAsync({
|
||||
ca: await getAllTrustedCertificatesAsync(),
|
||||
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.success) return result;
|
||||
|
||||
const error = result.error;
|
||||
if (error instanceof TestConnectionError) return error.toResult();
|
||||
|
||||
return TestConnectionError.UnknownResult(error);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!(error instanceof IntegrationError)) {
|
||||
return TestConnectionError.UnknownResult(error);
|
||||
}
|
||||
|
||||
if (!(error instanceof IntegrationRequestError)) {
|
||||
return TestConnectionError.FromIntegrationError(error).toResult();
|
||||
}
|
||||
|
||||
if (error.cause.type !== "certificate") {
|
||||
return TestConnectionError.FromIntegrationError(error).toResult();
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error as IntegrationRequestErrorOfType<"certificate">,
|
||||
} as const;
|
||||
});
|
||||
|
||||
if (testingResult.success) {
|
||||
logger.debug("Testing connection succeeded", {
|
||||
url: this.url.toString(),
|
||||
});
|
||||
|
||||
return testingResult;
|
||||
}
|
||||
|
||||
logger.debug("Testing connection failed", {
|
||||
url: this.url.toString(),
|
||||
error: `${testingResult.error.name}: ${testingResult.error.message}`,
|
||||
});
|
||||
|
||||
if (!(testingResult.error instanceof IntegrationRequestError)) {
|
||||
return testingResult.error.toResult();
|
||||
}
|
||||
|
||||
const certificate = await this.fetchCertificateAsync();
|
||||
if (!certificate) {
|
||||
return TestConnectionError.UnknownResult(new Error("Unable to fetch certificate"));
|
||||
}
|
||||
|
||||
return TestConnectionError.CertificateResult(testingResult.error.cause, certificate);
|
||||
}
|
||||
|
||||
private async fetchCertificateAsync(): Promise<X509Certificate | undefined> {
|
||||
logger.debug("Fetching certificate", {
|
||||
url: this.url.toString(),
|
||||
});
|
||||
|
||||
const url = this.url;
|
||||
const port = getPortFromUrl(url);
|
||||
const socket = await new Promise<tls.TLSSocket>((resolve, reject) => {
|
||||
try {
|
||||
const innerSocket = tls.connect(
|
||||
{
|
||||
host: url.hostname,
|
||||
servername: url.hostname,
|
||||
port,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
() => {
|
||||
resolve(innerSocket);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
reject(new Error("Unable to fetch certificate", { cause: error }));
|
||||
}
|
||||
});
|
||||
|
||||
const x509 = socket.getPeerX509Certificate();
|
||||
socket.destroy();
|
||||
|
||||
logger.debug("Fetched certificate", {
|
||||
url: this.url.toString(),
|
||||
subject: x509?.subject,
|
||||
issuer: x509?.issuer,
|
||||
});
|
||||
return x509;
|
||||
}
|
||||
}
|
||||
6
packages/integrations/src/base/types.ts
Normal file
6
packages/integrations/src/base/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
|
||||
export interface IntegrationSecret {
|
||||
kind: IntegrationSecretKind;
|
||||
value: string;
|
||||
}
|
||||
144
packages/integrations/src/codeberg/codeberg-integration.ts
Normal file
144
packages/integrations/src/codeberg/codeberg-integration.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { RequestInit, Response } from "undici";
|
||||
|
||||
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 { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import type {
|
||||
DetailsProviderResponse,
|
||||
ReleaseResponse,
|
||||
} from "../interfaces/releases-providers/releases-providers-types";
|
||||
import { detailsResponseSchema, releasesResponseSchema } from "./codeberg-schemas";
|
||||
|
||||
const logger = createLogger({ module: "codebergIntegration" });
|
||||
|
||||
export class CodebergIntegration extends Integration implements ReleasesProviderIntegration {
|
||||
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
|
||||
if (!this.hasSecretValue("personalAccessToken")) return await callback(undefined);
|
||||
|
||||
return await callback({
|
||||
Authorization: `token ${this.getSecretValue("personalAccessToken")}`,
|
||||
});
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await this.withHeadersAsync(async (headers) => {
|
||||
return await input.fetchAsync(this.url("/version"), {
|
||||
headers,
|
||||
});
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private parseIdentifier(identifier: string) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
logger.warn("Invalid identifier format. Expected 'owner/name', for identifier", {
|
||||
identifier,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
|
||||
const parsedIdentifier = this.parseIdentifier(identifier);
|
||||
if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
|
||||
|
||||
const { owner, name } = parsedIdentifier;
|
||||
|
||||
const releasesResponse = await this.withHeadersAsync(async (headers) => {
|
||||
return await fetchWithTrustedCertificatesAsync(
|
||||
this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/releases`),
|
||||
{ headers },
|
||||
);
|
||||
});
|
||||
if (!releasesResponse.ok) {
|
||||
return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
|
||||
}
|
||||
|
||||
const releasesResponseJson: unknown = await releasesResponse.json();
|
||||
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
|
||||
if (!success) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "unexpected",
|
||||
message: releasesResponseJson ? JSON.stringify(releasesResponseJson, null, 2) : error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const formattedReleases = data.map((tag) => ({
|
||||
latestRelease: tag.tag_name,
|
||||
latestReleaseAt: tag.published_at,
|
||||
releaseUrl: tag.url,
|
||||
releaseDescription: tag.body,
|
||||
isPreRelease: tag.prerelease,
|
||||
}));
|
||||
|
||||
const latestRelease = getLatestRelease(formattedReleases, versionRegex);
|
||||
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
|
||||
|
||||
const details = await this.getDetailsAsync(owner, name);
|
||||
|
||||
return { success: true, data: { ...details, ...latestRelease } };
|
||||
}
|
||||
|
||||
protected async getDetailsAsync(owner: string, name: string): Promise<DetailsProviderResponse | undefined> {
|
||||
const response = await this.withHeadersAsync(async (headers) => {
|
||||
return await fetchWithTrustedCertificatesAsync(
|
||||
this.url(`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`),
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn("Failed to get details", {
|
||||
owner,
|
||||
name,
|
||||
error: response.statusText,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const responseJson = await response.json();
|
||||
const { data, success, error } = detailsResponseSchema.safeParse(responseJson);
|
||||
|
||||
if (!success) {
|
||||
logger.warn("Failed to parse details", {
|
||||
owner,
|
||||
name,
|
||||
error,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
projectUrl: data.html_url,
|
||||
projectDescription: data.description,
|
||||
isFork: data.fork,
|
||||
isArchived: data.archived,
|
||||
createdAt: data.created_at,
|
||||
starsCount: data.stars_count,
|
||||
openIssues: data.open_issues_count,
|
||||
forksCount: data.forks_count,
|
||||
};
|
||||
}
|
||||
}
|
||||
22
packages/integrations/src/codeberg/codeberg-schemas.ts
Normal file
22
packages/integrations/src/codeberg/codeberg-schemas.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const releasesResponseSchema = z.array(
|
||||
z.object({
|
||||
tag_name: z.string(),
|
||||
published_at: z.string().transform((value) => new Date(value)),
|
||||
url: z.string(),
|
||||
body: z.string(),
|
||||
prerelease: z.boolean(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const detailsResponseSchema = z.object({
|
||||
html_url: z.string(),
|
||||
description: z.string(),
|
||||
fork: z.boolean(),
|
||||
archived: z.boolean(),
|
||||
created_at: z.string().transform((value) => new Date(value)),
|
||||
stars_count: z.number(),
|
||||
open_issues_count: z.number(),
|
||||
forks_count: z.number(),
|
||||
});
|
||||
203
packages/integrations/src/dashdot/dashdot-integration.ts
Normal file
203
packages/integrations/src/dashdot/dashdot-integration.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
|
||||
import "@homarr/redis";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel";
|
||||
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 { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
|
||||
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
|
||||
|
||||
export class DashDotIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/info"));
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
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 channel = this.getChannel();
|
||||
const history = await channel.getSliceUntilTimeAsync(dayjs().subtract(15, "minutes").toDate());
|
||||
|
||||
return {
|
||||
cpuUtilization: cpuLoad.sumLoad,
|
||||
memUsedInBytes: memoryLoad.loadInBytes,
|
||||
memAvailableInBytes: info.maxAvailableMemoryBytes - memoryLoad.loadInBytes,
|
||||
network: networkLoad,
|
||||
fileSystem: info.storage
|
||||
.filter((_, index) => storageLoad[index] !== -1) // filter out undermoutned drives, they display as -1 in the load API
|
||||
.map((storage, index) => ({
|
||||
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
used: humanFileSize(storageLoad[index]!),
|
||||
available: storageLoad[index] ? `${storage.size - storageLoad[index]}` : `${storage.size}`,
|
||||
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
|
||||
})),
|
||||
cpuModelName: info.cpuModel === "" ? `Unknown Model (${info.cpuBrand})` : `${info.cpuModel} (${info.cpuBrand})`,
|
||||
cpuTemp: cpuLoad.averageTemperature,
|
||||
availablePkgUpdates: 0,
|
||||
rebootRequired: false,
|
||||
smart: [],
|
||||
uptime: info.uptime,
|
||||
version: `${info.operatingSystemVersion}`,
|
||||
loadAverage: {
|
||||
"1min": Math.round(this.getAverageOfCpu(history[0])),
|
||||
"5min": Math.round(this.getAverageOfCpuFlat(history.slice(0, 4))),
|
||||
"15min": Math.round(this.getAverageOfCpuFlat(history.slice(0, 14))),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getInfoAsync() {
|
||||
const infoResponse = await fetchWithTrustedCertificatesAsync(this.url("/info"));
|
||||
const serverInfo = await internalServerInfoApi.parseAsync(await infoResponse.json());
|
||||
return {
|
||||
maxAvailableMemoryBytes: serverInfo.ram.size,
|
||||
storage: serverInfo.storage,
|
||||
cpuBrand: serverInfo.cpu.brand,
|
||||
cpuModel: serverInfo.cpu.model,
|
||||
operatingSystemVersion: `${serverInfo.os.distro} ${serverInfo.os.release} (${serverInfo.os.kernel})`,
|
||||
uptime: serverInfo.os.uptime,
|
||||
};
|
||||
}
|
||||
|
||||
private async getCurrentCpuLoadAsync() {
|
||||
const channel = this.getChannel();
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/load/cpu"));
|
||||
const result = await response.text();
|
||||
|
||||
// we convert it to text as the response is either valid json or empty if cpu widget is disabled.
|
||||
if (result.length === 0) {
|
||||
return {
|
||||
sumLoad: 0,
|
||||
averageTemperature: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await cpuLoadPerCoreApiList.parseAsync(JSON.parse(result));
|
||||
await channel.pushAsync(data);
|
||||
return {
|
||||
sumLoad: this.getAverageOfCpu(data),
|
||||
averageTemperature: data.reduce((acc, current) => acc + current.temp, 0) / data.length,
|
||||
};
|
||||
}
|
||||
|
||||
private getAverageOfCpuFlat(cpuLoad: z.infer<typeof cpuLoadPerCoreApiList>[]) {
|
||||
const averages = cpuLoad.map((load) => this.getAverageOfCpu(load));
|
||||
return averages.reduce((acc, current) => acc + current, 0) / averages.length;
|
||||
}
|
||||
|
||||
private getAverageOfCpu(cpuLoad?: z.infer<typeof cpuLoadPerCoreApiList>) {
|
||||
if (!cpuLoad) {
|
||||
return 0;
|
||||
}
|
||||
return cpuLoad.reduce((acc, current) => acc + current.load, 0) / cpuLoad.length;
|
||||
}
|
||||
|
||||
private async getCurrentStorageLoadAsync() {
|
||||
const storageLoad = await fetchWithTrustedCertificatesAsync(this.url("/load/storage"));
|
||||
// we convert it to text as the response is either valid json or empty if storage widget is disabled.
|
||||
const result = await storageLoad.text();
|
||||
if (result.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return JSON.parse(result) as number[];
|
||||
}
|
||||
|
||||
private async getCurrentMemoryLoadAsync() {
|
||||
const memoryLoad = await fetchWithTrustedCertificatesAsync(this.url("/load/ram"));
|
||||
const result = (await memoryLoad.json()) as object;
|
||||
|
||||
// somehow the response here is not empty and rather an empty json object if the ram widget is disabled.
|
||||
if (Object.keys(result).length === 0) {
|
||||
return {
|
||||
loadInBytes: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await memoryLoadApi.parseAsync(result);
|
||||
return {
|
||||
loadInBytes: data.load,
|
||||
};
|
||||
}
|
||||
|
||||
private async getCurrentNetworkLoadAsync() {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/load/network"));
|
||||
const result = await response.text();
|
||||
|
||||
// we convert it to text as the response is either valid json or empty if network widget is disabled.
|
||||
if (result.length === 0) return null;
|
||||
|
||||
return await networkLoadApi.parseAsync(JSON.parse(result));
|
||||
}
|
||||
|
||||
private getChannel() {
|
||||
return createChannelEventHistoryOld<z.infer<typeof cpuLoadPerCoreApiList>>(
|
||||
`integration:${this.integration.id}:history:cpu`,
|
||||
100,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cpuLoadPerCoreApi = z.object({
|
||||
load: z.number().min(0),
|
||||
temp: z.number().min(0),
|
||||
});
|
||||
|
||||
const memoryLoadApi = z.object({
|
||||
load: z.number().min(0),
|
||||
});
|
||||
|
||||
const networkLoadApi = z.object({
|
||||
up: z.number().min(0),
|
||||
down: z.number().min(0),
|
||||
});
|
||||
|
||||
const internalServerInfoApi = z.object({
|
||||
os: z.object({
|
||||
distro: z.string(),
|
||||
kernel: z.string(),
|
||||
release: z.string(),
|
||||
uptime: z.number().min(0),
|
||||
}),
|
||||
cpu: z.object({
|
||||
brand: z.string(),
|
||||
model: z.string(),
|
||||
}),
|
||||
ram: z.object({
|
||||
size: z.number().min(0),
|
||||
}),
|
||||
storage: z.array(
|
||||
z.object({
|
||||
size: z.number().min(0),
|
||||
disks: z.array(
|
||||
z.object({
|
||||
device: z.string(),
|
||||
brand: z.string(),
|
||||
type: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const cpuLoadPerCoreApiList = z.array(cpuLoadPerCoreApi);
|
||||
190
packages/integrations/src/docker-hub/docker-hub-integration.ts
Normal file
190
packages/integrations/src/docker-hub/docker-hub-integration.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { fetch, RequestInit, Response } from "undici";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
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 { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import type {
|
||||
DetailsProviderResponse,
|
||||
ReleaseResponse,
|
||||
} from "../interfaces/releases-providers/releases-providers-types";
|
||||
import { accessTokenResponseSchema, detailsResponseSchema, releasesResponseSchema } from "./docker-hub-schemas";
|
||||
|
||||
const logger = createLogger({ module: "dockerHubIntegration" });
|
||||
|
||||
export class DockerHubIntegration extends Integration implements ReleasesProviderIntegration {
|
||||
private readonly sessionStore: SessionStore<string>;
|
||||
|
||||
constructor(integration: IntegrationInput) {
|
||||
super(integration);
|
||||
this.sessionStore = createSessionStore(integration);
|
||||
}
|
||||
|
||||
private async withHeadersAsync(callback: (headers: RequestInit["headers"]) => Promise<Response>): Promise<Response> {
|
||||
if (!this.hasSecretValue("username") || !this.hasSecretValue("personalAccessToken"))
|
||||
return await callback(undefined);
|
||||
|
||||
const storedSession = await this.sessionStore.getAsync();
|
||||
|
||||
if (storedSession) {
|
||||
logger.debug("Using stored session for request", { integrationId: this.integration.id });
|
||||
const response = await callback({
|
||||
Authorization: `Bearer ${storedSession}`,
|
||||
});
|
||||
if (response.status !== 401) {
|
||||
return response;
|
||||
}
|
||||
|
||||
logger.debug("Session expired, getting new session", { integrationId: this.integration.id });
|
||||
}
|
||||
|
||||
const accessToken = await this.getSessionAsync();
|
||||
await this.sessionStore.setAsync(accessToken);
|
||||
return await callback({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
});
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const hasAuth = this.hasSecretValue("username") && this.hasSecretValue("personalAccessToken");
|
||||
|
||||
if (hasAuth) {
|
||||
logger.debug("Testing DockerHub connection with authentication", { integrationId: this.integration.id });
|
||||
await this.getSessionAsync(input.fetchAsync);
|
||||
} else {
|
||||
logger.debug("Testing DockerHub connection without authentication", { integrationId: this.integration.id });
|
||||
const response = await input.fetchAsync(this.url("/v2/repositories/library"));
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private parseIdentifier(identifier: string) {
|
||||
if (!identifier.includes("/")) return { owner: "", name: identifier };
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
logger.warn("Invalid identifier format. Expected 'owner/name' or 'name', for identifier", {
|
||||
identifier,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
|
||||
const parsedIdentifier = this.parseIdentifier(identifier);
|
||||
if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
|
||||
|
||||
const { owner, name } = parsedIdentifier;
|
||||
|
||||
const relativeUrl: `/${string}` = owner
|
||||
? `/v2/namespaces/${encodeURIComponent(owner)}/repositories/${encodeURIComponent(name)}`
|
||||
: `/v2/repositories/library/${encodeURIComponent(name)}`;
|
||||
|
||||
for (let page = 0; page <= 5; page++) {
|
||||
const releasesResponse = await this.withHeadersAsync(async (headers) => {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/tags?page_size=100&page=${page}`), {
|
||||
headers,
|
||||
});
|
||||
});
|
||||
if (!releasesResponse.ok) {
|
||||
return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
|
||||
}
|
||||
|
||||
const releasesResponseJson: unknown = await releasesResponse.json();
|
||||
const releasesResult = releasesResponseSchema.safeParse(releasesResponseJson);
|
||||
if (!releasesResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "unexpected",
|
||||
message: releasesResponseJson
|
||||
? JSON.stringify(releasesResponseJson, null, 2)
|
||||
: releasesResult.error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const latestRelease = getLatestRelease(releasesResult.data.results, versionRegex);
|
||||
if (!latestRelease) continue;
|
||||
|
||||
const details = await this.getDetailsAsync(relativeUrl);
|
||||
|
||||
return { success: true, data: { ...details, ...latestRelease } };
|
||||
}
|
||||
|
||||
return { success: false, error: { code: "noMatchingVersion" } };
|
||||
}
|
||||
|
||||
private async getDetailsAsync(relativeUrl: `/${string}`): Promise<DetailsProviderResponse | undefined> {
|
||||
const response = await this.withHeadersAsync(async (headers) => {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url(`${relativeUrl}/`), {
|
||||
headers,
|
||||
});
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn("Failed to get details response", {
|
||||
relativeUrl,
|
||||
error: response.statusText,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const responseJson = await response.json();
|
||||
const { data, success, error } = detailsResponseSchema.safeParse(responseJson);
|
||||
|
||||
if (!success) {
|
||||
logger.warn("Failed to parse details response", {
|
||||
relativeUrl,
|
||||
error,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
projectUrl: `https://hub.docker.com/r/${data.namespace === "library" ? "_" : data.namespace}/${data.name}`,
|
||||
projectDescription: data.description,
|
||||
createdAt: data.date_registered,
|
||||
starsCount: data.star_count,
|
||||
};
|
||||
}
|
||||
|
||||
private async getSessionAsync(fetchAsync: typeof fetch = fetchWithTrustedCertificatesAsync): Promise<string> {
|
||||
const response = await fetchAsync(this.url("/v2/auth/token"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
identifier: this.getSecretValue("username"),
|
||||
secret: this.getSecretValue("personalAccessToken"),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
const data = await response.json();
|
||||
const result = await accessTokenResponseSchema.parseAsync(data);
|
||||
|
||||
if (!result.access_token) {
|
||||
throw new ResponseError({ status: 401, url: response.url });
|
||||
}
|
||||
|
||||
logger.info("Received session successfully", { integrationId: this.integration.id });
|
||||
|
||||
return result.access_token;
|
||||
}
|
||||
}
|
||||
22
packages/integrations/src/docker-hub/docker-hub-schemas.ts
Normal file
22
packages/integrations/src/docker-hub/docker-hub-schemas.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const accessTokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
});
|
||||
|
||||
export const releasesResponseSchema = z.object({
|
||||
results: z.array(
|
||||
z.object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) }).transform((tag) => ({
|
||||
latestRelease: tag.name,
|
||||
latestReleaseAt: tag.last_updated,
|
||||
})),
|
||||
),
|
||||
});
|
||||
|
||||
export const detailsResponseSchema = z.object({
|
||||
name: z.string(),
|
||||
namespace: z.string(),
|
||||
description: z.string(),
|
||||
star_count: z.number(),
|
||||
date_registered: z.string().transform((value) => new Date(value)),
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import path from "path";
|
||||
import type { fetch as undiciFetch } from "undici";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
|
||||
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";
|
||||
|
||||
export class Aria2Integration extends Integration implements IDownloadClientIntegration {
|
||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||
const client = this.getClient();
|
||||
const keys: (keyof Aria2Download)[] = [
|
||||
"bittorrent",
|
||||
"uploadLength",
|
||||
"uploadSpeed",
|
||||
"downloadSpeed",
|
||||
"totalLength",
|
||||
"completedLength",
|
||||
"files",
|
||||
"status",
|
||||
"gid",
|
||||
];
|
||||
const [activeDownloads, waitingDownloads, stoppedDownloads, globalStats] = await Promise.all([
|
||||
client.tellActive(),
|
||||
client.tellWaiting(0, input.limit, keys),
|
||||
client.tellStopped(0, input.limit, keys),
|
||||
client.getGlobalStat(),
|
||||
]);
|
||||
|
||||
const downloads = [...activeDownloads, ...waitingDownloads, ...stoppedDownloads].slice(0, input.limit);
|
||||
const allPaused = downloads.every((download) => download.status === "paused");
|
||||
|
||||
return {
|
||||
status: {
|
||||
types: ["torrent", "miscellaneous"],
|
||||
paused: allPaused,
|
||||
rates: {
|
||||
up: Number(globalStats.uploadSpeed),
|
||||
down: Number(globalStats.downloadSpeed),
|
||||
},
|
||||
},
|
||||
items: downloads.map((download, index) => {
|
||||
const totalSize = Number(download.totalLength);
|
||||
const completedSize = Number(download.completedLength);
|
||||
const progress = totalSize > 0 ? completedSize / totalSize : 0;
|
||||
|
||||
const itemName = download.bittorrent?.info?.name ?? path.basename(download.files[0]?.path ?? "Unknown");
|
||||
|
||||
return {
|
||||
index,
|
||||
id: download.gid,
|
||||
name: itemName,
|
||||
type: download.bittorrent ? "torrent" : "miscellaneous",
|
||||
size: totalSize,
|
||||
sent: Number(download.uploadLength),
|
||||
downSpeed: Number(download.downloadSpeed),
|
||||
upSpeed: Number(download.uploadSpeed),
|
||||
time: this.calculateEta(completedSize, totalSize, Number(download.downloadSpeed)),
|
||||
state: this.getState(download.status, Boolean(download.bittorrent)),
|
||||
category: [],
|
||||
progress,
|
||||
};
|
||||
}),
|
||||
} as DownloadClientJobsAndStatus;
|
||||
}
|
||||
public async pauseQueueAsync(): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.pauseAll();
|
||||
}
|
||||
public async pauseItemAsync(item: DownloadClientItem): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.pause(item.id);
|
||||
}
|
||||
public async resumeQueueAsync(): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.unpauseAll();
|
||||
}
|
||||
public async resumeItemAsync(item: DownloadClientItem): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.unpause(item.id);
|
||||
}
|
||||
public async deleteItemAsync(item: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
const client = this.getClient();
|
||||
// Note: Remove download file is not support by aria2, replace with forceremove
|
||||
|
||||
if (item.state in ["downloading", "leeching", "paused"]) {
|
||||
await (fromDisk ? client.remove(item.id) : client.forceRemove(item.id));
|
||||
} else {
|
||||
await client.removeDownloadResult(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const client = this.getClient(input.fetchAsync);
|
||||
await client.getVersion();
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private getClient(fetchAsync: typeof undiciFetch = fetchWithTrustedCertificatesAsync) {
|
||||
const url = this.url("/jsonrpc");
|
||||
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (target, method: keyof Aria2GetClient) => {
|
||||
return async (...args: Parameters<Aria2GetClient[typeof method]>) => {
|
||||
let params = [...args];
|
||||
if (this.hasSecretValue("apiKey")) {
|
||||
params = [`token:${this.getSecretValue("apiKey")}`, ...params];
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: btoa(["Homarr", Date.now().toString(), Math.random()].join(".")), // unique id per request
|
||||
method: `aria2.${method}`,
|
||||
params,
|
||||
});
|
||||
|
||||
return await fetchAsync(url, { method: "POST", body })
|
||||
.then(async (response) => {
|
||||
const responseBody = (await response.json()) as { result: ReturnType<Aria2GetClient[typeof method]> };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
return responseBody.result;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error("Error communicating with Aria2", {
|
||||
cause: error,
|
||||
});
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
) as Aria2GetClient;
|
||||
}
|
||||
|
||||
private getState(aria2Status: Aria2Download["status"], isTorrent: boolean): DownloadClientItem["state"] {
|
||||
return isTorrent ? this.getTorrentState(aria2Status) : this.getNonTorrentState(aria2Status);
|
||||
}
|
||||
private getNonTorrentState(aria2Status: Aria2Download["status"]): DownloadClientItem["state"] {
|
||||
switch (aria2Status) {
|
||||
case "active":
|
||||
return "downloading";
|
||||
case "waiting":
|
||||
return "queued";
|
||||
case "paused":
|
||||
return "paused";
|
||||
case "complete":
|
||||
return "completed";
|
||||
case "error":
|
||||
return "failed";
|
||||
case "removed":
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
private getTorrentState(aria2Status: Aria2Download["status"]): DownloadClientItem["state"] {
|
||||
switch (aria2Status) {
|
||||
case "active":
|
||||
return "leeching";
|
||||
case "waiting":
|
||||
return "queued";
|
||||
case "paused":
|
||||
return "paused";
|
||||
case "complete":
|
||||
return "completed";
|
||||
case "error":
|
||||
return "failed";
|
||||
case "removed":
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
private calculateEta(completed: number, total: number, speed: number): number {
|
||||
if (speed === 0 || completed >= total) return 0;
|
||||
return Math.floor((total - completed) / speed) * 1000; // Convert to milliseconds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export interface Aria2GetClient {
|
||||
getVersion: Aria2VoidFunc<Aria2GetVersion>;
|
||||
getGlobalStat: Aria2VoidFunc<Aria2GetGlobalStat>;
|
||||
|
||||
tellActive: Aria2VoidFunc<Aria2Download[]>;
|
||||
tellWaiting: Aria2VoidFunc<Aria2Download[], Aria2TellStatusListParams>;
|
||||
tellStopped: Aria2VoidFunc<Aria2Download[], Aria2TellStatusListParams>;
|
||||
tellStatus: Aria2GidFunc<Aria2Download, Aria2TellStatusListParams>;
|
||||
|
||||
pause: Aria2GidFunc<AriaGID>;
|
||||
pauseAll: Aria2VoidFunc<"OK">;
|
||||
unpause: Aria2GidFunc<AriaGID>;
|
||||
unpauseAll: Aria2VoidFunc<"OK">;
|
||||
remove: Aria2GidFunc<AriaGID>;
|
||||
forceRemove: Aria2GidFunc<AriaGID>;
|
||||
removeDownloadResult: Aria2GidFunc<"OK">;
|
||||
}
|
||||
type AriaGID = string;
|
||||
|
||||
type Aria2GidFunc<R = void, T extends unknown[] = []> = (gid: string, ...args: T) => Promise<R>;
|
||||
type Aria2VoidFunc<R = void, T extends unknown[] = []> = (...args: T) => Promise<R>;
|
||||
|
||||
type Aria2TellStatusListParams = [offset: number, num: number, keys?: [keyof Aria2Download] | (keyof Aria2Download)[]];
|
||||
|
||||
export interface Aria2GetVersion {
|
||||
enabledFeatures: string[];
|
||||
version: string;
|
||||
}
|
||||
export interface Aria2GetGlobalStat {
|
||||
downloadSpeed: string;
|
||||
uploadSpeed: string;
|
||||
numActive: string;
|
||||
numWaiting: string;
|
||||
numStopped: string;
|
||||
numStoppedTotal: string;
|
||||
}
|
||||
export interface Aria2Download {
|
||||
gid: AriaGID;
|
||||
status: "active" | "waiting" | "paused" | "error" | "complete" | "removed";
|
||||
totalLength: string;
|
||||
completedLength: string;
|
||||
uploadLength: string;
|
||||
bitfield: string;
|
||||
downloadSpeed: string;
|
||||
uploadSpeed: string;
|
||||
infoHash?: string;
|
||||
numSeeders?: string;
|
||||
seeder?: "true" | "false";
|
||||
pieceLength: string;
|
||||
numPieces: string;
|
||||
connections: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
followedBy?: AriaGID[];
|
||||
following?: AriaGID;
|
||||
belongsTo?: AriaGID;
|
||||
dir: string;
|
||||
files: {
|
||||
index: number;
|
||||
path: string;
|
||||
length: string;
|
||||
completedLength: string;
|
||||
selected: "true" | "false";
|
||||
uris: {
|
||||
status: "waiting" | "used";
|
||||
uri: string;
|
||||
}[];
|
||||
}[];
|
||||
bittorrent?: {
|
||||
announceList: string[];
|
||||
comment?: string | { "utf-8": string };
|
||||
creationDate?: number;
|
||||
mode?: "single" | "multi";
|
||||
info?: {
|
||||
name: string | { "utf-8": string };
|
||||
};
|
||||
verifiedLength?: number;
|
||||
verifyIntegrityPending?: boolean;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Deluge } from "@ctrl/deluge";
|
||||
import dayjs from "dayjs";
|
||||
import type { Dispatcher } from "undici";
|
||||
|
||||
import { createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { HandleIntegrationErrors } from "../../base/errors/decorator";
|
||||
import { integrationOFetchHttpErrorHandler } from "../../base/errors/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 { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
|
||||
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";
|
||||
|
||||
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
|
||||
export class DelugeIntegration extends Integration implements IDownloadClientIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const client = await this.getClientAsync(input.dispatcher);
|
||||
const isSuccess = await client.login();
|
||||
|
||||
if (!isSuccess) {
|
||||
return TestConnectionError.UnauthorizedResult(401);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||
const type = "torrent";
|
||||
const client = await this.getClientAsync();
|
||||
// Currently there is no way to limit the number of returned torrents
|
||||
const {
|
||||
stats: { download_rate, upload_rate },
|
||||
torrents: rawTorrents,
|
||||
} = (await client.listTorrents(["completed_time"])).result;
|
||||
const torrents = Object.entries(rawTorrents).map(([id, torrent]) => ({
|
||||
...(torrent as { completed_time: number } & typeof torrent),
|
||||
id,
|
||||
}));
|
||||
const paused = torrents.find(({ state }) => DelugeIntegration.getTorrentState(state) !== "paused") === undefined;
|
||||
const status: DownloadClientStatus = {
|
||||
paused,
|
||||
rates: {
|
||||
down: Math.floor(download_rate),
|
||||
up: Math.floor(upload_rate),
|
||||
},
|
||||
types: [type],
|
||||
};
|
||||
const items = torrents
|
||||
.map((torrent): DownloadClientItem => {
|
||||
const state = DelugeIntegration.getTorrentState(torrent.state);
|
||||
return {
|
||||
type,
|
||||
id: torrent.id,
|
||||
index: torrent.queue,
|
||||
name: torrent.name,
|
||||
size: torrent.total_wanted,
|
||||
sent: torrent.total_uploaded,
|
||||
downSpeed: torrent.progress !== 100 ? torrent.download_payload_rate : undefined,
|
||||
upSpeed: torrent.upload_payload_rate,
|
||||
time:
|
||||
torrent.progress === 100
|
||||
? Math.min((torrent.completed_time - dayjs().unix()) * 1000, -1)
|
||||
: Math.max(torrent.eta * 1000, 0),
|
||||
added: torrent.time_added * 1000,
|
||||
state,
|
||||
progress: torrent.progress / 100,
|
||||
category: torrent.label,
|
||||
};
|
||||
})
|
||||
.slice(0, input.limit);
|
||||
return { status, items };
|
||||
}
|
||||
|
||||
public async pauseQueueAsync() {
|
||||
const client = await this.getClientAsync();
|
||||
const store = (await client.listTorrents()).result.torrents;
|
||||
await Promise.all(
|
||||
Object.entries(store).map(async ([id]) => {
|
||||
await client.pauseTorrent(id);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
const client = await this.getClientAsync();
|
||||
await client.pauseTorrent(id);
|
||||
}
|
||||
|
||||
public async resumeQueueAsync() {
|
||||
const client = await this.getClientAsync();
|
||||
const store = (await client.listTorrents()).result.torrents;
|
||||
await Promise.all(
|
||||
Object.entries(store).map(async ([id]) => {
|
||||
await client.resumeTorrent(id);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
const client = await this.getClientAsync();
|
||||
await client.resumeTorrent(id);
|
||||
}
|
||||
|
||||
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
const client = await this.getClientAsync();
|
||||
await client.removeTorrent(id, fromDisk);
|
||||
}
|
||||
|
||||
private async getClientAsync(dispatcher?: Dispatcher) {
|
||||
return new Deluge({
|
||||
baseUrl: this.url("/").toString(),
|
||||
password: this.getSecretValue("password"),
|
||||
dispatcher: dispatcher ?? (await createCertificateAgentAsync()),
|
||||
});
|
||||
}
|
||||
|
||||
private static getTorrentState(state: string): DownloadClientItem["state"] {
|
||||
switch (state) {
|
||||
case "Queued":
|
||||
case "Checking":
|
||||
case "Allocating":
|
||||
case "Downloading":
|
||||
return "leeching";
|
||||
case "Seeding":
|
||||
return "seeding";
|
||||
case "Paused":
|
||||
return "paused";
|
||||
case "Error":
|
||||
case "Moving":
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { fetch as undiciFetch } from "undici";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
|
||||
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 { NzbGetClient } from "./nzbget-types";
|
||||
|
||||
export class NzbGetIntegration extends Integration implements IDownloadClientIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
await this.nzbGetApiCallWithCustomFetchAsync(input.fetchAsync, "version");
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
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 status: DownloadClientStatus = {
|
||||
paused: nzbGetStatus.DownloadPaused,
|
||||
rates: { down: nzbGetStatus.DownloadRate },
|
||||
types: [type],
|
||||
};
|
||||
const items = queue
|
||||
.map((file): DownloadClientItem => {
|
||||
const state = NzbGetIntegration.getUsenetQueueState(file.Status);
|
||||
const time =
|
||||
(file.RemainingSizeLo + file.RemainingSizeHi * Math.pow(2, 32)) / (nzbGetStatus.DownloadRate / 1000);
|
||||
return {
|
||||
type,
|
||||
id: file.NZBID.toString(),
|
||||
index: file.MaxPriority,
|
||||
name: file.NZBName,
|
||||
size: file.FileSizeLo + file.FileSizeHi * Math.pow(2, 32),
|
||||
downSpeed: file.ActiveDownloads > 0 ? nzbGetStatus.DownloadRate : 0,
|
||||
time: Number.isFinite(time) ? time : 0,
|
||||
added: (dayjs().unix() - file.DownloadTimeSec) * 1000,
|
||||
state,
|
||||
progress: file.DownloadedSizeMB / file.FileSizeMB,
|
||||
category: file.Category,
|
||||
};
|
||||
})
|
||||
.concat(
|
||||
history.map((file, index): DownloadClientItem => {
|
||||
const state = NzbGetIntegration.getUsenetHistoryState(file.ScriptStatus);
|
||||
return {
|
||||
type,
|
||||
id: file.NZBID.toString(),
|
||||
index,
|
||||
name: file.Name,
|
||||
size: file.FileSizeLo + file.FileSizeHi * Math.pow(2, 32),
|
||||
time: (dayjs().unix() - file.HistoryTime) * 1000,
|
||||
added: (file.HistoryTime - file.DownloadTimeSec) * 1000,
|
||||
state,
|
||||
progress: 1,
|
||||
category: file.Category,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.slice(0, input.limit);
|
||||
return { status, items };
|
||||
}
|
||||
|
||||
public async pauseQueueAsync() {
|
||||
await this.nzbGetApiCallAsync("pausedownload");
|
||||
}
|
||||
|
||||
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
await this.nzbGetApiCallAsync("editqueue", "GroupPause", "", [Number(id)]);
|
||||
}
|
||||
|
||||
public async resumeQueueAsync() {
|
||||
await this.nzbGetApiCallAsync("resumedownload");
|
||||
}
|
||||
|
||||
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
await this.nzbGetApiCallAsync("editqueue", "GroupResume", "", [Number(id)]);
|
||||
}
|
||||
|
||||
public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
if (fromDisk) {
|
||||
const filesIds = (await this.nzbGetApiCallAsync("listfiles", 0, 0, Number(id))).map((file) => file.ID);
|
||||
await this.nzbGetApiCallAsync("editqueue", "FileDelete", "", filesIds);
|
||||
}
|
||||
if (progress === 1) {
|
||||
await this.nzbGetApiCallAsync("editqueue", "GroupFinalDelete", "", [Number(id)]);
|
||||
} else {
|
||||
await this.nzbGetApiCallAsync("editqueue", "HistoryFinalDelete", "", [Number(id)]);
|
||||
}
|
||||
}
|
||||
|
||||
private async nzbGetApiCallAsync<CallType extends keyof NzbGetClient>(
|
||||
method: CallType,
|
||||
...params: Parameters<NzbGetClient[CallType]>
|
||||
): Promise<ReturnType<NzbGetClient[CallType]>> {
|
||||
return await this.nzbGetApiCallWithCustomFetchAsync(fetchWithTrustedCertificatesAsync, method, ...params);
|
||||
}
|
||||
|
||||
private async nzbGetApiCallWithCustomFetchAsync<CallType extends keyof NzbGetClient>(
|
||||
fetchAsync: typeof undiciFetch,
|
||||
method: CallType,
|
||||
...params: Parameters<NzbGetClient[CallType]>
|
||||
): Promise<ReturnType<NzbGetClient[CallType]>> {
|
||||
const username = this.getSecretValue("username");
|
||||
const password = this.getSecretValue("password");
|
||||
const url = this.url(`/${encodeURIComponent(username)}:${encodeURIComponent(password)}/jsonrpc`);
|
||||
const body = JSON.stringify({ method, params });
|
||||
return await fetchAsync(url, { method: "POST", body })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
return ((await response.json()) as { result: ReturnType<NzbGetClient[CallType]> }).result;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error("Error communicating with NzbGet", {
|
||||
cause: error,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static getUsenetQueueState(status: string): DownloadClientItem["state"] {
|
||||
switch (status) {
|
||||
case "QUEUED":
|
||||
return "queued";
|
||||
case "PAUSED":
|
||||
return "paused";
|
||||
default:
|
||||
return "downloading";
|
||||
}
|
||||
}
|
||||
|
||||
private static getUsenetHistoryState(status: string): DownloadClientItem["state"] {
|
||||
switch (status) {
|
||||
case "FAILURE":
|
||||
return "failed";
|
||||
case "SUCCESS":
|
||||
return "completed";
|
||||
default:
|
||||
return "processing";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface NzbGetClient {
|
||||
version: () => string;
|
||||
status: () => NzbGetStatus;
|
||||
listgroups: () => NzbGetGroup[];
|
||||
history: () => NzbGetHistory[];
|
||||
pausedownload: () => void;
|
||||
resumedownload: () => void;
|
||||
editqueue: (Command: string, Param: string, IDs: number[]) => void;
|
||||
listfiles: (IDFrom: number, IDTo: number, NZBID: number) => { ID: number }[];
|
||||
}
|
||||
|
||||
interface NzbGetStatus {
|
||||
DownloadPaused: boolean;
|
||||
DownloadRate: number;
|
||||
}
|
||||
|
||||
interface NzbGetGroup {
|
||||
Status: string;
|
||||
NZBID: number;
|
||||
MaxPriority: number;
|
||||
NZBName: string;
|
||||
FileSizeLo: number;
|
||||
FileSizeHi: number;
|
||||
ActiveDownloads: number;
|
||||
RemainingSizeLo: number;
|
||||
RemainingSizeHi: number;
|
||||
DownloadTimeSec: number;
|
||||
Category: string;
|
||||
DownloadedSizeMB: number;
|
||||
FileSizeMB: number;
|
||||
}
|
||||
|
||||
interface NzbGetHistory {
|
||||
ScriptStatus: string;
|
||||
NZBID: number;
|
||||
Name: string;
|
||||
FileSizeLo: number;
|
||||
FileSizeHi: number;
|
||||
HistoryTime: number;
|
||||
DownloadTimeSec: number;
|
||||
Category: string;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { QBittorrent } from "@ctrl/qbittorrent";
|
||||
import dayjs from "dayjs";
|
||||
import type { Dispatcher } from "undici";
|
||||
|
||||
import { createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { HandleIntegrationErrors } from "../../base/errors/decorator";
|
||||
import { integrationOFetchHttpErrorHandler } from "../../base/errors/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 { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
|
||||
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";
|
||||
|
||||
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
|
||||
export class QBitTorrentIntegration extends Integration implements IDownloadClientIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const client = await this.getClientAsync(input.dispatcher);
|
||||
const isSuccess = await client.login();
|
||||
if (!isSuccess) return TestConnectionError.UnauthorizedResult(401);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||
const type = "torrent";
|
||||
const client = await this.getClientAsync();
|
||||
const torrents = await client.listTorrents({ limit: input.limit });
|
||||
const rates = torrents.reduce(
|
||||
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
|
||||
{ down: 0, up: 0 },
|
||||
);
|
||||
const paused =
|
||||
torrents.find(({ state }) => QBitTorrentIntegration.getTorrentState(state) !== "paused") === undefined;
|
||||
const status: DownloadClientStatus = { paused, rates, types: [type] };
|
||||
const items = torrents.map((torrent): DownloadClientItem => {
|
||||
const state = QBitTorrentIntegration.getTorrentState(torrent.state);
|
||||
return {
|
||||
type,
|
||||
id: torrent.hash,
|
||||
index: torrent.priority,
|
||||
name: torrent.name,
|
||||
size: torrent.size,
|
||||
sent: torrent.uploaded,
|
||||
downSpeed: torrent.progress !== 1 ? torrent.dlspeed : undefined,
|
||||
upSpeed: torrent.upspeed,
|
||||
time:
|
||||
torrent.progress === 1
|
||||
? Math.min(torrent.completion_on * 1000 - dayjs().valueOf(), -1)
|
||||
: torrent.eta === 8640000
|
||||
? 0
|
||||
: Math.max(torrent.eta * 1000, 0),
|
||||
added: torrent.added_on * 1000,
|
||||
state,
|
||||
progress: torrent.progress,
|
||||
category: torrent.category,
|
||||
};
|
||||
});
|
||||
return { status, items };
|
||||
}
|
||||
|
||||
public async pauseQueueAsync() {
|
||||
const client = await this.getClientAsync();
|
||||
await client.pauseTorrent("all");
|
||||
}
|
||||
|
||||
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
const client = await this.getClientAsync();
|
||||
await client.pauseTorrent(id);
|
||||
}
|
||||
|
||||
public async resumeQueueAsync() {
|
||||
const client = await this.getClientAsync();
|
||||
await client.resumeTorrent("all");
|
||||
}
|
||||
|
||||
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
const client = await this.getClientAsync();
|
||||
await client.resumeTorrent(id);
|
||||
}
|
||||
|
||||
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
const client = await this.getClientAsync();
|
||||
await client.removeTorrent(id, fromDisk);
|
||||
}
|
||||
|
||||
private async getClientAsync(dispatcher?: Dispatcher) {
|
||||
return new QBittorrent({
|
||||
baseUrl: this.url("/").toString(),
|
||||
username: this.getSecretValue("username"),
|
||||
password: this.getSecretValue("password"),
|
||||
dispatcher: dispatcher ?? (await createCertificateAgentAsync()),
|
||||
});
|
||||
}
|
||||
|
||||
private static getTorrentState(state: string): DownloadClientItem["state"] {
|
||||
switch (state) {
|
||||
case "allocating":
|
||||
case "checkingDL":
|
||||
case "downloading":
|
||||
case "forcedDL":
|
||||
case "forcedMetaDL":
|
||||
case "metaDL":
|
||||
case "queuedDL":
|
||||
case "queuedForChecking":
|
||||
return "leeching";
|
||||
case "checkingUP":
|
||||
case "forcedUP":
|
||||
case "queuedUP":
|
||||
case "uploading":
|
||||
case "stalledUP":
|
||||
return "seeding";
|
||||
case "pausedDL":
|
||||
case "pausedUP":
|
||||
return "paused";
|
||||
case "stalledDL":
|
||||
return "stalled";
|
||||
case "error":
|
||||
case "checkingResumeData":
|
||||
case "missingFiles":
|
||||
case "moving":
|
||||
case "unknown":
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import type { fetch as undiciFetch } from "undici";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
|
||||
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 { historySchema, queueSchema } from "./sabnzbd-schema";
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
export class SabnzbdIntegration extends Integration implements IDownloadClientIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
//This is the one call that uses the least amount of data while requiring the api key
|
||||
await this.sabNzbApiCallWithCustomFetchAsync(input.fetchAsync, "translate", { value: "ping" });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
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 status: DownloadClientStatus = {
|
||||
paused: queue.paused,
|
||||
rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps ()
|
||||
types: [type],
|
||||
};
|
||||
const items = queue.slots
|
||||
.map((slot): DownloadClientItem => {
|
||||
const state = SabnzbdIntegration.getUsenetQueueState(slot.status);
|
||||
const times = slot.timeleft.split(":").reverse();
|
||||
const time = dayjs
|
||||
.duration({
|
||||
seconds: Number(times[0] ?? 0),
|
||||
minutes: Number(times[1] ?? 0),
|
||||
hours: Number(times[2] ?? 0),
|
||||
days: Number(times[3] ?? 0),
|
||||
})
|
||||
.asMilliseconds();
|
||||
return {
|
||||
type,
|
||||
id: slot.nzo_id,
|
||||
index: slot.index,
|
||||
name: slot.filename,
|
||||
size: Math.ceil(parseFloat(slot.mb) * 1024 * 1024), //Actually rounded MiB
|
||||
downSpeed: slot.index > 0 ? 0 : status.rates.down,
|
||||
time,
|
||||
//added: 0, <- Only part from all integrations that is missing the timestamp (or from which it could be inferred)
|
||||
state,
|
||||
progress: parseFloat(slot.percentage) / 100,
|
||||
category: slot.cat,
|
||||
};
|
||||
})
|
||||
.concat(
|
||||
history.slots.map((slot, index): DownloadClientItem => {
|
||||
const state = SabnzbdIntegration.getUsenetHistoryState(slot.status);
|
||||
return {
|
||||
type,
|
||||
id: slot.nzo_id,
|
||||
index,
|
||||
name: slot.name,
|
||||
size: slot.bytes,
|
||||
time: slot.completed * 1000 - dayjs().valueOf(),
|
||||
added: (slot.completed - slot.download_time - slot.postproc_time) * 1000,
|
||||
state,
|
||||
progress: 1,
|
||||
category: slot.category,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.slice(0, input.limit);
|
||||
return { status, items };
|
||||
}
|
||||
|
||||
public async pauseQueueAsync() {
|
||||
await this.sabNzbApiCallAsync("pause");
|
||||
}
|
||||
|
||||
public async pauseItemAsync({ id }: DownloadClientItem) {
|
||||
await this.sabNzbApiCallAsync("queue", { name: "pause", value: id });
|
||||
}
|
||||
|
||||
public async resumeQueueAsync() {
|
||||
await this.sabNzbApiCallAsync("resume");
|
||||
}
|
||||
|
||||
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
await this.sabNzbApiCallAsync("queue", { name: "resume", value: id });
|
||||
}
|
||||
|
||||
//Delete files prevented on completed files. https://github.com/sabnzbd/sabnzbd/issues/2754
|
||||
//Works on all other in downloading and post-processing.
|
||||
//Will stop working as soon as the finished files is moved to completed folder.
|
||||
public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
await this.sabNzbApiCallAsync(progress !== 1 ? "queue" : "history", {
|
||||
name: "delete",
|
||||
archive: fromDisk ? "0" : "1",
|
||||
value: id,
|
||||
del_files: fromDisk ? "1" : "0",
|
||||
});
|
||||
}
|
||||
|
||||
private async sabNzbApiCallAsync(mode: string, searchParams?: Record<string, string>): Promise<unknown> {
|
||||
return await this.sabNzbApiCallWithCustomFetchAsync(fetchWithTrustedCertificatesAsync, mode, searchParams);
|
||||
}
|
||||
private async sabNzbApiCallWithCustomFetchAsync(
|
||||
fetchAsync: typeof undiciFetch,
|
||||
mode: string,
|
||||
searchParams?: Record<string, string>,
|
||||
): Promise<unknown> {
|
||||
const url = this.url("/api", {
|
||||
...searchParams,
|
||||
output: "json",
|
||||
mode,
|
||||
apikey: this.getSecretValue("apiKey"),
|
||||
});
|
||||
|
||||
return await fetchAsync(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error("Error communicating with SABnzbd", {
|
||||
cause: error,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static getUsenetQueueState(status: string): DownloadClientItem["state"] {
|
||||
switch (status) {
|
||||
case "Queued":
|
||||
return "queued";
|
||||
case "Paused":
|
||||
return "paused";
|
||||
default:
|
||||
return "downloading";
|
||||
}
|
||||
}
|
||||
|
||||
private static getUsenetHistoryState(status: string): DownloadClientItem["state"] {
|
||||
switch (status) {
|
||||
case "Completed":
|
||||
return "completed";
|
||||
case "Failed":
|
||||
return "failed";
|
||||
default:
|
||||
return "processing";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const queueSchema = z.object({
|
||||
queue: z.object({
|
||||
paused: z.boolean(),
|
||||
kbpersec: z.string(),
|
||||
slots: z.array(
|
||||
z.object({
|
||||
status: z.string(),
|
||||
index: z.number(),
|
||||
mb: z.string(),
|
||||
filename: z.string(),
|
||||
cat: z.string(),
|
||||
timeleft: z.string(),
|
||||
percentage: z.string(),
|
||||
nzo_id: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export const historySchema = z.object({
|
||||
history: z.object({
|
||||
slots: z.array(
|
||||
z.object({
|
||||
category: z.string(),
|
||||
download_time: z.number(),
|
||||
status: z.string(),
|
||||
completed: z.number(),
|
||||
nzo_id: z.string(),
|
||||
postproc_time: z.number(),
|
||||
name: z.string(),
|
||||
bytes: z.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Transmission } from "@ctrl/transmission";
|
||||
import dayjs from "dayjs";
|
||||
import type { Dispatcher } from "undici";
|
||||
|
||||
import { createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { HandleIntegrationErrors } from "../../base/errors/decorator";
|
||||
import { integrationOFetchHttpErrorHandler } from "../../base/errors/http";
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { IntegrationTestingInput } from "../../base/integration";
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
|
||||
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";
|
||||
|
||||
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
|
||||
export class TransmissionIntegration extends Integration implements IDownloadClientIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const client = await this.getClientAsync(input.dispatcher);
|
||||
await client.getSession();
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||
const type = "torrent";
|
||||
const client = await this.getClientAsync();
|
||||
// Currently there is no way to limit the number of returned torrents
|
||||
const { torrents } = (await client.listTorrents()).arguments;
|
||||
const rates = torrents.reduce(
|
||||
({ down, up }, { rateDownload, rateUpload }) => ({ down: down + rateDownload, up: up + rateUpload }),
|
||||
{ down: 0, up: 0 },
|
||||
);
|
||||
const paused =
|
||||
torrents.find(({ status }) => TransmissionIntegration.getTorrentState(status) !== "paused") === undefined;
|
||||
const status: DownloadClientStatus = { paused, rates, types: [type] };
|
||||
const items = torrents
|
||||
.map((torrent): DownloadClientItem => {
|
||||
const state = TransmissionIntegration.getTorrentState(torrent.status);
|
||||
return {
|
||||
type,
|
||||
id: torrent.hashString,
|
||||
index: torrent.queuePosition,
|
||||
name: torrent.name,
|
||||
size: torrent.totalSize,
|
||||
sent: torrent.uploadedEver,
|
||||
received: torrent.downloadedEver,
|
||||
downSpeed: torrent.percentDone !== 1 ? torrent.rateDownload : undefined,
|
||||
upSpeed: torrent.rateUpload,
|
||||
time:
|
||||
torrent.percentDone === 1
|
||||
? Math.min(torrent.doneDate * 1000 - dayjs().valueOf(), -1)
|
||||
: Math.max(torrent.eta * 1000, 0),
|
||||
added: torrent.addedDate * 1000,
|
||||
state,
|
||||
progress: torrent.percentDone,
|
||||
category: torrent.labels,
|
||||
};
|
||||
})
|
||||
.slice(0, input.limit);
|
||||
return { status, items };
|
||||
}
|
||||
|
||||
public async pauseQueueAsync() {
|
||||
const client = await this.getClientAsync();
|
||||
const ids = (await client.listTorrents()).arguments.torrents.map(({ hashString }) => hashString);
|
||||
await client.pauseTorrent(ids);
|
||||
}
|
||||
|
||||
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
const client = await this.getClientAsync();
|
||||
await client.pauseTorrent(id);
|
||||
}
|
||||
|
||||
public async resumeQueueAsync() {
|
||||
const client = await this.getClientAsync();
|
||||
const ids = (await client.listTorrents()).arguments.torrents.map(({ hashString }) => hashString);
|
||||
await client.resumeTorrent(ids);
|
||||
}
|
||||
|
||||
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
const client = await this.getClientAsync();
|
||||
await client.resumeTorrent(id);
|
||||
}
|
||||
|
||||
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
const client = await this.getClientAsync();
|
||||
await client.removeTorrent(id, fromDisk);
|
||||
}
|
||||
|
||||
private async getClientAsync(dispatcher?: Dispatcher) {
|
||||
return new Transmission({
|
||||
baseUrl: this.url("/").toString(),
|
||||
username: this.getSecretValue("username"),
|
||||
password: this.getSecretValue("password"),
|
||||
dispatcher: dispatcher ?? (await createCertificateAgentAsync()),
|
||||
});
|
||||
}
|
||||
|
||||
private static getTorrentState(status: number): DownloadClientItem["state"] {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return "paused";
|
||||
case 1:
|
||||
case 3:
|
||||
return "stalled";
|
||||
case 2:
|
||||
case 4:
|
||||
return "leeching";
|
||||
case 5:
|
||||
case 6:
|
||||
return "seeding";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
222
packages/integrations/src/emby/emby-integration.ts
Normal file
222
packages/integrations/src/emby/emby-integration.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
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 { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
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 type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types";
|
||||
|
||||
const sessionSchema = z.object({
|
||||
NowPlayingItem: z
|
||||
.object({
|
||||
Type: z.nativeEnum(BaseItemKind).optional(),
|
||||
SeriesName: z.string().nullish(),
|
||||
Name: z.string().nullish(),
|
||||
SeasonName: z.string().nullish(),
|
||||
EpisodeTitle: z.string().nullish(),
|
||||
Album: z.string().nullish(),
|
||||
EpisodeCount: z.number().nullish(),
|
||||
})
|
||||
.optional(),
|
||||
Id: z.string(),
|
||||
Client: z.string().nullish(),
|
||||
DeviceId: z.string().nullish(),
|
||||
DeviceName: z.string().nullish(),
|
||||
UserId: z.string().optional(),
|
||||
UserName: z.string().nullish(),
|
||||
});
|
||||
|
||||
const itemSchema = z.object({
|
||||
Id: z.string(),
|
||||
ServerId: z.string(),
|
||||
Name: z.string(),
|
||||
Taglines: z.array(z.string()),
|
||||
Studios: z.array(z.object({ Name: z.string() })),
|
||||
Overview: z.string().optional(),
|
||||
PremiereDate: z
|
||||
.string()
|
||||
.datetime()
|
||||
.transform((date) => new Date(date))
|
||||
.optional(),
|
||||
DateCreated: z
|
||||
.string()
|
||||
.datetime()
|
||||
.transform((date) => new Date(date)),
|
||||
Genres: z.array(z.string()),
|
||||
CommunityRating: z.number().optional(),
|
||||
RunTimeTicks: z.number(),
|
||||
Type: z.string(), // for example "Movie"
|
||||
});
|
||||
|
||||
const userSchema = z.object({
|
||||
Id: z.string(),
|
||||
Name: z.string(),
|
||||
});
|
||||
|
||||
export class EmbyIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||
private static readonly apiKeyHeader = "X-Emby-Token";
|
||||
private static readonly deviceId = "homarr-emby-integration";
|
||||
private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`;
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const response = await input.fetchAsync(super.url("/emby/System/Ping"), {
|
||||
headers: {
|
||||
[EmbyIntegration.apiKeyHeader]: apiKey,
|
||||
Authorization: EmbyIntegration.authorizationHeaderValue,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const response = await fetchWithTrustedCertificatesAsync(super.url("/emby/Sessions"), {
|
||||
headers: {
|
||||
[EmbyIntegration.apiKeyHeader]: apiKey,
|
||||
Authorization: EmbyIntegration.authorizationHeaderValue,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Emby server ${this.integration.id} returned a non successful status code: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = z.array(sessionSchema).safeParse(await response.json());
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Emby server ${this.integration.id} returned an unexpected response: ${result.error.message}`);
|
||||
}
|
||||
|
||||
return result.data
|
||||
.filter((sessionInfo) => sessionInfo.UserId !== undefined)
|
||||
.filter((sessionInfo) => sessionInfo.DeviceId !== EmbyIntegration.deviceId)
|
||||
.filter((sessionInfo) => !options.showOnlyPlaying || sessionInfo.NowPlayingItem !== undefined)
|
||||
.map((sessionInfo): StreamSession => {
|
||||
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
|
||||
|
||||
if (sessionInfo.NowPlayingItem) {
|
||||
currentlyPlaying = {
|
||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
metadata: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: `${sessionInfo.Id}`,
|
||||
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
|
||||
user: {
|
||||
profilePictureUrl: super.externalUrl(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
|
||||
userId: sessionInfo.UserId ?? "",
|
||||
username: sessionInfo.UserName ?? "",
|
||||
},
|
||||
currentlyPlaying,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
|
||||
const limit = 100;
|
||||
const users = await this.fetchUsersPublicAsync();
|
||||
const userId = users.at(0)?.id;
|
||||
if (!userId) {
|
||||
throw new Error("No users found");
|
||||
}
|
||||
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
super.url(
|
||||
`/Users/${userId}/Items/Latest?Limit=${limit}&Fields=CommunityRating,Studios,PremiereDate,Genres,ChildCount,ProductionYear,DateCreated,Overview,Taglines`,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
[EmbyIntegration.apiKeyHeader]: apiKey,
|
||||
Authorization: EmbyIntegration.authorizationHeaderValue,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const items = z.array(itemSchema).parse(await response.json());
|
||||
|
||||
return items.map((item) => ({
|
||||
id: item.Id,
|
||||
type: this.mapMediaReleaseType(item.Type),
|
||||
title: item.Name,
|
||||
subtitle: item.Taglines.at(0),
|
||||
description: item.Overview,
|
||||
releaseDate: item.PremiereDate ?? item.DateCreated,
|
||||
imageUrls: {
|
||||
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
||||
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
||||
},
|
||||
producer: item.Studios.at(0)?.Name,
|
||||
rating: item.CommunityRating?.toFixed(1),
|
||||
tags: item.Genres,
|
||||
href: super.externalUrl(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
private mapMediaReleaseType(type: string | undefined): MediaType {
|
||||
switch (type) {
|
||||
case "Audio":
|
||||
case "AudioBook":
|
||||
case "MusicAlbum":
|
||||
return "music";
|
||||
case "Book":
|
||||
return "book";
|
||||
case "Episode":
|
||||
case "Series":
|
||||
case "Season":
|
||||
return "tv";
|
||||
case "Movie":
|
||||
return "movie";
|
||||
case "Video":
|
||||
return "video";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// https://dev.emby.media/reference/RestAPI/UserService/getUsersPublic.html
|
||||
private async fetchUsersPublicAsync(): Promise<{ id: string; name: string }[]> {
|
||||
const apiKey = super.getSecretValue("apiKey");
|
||||
const response = await fetchWithTrustedCertificatesAsync(super.url("/Users/Public"), {
|
||||
headers: {
|
||||
[EmbyIntegration.apiKeyHeader]: apiKey,
|
||||
Authorization: EmbyIntegration.authorizationHeaderValue,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
const users = z.array(userSchema).parse(await response.json());
|
||||
|
||||
return users.map((user) => ({
|
||||
id: user.Id,
|
||||
name: user.Name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { createAppAuth } from "@octokit/auth-app";
|
||||
import { Octokit, RequestError } from "octokit";
|
||||
import type { fetch } from "undici";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { HandleIntegrationErrors } from "../base/errors/decorator";
|
||||
import { integrationOctokitHttpErrorHandler } from "../base/errors/http";
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import type {
|
||||
DetailsProviderResponse,
|
||||
ReleaseProviderResponse,
|
||||
ReleaseResponse,
|
||||
} from "../interfaces/releases-providers/releases-providers-types";
|
||||
|
||||
const logger = createLogger({ module: "githubContainerRegistryIntegration" });
|
||||
|
||||
@HandleIntegrationErrors([integrationOctokitHttpErrorHandler])
|
||||
export class GitHubContainerRegistryIntegration extends Integration implements ReleasesProviderIntegration {
|
||||
private static readonly userAgent = "Homarr-Lab/Homarr:GitHubContainerRegistryIntegration";
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const api = this.getApi(input.fetchAsync);
|
||||
|
||||
if (this.hasSecretValue("personalAccessToken")) {
|
||||
await api.rest.users.getAuthenticated();
|
||||
} else if (this.hasSecretValue("githubAppId")) {
|
||||
await api.rest.apps.getInstallation({
|
||||
installation_id: Number(this.getSecretValue("githubInstallationId")),
|
||||
});
|
||||
} else {
|
||||
await api.request("GET /octocat");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private parseIdentifier(identifier: string) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
logger.warn("Invalid identifier format. Expected 'owner/name', for identifier", { identifier });
|
||||
return null;
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
|
||||
const parsedIdentifier = this.parseIdentifier(identifier);
|
||||
if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
|
||||
|
||||
const { owner, name } = parsedIdentifier;
|
||||
const api = this.getApi();
|
||||
|
||||
try {
|
||||
const releasesResponse = await api.rest.packages.getAllPackageVersionsForPackageOwnedByUser({
|
||||
username: owner,
|
||||
package_type: "container",
|
||||
package_name: name,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const releasesProviderResponse = releasesResponse.data.reduce<ReleaseProviderResponse[]>((acc, release) => {
|
||||
if (!release.metadata?.container?.tags || !(release.metadata.container.tags.length > 0)) return acc;
|
||||
|
||||
release.metadata.container.tags.forEach((tag) => {
|
||||
acc.push({
|
||||
latestRelease: tag,
|
||||
latestReleaseAt: new Date(release.updated_at),
|
||||
releaseUrl: release.html_url,
|
||||
releaseDescription: release.description ?? undefined,
|
||||
});
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex);
|
||||
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
|
||||
|
||||
const details = await this.getDetailsAsync(api, owner, name);
|
||||
|
||||
return { success: true, data: { ...details, ...latestRelease } };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof RequestError ? error.message : String(error);
|
||||
logger.warn("Failed to get releases", {
|
||||
owner,
|
||||
name,
|
||||
error: errorMessage,
|
||||
});
|
||||
return { success: false, error: { code: "unexpected", message: errorMessage } };
|
||||
}
|
||||
}
|
||||
|
||||
protected async getDetailsAsync(
|
||||
api: Octokit,
|
||||
owner: string,
|
||||
name: string,
|
||||
): Promise<DetailsProviderResponse | undefined> {
|
||||
try {
|
||||
const response = await api.rest.packages.getPackageForUser({
|
||||
username: owner,
|
||||
package_type: "container",
|
||||
package_name: name,
|
||||
});
|
||||
|
||||
return {
|
||||
projectUrl: response.data.repository?.html_url ?? response.data.html_url,
|
||||
projectDescription: response.data.repository?.description ?? undefined,
|
||||
isFork: response.data.repository?.fork,
|
||||
isArchived: response.data.repository?.archived,
|
||||
createdAt: new Date(response.data.created_at),
|
||||
starsCount: response.data.repository?.stargazers_count,
|
||||
openIssues: response.data.repository?.open_issues_count,
|
||||
forksCount: response.data.repository?.forks_count,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn("Failed to get details", {
|
||||
owner,
|
||||
name,
|
||||
error: error instanceof RequestError ? error.message : String(error),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getAuthProperties(): Pick<OctokitOptions, "auth" | "authStrategy"> {
|
||||
if (this.hasSecretValue("personalAccessToken"))
|
||||
return {
|
||||
auth: this.getSecretValue("personalAccessToken"),
|
||||
};
|
||||
|
||||
if (this.hasSecretValue("githubAppId"))
|
||||
return {
|
||||
authStrategy: createAppAuth,
|
||||
auth: {
|
||||
appId: this.getSecretValue("githubAppId"),
|
||||
installationId: this.getSecretValue("githubInstallationId"),
|
||||
privateKey: this.getSecretValue("privateKey"),
|
||||
} satisfies Parameters<typeof createAppAuth>[0],
|
||||
};
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private getApi(customFetch?: typeof fetch) {
|
||||
return new Octokit({
|
||||
baseUrl: this.url("/").origin,
|
||||
request: {
|
||||
fetch: customFetch ?? fetchWithTrustedCertificatesAsync,
|
||||
},
|
||||
userAgent: GitHubContainerRegistryIntegration.userAgent,
|
||||
// Disable throttling for this integration, Octokit will retry by default after a set time,
|
||||
// thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
|
||||
throttle: { enabled: false },
|
||||
...this.getAuthProperties(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type OctokitOptions = Exclude<ConstructorParameters<typeof Octokit>[0], undefined>;
|
||||
168
packages/integrations/src/github/github-integration.ts
Normal file
168
packages/integrations/src/github/github-integration.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { createAppAuth } from "@octokit/auth-app";
|
||||
import { Octokit, RequestError as OctokitRequestError } from "octokit";
|
||||
import type { fetch } from "undici";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { HandleIntegrationErrors } from "../base/errors/decorator";
|
||||
import { integrationOctokitHttpErrorHandler } from "../base/errors/http";
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import type {
|
||||
DetailsProviderResponse,
|
||||
ReleaseProviderResponse,
|
||||
ReleaseResponse,
|
||||
} from "../interfaces/releases-providers/releases-providers-types";
|
||||
|
||||
const logger = createLogger({ module: "githubIntegration" });
|
||||
|
||||
@HandleIntegrationErrors([integrationOctokitHttpErrorHandler])
|
||||
export class GithubIntegration extends Integration implements ReleasesProviderIntegration {
|
||||
private static readonly userAgent = "Homarr-Lab/Homarr:GithubIntegration";
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const api = this.getApi(input.fetchAsync);
|
||||
|
||||
if (this.hasSecretValue("personalAccessToken")) {
|
||||
await api.rest.users.getAuthenticated();
|
||||
} else if (this.hasSecretValue("githubAppId")) {
|
||||
await api.rest.apps.getInstallation({
|
||||
installation_id: Number(this.getSecretValue("githubInstallationId")),
|
||||
});
|
||||
} else {
|
||||
await api.request("GET /octocat");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private parseIdentifier(identifier: string) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
logger.warn("Invalid identifier format. Expected 'owner/name' for identifier", {
|
||||
identifier,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
|
||||
const parsedIdentifier = this.parseIdentifier(identifier);
|
||||
if (!parsedIdentifier) return { success: false, error: { code: "invalidIdentifier" } };
|
||||
|
||||
const { owner, name } = parsedIdentifier;
|
||||
const api = this.getApi();
|
||||
|
||||
try {
|
||||
const releasesResponse = await api.rest.repos.listReleases({ owner, repo: name });
|
||||
|
||||
if (releasesResponse.data.length === 0) {
|
||||
logger.warn("No releases found", {
|
||||
identifier: `${owner}/${name}`,
|
||||
});
|
||||
return { success: false, error: { code: "noMatchingVersion" } };
|
||||
}
|
||||
|
||||
const releasesProviderResponse = releasesResponse.data.reduce<ReleaseProviderResponse[]>((acc, release) => {
|
||||
if (!release.published_at) return acc;
|
||||
|
||||
acc.push({
|
||||
latestRelease: release.tag_name,
|
||||
latestReleaseAt: new Date(release.published_at),
|
||||
releaseUrl: release.html_url,
|
||||
releaseDescription: release.body ?? undefined,
|
||||
isPreRelease: release.prerelease,
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex);
|
||||
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
|
||||
|
||||
const details = await this.getDetailsAsync(api, owner, name);
|
||||
|
||||
return { success: true, data: { ...details, ...latestRelease } };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof OctokitRequestError ? error.message : String(error);
|
||||
logger.warn("Failed to get releases", {
|
||||
owner,
|
||||
name,
|
||||
error: errorMessage,
|
||||
});
|
||||
return { success: false, error: { code: "unexpected", message: errorMessage } };
|
||||
}
|
||||
}
|
||||
|
||||
protected async getDetailsAsync(
|
||||
api: Octokit,
|
||||
owner: string,
|
||||
name: string,
|
||||
): Promise<DetailsProviderResponse | undefined> {
|
||||
try {
|
||||
const response = await api.rest.repos.get({
|
||||
owner,
|
||||
repo: name,
|
||||
});
|
||||
|
||||
return {
|
||||
projectUrl: response.data.html_url,
|
||||
projectDescription: response.data.description ?? undefined,
|
||||
isFork: response.data.fork,
|
||||
isArchived: response.data.archived,
|
||||
createdAt: new Date(response.data.created_at),
|
||||
starsCount: response.data.stargazers_count,
|
||||
openIssues: response.data.open_issues_count,
|
||||
forksCount: response.data.forks_count,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn("Failed to get details", {
|
||||
owner,
|
||||
name,
|
||||
error: error instanceof OctokitRequestError ? error.message : String(error),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getAuthProperties(): Pick<OctokitOptions, "auth" | "authStrategy"> {
|
||||
if (this.hasSecretValue("personalAccessToken"))
|
||||
return {
|
||||
auth: this.getSecretValue("personalAccessToken"),
|
||||
};
|
||||
|
||||
if (this.hasSecretValue("githubAppId"))
|
||||
return {
|
||||
authStrategy: createAppAuth,
|
||||
auth: {
|
||||
appId: this.getSecretValue("githubAppId"),
|
||||
installationId: this.getSecretValue("githubInstallationId"),
|
||||
privateKey: this.getSecretValue("privateKey"),
|
||||
} satisfies Parameters<typeof createAppAuth>[0],
|
||||
};
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private getApi(customFetch?: typeof fetch) {
|
||||
return new Octokit({
|
||||
baseUrl: this.url("/").origin,
|
||||
request: {
|
||||
fetch: customFetch ?? fetchWithTrustedCertificatesAsync,
|
||||
},
|
||||
userAgent: GithubIntegration.userAgent,
|
||||
// Disable throttling for this integration, Octokit will retry by default after a set time,
|
||||
// thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
|
||||
throttle: { enabled: false },
|
||||
...this.getAuthProperties(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type OctokitOptions = Exclude<ConstructorParameters<typeof Octokit>[0], undefined>;
|
||||
155
packages/integrations/src/gitlab/gitlab-integration.ts
Normal file
155
packages/integrations/src/gitlab/gitlab-integration.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { Gitlab as CoreGitlab } from "@gitbeaker/core";
|
||||
import { createRequesterFn, defaultOptionsHandler } from "@gitbeaker/requester-utils";
|
||||
import type { FormattedResponse, RequestOptions, ResourceOptions } from "@gitbeaker/requester-utils";
|
||||
import { Gitlab } from "@gitbeaker/rest";
|
||||
|
||||
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 { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import type {
|
||||
DetailsProviderResponse,
|
||||
ReleaseProviderResponse,
|
||||
ReleaseResponse,
|
||||
} from "../interfaces/releases-providers/releases-providers-types";
|
||||
|
||||
const logger = createLogger({ module: "gitlabIntegration" });
|
||||
|
||||
export class GitlabIntegration extends Integration implements ReleasesProviderIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/v4/projects"), {
|
||||
headers: {
|
||||
...(this.hasSecretValue("personalAccessToken")
|
||||
? { Authorization: `Bearer ${this.getSecretValue("personalAccessToken")}` }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
public async getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse> {
|
||||
const api = this.getApi();
|
||||
|
||||
try {
|
||||
const releasesResponse = await api.ProjectReleases.all(identifier, {
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
if (releasesResponse instanceof Error) {
|
||||
logger.warn("No releases found", {
|
||||
identifier,
|
||||
error: releasesResponse.message,
|
||||
});
|
||||
return { success: false, error: { code: "noReleasesFound" } };
|
||||
}
|
||||
|
||||
const releasesProviderResponse = releasesResponse.reduce<ReleaseProviderResponse[]>((acc, release) => {
|
||||
if (!release.released_at) return acc;
|
||||
|
||||
const releaseDate = new Date(release.released_at);
|
||||
|
||||
acc.push({
|
||||
latestRelease: release.name ?? release.tag_name,
|
||||
latestReleaseAt: releaseDate,
|
||||
releaseUrl: release._links.self,
|
||||
releaseDescription: release.description ?? undefined,
|
||||
isPreRelease: releaseDate > new Date(), // For upcoming releases the `released_at` will be set to the future (https://docs.gitlab.com/api/releases/#upcoming-releases). Gitbreaker doesn't currently support the `upcoming_release` field (https://github.com/jdalrymple/gitbeaker/issues/3730)
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const latestRelease = getLatestRelease(releasesProviderResponse, versionRegex);
|
||||
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
|
||||
|
||||
const details = await this.getDetailsAsync(api, identifier);
|
||||
|
||||
return { success: true, data: { ...details, ...latestRelease } };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn("Failed to get releases", {
|
||||
identifier,
|
||||
error: errorMessage,
|
||||
});
|
||||
return { success: false, error: { code: "unexpected", message: errorMessage } };
|
||||
}
|
||||
}
|
||||
|
||||
protected async getDetailsAsync(api: CoreGitlab, identifier: string): Promise<DetailsProviderResponse | undefined> {
|
||||
try {
|
||||
const response = await api.Projects.show(identifier);
|
||||
|
||||
if (response instanceof Error) {
|
||||
logger.warn("Failed to get details", {
|
||||
identifier,
|
||||
error: response.message,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!response.web_url) {
|
||||
logger.warn("No web URL found", {
|
||||
identifier,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
projectUrl: response.web_url,
|
||||
projectDescription: response.description ?? undefined,
|
||||
isFork: response.forked_from_project !== null,
|
||||
isArchived: response.archived,
|
||||
createdAt: new Date(response.created_at),
|
||||
starsCount: response.star_count,
|
||||
openIssues: response.open_issues_count,
|
||||
forksCount: response.forks_count,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn("Failed to get details", {
|
||||
identifier,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getApi() {
|
||||
return new Gitlab({
|
||||
host: this.url("/").origin,
|
||||
requesterFn: createRequesterFn(
|
||||
async (serviceOptions: ResourceOptions, _: RequestOptions) => await defaultOptionsHandler(serviceOptions),
|
||||
async (endpoint: string, options?: Record<string, unknown>): Promise<FormattedResponse> => {
|
||||
if (options === undefined) {
|
||||
throw new Error("Gitlab library is not configured correctly. Options must be provided.");
|
||||
}
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
`${options.prefixUrl as string}${endpoint}`,
|
||||
options,
|
||||
);
|
||||
const headers = Object.fromEntries(response.headers.entries());
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
headers,
|
||||
body: await response.json(),
|
||||
} as FormattedResponse;
|
||||
},
|
||||
),
|
||||
...(this.hasSecretValue("personalAccessToken") ? { token: this.getSecretValue("personalAccessToken") } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import z from "zod";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
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 { ICalendarIntegration } from "../interfaces/calendar/calendar-integration";
|
||||
import type { ISmartHomeIntegration } from "../interfaces/smart-home/smart-home-integration";
|
||||
import type { CalendarEvent } from "../types";
|
||||
import { calendarEventSchema, calendarsSchema, entityStateSchema } from "./homeassistant-types";
|
||||
|
||||
const logger = createLogger({ module: "homeAssistantIntegration" });
|
||||
|
||||
export class HomeAssistantIntegration extends Integration implements ISmartHomeIntegration, ICalendarIntegration {
|
||||
public async getEntityStateAsync(entityId: string) {
|
||||
try {
|
||||
const response = await this.getAsync(`/api/states/${entityId}`);
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
logger.warn("Response did not indicate success");
|
||||
return {
|
||||
success: false as const,
|
||||
error: "Response did not indicate success",
|
||||
};
|
||||
}
|
||||
return entityStateSchema.safeParseAsync(body);
|
||||
} catch (err) {
|
||||
logger.error(new ErrorWithMetadata("Failed to fetch entity state", { entityId }, { cause: err }));
|
||||
return {
|
||||
success: false as const,
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async triggerAutomationAsync(entityId: string) {
|
||||
try {
|
||||
const response = await this.postAsync("/api/services/automation/trigger", {
|
||||
entity_id: entityId,
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (err) {
|
||||
logger.error(new ErrorWithMetadata("Failed to trigger automation", { entityId }, { cause: err }));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a toggle action for a specific entity.
|
||||
*
|
||||
* @param entityId - The ID of the entity to toggle.
|
||||
* @returns A boolean indicating whether the toggle action was successful.
|
||||
*/
|
||||
public async triggerToggleAsync(entityId: string) {
|
||||
try {
|
||||
const response = await this.postAsync("/api/services/homeassistant/toggle", {
|
||||
entity_id: entityId,
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (err) {
|
||||
logger.error(new ErrorWithMetadata("Failed to toggle entity", { entityId }, { cause: err }));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async getCalendarEventsAsync(start: Date, end: Date): Promise<CalendarEvent[]> {
|
||||
const calendarsResponse = await this.getAsync("/api/calendars");
|
||||
if (!calendarsResponse.ok) throw new ResponseError(calendarsResponse);
|
||||
const calendars = await calendarsSchema.parseAsync(await calendarsResponse.json());
|
||||
|
||||
return await Promise.all(
|
||||
calendars.map(async (calendar) => {
|
||||
const response = await this.getAsync(`/api/calendars/${calendar.entity_id}`, { start, end });
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
return await z.array(calendarEventSchema).parseAsync(await response.json());
|
||||
}),
|
||||
).then((events) =>
|
||||
events.flat().map(
|
||||
(event): CalendarEvent => ({
|
||||
title: event.summary,
|
||||
subTitle: null,
|
||||
description: event.description,
|
||||
// If not reseting it to 0 o'clock it uses utc time and therefore shows as 2 o'clock
|
||||
startDate: "date" in event.start ? new Date(`${event.start.date}T00:00:00`) : new Date(event.start.dateTime),
|
||||
endDate: "date" in event.end ? new Date(`${event.end.date}T00:00:00`) : new Date(event.end.dateTime),
|
||||
image: null,
|
||||
indicatorColor: "#18bcf2",
|
||||
links: [],
|
||||
location: event.location,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/config"), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a GET request to the Home Assistant API.
|
||||
* It includes the authorization header with the API key.
|
||||
* @param path full path to the API endpoint
|
||||
* @returns the response from the API
|
||||
*/
|
||||
private async getAsync(path: `/api/${string}`, queryParams?: Record<string, string | Date | number | boolean>) {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url(path, queryParams), {
|
||||
headers: this.getAuthHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a POST request to the Home Assistant API.
|
||||
* It includes the authorization header with the API key.
|
||||
* @param path full path to the API endpoint
|
||||
* @param body the body of the request
|
||||
* @returns the response from the API
|
||||
*/
|
||||
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
|
||||
return await fetchWithTrustedCertificatesAsync(this.url(path), {
|
||||
headers: this.getAuthHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the headers required for authorization.
|
||||
* @returns the authorization headers
|
||||
*/
|
||||
private getAuthHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const entityStateSchema = z.object({
|
||||
attributes: z.record(
|
||||
z.string(),
|
||||
z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))]),
|
||||
),
|
||||
entity_id: z.string(),
|
||||
last_changed: z.string().pipe(z.coerce.date()),
|
||||
last_updated: z.string().pipe(z.coerce.date()),
|
||||
state: z.string(),
|
||||
});
|
||||
|
||||
export type EntityState = z.infer<typeof entityStateSchema>;
|
||||
|
||||
export const calendarsSchema = z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
entity_id: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
const calendarMomentSchema = z
|
||||
.object({
|
||||
date: z.string(),
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
dateTime: z.string(),
|
||||
}),
|
||||
);
|
||||
export const calendarEventSchema = z.object({
|
||||
start: calendarMomentSchema,
|
||||
end: calendarMomentSchema,
|
||||
summary: z.string(),
|
||||
description: z.string().nullable(),
|
||||
location: z.string().nullable(),
|
||||
});
|
||||
67
packages/integrations/src/ical/ical-integration.ts
Normal file
67
packages/integrations/src/ical/ical-integration.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import ICAL from "ical.js";
|
||||
|
||||
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 { ICalendarIntegration } from "../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent } from "../interfaces/calendar/calendar-types";
|
||||
|
||||
export class ICalIntegration extends Integration implements ICalendarIntegration {
|
||||
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();
|
||||
|
||||
if (startDate > end) return prev;
|
||||
if (endDate < start) return prev;
|
||||
|
||||
return prev.concat({
|
||||
title: event.summary,
|
||||
subTitle: null,
|
||||
description: event.description,
|
||||
startDate,
|
||||
endDate,
|
||||
image: null,
|
||||
location: event.location,
|
||||
indicatorColor: "red",
|
||||
links: [],
|
||||
});
|
||||
}, [] as CalendarEvent[]);
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(super.getSecretValue("url"));
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
const result = await response.text();
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const jcal = ICAL.parse(result);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const comp = new ICAL.Component(jcal);
|
||||
return comp.getAllSubcomponents("vevent").length > 0
|
||||
? { success: true }
|
||||
: TestConnectionError.ParseResult({
|
||||
name: "Calendar parse error",
|
||||
message: "No events found",
|
||||
cause: new Error("No events found"),
|
||||
});
|
||||
} catch (error) {
|
||||
return TestConnectionError.ParseResult({
|
||||
name: "Calendar parse error",
|
||||
message: "Failed to parse calendar",
|
||||
cause: error as Error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
58
packages/integrations/src/index.ts
Normal file
58
packages/integrations/src/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// General integrations
|
||||
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
||||
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
|
||||
export { DelugeIntegration } from "./download-client/deluge/deluge-integration";
|
||||
export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
|
||||
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
|
||||
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
|
||||
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
|
||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
||||
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-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";
|
||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||
export { NextcloudIntegration } from "./nextcloud/nextcloud.integration";
|
||||
export { NTFYIntegration } from "./ntfy/ntfy-integration";
|
||||
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
|
||||
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
|
||||
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 { TrueNasIntegration } from "./truenas/truenas-integration";
|
||||
export { UnraidIntegration } from "./unraid/unraid-integration";
|
||||
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
|
||||
export { ICalIntegration } from "./ical/ical-integration";
|
||||
|
||||
// Types
|
||||
export type { IntegrationInput } from "./base/integration";
|
||||
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
|
||||
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
|
||||
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
|
||||
export type {
|
||||
FirewallInterface,
|
||||
FirewallCpuSummary,
|
||||
FirewallInterfacesSummary,
|
||||
FirewallVersionSummary,
|
||||
FirewallMemorySummary,
|
||||
} from "./interfaces/firewall-summary/firewall-summary-types";
|
||||
export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types";
|
||||
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 {
|
||||
TdarrQueue,
|
||||
TdarrPieSegment,
|
||||
TdarrStatistics,
|
||||
TdarrWorker,
|
||||
} from "./interfaces/media-transcoding/media-transcoding-types";
|
||||
export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types";
|
||||
export type { Notification } from "./interfaces/notifications/notification-types";
|
||||
|
||||
// Schemas
|
||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||
|
||||
// Helpers
|
||||
export { createIntegrationAsync } from "./base/creator";
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { CalendarEvent } from "./calendar-types";
|
||||
|
||||
export interface ICalendarIntegration {
|
||||
getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored: boolean): Promise<CalendarEvent[]>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const;
|
||||
export type RadarrReleaseType = (typeof radarrReleaseTypes)[number];
|
||||
|
||||
export interface RadarrMetadata {
|
||||
type: "radarr";
|
||||
releaseType: RadarrReleaseType;
|
||||
}
|
||||
|
||||
export type CalendarMetadata = RadarrMetadata;
|
||||
|
||||
export interface CalendarLink {
|
||||
name: string;
|
||||
isDark: boolean;
|
||||
href: string;
|
||||
color?: string;
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
export interface CalendarImageBadge {
|
||||
content: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface CalendarImage {
|
||||
src: string;
|
||||
badge?: CalendarImageBadge;
|
||||
aspectRatio?: { width: number; height: number };
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
title: string;
|
||||
subTitle: string | null;
|
||||
description: string | null;
|
||||
startDate: Date;
|
||||
endDate: Date | null;
|
||||
image: CalendarImage | null;
|
||||
location: string | null;
|
||||
metadata?: CalendarMetadata;
|
||||
indicatorColor: string;
|
||||
links: CalendarLink[];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { DnsHoleSummary } from "./dns-hole-summary-types";
|
||||
|
||||
export interface DnsHoleSummaryIntegration {
|
||||
getSummaryAsync(): Promise<DnsHoleSummary>;
|
||||
enableAsync(): Promise<void>;
|
||||
disableAsync(duration?: number): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface DnsHoleSummary {
|
||||
status?: "enabled" | "disabled";
|
||||
domainsBeingBlocked: number;
|
||||
adsBlockedToday: number;
|
||||
adsBlockedTodayPercentage: number;
|
||||
dnsQueriesToday: number;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { DownloadClientItem } from "./download-client-items";
|
||||
import type { DownloadClientStatus } from "./download-client-status";
|
||||
|
||||
export interface DownloadClientJobsAndStatus {
|
||||
status: DownloadClientStatus;
|
||||
items: DownloadClientItem[];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { DownloadClientJobsAndStatus } from "./download-client-data";
|
||||
import type { DownloadClientItem } from "./download-client-items";
|
||||
|
||||
export interface IDownloadClientIntegration {
|
||||
/** Get download client's status and list of all of it's items */
|
||||
getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus>;
|
||||
/** Pauses the client or all of it's items */
|
||||
pauseQueueAsync(): Promise<void>;
|
||||
/** Pause a single item using it's ID */
|
||||
pauseItemAsync(item: DownloadClientItem): Promise<void>;
|
||||
/** Resumes the client or all of it's items */
|
||||
resumeQueueAsync(): Promise<void>;
|
||||
/** Resume a single item using it's ID */
|
||||
resumeItemAsync(item: DownloadClientItem): Promise<void>;
|
||||
/** Delete an entry on the client or a file from disk */
|
||||
deleteItemAsync(item: DownloadClientItem, fromDisk: boolean): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
|
||||
const usenetQueueState = ["downloading", "queued", "paused"] as const;
|
||||
const usenetHistoryState = ["completed", "failed", "processing"] as const;
|
||||
const torrentState = ["leeching", "stalled", "paused", "seeding"] as const;
|
||||
|
||||
/**
|
||||
* DownloadClientItem
|
||||
* Description:
|
||||
* Normalized interface for downloading clients for Usenet and
|
||||
* Torrents alike, using common properties and few extra optionals
|
||||
* from each.
|
||||
*/
|
||||
export const downloadClientItemSchema = z.object({
|
||||
/** Unique Identifier provided by client */
|
||||
id: z.string(),
|
||||
/** Position in queue */
|
||||
index: z.number(),
|
||||
/** Filename */
|
||||
name: z.string(),
|
||||
/** Download Client identifier */
|
||||
type: z.enum(["torrent", "usenet", "miscellaneous"]),
|
||||
/** Item size in Bytes */
|
||||
size: z.number(),
|
||||
/** Total uploaded in Bytes, only required for Torrent items */
|
||||
sent: z.number().optional(),
|
||||
/** Total downloaded in Bytes, only required for Torrent items */
|
||||
received: z.number().optional(),
|
||||
/** Download speed in Bytes/s, only required if not complete
|
||||
* (Says 0 only if it should be downloading but isn't) */
|
||||
downSpeed: z.number().optional(),
|
||||
/** Upload speed in Bytes/s, only required for Torrent items */
|
||||
upSpeed: z.number().optional(),
|
||||
/** Positive = eta (until completion, 0 meaning infinite), Negative = time since completion, in milliseconds*/
|
||||
time: z.number(),
|
||||
/** Unix timestamp in milliseconds when the item was added to the client */
|
||||
added: z.number().optional(),
|
||||
/** Status message, mostly as information to display and not for logic */
|
||||
state: z.enum(["unknown", ...usenetQueueState, ...usenetHistoryState, ...torrentState]),
|
||||
/** Progress expressed between 0 and 1, can infer completion from progress === 1 */
|
||||
progress: z.number().min(0).max(1),
|
||||
/** Category given to the item */
|
||||
category: z.string().or(z.array(z.string())).optional(),
|
||||
});
|
||||
|
||||
export type DownloadClientItem = z.infer<typeof downloadClientItemSchema>;
|
||||
|
||||
export type ExtendedDownloadClientItem = {
|
||||
integration: Pick<Integration, "id" | "name" | "kind">;
|
||||
received: number;
|
||||
ratio?: number;
|
||||
actions?: {
|
||||
resume: () => void;
|
||||
pause: () => void;
|
||||
delete: ({ fromDisk }: { fromDisk: boolean }) => void;
|
||||
};
|
||||
} & DownloadClientItem;
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
|
||||
export interface DownloadClientStatus {
|
||||
/** If client is considered paused */
|
||||
paused: boolean;
|
||||
/** Download/Upload speeds for the client */
|
||||
rates: {
|
||||
down: number;
|
||||
up?: number;
|
||||
};
|
||||
types: ("usenet" | "torrent" | "miscellaneous")[];
|
||||
}
|
||||
export interface ExtendedClientStatus {
|
||||
integration: Pick<Integration, "id" | "name" | "kind"> & { updatedAt: Date };
|
||||
interact: boolean;
|
||||
status?: {
|
||||
/** To derive from current items */
|
||||
totalDown?: number;
|
||||
/** To derive from current items */
|
||||
totalUp?: number;
|
||||
ratio?: number;
|
||||
} & DownloadClientStatus;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {
|
||||
FirewallCpuSummary,
|
||||
FirewallInterfacesSummary,
|
||||
FirewallMemorySummary,
|
||||
FirewallVersionSummary,
|
||||
} from "./firewall-summary-types";
|
||||
|
||||
export interface FirewallSummaryIntegration {
|
||||
getFirewallCpuAsync(): Promise<FirewallCpuSummary>;
|
||||
getFirewallMemoryAsync(): Promise<FirewallMemorySummary>;
|
||||
getFirewallInterfacesAsync(): Promise<FirewallInterfacesSummary[]>;
|
||||
getFirewallVersionAsync(): Promise<FirewallVersionSummary>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface FirewallInterfacesSummary {
|
||||
data: FirewallInterface[];
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface FirewallInterface {
|
||||
name: string;
|
||||
receive: number;
|
||||
transmit: number;
|
||||
}
|
||||
|
||||
export interface FirewallVersionSummary {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface FirewallCpuSummary {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface FirewallMemorySummary {
|
||||
used: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ClusterHealthMonitoring, SystemHealthMonitoring } from "./health-monitoring-types";
|
||||
|
||||
export interface ISystemHealthMonitoringIntegration {
|
||||
getSystemInfoAsync(): Promise<SystemHealthMonitoring>;
|
||||
}
|
||||
|
||||
export interface IClusterHealthMonitoringIntegration {
|
||||
getClusterInfoAsync(): Promise<ClusterHealthMonitoring>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { LxcResource, NodeResource, QemuResource, StorageResource } from "../../types";
|
||||
|
||||
export interface SystemHealthMonitoring {
|
||||
version: string;
|
||||
cpuModelName: string;
|
||||
cpuUtilization: number;
|
||||
memUsedInBytes: number;
|
||||
memAvailableInBytes: number;
|
||||
uptime: number;
|
||||
network: {
|
||||
up: number;
|
||||
down: number;
|
||||
} | null;
|
||||
loadAverage: {
|
||||
"1min": number;
|
||||
"5min": number;
|
||||
"15min": number;
|
||||
} | null;
|
||||
rebootRequired: boolean;
|
||||
availablePkgUpdates: number;
|
||||
cpuTemp: number | undefined;
|
||||
fileSystem: {
|
||||
deviceName: string;
|
||||
used: string;
|
||||
available: string;
|
||||
percentage: number;
|
||||
}[];
|
||||
smart: {
|
||||
deviceName: string;
|
||||
temperature: number | null;
|
||||
overallStatus: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// TODO: in the future decouple this from the Proxmox integration
|
||||
export interface ClusterHealthMonitoring {
|
||||
nodes: NodeResource[];
|
||||
lxcs: LxcResource[];
|
||||
vms: QemuResource[];
|
||||
storages: StorageResource[];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Indexer } from "./indexer-manager-types";
|
||||
|
||||
export interface IIndexerManagerIntegration {
|
||||
getIndexersAsync(): Promise<Indexer[]>;
|
||||
testAllAsync(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface Indexer {
|
||||
id: number;
|
||||
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.
|
||||
*/
|
||||
enabled: boolean;
|
||||
status: boolean;
|
||||
}
|
||||
76
packages/integrations/src/interfaces/media-releases.ts
Normal file
76
packages/integrations/src/interfaces/media-releases.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
|
||||
export const mediaTypeConfigurations = {
|
||||
movie: {
|
||||
color: "blue",
|
||||
},
|
||||
tv: {
|
||||
color: "violet",
|
||||
},
|
||||
music: {
|
||||
color: "green",
|
||||
},
|
||||
book: {
|
||||
color: "orange",
|
||||
},
|
||||
game: {
|
||||
color: "yellow",
|
||||
},
|
||||
video: {
|
||||
color: "red",
|
||||
},
|
||||
article: {
|
||||
color: "pink",
|
||||
},
|
||||
unknown: {
|
||||
color: "gray",
|
||||
},
|
||||
} satisfies Record<string, { color: MantineColor }>;
|
||||
|
||||
export type MediaType = keyof typeof mediaTypeConfigurations;
|
||||
|
||||
export interface MediaRelease {
|
||||
id: string;
|
||||
type: MediaType;
|
||||
title: string;
|
||||
/**
|
||||
* The subtitle of the media item, if applicable.
|
||||
* Can also contain the season number for TV shows.
|
||||
*/
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
releaseDate: Date;
|
||||
imageUrls: {
|
||||
poster: string | undefined;
|
||||
backdrop: string | undefined;
|
||||
};
|
||||
/**
|
||||
* The name of the studio, publisher or author.
|
||||
*/
|
||||
producer?: string;
|
||||
/**
|
||||
* Price in USD
|
||||
*/
|
||||
price?: number;
|
||||
/**
|
||||
* Rating in any format (e.g. 5/10, 4.5/5, 90%, etc.)
|
||||
*/
|
||||
rating?: string;
|
||||
/**
|
||||
* List of tags / genres / categories
|
||||
*/
|
||||
tags: string[];
|
||||
/**
|
||||
* Link to the media item
|
||||
*/
|
||||
href: string;
|
||||
/*
|
||||
* Video / Music: duration in seconds
|
||||
* Book: number of pages
|
||||
*/
|
||||
length?: number;
|
||||
}
|
||||
|
||||
export interface IMediaReleasesIntegration {
|
||||
getMediaReleasesAsync(): Promise<MediaRelease[]>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MediaInformation, MediaRequest, RequestStats, RequestUser } from "./media-request-types";
|
||||
|
||||
export interface IMediaRequestIntegration {
|
||||
getSeriesInformationAsync(mediaType: "movie" | "tv", id: number): Promise<MediaInformation>;
|
||||
requestMediaAsync(mediaType: "movie" | "tv", id: number, seasons?: number[]): Promise<void>;
|
||||
getRequestsAsync(): Promise<MediaRequest[]>;
|
||||
getStatsAsync(): Promise<RequestStats>;
|
||||
getUsersAsync(): Promise<RequestUser[]>;
|
||||
approveRequestAsync(requestId: number): Promise<void>;
|
||||
declineRequestAsync(requestId: number): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { objectKeys } from "@homarr/common";
|
||||
|
||||
interface SerieSeason {
|
||||
id: number;
|
||||
seasonNumber: number;
|
||||
name: string;
|
||||
episodeCount: number;
|
||||
}
|
||||
|
||||
interface SeriesInformation {
|
||||
id: number;
|
||||
overview: string;
|
||||
seasons: SerieSeason[];
|
||||
posterPath: string;
|
||||
}
|
||||
|
||||
interface MovieInformation {
|
||||
id: number;
|
||||
overview: string;
|
||||
posterPath: string;
|
||||
}
|
||||
|
||||
export type MediaInformation = SeriesInformation | MovieInformation;
|
||||
|
||||
export interface MediaRequest {
|
||||
id: number;
|
||||
name: string;
|
||||
type: "movie" | "tv";
|
||||
backdropImageUrl: string;
|
||||
posterImagePath: string;
|
||||
href: string;
|
||||
createdAt: Date;
|
||||
airDate?: Date;
|
||||
status: MediaRequestStatus;
|
||||
availability: MediaAvailability;
|
||||
requestedBy?: Omit<RequestUser, "requestCount">;
|
||||
}
|
||||
|
||||
export const mediaAvailabilityConfiguration = {
|
||||
available: {
|
||||
color: "green",
|
||||
},
|
||||
partiallyAvailable: {
|
||||
color: "yellow",
|
||||
},
|
||||
processing: {
|
||||
color: "blue",
|
||||
},
|
||||
requested: {
|
||||
color: "violet",
|
||||
},
|
||||
pending: {
|
||||
color: "violet",
|
||||
},
|
||||
unknown: {
|
||||
color: "orange",
|
||||
},
|
||||
deleted: {
|
||||
color: "red",
|
||||
},
|
||||
blacklisted: {
|
||||
color: "gray",
|
||||
},
|
||||
} satisfies Record<string, { color: string }>;
|
||||
|
||||
export const mediaAvailabilities = objectKeys(mediaAvailabilityConfiguration);
|
||||
|
||||
export type MediaAvailability = (typeof mediaAvailabilities)[number];
|
||||
|
||||
export const mediaRequestStatusConfiguration = {
|
||||
pending: {
|
||||
color: "blue",
|
||||
position: 1,
|
||||
},
|
||||
approved: {
|
||||
color: "green",
|
||||
position: 2,
|
||||
},
|
||||
declined: {
|
||||
color: "red",
|
||||
position: 3,
|
||||
},
|
||||
failed: {
|
||||
color: "red",
|
||||
position: 4,
|
||||
},
|
||||
completed: {
|
||||
color: "green",
|
||||
position: 5,
|
||||
},
|
||||
} satisfies Record<string, { color: string; position: number }>;
|
||||
|
||||
export const mediaRequestStatuses = objectKeys(mediaRequestStatusConfiguration);
|
||||
|
||||
export type MediaRequestStatus = (typeof mediaRequestStatuses)[number];
|
||||
|
||||
export interface MediaRequestList {
|
||||
integration: {
|
||||
id: string;
|
||||
};
|
||||
medias: MediaRequest[];
|
||||
}
|
||||
|
||||
export interface RequestStats {
|
||||
total: number;
|
||||
movie: number;
|
||||
tv: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
declined: number;
|
||||
processing: number;
|
||||
available: number;
|
||||
}
|
||||
|
||||
export interface RequestUser {
|
||||
id: number;
|
||||
displayName: string;
|
||||
avatar: string;
|
||||
requestCount: number;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface MediaRequestStats {
|
||||
stats: RequestStats;
|
||||
users: RequestUser[];
|
||||
}
|
||||
|
||||
// https://github.com/fallenbagel/jellyseerr/blob/0fd03f38480f853e7015ad9229ed98160e37602e/server/constants/media.ts#L1
|
||||
export enum UpstreamMediaRequestStatus {
|
||||
PendingApproval = 1,
|
||||
Approved = 2,
|
||||
Declined = 3,
|
||||
Failed = 4,
|
||||
Completed = 5,
|
||||
}
|
||||
|
||||
// https://github.com/fallenbagel/jellyseerr/blob/0fd03f38480f853e7015ad9229ed98160e37602e/server/constants/media.ts#L14
|
||||
export enum UpstreamMediaAvailability {
|
||||
Unknown = 1,
|
||||
Pending = 2,
|
||||
Processing = 3,
|
||||
PartiallyAvailable = 4,
|
||||
Available = 5,
|
||||
JellyseerrBlacklistedOrOverseerrDeleted = 6,
|
||||
JellyseerrDeleted = 7,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { CurrentSessionsInput, StreamSession } from "./media-server-types";
|
||||
|
||||
export interface IMediaServerIntegration {
|
||||
getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]>;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export interface StreamSession {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
user: {
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl: string | null;
|
||||
};
|
||||
currentlyPlaying: {
|
||||
type: "audio" | "video" | "tv" | "movie";
|
||||
name: string;
|
||||
seasonName: string | undefined;
|
||||
episodeName?: string | null;
|
||||
albumName?: string | null;
|
||||
episodeCount?: number | null;
|
||||
metadata: {
|
||||
video: {
|
||||
resolution: {
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
frameRate: number | null;
|
||||
};
|
||||
audio: {
|
||||
channelCount: number | null;
|
||||
codec: string | null;
|
||||
};
|
||||
transcoding: {
|
||||
container: string | null;
|
||||
resolution: {
|
||||
width: number;
|
||||
height: number;
|
||||
} | null;
|
||||
target: {
|
||||
audioCodec: string | null;
|
||||
videoCodec: string | null;
|
||||
};
|
||||
};
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface CurrentSessionsInput {
|
||||
showOnlyPlaying: boolean;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "./media-transcoding-types";
|
||||
|
||||
export interface IMediaTranscodingIntegration {
|
||||
getStatisticsAsync(): Promise<TdarrStatistics>;
|
||||
getWorkersAsync(): Promise<TdarrWorker[]>;
|
||||
getQueueAsync(firstItemIndex: number, pageSize: number): Promise<TdarrQueue>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export interface TdarrQueue {
|
||||
array: {
|
||||
id: string;
|
||||
healthCheck: string;
|
||||
transcode: string;
|
||||
filePath: string;
|
||||
fileSize: number;
|
||||
container: string;
|
||||
codec: string;
|
||||
resolution: string;
|
||||
type: "transcode" | "health-check";
|
||||
}[];
|
||||
totalCount: number;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
export interface TdarrPieSegment {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface TdarrStatistics {
|
||||
libraryName: string;
|
||||
totalFileCount: number;
|
||||
totalTranscodeCount: number;
|
||||
totalHealthCheckCount: number;
|
||||
failedTranscodeCount: number;
|
||||
failedHealthCheckCount: number;
|
||||
stagedTranscodeCount: number;
|
||||
stagedHealthCheckCount: number;
|
||||
totalSavedSpace: number;
|
||||
transcodeStatus: TdarrPieSegment[];
|
||||
healthCheckStatus: TdarrPieSegment[];
|
||||
videoCodecs: TdarrPieSegment[];
|
||||
videoContainers: TdarrPieSegment[];
|
||||
videoResolutions: TdarrPieSegment[];
|
||||
audioCodecs: TdarrPieSegment[];
|
||||
audioContainers: TdarrPieSegment[];
|
||||
}
|
||||
|
||||
export interface TdarrWorker {
|
||||
id: string;
|
||||
filePath: string;
|
||||
fps: number;
|
||||
percentage: number;
|
||||
ETA: string;
|
||||
jobType: string;
|
||||
status: string;
|
||||
step: string;
|
||||
originalSize: number;
|
||||
estimatedSize: number | null;
|
||||
outputSize: number | null;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { NetworkControllerSummary } from "./network-controller-summary-types";
|
||||
|
||||
export interface NetworkControllerSummaryIntegration {
|
||||
getNetworkSummaryAsync(): Promise<NetworkControllerSummary>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface NetworkControllerSummary {
|
||||
wanStatus: "enabled" | "disabled";
|
||||
|
||||
www: {
|
||||
status: "enabled" | "disabled";
|
||||
latency: number;
|
||||
ping: number;
|
||||
uptime: number;
|
||||
};
|
||||
|
||||
wifi: {
|
||||
status: "enabled" | "disabled";
|
||||
users: number;
|
||||
guests: number;
|
||||
};
|
||||
|
||||
lan: {
|
||||
status: "enabled" | "disabled";
|
||||
users: number;
|
||||
guests: number;
|
||||
};
|
||||
|
||||
vpn: {
|
||||
status: "enabled" | "disabled";
|
||||
users: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface Notification {
|
||||
id: string;
|
||||
time: Date;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Notification } from "./notification-types";
|
||||
|
||||
export interface INotificationsIntegration {
|
||||
getNotificationsAsync(): Promise<Notification[]>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReleaseProviderResponse, ReleaseResponse } from "./releases-providers-types";
|
||||
|
||||
export const getLatestRelease = (
|
||||
releases: ReleaseProviderResponse[],
|
||||
versionRegex?: string,
|
||||
): ReleaseProviderResponse | null => {
|
||||
const validReleases = releases.filter((result) => {
|
||||
if (result.latestRelease) {
|
||||
return versionRegex ? new RegExp(versionRegex).test(result.latestRelease) : true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return validReleases.length === 0
|
||||
? null
|
||||
: validReleases.reduce((latest, current) => (current.latestReleaseAt > latest.latestReleaseAt ? current : latest));
|
||||
};
|
||||
|
||||
export interface ReleasesProviderIntegration {
|
||||
getLatestMatchingReleaseAsync(identifier: string, versionRegex?: string): Promise<ReleaseResponse>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
|
||||
export interface ReleasesRepository extends Record<string, unknown> {
|
||||
id: string;
|
||||
identifier: string;
|
||||
versionRegex?: string;
|
||||
}
|
||||
|
||||
export interface DetailsProviderResponse {
|
||||
projectUrl?: string;
|
||||
projectDescription?: string;
|
||||
isFork?: boolean;
|
||||
isArchived?: boolean;
|
||||
createdAt?: Date;
|
||||
starsCount?: number;
|
||||
openIssues?: number;
|
||||
forksCount?: number;
|
||||
}
|
||||
|
||||
export interface ReleaseProviderResponse {
|
||||
latestRelease: string;
|
||||
latestReleaseAt: Date;
|
||||
releaseUrl?: string;
|
||||
releaseDescription?: string;
|
||||
isPreRelease?: boolean;
|
||||
}
|
||||
|
||||
type ReleasesErrorKeys = keyof TranslationObject["widget"]["releases"]["error"]["messages"];
|
||||
|
||||
export type ReleaseData = DetailsProviderResponse & ReleaseProviderResponse;
|
||||
|
||||
export type ReleaseError = { code: ReleasesErrorKeys } | { code: "unexpected"; message: string };
|
||||
|
||||
export type ReleaseResponse = { success: true; data: ReleaseData } | { success: false; error: ReleaseError };
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { EntityStateResult } from "./smart-home-types";
|
||||
|
||||
export interface ISmartHomeIntegration {
|
||||
getEntityStateAsync(entityId: string): Promise<EntityStateResult>;
|
||||
triggerAutomationAsync(entityId: string): Promise<boolean>;
|
||||
triggerToggleAsync(entityId: string): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
interface EntityState {
|
||||
attributes: Record<string, string | number | boolean | null | (string | number)[]>;
|
||||
entity_id: string;
|
||||
last_changed: Date;
|
||||
last_updated: Date;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export type EntityStateResult =
|
||||
| {
|
||||
success: true;
|
||||
data: EntityState;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: unknown;
|
||||
};
|
||||
204
packages/integrations/src/jellyfin/jellyfin-integration.ts
Normal file
204
packages/integrations/src/jellyfin/jellyfin-integration.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Jellyfin } from "@jellyfin/sdk";
|
||||
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
|
||||
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 { HandleIntegrationErrors } from "../base/errors/decorator";
|
||||
import { integrationAxiosHttpErrorHandler } from "../base/errors/http";
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
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";
|
||||
|
||||
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
|
||||
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
|
||||
private readonly jellyfin: Jellyfin = new Jellyfin({
|
||||
clientInfo: {
|
||||
name: "Homarr",
|
||||
version: "0.0.1",
|
||||
},
|
||||
deviceInfo: {
|
||||
name: "Homarr",
|
||||
id: "homarr",
|
||||
},
|
||||
});
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const api = await this.getApiAsync(input.axiosInstance);
|
||||
const systemApi = getSystemApi(api);
|
||||
await systemApi.getPingSystem();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||
const api = await this.getApiAsync();
|
||||
const sessionApi = getSessionApi(api);
|
||||
const sessions = await sessionApi.getSessions();
|
||||
|
||||
return sessions.data
|
||||
.filter((sessionInfo) => sessionInfo.UserId !== undefined)
|
||||
.filter((sessionInfo) => sessionInfo.DeviceId !== "homarr")
|
||||
.filter((sessionInfo) => !options.showOnlyPlaying || sessionInfo.NowPlayingItem !== undefined)
|
||||
.map((sessionInfo): StreamSession => {
|
||||
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
|
||||
|
||||
if (sessionInfo.NowPlayingItem) {
|
||||
currentlyPlaying = {
|
||||
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
|
||||
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
|
||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
metadata: {
|
||||
video: {
|
||||
resolution:
|
||||
sessionInfo.NowPlayingItem.Width && sessionInfo.NowPlayingItem.Height
|
||||
? {
|
||||
width: sessionInfo.NowPlayingItem.Width,
|
||||
height: sessionInfo.NowPlayingItem.Height,
|
||||
}
|
||||
: null,
|
||||
frameRate: sessionInfo.TranscodingInfo?.Framerate ?? null,
|
||||
},
|
||||
audio: {
|
||||
channelCount: sessionInfo.TranscodingInfo?.AudioChannels ?? null,
|
||||
codec: sessionInfo.TranscodingInfo?.AudioCodec ?? null,
|
||||
},
|
||||
transcoding: {
|
||||
resolution:
|
||||
sessionInfo.TranscodingInfo?.Width && sessionInfo.TranscodingInfo.Height
|
||||
? {
|
||||
width: sessionInfo.TranscodingInfo.Width,
|
||||
height: sessionInfo.TranscodingInfo.Height,
|
||||
}
|
||||
: null,
|
||||
target: {
|
||||
audioCodec: sessionInfo.TranscodingInfo?.AudioCodec ?? null,
|
||||
videoCodec: sessionInfo.TranscodingInfo?.VideoCodec ?? null,
|
||||
},
|
||||
container: sessionInfo.TranscodingInfo?.Container ?? null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: `${sessionInfo.Id}`,
|
||||
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
|
||||
user: {
|
||||
profilePictureUrl: this.externalUrl(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
|
||||
userId: sessionInfo.UserId ?? "",
|
||||
username: sessionInfo.UserName ?? "",
|
||||
},
|
||||
currentlyPlaying,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
|
||||
const apiClient = await this.getApiAsync();
|
||||
const userLibraryApi = getUserLibraryApi(apiClient);
|
||||
const userApi = getUserApi(apiClient);
|
||||
|
||||
const users = await userApi.getUsers();
|
||||
const userId = users.data.at(0)?.Id;
|
||||
if (!userId) {
|
||||
throw new Error("No users found");
|
||||
}
|
||||
|
||||
const result = await userLibraryApi.getLatestMedia({
|
||||
fields: ["CustomRating", "Studios", "Genres", "ChildCount", "DateCreated", "Overview", "Taglines"],
|
||||
userId,
|
||||
limit: 100,
|
||||
});
|
||||
return result.data.map((item) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
id: item.Id!,
|
||||
type: this.mapMediaReleaseType(item.Type),
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
title: item.Name!,
|
||||
subtitle: item.Taglines?.at(0),
|
||||
description: item.Overview ?? undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
releaseDate: new Date(item.PremiereDate ?? item.DateCreated!),
|
||||
imageUrls: {
|
||||
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
||||
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
||||
},
|
||||
producer: item.Studios?.at(0)?.Name ?? undefined,
|
||||
rating: item.CommunityRating?.toFixed(1),
|
||||
tags: item.Genres ?? [],
|
||||
href: super.externalUrl(`/web/index.html#!/details?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
private mapMediaReleaseType(type: BaseItemKind | undefined): MediaType {
|
||||
switch (type) {
|
||||
case "Audio":
|
||||
case "AudioBook":
|
||||
case "MusicAlbum":
|
||||
return "music";
|
||||
case "Book":
|
||||
return "book";
|
||||
case "Episode":
|
||||
case "Series":
|
||||
case "Season":
|
||||
return "tv";
|
||||
case "Movie":
|
||||
return "movie";
|
||||
case "Video":
|
||||
return "video";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an ApiClient synchronously with an ApiKey or asynchronously
|
||||
* with a username and password.
|
||||
* @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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export const convertJellyfinType = (
|
||||
kind: BaseItemKind | undefined,
|
||||
): Exclude<StreamSession["currentlyPlaying"], null>["type"] => {
|
||||
switch (kind) {
|
||||
case BaseItemKind.Audio:
|
||||
case BaseItemKind.MusicVideo:
|
||||
return "audio";
|
||||
case BaseItemKind.Episode:
|
||||
case BaseItemKind.Video:
|
||||
return "video";
|
||||
case BaseItemKind.Movie:
|
||||
return "movie";
|
||||
case BaseItemKind.TvChannel:
|
||||
case BaseItemKind.TvProgram:
|
||||
case BaseItemKind.LiveTvChannel:
|
||||
case BaseItemKind.LiveTvProgram:
|
||||
default:
|
||||
return "tv";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { MediaAvailability } from "../interfaces/media-requests/media-request-types";
|
||||
import { UpstreamMediaAvailability } from "../interfaces/media-requests/media-request-types";
|
||||
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||
|
||||
export class JellyseerrIntegration extends OverseerrIntegration {
|
||||
protected override mapAvailability(availability: UpstreamMediaAvailability, inProgress: boolean): MediaAvailability {
|
||||
// Availability statuses are not exactly the same between Jellyseerr and Overseerr (Jellyseerr has "blacklisted" additionally (deleted is the same value in overseerr))
|
||||
if (availability === UpstreamMediaAvailability.JellyseerrBlacklistedOrOverseerrDeleted) return "blacklisted";
|
||||
if (availability === UpstreamMediaAvailability.JellyseerrDeleted) return "deleted";
|
||||
return super.mapAvailability(availability, inProgress);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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 { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
|
||||
import type { ReleaseResponse } from "../interfaces/releases-providers/releases-providers-types";
|
||||
import { releasesResponseSchema } from "./linuxserverio-schemas";
|
||||
|
||||
const logger = createLogger({ module: "linuxServerIOIntegration" });
|
||||
|
||||
export class LinuxServerIOIntegration extends Integration implements ReleasesProviderIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/health"));
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
private parseIdentifier(identifier: string) {
|
||||
const [owner, name] = identifier.split("/");
|
||||
if (!owner || !name) {
|
||||
logger.warn("Invalid identifier format. Expected 'owner/name' for identifier", { identifier });
|
||||
return null;
|
||||
}
|
||||
return { owner, name };
|
||||
}
|
||||
|
||||
public async getLatestMatchingReleaseAsync(identifier: string): Promise<ReleaseResponse> {
|
||||
const { name } = this.parseIdentifier(identifier) ?? {};
|
||||
if (!name) return { success: false, error: { code: "invalidIdentifier" } };
|
||||
|
||||
const releasesResponse = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/images"));
|
||||
if (!releasesResponse.ok) {
|
||||
return { success: false, error: { code: "unexpected", message: releasesResponse.statusText } };
|
||||
}
|
||||
|
||||
const releasesResponseJson: unknown = await releasesResponse.json();
|
||||
const { data, success, error } = releasesResponseSchema.safeParse(releasesResponseJson);
|
||||
if (!success) {
|
||||
return { success: false, error: { code: "unexpected", message: error.message } };
|
||||
}
|
||||
|
||||
const release = data.data.repositories.linuxserver.find((repo) => repo.name === name);
|
||||
if (!release) {
|
||||
logger.warn("Repository not found on provider", {
|
||||
name,
|
||||
});
|
||||
return { success: false, error: { code: "noMatchingVersion" } };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
latestRelease: release.version,
|
||||
latestReleaseAt: release.version_timestamp,
|
||||
releaseDescription: release.changelog?.shift()?.desc,
|
||||
projectUrl: release.github_url,
|
||||
projectDescription: release.description,
|
||||
isArchived: release.deprecated,
|
||||
createdAt: release.initial_date ? new Date(release.initial_date) : undefined,
|
||||
starsCount: release.stars,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const releasesResponseSchema = z.object({
|
||||
data: z.object({
|
||||
repositories: z.object({
|
||||
linuxserver: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
initial_date: z
|
||||
.string()
|
||||
.transform((value) => new Date(value))
|
||||
.optional(),
|
||||
github_url: z.string(),
|
||||
description: z.string(),
|
||||
version: z.string(),
|
||||
version_timestamp: z.string().transform((value) => new Date(value)),
|
||||
stars: z.number(),
|
||||
deprecated: z.boolean(),
|
||||
changelog: z
|
||||
.array(
|
||||
z.object({
|
||||
date: z.string().transform((value) => new Date(value)),
|
||||
desc: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
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 { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
|
||||
import { mediaOrganizerPriorities } from "../media-organizer";
|
||||
|
||||
const logger = createLogger({ module: "lidarrIntegration" });
|
||||
|
||||
export class LidarrIntegration extends Integration implements ICalendarIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
await response.json();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the events in the Lidarr calendar between two dates.
|
||||
* @param start The start date
|
||||
* @param end The end date
|
||||
* @param includeUnmonitored When true results will include unmonitored items of the Tadarr library.
|
||||
*/
|
||||
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> {
|
||||
const url = this.url("/api/v1/calendar", {
|
||||
start,
|
||||
end,
|
||||
unmonitored: includeUnmonitored,
|
||||
});
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: {
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
const lidarrCalendarEvents = await z.array(lidarrCalendarEventSchema).parseAsync(await response.json());
|
||||
|
||||
return lidarrCalendarEvents.map((lidarrCalendarEvent): CalendarEvent => {
|
||||
const imageSrc = this.chooseBestImage(lidarrCalendarEvent);
|
||||
return {
|
||||
title: lidarrCalendarEvent.title,
|
||||
subTitle: lidarrCalendarEvent.artist.artistName,
|
||||
description: lidarrCalendarEvent.overview ?? null,
|
||||
startDate: lidarrCalendarEvent.releaseDate,
|
||||
endDate: null,
|
||||
image: imageSrc
|
||||
? {
|
||||
src: imageSrc.remoteUrl,
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
}
|
||||
: null,
|
||||
location: null,
|
||||
indicatorColor: "cyan",
|
||||
links: this.getLinksForLidarrCalendarEvent(lidarrCalendarEvent),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getLinksForLidarrCalendarEvent = (event: z.infer<typeof lidarrCalendarEventSchema>) => {
|
||||
const links: CalendarLink[] = [];
|
||||
|
||||
for (const link of event.artist.links) {
|
||||
switch (link.name) {
|
||||
case "vgmdb":
|
||||
links.push({
|
||||
href: link.url,
|
||||
name: "VgmDB",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/vgmdb.svg",
|
||||
});
|
||||
break;
|
||||
case "imdb":
|
||||
links.push({
|
||||
href: link.url,
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.png",
|
||||
});
|
||||
break;
|
||||
case "last":
|
||||
links.push({
|
||||
href: link.url,
|
||||
name: "LastFM",
|
||||
color: "#cf222a",
|
||||
isDark: false,
|
||||
logo: "/images/apps/lastfm.svg",
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
};
|
||||
|
||||
private chooseBestImage = (
|
||||
event: z.infer<typeof lidarrCalendarEventSchema>,
|
||||
): z.infer<typeof lidarrCalendarEventSchema>["images"][number] | undefined => {
|
||||
const flatImages = [...event.images];
|
||||
|
||||
const sortedImages = flatImages.sort(
|
||||
(imageA, imageB) =>
|
||||
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
|
||||
);
|
||||
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
|
||||
return sortedImages[0];
|
||||
};
|
||||
|
||||
private chooseBestImageAsURL = (event: z.infer<typeof lidarrCalendarEventSchema>): string | undefined => {
|
||||
const bestImage = this.chooseBestImage(event);
|
||||
if (!bestImage) {
|
||||
return undefined;
|
||||
}
|
||||
return bestImage.remoteUrl;
|
||||
};
|
||||
}
|
||||
|
||||
const lidarrCalendarEventImageSchema = z.array(
|
||||
z.object({
|
||||
// See https://github.com/Lidarr/Lidarr/blob/bc6417229e9da3d3cab418f92b46eec7a76168c2/src/NzbDrone.Core/MediaCover/MediaCover.cs#L8-L20
|
||||
coverType: z.enum([
|
||||
"unknown",
|
||||
"poster",
|
||||
"banner",
|
||||
"fanart",
|
||||
"screenshot",
|
||||
"headshot",
|
||||
"cover",
|
||||
"disc",
|
||||
"logo",
|
||||
"clearlogo",
|
||||
]),
|
||||
remoteUrl: z.string().url(),
|
||||
}),
|
||||
);
|
||||
|
||||
const lidarrCalendarEventSchema = z.object({
|
||||
title: z.string(),
|
||||
overview: z.string().optional(),
|
||||
images: lidarrCalendarEventImageSchema,
|
||||
artist: z.object({ links: z.array(z.object({ url: z.string().url(), name: z.string() })), artistName: z.string() }),
|
||||
releaseDate: z.string().transform((value) => new Date(value)),
|
||||
});
|
||||
17
packages/integrations/src/media-organizer/media-organizer.ts
Normal file
17
packages/integrations/src/media-organizer/media-organizer.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Priority list that determines the quality of images using their order.
|
||||
* Types at the start of the list are better than those at the end.
|
||||
* We do this to attempt to find the best quality image for the show.
|
||||
*/
|
||||
export const mediaOrganizerPriorities = [
|
||||
"cover", // Official, perfect aspect ratio, best for music
|
||||
"poster", // Official, perfect aspect ratio
|
||||
"banner", // Official, bad aspect ratio
|
||||
"disc", // Official, second best for music / books
|
||||
"logo", // Official, possibly unrelated
|
||||
"fanart", // Unofficial, possibly bad quality
|
||||
"screenshot", // Bad aspect ratio, possibly bad quality
|
||||
"clearlogo", // Without background, bad aspect ratio,
|
||||
"headshot", // Unrelated
|
||||
"unknown", // Not known, possibly good or bad, better not to choose
|
||||
];
|
||||
@@ -0,0 +1,153 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
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 { 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, CalendarLink } from "../../interfaces/calendar/calendar-types";
|
||||
import { radarrReleaseTypes } from "../../interfaces/calendar/calendar-types";
|
||||
import { mediaOrganizerPriorities } from "../media-organizer";
|
||||
|
||||
const logger = createLogger({ module: "radarrIntegration" });
|
||||
|
||||
export class RadarrIntegration extends Integration implements ICalendarIntegration {
|
||||
/**
|
||||
* Gets the events in the Radarr calendar between two dates.
|
||||
* @param start The start date
|
||||
* @param end The end date
|
||||
* @param includeUnmonitored When true results will include unmonitored items of the Tadarr library.
|
||||
*/
|
||||
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> {
|
||||
const url = this.url("/api/v3/calendar", {
|
||||
start,
|
||||
end,
|
||||
unmonitored: includeUnmonitored,
|
||||
});
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: {
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json());
|
||||
|
||||
return radarrCalendarEvents.flatMap((radarrCalendarEvent): CalendarEvent[] => {
|
||||
const imageSrc = this.chooseBestImageAsURL(radarrCalendarEvent);
|
||||
|
||||
return radarrReleaseTypes
|
||||
.map((releaseType) => ({ type: releaseType, date: radarrCalendarEvent[releaseType] }))
|
||||
.filter((item) => item.date !== undefined)
|
||||
.map((item) => ({
|
||||
title: radarrCalendarEvent.title,
|
||||
subTitle: radarrCalendarEvent.originalTitle,
|
||||
description: radarrCalendarEvent.overview ?? null,
|
||||
// Check is done above in the filter
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
startDate: item.date!,
|
||||
endDate: null,
|
||||
image: imageSrc
|
||||
? {
|
||||
src: imageSrc,
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
}
|
||||
: null,
|
||||
location: null,
|
||||
metadata: {
|
||||
type: "radarr",
|
||||
releaseType: item.type,
|
||||
},
|
||||
indicatorColor: "yellow",
|
||||
links: this.getLinksForRadarrCalendarEvent(radarrCalendarEvent),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
|
||||
const links: CalendarLink[] = [
|
||||
{
|
||||
href: this.externalUrl(`/movie/${event.titleSlug}`).toString(),
|
||||
name: "Radarr",
|
||||
logo: "/images/apps/radarr.svg",
|
||||
color: undefined,
|
||||
isDark: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (event.imdbId) {
|
||||
links.push({
|
||||
href: `https://www.imdb.com/title/${event.imdbId}/`,
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.svg",
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
};
|
||||
|
||||
private chooseBestImage = (
|
||||
event: z.infer<typeof radarrCalendarEventSchema>,
|
||||
): z.infer<typeof radarrCalendarEventSchema>["images"][number] | undefined => {
|
||||
const flatImages = [...event.images];
|
||||
|
||||
const sortedImages = flatImages.sort(
|
||||
(imageA, imageB) =>
|
||||
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
|
||||
);
|
||||
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
|
||||
return sortedImages[0];
|
||||
};
|
||||
|
||||
private chooseBestImageAsURL = (event: z.infer<typeof radarrCalendarEventSchema>): string | undefined => {
|
||||
const bestImage = this.chooseBestImage(event);
|
||||
if (!bestImage) {
|
||||
return undefined;
|
||||
}
|
||||
return bestImage.remoteUrl;
|
||||
};
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
await response.json();
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
const radarrCalendarEventImageSchema = z.array(
|
||||
z.object({
|
||||
// See https://github.com/Radarr/Radarr/blob/a3b1512552a8a5bc0c0d399d961ccbf0dba97749/src/NzbDrone.Core/MediaCover/MediaCover.cs#L6-L15
|
||||
coverType: z.enum(["unknown", "poster", "banner", "fanart", "screenshot", "headshot", "clearlogo"]),
|
||||
remoteUrl: z.string().url(),
|
||||
}),
|
||||
);
|
||||
|
||||
const radarrCalendarEventSchema = z.object({
|
||||
title: z.string(),
|
||||
originalTitle: z.string(),
|
||||
inCinemas: z
|
||||
.string()
|
||||
.transform((value) => new Date(value))
|
||||
.optional(),
|
||||
physicalRelease: z
|
||||
.string()
|
||||
.transform((value) => new Date(value))
|
||||
.optional(),
|
||||
digitalRelease: z
|
||||
.string()
|
||||
.transform((value) => new Date(value))
|
||||
.optional(),
|
||||
overview: z.string().optional(),
|
||||
titleSlug: z.string(),
|
||||
images: radarrCalendarEventImageSchema,
|
||||
imdbId: z.string().optional(),
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
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 { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
|
||||
import { mediaOrganizerPriorities } from "../media-organizer";
|
||||
|
||||
const logger = createLogger({ module: "readarrIntegration" });
|
||||
|
||||
export class ReadarrIntegration extends Integration implements ICalendarIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
await response.json();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the events in the Lidarr calendar between two dates.
|
||||
* @param start The start date
|
||||
* @param end The end date
|
||||
* @param includeUnmonitored When true results will include unmonitored items of the Tadarr library.
|
||||
*/
|
||||
async getCalendarEventsAsync(
|
||||
start: Date,
|
||||
end: Date,
|
||||
includeUnmonitored = true,
|
||||
includeAuthor = true,
|
||||
): Promise<CalendarEvent[]> {
|
||||
const url = this.url("/api/v1/calendar", {
|
||||
start,
|
||||
end,
|
||||
unmonitored: includeUnmonitored,
|
||||
includeAuthor,
|
||||
});
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: {
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
const readarrCalendarEvents = await z.array(readarrCalendarEventSchema).parseAsync(await response.json());
|
||||
|
||||
return readarrCalendarEvents.map((readarrCalendarEvent): CalendarEvent => {
|
||||
const imageSrc = this.chooseBestImageAsURL(readarrCalendarEvent);
|
||||
|
||||
return {
|
||||
title: readarrCalendarEvent.title,
|
||||
subTitle: readarrCalendarEvent.author.authorName,
|
||||
description: readarrCalendarEvent.overview ?? null,
|
||||
startDate: readarrCalendarEvent.releaseDate,
|
||||
endDate: null,
|
||||
image: imageSrc
|
||||
? {
|
||||
src: imageSrc,
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
}
|
||||
: null,
|
||||
location: null,
|
||||
indicatorColor: "#f5c518",
|
||||
links: this.getLinksForReadarrCalendarEvent(readarrCalendarEvent),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getLinksForReadarrCalendarEvent = (event: z.infer<typeof readarrCalendarEventSchema>) => {
|
||||
return [
|
||||
{
|
||||
href: this.externalUrl(`/author/${event.author.foreignAuthorId}`).toString(),
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/readarr.svg",
|
||||
name: "Readarr",
|
||||
},
|
||||
] satisfies CalendarLink[];
|
||||
};
|
||||
|
||||
private chooseBestImage = (
|
||||
event: z.infer<typeof readarrCalendarEventSchema>,
|
||||
): z.infer<typeof readarrCalendarEventSchema>["images"][number] | undefined => {
|
||||
const flatImages = [...event.images];
|
||||
|
||||
const sortedImages = flatImages.sort(
|
||||
(imageA, imageB) =>
|
||||
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
|
||||
);
|
||||
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
|
||||
return sortedImages[0];
|
||||
};
|
||||
|
||||
private chooseBestImageAsURL = (event: z.infer<typeof readarrCalendarEventSchema>): string | undefined => {
|
||||
const bestImage = this.chooseBestImage(event);
|
||||
if (!bestImage) {
|
||||
return undefined;
|
||||
}
|
||||
return this.externalUrl(bestImage.url as `/${string}`).toString();
|
||||
};
|
||||
}
|
||||
|
||||
const readarrCalendarEventImageSchema = z.array(
|
||||
z.object({
|
||||
// See https://github.com/Readarr/Readarr/blob/e5519d60c969105db2f2ab3a8f1cf61814551bb9/src/NzbDrone.Core/MediaCover/MediaCover.cs#L8-L20
|
||||
coverType: z.enum([
|
||||
"unknown",
|
||||
"poster",
|
||||
"banner",
|
||||
"fanart",
|
||||
"screenshot",
|
||||
"headshot",
|
||||
"cover",
|
||||
"disc",
|
||||
"logo",
|
||||
"clearlogo",
|
||||
]),
|
||||
url: z.string().transform((url) => url.replace(/\?lastWrite=[0-9]+/, "")), // returns a random string, needs to be removed for loading the image
|
||||
}),
|
||||
);
|
||||
|
||||
const readarrCalendarEventSchema = z.object({
|
||||
title: z.string(),
|
||||
overview: z.string().optional(),
|
||||
images: readarrCalendarEventImageSchema,
|
||||
links: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
),
|
||||
author: z.object({
|
||||
authorName: z.string(),
|
||||
foreignAuthorId: z.string(),
|
||||
}),
|
||||
releaseDate: z.string().transform((value) => new Date(value)),
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
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 { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
|
||||
import { mediaOrganizerPriorities } from "../media-organizer";
|
||||
|
||||
const logger = createLogger({ module: "sonarrIntegration" });
|
||||
|
||||
export class SonarrIntegration extends Integration implements ICalendarIntegration {
|
||||
/**
|
||||
* Gets the events in the Sonarr calendar between two dates.
|
||||
* @param start The start date
|
||||
* @param end The end date
|
||||
* @param includeUnmonitored When true results will include unmonitored items of the Sonarr library.
|
||||
*/
|
||||
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> {
|
||||
const url = this.url("/api/v3/calendar", {
|
||||
start,
|
||||
end,
|
||||
unmonitored: includeUnmonitored,
|
||||
includeSeries: true,
|
||||
includeEpisodeFile: true,
|
||||
includeEpisodeImages: true,
|
||||
});
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: {
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
const sonarrCalendarEvents = await z.array(sonarrCalendarEventSchema).parseAsync(await response.json());
|
||||
|
||||
return sonarrCalendarEvents.map((event): CalendarEvent => {
|
||||
const imageSrc = this.chooseBestImageAsURL(event);
|
||||
return {
|
||||
title: event.title,
|
||||
subTitle: event.series.title,
|
||||
description: event.series.overview ?? null,
|
||||
startDate: event.airDateUtc,
|
||||
endDate: null,
|
||||
image: imageSrc
|
||||
? {
|
||||
src: imageSrc,
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
badge: {
|
||||
color: "red",
|
||||
content: `S${event.seasonNumber}/E${event.episodeNumber}`,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
location: null,
|
||||
indicatorColor: "blue",
|
||||
links: this.getLinksForSonarrCalendarEvent(event),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getLinksForSonarrCalendarEvent = (event: z.infer<typeof sonarrCalendarEventSchema>) => {
|
||||
const links: CalendarLink[] = [
|
||||
{
|
||||
href: this.externalUrl(`/series/${event.series.titleSlug}`).toString(),
|
||||
name: "Sonarr",
|
||||
logo: "/images/apps/sonarr.svg",
|
||||
color: undefined,
|
||||
isDark: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (event.series.imdbId) {
|
||||
links.push({
|
||||
href: `https://www.imdb.com/title/${event.series.imdbId}/`,
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.svg",
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
};
|
||||
|
||||
private chooseBestImage = (
|
||||
event: z.infer<typeof sonarrCalendarEventSchema>,
|
||||
): z.infer<typeof sonarrCalendarEventSchema>["images"][number] | undefined => {
|
||||
const flatImages = [...event.images, ...event.series.images];
|
||||
|
||||
const sortedImages = flatImages.sort(
|
||||
(imageA, imageB) =>
|
||||
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
|
||||
);
|
||||
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
|
||||
return sortedImages[0];
|
||||
};
|
||||
|
||||
private chooseBestImageAsURL = (event: z.infer<typeof sonarrCalendarEventSchema>): string | undefined => {
|
||||
const bestImage = this.chooseBestImage(event);
|
||||
if (!bestImage) {
|
||||
return undefined;
|
||||
}
|
||||
return bestImage.remoteUrl;
|
||||
};
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
await response.json();
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
const sonarrCalendarEventImageSchema = z.array(
|
||||
z.object({
|
||||
// See https://github.com/Sonarr/Sonarr/blob/9e5ebdc6245d4714776b53127a1e6b63c25fbcb9/src/NzbDrone.Core/MediaCover/MediaCover.cs#L5-L14
|
||||
coverType: z.enum(["unknown", "poster", "banner", "fanart", "screenshot", "headshot", "clearlogo"]),
|
||||
remoteUrl: z.string().url(),
|
||||
}),
|
||||
);
|
||||
|
||||
const sonarrCalendarEventSchema = z.object({
|
||||
title: z.string(),
|
||||
airDateUtc: z.string().transform((value) => new Date(value)),
|
||||
seasonNumber: z.number().min(0),
|
||||
episodeNumber: z.number().min(0),
|
||||
series: z.object({
|
||||
overview: z.string().optional(),
|
||||
title: z.string(),
|
||||
titleSlug: z.string(),
|
||||
images: sonarrCalendarEventImageSchema,
|
||||
imdbId: z.string().optional(),
|
||||
}),
|
||||
images: sonarrCalendarEventImageSchema,
|
||||
});
|
||||
197
packages/integrations/src/media-transcoding/tdarr-integration.ts
Normal file
197
packages/integrations/src/media-transcoding/tdarr-integration.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
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 { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
|
||||
import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "../interfaces/media-transcoding/media-transcoding-types";
|
||||
import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } from "./tdarr-validation-schemas";
|
||||
|
||||
export class TdarrIntegration extends Integration implements IMediaTranscodingIntegration {
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api/v2/is-server-alive"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"X-Api-Key": super.hasSecretValue("apiKey") ? super.getSecretValue("apiKey") : "",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
await response.json();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getStatisticsAsync(): Promise<TdarrStatistics> {
|
||||
const url = this.url("/api/v2/stats/get-pies");
|
||||
|
||||
const headerParams = {
|
||||
accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
||||
};
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "POST",
|
||||
headers: headerParams,
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
libraryId: "", // empty string to get all libraries
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const statisticsData = await getStatisticsSchema.parseAsync(await response.json());
|
||||
|
||||
return {
|
||||
libraryName: "All",
|
||||
totalFileCount: statisticsData.pieStats.totalFiles,
|
||||
totalTranscodeCount: statisticsData.pieStats.totalTranscodeCount,
|
||||
totalHealthCheckCount: statisticsData.pieStats.totalHealthCheckCount,
|
||||
// The Tdarr API only returns a category if there is at least one item in it
|
||||
failedTranscodeCount:
|
||||
statisticsData.pieStats.status.transcode.find((transcode) => transcode.name === "Transcode error")?.value ?? 0,
|
||||
failedHealthCheckCount:
|
||||
statisticsData.pieStats.status.healthcheck.find((healthcheck) => healthcheck.name === "Error")?.value ?? 0,
|
||||
stagedTranscodeCount:
|
||||
statisticsData.pieStats.status.transcode.find((transcode) => transcode.name === "Transcode success")?.value ??
|
||||
0,
|
||||
stagedHealthCheckCount:
|
||||
statisticsData.pieStats.status.healthcheck.find((healthcheck) => healthcheck.name === "Queued")?.value ?? 0,
|
||||
|
||||
totalSavedSpace: statisticsData.pieStats.sizeDiff * 1_000_000_000, // sizeDiff is in GB, convert to bytes
|
||||
transcodeStatus: statisticsData.pieStats.status.transcode,
|
||||
healthCheckStatus: statisticsData.pieStats.status.healthcheck,
|
||||
videoCodecs: statisticsData.pieStats.video.codecs,
|
||||
videoContainers: statisticsData.pieStats.video.containers,
|
||||
videoResolutions: statisticsData.pieStats.video.resolutions,
|
||||
audioCodecs: statisticsData.pieStats.audio.codecs,
|
||||
audioContainers: statisticsData.pieStats.audio.containers,
|
||||
};
|
||||
}
|
||||
|
||||
public async getWorkersAsync(): Promise<TdarrWorker[]> {
|
||||
const url = this.url("/api/v2/get-nodes");
|
||||
const headerParams = {
|
||||
"Content-Type": "application/json",
|
||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
||||
};
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "GET",
|
||||
headers: headerParams,
|
||||
});
|
||||
|
||||
const nodesData = await getNodesResponseSchema.parseAsync(await response.json());
|
||||
const workers = Object.values(nodesData).flatMap((node) => {
|
||||
return Object.values(node.workers);
|
||||
});
|
||||
|
||||
return workers.map((worker) => ({
|
||||
id: worker._id,
|
||||
filePath: worker.file,
|
||||
fps: worker.fps,
|
||||
percentage: worker.percentage,
|
||||
ETA: worker.ETA,
|
||||
jobType: worker.job.type,
|
||||
status: worker.status,
|
||||
step: worker.lastPluginDetails?.number ?? "",
|
||||
originalSize: worker.originalfileSizeInGbytes * 1_000_000_000, // file_size is in GB, convert to bytes,
|
||||
estimatedSize: worker.estSize ? worker.estSize * 1_000_000_000 : null, // file_size is in GB, convert to bytes,
|
||||
outputSize: worker.outputFileSizeInGbytes ? worker.outputFileSizeInGbytes * 1_000_000_000 : null, // file_size is in GB, convert to bytes,
|
||||
}));
|
||||
}
|
||||
|
||||
public async getQueueAsync(firstItemIndex: number, pageSize: number): Promise<TdarrQueue> {
|
||||
const transcodingQueue = await this.getTranscodingQueueAsync(firstItemIndex, pageSize);
|
||||
const healthChecks = await this.getHealthCheckDataAsync(firstItemIndex, pageSize, transcodingQueue.totalCount);
|
||||
|
||||
const combinedArray = [...transcodingQueue.array, ...healthChecks.array].slice(0, pageSize);
|
||||
return {
|
||||
array: combinedArray,
|
||||
totalCount: transcodingQueue.totalCount + healthChecks.totalCount,
|
||||
startIndex: firstItemIndex,
|
||||
endIndex: firstItemIndex + combinedArray.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
private async getTranscodingQueueAsync(firstItemIndex: number, pageSize: number) {
|
||||
const url = this.url("/api/v2/client/status-tables");
|
||||
const headerParams = {
|
||||
"Content-Type": "application/json",
|
||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
||||
};
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "POST",
|
||||
headers: headerParams,
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
start: firstItemIndex,
|
||||
pageSize,
|
||||
filters: [],
|
||||
sorts: [],
|
||||
opts: { table: "table1" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const transcodesQueueData = await getStatusTableSchema.parseAsync(await response.json());
|
||||
|
||||
return {
|
||||
array: transcodesQueueData.array.map((item) => ({
|
||||
id: item._id,
|
||||
healthCheck: item.HealthCheck,
|
||||
transcode: item.TranscodeDecisionMaker,
|
||||
filePath: item.file,
|
||||
fileSize: Math.floor(item.file_size * 1_000_000), // file_size is in MB, convert to bytes, floor because it returns as float
|
||||
container: item.container,
|
||||
codec: item.video_codec_name,
|
||||
resolution: item.video_resolution,
|
||||
type: "transcode" as const,
|
||||
})),
|
||||
totalCount: transcodesQueueData.totalCount,
|
||||
startIndex: firstItemIndex,
|
||||
endIndex: firstItemIndex + transcodesQueueData.array.length - 1,
|
||||
};
|
||||
}
|
||||
|
||||
private async getHealthCheckDataAsync(firstItemIndex: number, pageSize: number, totalQueueCount: number) {
|
||||
const url = this.url("/api/v2/client/status-tables");
|
||||
const headerParams = {
|
||||
"Content-Type": "application/json",
|
||||
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
|
||||
};
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
method: "POST",
|
||||
headers: headerParams,
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
start: Math.max(firstItemIndex - totalQueueCount, 0),
|
||||
pageSize,
|
||||
filters: [],
|
||||
sorts: [],
|
||||
opts: {
|
||||
table: "table4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const healthCheckData = await getStatusTableSchema.parseAsync(await response.json());
|
||||
|
||||
return {
|
||||
array: healthCheckData.array.map((item) => ({
|
||||
id: item._id,
|
||||
healthCheck: item.HealthCheck,
|
||||
transcode: item.TranscodeDecisionMaker,
|
||||
filePath: item.file,
|
||||
fileSize: Math.floor(item.file_size * 1_000_000), // file_size is in MB, convert to bytes, floor because it returns as float
|
||||
container: item.container,
|
||||
codec: item.video_codec_name,
|
||||
resolution: item.video_resolution,
|
||||
type: "health-check" as const,
|
||||
})),
|
||||
totalCount: healthCheckData.totalCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const getStatisticsSchema = z.object({
|
||||
pieStats: z.object({
|
||||
totalFiles: z.number(),
|
||||
totalTranscodeCount: z.number(),
|
||||
sizeDiff: z.number(),
|
||||
totalHealthCheckCount: z.number(),
|
||||
status: z.object({
|
||||
transcode: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.transform((arr) => arr ?? []),
|
||||
healthcheck: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.transform((arr) => arr ?? []),
|
||||
}),
|
||||
video: z.object({
|
||||
codecs: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.transform((arr) => arr ?? []),
|
||||
containers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.transform((arr) => arr ?? []),
|
||||
resolutions: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.transform((arr) => arr ?? []),
|
||||
}),
|
||||
audio: z.object({
|
||||
codecs: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.transform((arr) => arr ?? []),
|
||||
containers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.transform((arr) => arr ?? []),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const getNodesResponseSchema = z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
_id: z.string(),
|
||||
nodeName: z.string(),
|
||||
nodePaused: z.boolean(),
|
||||
workers: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
_id: z.string(),
|
||||
file: z.string(),
|
||||
fps: z.number(),
|
||||
percentage: z.number(),
|
||||
ETA: z.string(),
|
||||
job: z.object({
|
||||
type: z.string(),
|
||||
}),
|
||||
status: z.string(),
|
||||
lastPluginDetails: z
|
||||
.object({
|
||||
number: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
originalfileSizeInGbytes: z.number(),
|
||||
estSize: z.number().optional(),
|
||||
outputFileSizeInGbytes: z.number().optional(),
|
||||
workerType: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const getStatusTableSchema = z.object({
|
||||
array: z.array(
|
||||
z.object({
|
||||
_id: z.string(),
|
||||
HealthCheck: z.string(),
|
||||
TranscodeDecisionMaker: z.string(),
|
||||
file: z.string(),
|
||||
file_size: z.number(),
|
||||
container: z.string(),
|
||||
video_codec_name: z.string(),
|
||||
video_resolution: z.string(),
|
||||
}),
|
||||
),
|
||||
totalCount: z.number(),
|
||||
});
|
||||
91
packages/integrations/src/mock/data/calendar.ts
Normal file
91
packages/integrations/src/mock/data/calendar.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
|
||||
|
||||
export class CalendarMockService implements ICalendarIntegration {
|
||||
public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise<CalendarEvent[]> {
|
||||
const result = [homarrMeetup(start, end), titanicRelease(start, end), seriesRelease(start, end)];
|
||||
return await Promise.resolve(result);
|
||||
}
|
||||
}
|
||||
|
||||
const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
|
||||
const startDate = randomDateBetween(start, end);
|
||||
const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // 2 hours later
|
||||
return {
|
||||
title: "Homarr Meetup",
|
||||
subTitle: "",
|
||||
description: "Yearly meetup of the Homarr community",
|
||||
startDate,
|
||||
endDate,
|
||||
image: null,
|
||||
location: "Mountains",
|
||||
indicatorColor: "#fa5252",
|
||||
links: [
|
||||
{
|
||||
href: "https://homarr.dev",
|
||||
name: "Homarr",
|
||||
logo: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
|
||||
color: "#000000",
|
||||
isDark: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
|
||||
title: "Titanic",
|
||||
subTitle: "A classic movie",
|
||||
description: "A tragic love story set on the ill-fated RMS Titanic.",
|
||||
startDate: randomDateBetween(start, end),
|
||||
endDate: null,
|
||||
image: {
|
||||
src: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
},
|
||||
location: null,
|
||||
metadata: {
|
||||
type: "radarr",
|
||||
releaseType: "inCinemas",
|
||||
},
|
||||
indicatorColor: "cyan",
|
||||
links: [
|
||||
{
|
||||
href: "https://www.imdb.com/title/tt0120338/",
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.svg",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
|
||||
title: "The Mandalorian",
|
||||
subTitle: "A Star Wars Series",
|
||||
description: "A lone bounty hunter in the outer reaches of the galaxy.",
|
||||
startDate: randomDateBetween(start, end),
|
||||
endDate: null,
|
||||
image: {
|
||||
src: "https://image.tmdb.org/t/p/original/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
|
||||
aspectRatio: { width: 7, height: 12 },
|
||||
badge: {
|
||||
content: "S1/E1",
|
||||
color: "red",
|
||||
},
|
||||
},
|
||||
location: null,
|
||||
indicatorColor: "blue",
|
||||
links: [
|
||||
{
|
||||
href: "https://www.imdb.com/title/tt8111088/",
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.svg",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
function randomDateBetween(start: Date, end: Date): Date {
|
||||
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
|
||||
}
|
||||
100
packages/integrations/src/mock/data/cluster-health-monitoring.ts
Normal file
100
packages/integrations/src/mock/data/cluster-health-monitoring.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { IClusterHealthMonitoringIntegration } from "../../interfaces/health-monitoring/health-monitoring-integration";
|
||||
import type { ClusterHealthMonitoring } from "../../types";
|
||||
|
||||
export class ClusterHealthMonitoringMockService implements IClusterHealthMonitoringIntegration {
|
||||
public async getClusterInfoAsync(): Promise<ClusterHealthMonitoring> {
|
||||
return Promise.resolve({
|
||||
nodes: Array.from({ length: 5 }, (_, index) => ClusterHealthMonitoringMockService.createNode(index)),
|
||||
lxcs: Array.from({ length: 3 }, (_, index) => ClusterHealthMonitoringMockService.createLxc(index)),
|
||||
vms: Array.from({ length: 7 }, (_, index) => ClusterHealthMonitoringMockService.createVm(index)),
|
||||
storages: Array.from({ length: 9 }, (_, index) => ClusterHealthMonitoringMockService.createStorage(index)),
|
||||
});
|
||||
}
|
||||
|
||||
private static createNode(index: number): ClusterHealthMonitoring["nodes"][number] {
|
||||
return {
|
||||
id: index.toString(),
|
||||
name: `Node ${index}`,
|
||||
isRunning: Math.random() > 0.1, // 90% chance of being running
|
||||
node: `Node ${index}`,
|
||||
status: Math.random() > 0.5 ? "online" : "offline",
|
||||
type: "node",
|
||||
uptime: Math.floor(Math.random() * 1000000), // Randomly generate uptime in seconds
|
||||
haState: null,
|
||||
...this.createResourceUsage(),
|
||||
};
|
||||
}
|
||||
|
||||
private static createResourceUsage() {
|
||||
const totalMemory = Math.pow(2, Math.floor(Math.random() * 6) + 1) * 1024 * 1024 * 1024; // Randomly generate between 2GB and 64GB
|
||||
const totalStorage = Math.pow(2, Math.floor(Math.random() * 6) + 1) * 1024 * 1024 * 1024; // Randomly generate between 2GB and 64GB
|
||||
|
||||
return {
|
||||
cpu: {
|
||||
cores: Math.pow(2, Math.floor(Math.random() * 5) + 1), // Randomly generate between 2 and 32 cores,
|
||||
utilization: Math.random(),
|
||||
},
|
||||
memory: {
|
||||
total: totalMemory,
|
||||
used: Math.floor(Math.random() * totalMemory), // Randomly generate used memory
|
||||
},
|
||||
network: {
|
||||
in: Math.floor(Math.random() * 1000), // Randomly generate network in
|
||||
out: Math.floor(Math.random() * 1000), // Randomly generate network out
|
||||
},
|
||||
storage: {
|
||||
total: totalStorage,
|
||||
used: Math.floor(Math.random() * totalStorage), // Randomly generate used storage
|
||||
read: Math.floor(Math.random() * 1000), // Randomly generate read
|
||||
write: Math.floor(Math.random() * 1000), // Randomly generate write
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static createVm(index: number): ClusterHealthMonitoring["vms"][number] {
|
||||
return {
|
||||
id: index.toString(),
|
||||
name: `VM ${index}`,
|
||||
vmId: index + 1000, // VM IDs start from 1000
|
||||
...this.createResourceUsage(),
|
||||
haState: null,
|
||||
isRunning: Math.random() > 0.1, // 90% chance of being running
|
||||
node: `Node ${Math.floor(index / 2)}`, // Assign to a node
|
||||
status: Math.random() > 0.5 ? "online" : "offline",
|
||||
type: "qemu",
|
||||
uptime: Math.floor(Math.random() * 1000000), // Randomly generate uptime in seconds
|
||||
};
|
||||
}
|
||||
|
||||
private static createLxc(index: number): ClusterHealthMonitoring["lxcs"][number] {
|
||||
return {
|
||||
id: index.toString(),
|
||||
name: `LXC ${index}`,
|
||||
vmId: index + 2000, // LXC IDs start from 2000
|
||||
...this.createResourceUsage(),
|
||||
haState: null,
|
||||
isRunning: Math.random() > 0.1, // 90% chance of being running
|
||||
node: `Node ${Math.floor(index / 2)}`, // Assign to a node
|
||||
status: Math.random() > 0.5 ? "online" : "offline",
|
||||
type: "lxc",
|
||||
uptime: Math.floor(Math.random() * 1000000), // Randomly generate uptime in seconds
|
||||
};
|
||||
}
|
||||
|
||||
private static createStorage(index: number): ClusterHealthMonitoring["storages"][number] {
|
||||
const total = Math.pow(2, Math.floor(Math.random() * 6) + 1) * 1024 * 1024 * 1024; // Randomly generate between 2GB and 64GB
|
||||
|
||||
return {
|
||||
id: index.toString(),
|
||||
name: `Storage ${index}`,
|
||||
isRunning: Math.random() > 0.1, // 90% chance of being running
|
||||
node: `Node ${Math.floor(index / 2)}`, // Assign to a node
|
||||
status: Math.random() > 0.5 ? "online" : "offline",
|
||||
isShared: Math.random() > 0.5, // 50% chance of being shared
|
||||
storagePlugin: `Plugin ${index}`,
|
||||
total,
|
||||
used: Math.floor(Math.random() * total), // Randomly generate used storage
|
||||
type: "storage",
|
||||
};
|
||||
}
|
||||
}
|
||||
26
packages/integrations/src/mock/data/dns-hole.ts
Normal file
26
packages/integrations/src/mock/data/dns-hole.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { DnsHoleSummaryIntegration } from "../../interfaces/dns-hole-summary/dns-hole-summary-integration";
|
||||
import type { DnsHoleSummary } from "../../types";
|
||||
|
||||
export class DnsHoleMockService implements DnsHoleSummaryIntegration {
|
||||
private static isEnabled = true;
|
||||
|
||||
public async getSummaryAsync(): Promise<DnsHoleSummary> {
|
||||
const blocked = Math.floor(Math.random() * Math.pow(10, 4)) + 1; // Ensure we never devide by zero
|
||||
const queries = Math.max(Math.floor(Math.random() * Math.pow(10, 5)), blocked);
|
||||
return await Promise.resolve({
|
||||
status: DnsHoleMockService.isEnabled ? "enabled" : "disabled",
|
||||
domainsBeingBlocked: Math.floor(Math.random() * Math.pow(10, 6)),
|
||||
adsBlockedToday: blocked,
|
||||
adsBlockedTodayPercentage: blocked / queries,
|
||||
dnsQueriesToday: queries,
|
||||
});
|
||||
}
|
||||
public async enableAsync(): Promise<void> {
|
||||
DnsHoleMockService.isEnabled = true;
|
||||
return await Promise.resolve();
|
||||
}
|
||||
public async disableAsync(_duration?: number): Promise<void> {
|
||||
DnsHoleMockService.isEnabled = false;
|
||||
return await Promise.resolve();
|
||||
}
|
||||
}
|
||||
58
packages/integrations/src/mock/data/download.ts
Normal file
58
packages/integrations/src/mock/data/download.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
|
||||
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
|
||||
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
|
||||
|
||||
export class DownloadClientMockService implements IDownloadClientIntegration {
|
||||
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
|
||||
return await Promise.resolve({
|
||||
status: {
|
||||
paused: Math.random() < 0.5,
|
||||
rates: {
|
||||
down: Math.floor(Math.random() * 5000),
|
||||
up: Math.floor(Math.random() * 5000),
|
||||
},
|
||||
types: ["torrent", "usenet"],
|
||||
},
|
||||
items: Array.from({ length: 20 }, (_, index) => DownloadClientMockService.createItem(index)).slice(
|
||||
0,
|
||||
input.limit,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
public async pauseQueueAsync(): Promise<void> {
|
||||
return await Promise.resolve();
|
||||
}
|
||||
|
||||
public async pauseItemAsync(_item: DownloadClientItem): Promise<void> {
|
||||
return await Promise.resolve();
|
||||
}
|
||||
|
||||
public async resumeQueueAsync(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
public async resumeItemAsync(_item: DownloadClientItem): Promise<void> {
|
||||
return await Promise.resolve();
|
||||
}
|
||||
|
||||
public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise<void> {
|
||||
return await Promise.resolve();
|
||||
}
|
||||
|
||||
private static createItem(index: number): DownloadClientItem {
|
||||
const progress = Math.random() < 0.5 ? Math.random() : 1;
|
||||
return {
|
||||
id: `item-${index}`,
|
||||
index,
|
||||
name: `Item ${index}`,
|
||||
type: Math.random() > 0.5 ? "torrent" : "usenet",
|
||||
progress,
|
||||
size: Math.floor(Math.random() * 10000) + 1,
|
||||
downSpeed: Math.floor(Math.random() * 5000),
|
||||
upSpeed: Math.floor(Math.random() * 5000),
|
||||
state: progress >= 1 ? "completed" : "downloading",
|
||||
time: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
23
packages/integrations/src/mock/data/indexer-manager.ts
Normal file
23
packages/integrations/src/mock/data/indexer-manager.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { IIndexerManagerIntegration } from "../../interfaces/indexer-manager/indexer-manager-integration";
|
||||
import type { Indexer } from "../../types";
|
||||
|
||||
export class IndexerManagerMockService implements IIndexerManagerIntegration {
|
||||
public async getIndexersAsync(): Promise<Indexer[]> {
|
||||
return await Promise.resolve(
|
||||
Array.from({ length: 10 }, (_, index) => IndexerManagerMockService.createIndexer(index + 1)),
|
||||
);
|
||||
}
|
||||
public async testAllAsync(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
private static createIndexer(index: number): Indexer {
|
||||
return {
|
||||
id: index,
|
||||
name: `Mock Indexer ${index}`,
|
||||
url: `https://mock-indexer-${index}.com`,
|
||||
enabled: Math.random() > 0.2, // 80% chance of being enabled
|
||||
status: Math.random() > 0.2, // 80% chance of being active
|
||||
};
|
||||
}
|
||||
}
|
||||
128
packages/integrations/src/mock/data/media-releases.ts
Normal file
128
packages/integrations/src/mock/data/media-releases.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { IMediaReleasesIntegration, MediaRelease } from "../../interfaces/media-releases";
|
||||
|
||||
export class MediaReleasesMockService implements IMediaReleasesIntegration {
|
||||
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
|
||||
return await Promise.resolve(mockMediaReleases);
|
||||
}
|
||||
}
|
||||
|
||||
export const mockMediaReleases: MediaRelease[] = [
|
||||
{
|
||||
id: "1",
|
||||
type: "movie",
|
||||
title: "Inception",
|
||||
subtitle: "A mind-bending thriller",
|
||||
description:
|
||||
"A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a CEO.",
|
||||
releaseDate: new Date("2010-07-16"),
|
||||
imageUrls: {
|
||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||
backdrop: "https://example.com/inception_backdrop.jpg",
|
||||
},
|
||||
producer: "Warner Bros.",
|
||||
price: 14.99,
|
||||
rating: "8.8/10",
|
||||
tags: ["Sci-Fi", "Thriller"],
|
||||
href: "https://example.com/inception",
|
||||
length: 148,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "tv",
|
||||
title: "Breaking Bad",
|
||||
subtitle: "S5E14 - Ozymandias",
|
||||
description: "When Walter White's secret is revealed, he must face the consequences of his actions.",
|
||||
releaseDate: new Date("2013-09-15"),
|
||||
imageUrls: {
|
||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||
backdrop: "https://example.com/breaking_bad_backdrop.jpg",
|
||||
},
|
||||
producer: "AMC",
|
||||
rating: "9.5/10",
|
||||
tags: ["Crime", "Drama"],
|
||||
href: "https://example.com/breaking_bad",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
type: "music",
|
||||
title: "Random Access Memories",
|
||||
subtitle: "Daft Punk",
|
||||
description: "The fourth studio album by French electronic music duo Daft Punk.",
|
||||
releaseDate: new Date("2013-05-17"),
|
||||
imageUrls: {
|
||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||
backdrop: "https://example.com/ram_backdrop.jpg",
|
||||
},
|
||||
producer: "Columbia Records",
|
||||
price: 9.99,
|
||||
rating: "8.5/10",
|
||||
tags: ["Electronic", "Dance", "Pop", "Funk"],
|
||||
href: "https://example.com/ram",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
type: "book",
|
||||
title: "The Great Gatsby",
|
||||
subtitle: "F. Scott Fitzgerald",
|
||||
description: "A novel about the American dream and the disillusionment that comes with it.",
|
||||
releaseDate: new Date("1925-04-10"),
|
||||
imageUrls: {
|
||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||
backdrop: "https://example.com/gatsby_backdrop.jpg",
|
||||
},
|
||||
producer: "Scribner",
|
||||
price: 10.99,
|
||||
rating: "4.2/5",
|
||||
tags: ["Classic", "Fiction"],
|
||||
href: "https://example.com/gatsby",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
type: "game",
|
||||
title: "The Legend of Zelda: Breath of the Wild",
|
||||
subtitle: "Nintendo Switch",
|
||||
description: "An open-world action-adventure game set in the fantasy land of Hyrule.",
|
||||
releaseDate: new Date("2017-03-03"),
|
||||
imageUrls: {
|
||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||
backdrop: "https://example.com/zelda_backdrop.jpg",
|
||||
},
|
||||
producer: "Nintendo",
|
||||
price: 59.99,
|
||||
rating: "10/10",
|
||||
tags: ["Action", "Adventure"],
|
||||
href: "https://example.com/zelda",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
type: "article",
|
||||
title: "The Rise of AI in Healthcare",
|
||||
subtitle: "Tech Innovations",
|
||||
description: "Exploring the impact of artificial intelligence on the healthcare industry.",
|
||||
releaseDate: new Date("2023-10-01"),
|
||||
imageUrls: {
|
||||
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
|
||||
backdrop: "https://example.com/ai_healthcare_backdrop.jpg",
|
||||
},
|
||||
producer: "Tech Innovations",
|
||||
rating: "4.8/5",
|
||||
tags: ["Technology", "Healthcare"],
|
||||
href: "https://example.com/ai_healthcare",
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
type: "video",
|
||||
title: "Wir LIEBEN unsere MAMAS | 50 Fragen zu Mamas",
|
||||
releaseDate: new Date("2024-05-18T17:00:00Z"),
|
||||
imageUrls: {
|
||||
poster:
|
||||
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
|
||||
backdrop:
|
||||
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
|
||||
},
|
||||
producer: "PietSmiet",
|
||||
rating: "1K",
|
||||
tags: [],
|
||||
href: "https://www.youtube.com/watch?v=a3qyfXc1Pfg",
|
||||
},
|
||||
];
|
||||
100
packages/integrations/src/mock/data/media-request.ts
Normal file
100
packages/integrations/src/mock/data/media-request.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { IMediaRequestIntegration } from "../../interfaces/media-requests/media-request-integration";
|
||||
import type {
|
||||
MediaAvailability,
|
||||
MediaInformation,
|
||||
MediaRequest,
|
||||
MediaRequestStatus,
|
||||
RequestStats,
|
||||
RequestUser,
|
||||
} from "../../types";
|
||||
import { mediaAvailabilities, mediaRequestStatuses } from "../../types";
|
||||
|
||||
export class MediaRequestMockService implements IMediaRequestIntegration {
|
||||
public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number): Promise<MediaInformation> {
|
||||
return await Promise.resolve({
|
||||
id,
|
||||
overview: `Overview of media ${id}`,
|
||||
posterPath: "https://image.tmdb.org/t/p/original/ztvm7C7hiUpS3CZRXFmJxljICzK.jpg",
|
||||
seasons:
|
||||
mediaType === "tv"
|
||||
? Array.from({ length: 3 }, (_, seasonIndex) => ({
|
||||
id: seasonIndex + 1,
|
||||
name: `Season ${seasonIndex + 1}`,
|
||||
episodeCount: Math.floor(Math.random() * 10) + 1,
|
||||
overview: `Overview of season ${seasonIndex + 1} of media ${id}`,
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
public async requestMediaAsync(_mediaType: "movie" | "tv", _id: number, _seasons?: number[]): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
public async getRequestsAsync(): Promise<MediaRequest[]> {
|
||||
const result = await Promise.resolve(
|
||||
Array.from({ length: 10 }, (_, index) => MediaRequestMockService.createRequest(index)),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
public async getStatsAsync(): Promise<RequestStats> {
|
||||
return await Promise.resolve({
|
||||
approved: Math.floor(Math.random() * 100),
|
||||
available: Math.floor(Math.random() * 100),
|
||||
declined: Math.floor(Math.random() * 100),
|
||||
movie: Math.floor(Math.random() * 100),
|
||||
pending: Math.floor(Math.random() * 100),
|
||||
processing: Math.floor(Math.random() * 100),
|
||||
total: Math.floor(Math.random() * 1000),
|
||||
tv: Math.floor(Math.random() * 100),
|
||||
});
|
||||
}
|
||||
public async getUsersAsync(): Promise<RequestUser[]> {
|
||||
return await Promise.resolve(Array.from({ length: 5 }, (_, index) => MediaRequestMockService.createUser(index)));
|
||||
}
|
||||
|
||||
public async approveRequestAsync(_requestId: number): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
public async declineRequestAsync(_requestId: number): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
private static createUser(index: number): RequestUser {
|
||||
return {
|
||||
id: index,
|
||||
displayName: `User ${index}`,
|
||||
avatar: "/images/mock/avatar.jpg",
|
||||
requestCount: Math.floor(Math.random() * 100),
|
||||
link: `https://example.com/user/${index}`,
|
||||
};
|
||||
}
|
||||
|
||||
private static createRequest(index: number): MediaRequest {
|
||||
return {
|
||||
id: index,
|
||||
name: `Media Request ${index}`,
|
||||
availability: this.randomAvailability(),
|
||||
backdropImageUrl: "https://image.tmdb.org/t/p/original/ztvm7C7hiUpS3CZRXFmJxljICzK.jpg",
|
||||
posterImagePath: "https://image.tmdb.org/t/p/original/ztvm7C7hiUpS3CZRXFmJxljICzK.jpg",
|
||||
createdAt: new Date(),
|
||||
airDate: new Date(Date.now() + (Math.random() - 0.5) * 1000 * 60 * 60 * 24 * 365 * 4),
|
||||
status: this.randomStatus(),
|
||||
href: `https://example.com/media/${index}`,
|
||||
type: Math.random() > 0.5 ? "movie" : "tv",
|
||||
requestedBy: {
|
||||
avatar: "/images/mock/avatar.jpg",
|
||||
displayName: `User ${index}`,
|
||||
id: index,
|
||||
link: `https://example.com/user/${index}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static randomAvailability(): MediaAvailability {
|
||||
return mediaAvailabilities.at(Math.floor(Math.random() * mediaAvailabilities.length)) ?? "unknown";
|
||||
}
|
||||
|
||||
private static randomStatus(): MediaRequestStatus {
|
||||
return mediaRequestStatuses.at(Math.floor(Math.random() * mediaRequestStatuses.length)) ?? "pending";
|
||||
}
|
||||
}
|
||||
36
packages/integrations/src/mock/data/media-server.ts
Normal file
36
packages/integrations/src/mock/data/media-server.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { IMediaServerIntegration } from "../../interfaces/media-server/media-server-integration";
|
||||
import type { CurrentSessionsInput, StreamSession } from "../../interfaces/media-server/media-server-types";
|
||||
|
||||
export class MediaServerMockService implements IMediaServerIntegration {
|
||||
public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
|
||||
return await Promise.resolve(
|
||||
Array.from({ length: 10 }, (_, index) => MediaServerMockService.createSession(index)).filter(
|
||||
(session) => !options.showOnlyPlaying || session.currentlyPlaying !== null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private static createSession(index: number): StreamSession {
|
||||
return {
|
||||
sessionId: `session-${index}`,
|
||||
sessionName: `Session ${index}`,
|
||||
user: {
|
||||
userId: `user-${index}`,
|
||||
username: `User${index}`,
|
||||
profilePictureUrl: "/images/mock/avatar.jpg",
|
||||
},
|
||||
currentlyPlaying:
|
||||
Math.random() > 0.9 // 10% chance of being null (not currently playing)
|
||||
? {
|
||||
type: "movie",
|
||||
name: `Movie ${index}`,
|
||||
seasonName: undefined,
|
||||
episodeName: null,
|
||||
albumName: null,
|
||||
episodeCount: null,
|
||||
metadata: null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
68
packages/integrations/src/mock/data/media-transcoding.ts
Normal file
68
packages/integrations/src/mock/data/media-transcoding.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { IMediaTranscodingIntegration } from "../../interfaces/media-transcoding/media-transcoding-integration";
|
||||
import type {
|
||||
TdarrQueue,
|
||||
TdarrStatistics,
|
||||
TdarrWorker,
|
||||
} from "../../interfaces/media-transcoding/media-transcoding-types";
|
||||
|
||||
export class MediaTranscodingMockService implements IMediaTranscodingIntegration {
|
||||
public async getStatisticsAsync(): Promise<TdarrStatistics> {
|
||||
return await Promise.resolve({
|
||||
libraryName: "Mock Library",
|
||||
totalFileCount: 1000,
|
||||
totalTranscodeCount: 200,
|
||||
totalHealthCheckCount: 150,
|
||||
failedTranscodeCount: 10,
|
||||
failedHealthCheckCount: 5,
|
||||
stagedTranscodeCount: 20,
|
||||
stagedHealthCheckCount: 15,
|
||||
totalSavedSpace: 5000000,
|
||||
audioCodecs: [{ name: "AAC", value: 300 }],
|
||||
audioContainers: [{ name: "MP4", value: 200 }],
|
||||
videoCodecs: [{ name: "H.264", value: 400 }],
|
||||
videoContainers: [{ name: "MKV", value: 250 }],
|
||||
videoResolutions: [{ name: "1080p", value: 600 }],
|
||||
healthCheckStatus: [{ name: "Healthy", value: 100 }],
|
||||
transcodeStatus: [{ name: "Transcode success", value: 180 }],
|
||||
});
|
||||
}
|
||||
public async getWorkersAsync(): Promise<TdarrWorker[]> {
|
||||
return await Promise.resolve(
|
||||
Array.from({ length: 5 }, (_, index) => MediaTranscodingMockService.createWorker(index)),
|
||||
);
|
||||
}
|
||||
public async getQueueAsync(firstItemIndex: number, pageSize: number): Promise<TdarrQueue> {
|
||||
return await Promise.resolve({
|
||||
array: Array.from({ length: pageSize }, (_, index) => ({
|
||||
id: `item-${firstItemIndex + index}`,
|
||||
healthCheck: "Pending",
|
||||
transcode: "Pending",
|
||||
filePath: `/path/to/file-${firstItemIndex + index}.mkv`,
|
||||
fileSize: 1000000000 + (firstItemIndex + index) * 100000000, // in bytes
|
||||
container: "MKV",
|
||||
codec: "H.264",
|
||||
resolution: "1080p",
|
||||
type: "transcode",
|
||||
})),
|
||||
totalCount: 50,
|
||||
startIndex: firstItemIndex,
|
||||
endIndex: firstItemIndex + pageSize - 1,
|
||||
});
|
||||
}
|
||||
|
||||
private static createWorker(index: number): TdarrWorker {
|
||||
return {
|
||||
id: `worker-${index}`,
|
||||
filePath: `/path/to/file-${index}.mkv`,
|
||||
fps: 24 + index,
|
||||
percentage: index * 20,
|
||||
ETA: `${30 - index * 5} minutes`,
|
||||
jobType: "Transcode",
|
||||
status: "In Progress",
|
||||
step: `Step ${index + 1}`,
|
||||
originalSize: 1000000000 + index * 100000000, // in bytes
|
||||
estimatedSize: 800000000 + index * 50000000, // in bytes
|
||||
outputSize: 750000000 + index * 40000000, // in bytes
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { NetworkControllerSummaryIntegration } from "../../interfaces/network-controller-summary/network-controller-summary-integration";
|
||||
import type { NetworkControllerSummary } from "../../types";
|
||||
|
||||
export class NetworkControllerSummaryMockService implements NetworkControllerSummaryIntegration {
|
||||
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
|
||||
return await Promise.resolve({
|
||||
lan: {
|
||||
guests: 5,
|
||||
users: 10,
|
||||
status: "enabled",
|
||||
},
|
||||
vpn: {
|
||||
users: 3,
|
||||
status: "enabled",
|
||||
},
|
||||
wanStatus: "disabled",
|
||||
wifi: {
|
||||
status: "disabled",
|
||||
guests: 0,
|
||||
users: 0,
|
||||
},
|
||||
www: {
|
||||
latency: 22,
|
||||
status: "enabled",
|
||||
ping: 32,
|
||||
uptime: 3600,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
19
packages/integrations/src/mock/data/notifications.ts
Normal file
19
packages/integrations/src/mock/data/notifications.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Notification } from "../../interfaces/notifications/notification-types";
|
||||
import type { INotificationsIntegration } from "../../interfaces/notifications/notifications-integration";
|
||||
|
||||
export class NotificationsMockService implements INotificationsIntegration {
|
||||
public async getNotificationsAsync(): Promise<Notification[]> {
|
||||
return await Promise.resolve(
|
||||
Array.from({ length: 10 }, (_, index) => NotificationsMockService.createNotification(index)),
|
||||
);
|
||||
}
|
||||
|
||||
private static createNotification(index: number): Notification {
|
||||
return {
|
||||
id: index.toString(),
|
||||
time: new Date(Date.now() - Math.random() * 1000000), // Random time within the next 11 days
|
||||
title: `Notification ${index}`,
|
||||
body: `This is the body of notification ${index}.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
27
packages/integrations/src/mock/data/smart-home.ts
Normal file
27
packages/integrations/src/mock/data/smart-home.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ISmartHomeIntegration } from "../../interfaces/smart-home/smart-home-integration";
|
||||
import type { EntityStateResult } from "../../interfaces/smart-home/smart-home-types";
|
||||
|
||||
export class SmartHomeMockService implements ISmartHomeIntegration {
|
||||
public async getEntityStateAsync(entityId: string): Promise<EntityStateResult> {
|
||||
return await Promise.resolve({
|
||||
success: true as const,
|
||||
data: {
|
||||
entity_id: entityId,
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: `Mock Entity ${entityId}`,
|
||||
device_class: "light",
|
||||
supported_features: 1,
|
||||
},
|
||||
last_changed: new Date(),
|
||||
last_updated: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
public async triggerAutomationAsync(_entityId: string): Promise<boolean> {
|
||||
return await Promise.resolve(true);
|
||||
}
|
||||
public async triggerToggleAsync(_entityId: string): Promise<boolean> {
|
||||
return await Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { SystemHealthMonitoring } from "../..";
|
||||
import type { ISystemHealthMonitoringIntegration } from "../../interfaces/health-monitoring/health-monitoring-integration";
|
||||
|
||||
export class SystemHealthMonitoringMockService implements ISystemHealthMonitoringIntegration {
|
||||
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
|
||||
return await Promise.resolve({
|
||||
version: "1.0.0",
|
||||
cpuModelName: "Mock CPU",
|
||||
cpuUtilization: Math.random(),
|
||||
memUsedInBytes: 4 * 1024 * 1024 * 1024, // 4 GB in bytes
|
||||
memAvailableInBytes: 8 * 1024 * 1024 * 1024, // 8 GB in bytes
|
||||
availablePkgUpdates: 0,
|
||||
network: {
|
||||
up: 1024 * 16,
|
||||
down: 1024 * 16 * 6,
|
||||
},
|
||||
rebootRequired: false,
|
||||
cpuTemp: Math.floor(Math.random() * 100), // Random temperature between 0 and 99
|
||||
uptime: Math.floor(Math.random() * 1000000), // Random uptime in seconds
|
||||
fileSystem: Array.from({ length: 3 }, (_, index) => ({
|
||||
deviceName: `sha${index + 1}`,
|
||||
used: "1 GB",
|
||||
available: "500 MB",
|
||||
percentage: Math.floor(Math.random() * 100), // Random percentage between 0 and 99
|
||||
})),
|
||||
loadAverage: {
|
||||
"1min": Math.random() * 10,
|
||||
"5min": Math.random() * 10,
|
||||
"15min": Math.random() * 10,
|
||||
},
|
||||
smart: [
|
||||
{
|
||||
deviceName: "Mock Device",
|
||||
temperature: Math.floor(Math.random() * 100), // Random temperature between 0 and 99
|
||||
overallStatus: "OK",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
126
packages/integrations/src/mock/mock-integration.ts
Normal file
126
packages/integrations/src/mock/mock-integration.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ICalendarIntegration } from "../interfaces/calendar/calendar-integration";
|
||||
import type { DnsHoleSummaryIntegration } from "../interfaces/dns-hole-summary/dns-hole-summary-integration";
|
||||
import type { IDownloadClientIntegration } from "../interfaces/downloads/download-client-integration";
|
||||
import type {
|
||||
IClusterHealthMonitoringIntegration,
|
||||
ISystemHealthMonitoringIntegration,
|
||||
} from "../interfaces/health-monitoring/health-monitoring-integration";
|
||||
import type { IIndexerManagerIntegration } from "../interfaces/indexer-manager/indexer-manager-integration";
|
||||
import type { IMediaReleasesIntegration } from "../interfaces/media-releases";
|
||||
import type { IMediaRequestIntegration } from "../interfaces/media-requests/media-request-integration";
|
||||
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
|
||||
import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
|
||||
import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-integration";
|
||||
import type { ISmartHomeIntegration } from "../interfaces/smart-home/smart-home-integration";
|
||||
import { CalendarMockService } from "./data/calendar";
|
||||
import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monitoring";
|
||||
import { DnsHoleMockService } from "./data/dns-hole";
|
||||
import { DownloadClientMockService } from "./data/download";
|
||||
import { IndexerManagerMockService } from "./data/indexer-manager";
|
||||
import { MediaReleasesMockService } from "./data/media-releases";
|
||||
import { MediaRequestMockService } from "./data/media-request";
|
||||
import { MediaServerMockService } from "./data/media-server";
|
||||
import { MediaTranscodingMockService } from "./data/media-transcoding";
|
||||
import { NetworkControllerSummaryMockService } from "./data/network-controller-summary";
|
||||
import { NotificationsMockService } from "./data/notifications";
|
||||
import { SmartHomeMockService } from "./data/smart-home";
|
||||
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
|
||||
|
||||
export class MockIntegration
|
||||
extends Integration
|
||||
implements
|
||||
DnsHoleSummaryIntegration,
|
||||
ICalendarIntegration,
|
||||
IDownloadClientIntegration,
|
||||
IClusterHealthMonitoringIntegration,
|
||||
ISystemHealthMonitoringIntegration,
|
||||
IIndexerManagerIntegration,
|
||||
IMediaReleasesIntegration,
|
||||
IMediaRequestIntegration,
|
||||
IMediaServerIntegration,
|
||||
IMediaTranscodingIntegration,
|
||||
NetworkControllerSummaryIntegration,
|
||||
ISmartHomeIntegration
|
||||
{
|
||||
private static readonly dnsHole = new DnsHoleMockService();
|
||||
private static readonly calendar = new CalendarMockService();
|
||||
private static readonly downloadClient = new DownloadClientMockService();
|
||||
private static readonly clusterMonitoring = new ClusterHealthMonitoringMockService();
|
||||
private static readonly systemMonitoring = new SystemHealthMonitoringMockService();
|
||||
private static readonly indexerManager = new IndexerManagerMockService();
|
||||
private static readonly mediaReleases = new MediaReleasesMockService();
|
||||
private static readonly mediaRequest = new MediaRequestMockService();
|
||||
private static readonly mediaServer = new MediaServerMockService();
|
||||
private static readonly mediaTranscoding = new MediaTranscodingMockService();
|
||||
private static readonly networkController = new NetworkControllerSummaryMockService();
|
||||
private static readonly notifications = new NotificationsMockService();
|
||||
private static readonly smartHome = new SmartHomeMockService();
|
||||
|
||||
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
|
||||
return await Promise.resolve({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
// CalendarIntegration
|
||||
getCalendarEventsAsync = MockIntegration.calendar.getCalendarEventsAsync.bind(MockIntegration.calendar);
|
||||
|
||||
// DnsHoleSummaryIntegration
|
||||
getSummaryAsync = MockIntegration.dnsHole.getSummaryAsync.bind(MockIntegration.dnsHole);
|
||||
enableAsync = MockIntegration.dnsHole.enableAsync.bind(MockIntegration.dnsHole);
|
||||
disableAsync = MockIntegration.dnsHole.disableAsync.bind(MockIntegration.dnsHole);
|
||||
|
||||
// IDownloadClientIntegration
|
||||
getClientJobsAndStatusAsync = MockIntegration.downloadClient.getClientJobsAndStatusAsync.bind(
|
||||
MockIntegration.downloadClient,
|
||||
);
|
||||
pauseQueueAsync = MockIntegration.downloadClient.pauseQueueAsync.bind(MockIntegration.downloadClient);
|
||||
pauseItemAsync = MockIntegration.downloadClient.pauseItemAsync.bind(MockIntegration.downloadClient);
|
||||
resumeQueueAsync = MockIntegration.downloadClient.resumeQueueAsync.bind(MockIntegration.downloadClient);
|
||||
resumeItemAsync = MockIntegration.downloadClient.resumeItemAsync.bind(MockIntegration.downloadClient);
|
||||
deleteItemAsync = MockIntegration.downloadClient.deleteItemAsync.bind(MockIntegration.downloadClient);
|
||||
|
||||
// Health Monitoring Integrations
|
||||
getSystemInfoAsync = MockIntegration.systemMonitoring.getSystemInfoAsync.bind(MockIntegration.systemMonitoring);
|
||||
getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.downloadClient);
|
||||
|
||||
// IndexerManagerIntegration
|
||||
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
|
||||
testAllAsync = MockIntegration.indexerManager.testAllAsync.bind(MockIntegration.indexerManager);
|
||||
|
||||
// MediaReleasesIntegration
|
||||
getMediaReleasesAsync = MockIntegration.mediaReleases.getMediaReleasesAsync.bind(MockIntegration.mediaReleases);
|
||||
|
||||
// MediaRequestIntegration
|
||||
getSeriesInformationAsync = MockIntegration.mediaRequest.getSeriesInformationAsync.bind(MockIntegration.mediaRequest);
|
||||
requestMediaAsync = MockIntegration.mediaRequest.requestMediaAsync.bind(MockIntegration.mediaRequest);
|
||||
getRequestsAsync = MockIntegration.mediaRequest.getRequestsAsync.bind(MockIntegration.mediaRequest);
|
||||
getStatsAsync = MockIntegration.mediaRequest.getStatsAsync.bind(MockIntegration.mediaRequest);
|
||||
getUsersAsync = MockIntegration.mediaRequest.getUsersAsync.bind(MockIntegration.mediaRequest);
|
||||
approveRequestAsync = MockIntegration.mediaRequest.approveRequestAsync.bind(MockIntegration.mediaRequest);
|
||||
declineRequestAsync = MockIntegration.mediaRequest.declineRequestAsync.bind(MockIntegration.mediaRequest);
|
||||
|
||||
// MediaServerIntegration
|
||||
getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaRequest);
|
||||
|
||||
// MediaTranscodingIntegration
|
||||
getStatisticsAsync = MockIntegration.mediaTranscoding.getStatisticsAsync.bind(MockIntegration.mediaTranscoding);
|
||||
getWorkersAsync = MockIntegration.mediaTranscoding.getWorkersAsync.bind(MockIntegration.mediaTranscoding);
|
||||
getQueueAsync = MockIntegration.mediaTranscoding.getQueueAsync.bind(MockIntegration.mediaTranscoding);
|
||||
|
||||
// NetworkControllerSummaryIntegration
|
||||
getNetworkSummaryAsync = MockIntegration.networkController.getNetworkSummaryAsync.bind(
|
||||
MockIntegration.networkController,
|
||||
);
|
||||
|
||||
// NotificationsIntegration
|
||||
getNotificationsAsync = MockIntegration.notifications.getNotificationsAsync.bind(MockIntegration.notifications);
|
||||
|
||||
// SmartHomeIntegration
|
||||
getEntityStateAsync = MockIntegration.smartHome.getEntityStateAsync.bind(MockIntegration.smartHome);
|
||||
triggerAutomationAsync = MockIntegration.smartHome.triggerAutomationAsync.bind(MockIntegration.smartHome);
|
||||
triggerToggleAsync = MockIntegration.smartHome.triggerToggleAsync.bind(MockIntegration.smartHome);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user