feat(integrations): add mock integration (#3505)

This commit is contained in:
Meier Lukas
2025-07-04 09:49:18 +02:00
committed by GitHub
parent 350a531d32
commit 58d5b14c51
73 changed files with 1049 additions and 156 deletions

View File

@@ -37,4 +37,7 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
TURBO_TELEMETRY_DISABLED=1
# Enable kubernetes tool
# ENABLE_KUBERNETES=true
# ENABLE_KUBERNETES=true
# Enable mock integration
UNSAFE_ENABLE_MOCK_INTEGRATION=true

View File

@@ -29,6 +29,7 @@
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"@homarr/env": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/gridstack": "^1.12.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

View File

@@ -10,15 +10,20 @@ import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
export const IntegrationCreateDropdownContent = () => {
interface IntegrationCreateDropdownContentProps {
enableMockIntegration: boolean;
}
export const IntegrationCreateDropdownContent = ({ enableMockIntegration }: IntegrationCreateDropdownContentProps) => {
const t = useI18n();
const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => {
return integrationKinds.filter((kind) =>
getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()),
);
}, [search]);
return integrationKinds
.filter((kind) => enableMockIntegration || kind !== "mock")
.filter((kind) => getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()))
.sort((kindA, kindB) => getIntegrationName(kindA).localeCompare(getIntegrationName(kindB)));
}, [search, enableMockIntegration]);
const handleSearch = React.useCallback(
(event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value),

View File

@@ -41,6 +41,7 @@ import { CountBadge, IntegrationAvatar } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { env } from "~/env";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
@@ -114,7 +115,7 @@ const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
>
{children}
<MenuDropdown>
<IntegrationCreateDropdownContent />
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
</MenuDropdown>
</Menu>
);

9
apps/nextjs/src/env.ts Normal file
View File

@@ -0,0 +1,9 @@
import { createEnv } from "@homarr/env";
import { createBooleanSchema } from "@homarr/env/schemas";
export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
},
experimental__runtimeEnv: process.env,
});

View File

@@ -1,6 +1,6 @@
import { observable } from "@trpc/server/observable";
import type { HealthMonitoring } from "@homarr/integrations";
import type { SystemHealthMonitoring } from "@homarr/integrations";
import type { ProxmoxClusterInfo } from "@homarr/integrations/types";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
@@ -9,7 +9,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot"))
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "mock"))
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
@@ -26,9 +26,9 @@ export const healthMonitoringRouter = createTRPCRouter({
);
}),
subscribeSystemHealthStatus: publicProcedure
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot"))
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "mock"))
.subscription(({ ctx }) => {
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) {
const innerHandler = systemInfoRequestHandler.handler(integration, {});
@@ -49,14 +49,14 @@ export const healthMonitoringRouter = createTRPCRouter({
});
}),
getClusterHealthStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "proxmox"))
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return data;
}),
subscribeClusterHealthStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "proxmox"))
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.subscription(({ ctx }) => {
return observable<ProxmoxClusterInfo>((emit) => {
const unsubscribes: (() => void)[] = [];

View File

@@ -8,7 +8,8 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC
createRequestIntegrationJobHandler(
(integration, itemOptions: Record<string, never>) => {
const { kind } = integration;
if (kind !== "proxmox") {
if (kind !== "proxmox" && kind !== "mock") {
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
}
return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions);

View File

@@ -176,6 +176,25 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg",
category: ["notifications"],
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/vitest.svg",
category: [
"calendar",
"dnsHole",
"downloadClient",
"healthMonitoring",
"indexerManager",
"mediaRequest",
"mediaService",
"mediaTranscoding",
"networkController",
"notifications",
"smartHomeServer",
],
},
} as const satisfies Record<string, integrationDefinition>;
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;

View File

@@ -20,6 +20,7 @@ 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 { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
@@ -94,6 +95,7 @@ export const integrationCreators = {
nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration,
ntfy: NTFYIntegration,
mock: MockIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {

View File

@@ -12,9 +12,10 @@ 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 { HealthMonitoring } from "../types";
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 {
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);
@@ -26,7 +27,7 @@ export class DashDotIntegration extends Integration {
};
}
public async getSystemInfoAsync(): Promise<HealthMonitoring> {
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const info = await this.getInfoAsync();
const cpuLoad = await this.getCurrentCpuLoadAsync();
const memoryLoad = await this.getCurrentMemoryLoadAsync();

View File

@@ -4,14 +4,15 @@ import type { fetch as undiciFetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
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 { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
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 DownloadClientIntegration {
export class Aria2Integration extends Integration implements IDownloadClientIntegration {
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const client = this.getClient();
const keys: (keyof Aria2Download)[] = [

View File

@@ -6,16 +6,17 @@ import { createCertificateAgentAsync } from "@homarr/certificates/server";
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 { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
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 DownloadClientIntegration {
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();

View File

@@ -4,15 +4,16 @@ import type { fetch as undiciFetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
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 { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
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 DownloadClientIntegration {
export class NzbGetIntegration extends Integration implements IDownloadClientIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await this.nzbGetApiCallWithCustomFetchAsync(input.fetchAsync, "version");
return {

View File

@@ -6,16 +6,17 @@ import { createCertificateAgentAsync } from "@homarr/certificates/server";
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 { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
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 DownloadClientIntegration {
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();

View File

@@ -5,17 +5,18 @@ import type { fetch as undiciFetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
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 { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
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 DownloadClientIntegration {
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" });

View File

@@ -6,15 +6,16 @@ import { createCertificateAgentAsync } from "@homarr/certificates/server";
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 { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
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 DownloadClientIntegration {
export class TransmissionIntegration extends Integration implements IDownloadClientIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.getClientAsync(input.dispatcher);
await client.getSession();

View File

@@ -7,7 +7,8 @@ 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 { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session";
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";
const sessionSchema = z.object({
@@ -30,7 +31,7 @@ const sessionSchema = z.object({
UserName: z.string().nullish(),
});
export class EmbyIntegration extends Integration {
export class EmbyIntegration extends Integration implements IMediaServerIntegration {
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"`;

View File

@@ -5,9 +5,10 @@ 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 { ISmartHomeIntegration } from "../interfaces/smart-home/smart-home-integration";
import { entityStateSchema } from "./homeassistant-types";
export class HomeAssistantIntegration extends Integration {
export class HomeAssistantIntegration extends Integration implements ISmartHomeIntegration {
public async getEntityStateAsync(entityId: string) {
try {
const response = await this.getAsync(`/api/states/${entityId}`);
@@ -15,6 +16,7 @@ export class HomeAssistantIntegration extends Integration {
if (!response.ok) {
logger.warn(`Response did not indicate success`);
return {
success: false as const,
error: "Response did not indicate success",
};
}

View File

@@ -7,7 +7,6 @@ export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorren
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration";
@@ -28,14 +27,17 @@ 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 { HealthMonitoring } from "./interfaces/health-monitoring/healt-monitoring";
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request";
export type { StreamSession } from "./interfaces/media-server/session";
export type { TdarrQueue } from "./interfaces/media-transcoding/queue";
export type { TdarrPieSegment, TdarrStatistics } from "./interfaces/media-transcoding/statistics";
export type { TdarrWorker } from "./interfaces/media-transcoding/workers";
export type { Notification } from "./interfaces/notifications/notification";
export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types";
export { MediaRequestStatus } 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 { Notification } from "./interfaces/notifications/notification-types";
// Schemas
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";

View File

@@ -0,0 +1,5 @@
import type { CalendarEvent } from "./calendar-types";
export interface ICalendarIntegration {
getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored: boolean): Promise<CalendarEvent[]>;
}

View File

@@ -1,18 +1,17 @@
import { Integration } from "../../base/integration";
import type { DownloadClientJobsAndStatus } from "./download-client-data";
import type { DownloadClientItem } from "./download-client-items";
export abstract class DownloadClientIntegration extends Integration {
export interface IDownloadClientIntegration {
/** Get download client's status and list of all of it's items */
public abstract getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus>;
getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus>;
/** Pauses the client or all of it's items */
public abstract pauseQueueAsync(): Promise<void>;
pauseQueueAsync(): Promise<void>;
/** Pause a single item using it's ID */
public abstract pauseItemAsync(item: DownloadClientItem): Promise<void>;
pauseItemAsync(item: DownloadClientItem): Promise<void>;
/** Resumes the client or all of it's items */
public abstract resumeQueueAsync(): Promise<void>;
resumeQueueAsync(): Promise<void>;
/** Resume a single item using it's ID */
public abstract resumeItemAsync(item: DownloadClientItem): Promise<void>;
resumeItemAsync(item: DownloadClientItem): Promise<void>;
/** Delete an entry on the client or a file from disk */
public abstract deleteItemAsync(item: DownloadClientItem, fromDisk: boolean): Promise<void>;
deleteItemAsync(item: DownloadClientItem, fromDisk: boolean): Promise<void>;
}

View File

@@ -0,0 +1,9 @@
import type { ClusterHealthMonitoring, SystemHealthMonitoring } from "./health-monitoring-types";
export interface ISystemHealthMonitoringIntegration {
getSystemInfoAsync(): Promise<SystemHealthMonitoring>;
}
export interface IClusterHealthMonitoringIntegration {
getClusterInfoAsync(): Promise<ClusterHealthMonitoring>;
}

View File

@@ -1,4 +1,6 @@
export interface HealthMonitoring {
import type { LxcResource, NodeResource, QemuResource, StorageResource } from "../../types";
export interface SystemHealthMonitoring {
version: string;
cpuModelName: string;
cpuUtilization: number;
@@ -25,3 +27,11 @@ export interface HealthMonitoring {
overallStatus: string;
}[];
}
// TODO: in the future decouple this from the Proxmox integration
export interface ClusterHealthMonitoring {
nodes: NodeResource[];
lxcs: LxcResource[];
vms: QemuResource[];
storages: StorageResource[];
}

View File

@@ -0,0 +1,6 @@
import type { Indexer } from "./indexer-manager-types";
export interface IIndexerManagerIntegration {
getIndexersAsync(): Promise<Indexer[]>;
testAllAsync(): Promise<void>;
}

View File

@@ -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>;
}

View File

@@ -1,3 +1,25 @@
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;

View File

@@ -0,0 +1,5 @@
import type { CurrentSessionsInput, StreamSession } from "./media-server-types";
export interface IMediaServerIntegration {
getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]>;
}

View File

@@ -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>;
}

View File

@@ -1,3 +1,20 @@
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;
@@ -21,3 +38,17 @@ export interface TdarrStatistics {
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;
}

View File

@@ -1,16 +0,0 @@
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;
}

View File

@@ -1,13 +0,0 @@
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;
}

View File

@@ -1,6 +1,5 @@
import { Integration } from "../../base/integration";
import type { Notification } from "./notification";
import type { Notification } from "./notification-types";
export abstract class NotificationsIntegration extends Integration {
public abstract getNotificationsAsync(): Promise<Notification[]>;
export interface INotificationsIntegration {
getNotificationsAsync(): Promise<Notification[]>;
}

View File

@@ -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>;
}

View File

@@ -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;
};

View File

@@ -11,10 +11,11 @@ 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 { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session";
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration {
export class JellyfinIntegration extends Integration implements IMediaServerIntegration {
private readonly jellyfin: Jellyfin = new Jellyfin({
clientInfo: {
name: "Homarr",

View File

@@ -3,13 +3,15 @@ import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
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 { CalendarEvent } from "../../calendar-types";
import { MediaOrganizerIntegration } from "../media-organizer-integration";
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer";
export class LidarrIntegration extends MediaOrganizerIntegration {
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") },
@@ -103,7 +105,8 @@ export class LidarrIntegration extends MediaOrganizerIntegration {
const flatImages = [...event.images];
const sortedImages = flatImages.sort(
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType),
(imageA, imageB) =>
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
);
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
return sortedImages[0];

View File

@@ -1,21 +0,0 @@
import { Integration } from "../base/integration";
export abstract class MediaOrganizerIntegration extends Integration {
/**
* 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.
*/
protected readonly priorities: string[] = [
"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
];
}

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

View File

@@ -4,14 +4,16 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import type { AtLeastOneOf } from "@homarr/common/types";
import { logger } from "@homarr/log";
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 { CalendarEvent } from "../../calendar-types";
import { radarrReleaseTypes } from "../../calendar-types";
import { MediaOrganizerIntegration } from "../media-organizer-integration";
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
import { radarrReleaseTypes } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer";
export class RadarrIntegration extends MediaOrganizerIntegration {
export class RadarrIntegration extends Integration implements ICalendarIntegration {
/**
* Gets the events in the Radarr calendar between two dates.
* @param start The start date
@@ -82,7 +84,8 @@ export class RadarrIntegration extends MediaOrganizerIntegration {
const flatImages = [...event.images];
const sortedImages = flatImages.sort(
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType),
(imageA, imageB) =>
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
);
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
return sortedImages[0];

View File

@@ -3,13 +3,15 @@ import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
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 { CalendarEvent } from "../../calendar-types";
import { MediaOrganizerIntegration } from "../media-organizer-integration";
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer";
export class ReadarrIntegration extends MediaOrganizerIntegration {
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") },
@@ -81,7 +83,8 @@ export class ReadarrIntegration extends MediaOrganizerIntegration {
const flatImages = [...event.images];
const sortedImages = flatImages.sort(
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType),
(imageA, imageB) =>
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
);
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
return sortedImages[0];

View File

@@ -3,13 +3,15 @@ import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
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 { CalendarEvent } from "../../calendar-types";
import { MediaOrganizerIntegration } from "../media-organizer-integration";
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
import { mediaOrganizerPriorities } from "../media-organizer";
export class SonarrIntegration extends MediaOrganizerIntegration {
export class SonarrIntegration extends Integration implements ICalendarIntegration {
/**
* Gets the events in the Sonarr calendar between two dates.
* @param start The start date
@@ -81,7 +83,8 @@ export class SonarrIntegration extends MediaOrganizerIntegration {
const flatImages = [...event.images, ...event.series.images];
const sortedImages = flatImages.sort(
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType),
(imageA, imageB) =>
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
);
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
return sortedImages[0];

View File

@@ -4,12 +4,11 @@ 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 { TdarrQueue } from "../interfaces/media-transcoding/queue";
import type { TdarrStatistics } from "../interfaces/media-transcoding/statistics";
import type { TdarrWorker } from "../interfaces/media-transcoding/workers";
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 {
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",

View File

@@ -0,0 +1,74 @@
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 => ({
name: "Homarr Meetup",
subName: "",
description: "Yearly meetup of the Homarr community",
date: randomDateBetween(start, end),
links: [
{
href: "https://homarr.dev",
name: "Homarr",
logo: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
color: "#000000",
notificationColor: "#fa5252",
isDark: true,
},
],
});
const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
name: "Titanic",
subName: "A classic movie",
description: "A tragic love story set on the ill-fated RMS Titanic.",
date: randomDateBetween(start, end),
thumbnail: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
mediaInformation: {
type: "movie",
},
links: [
{
href: "https://www.imdb.com/title/tt0120338/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
notificationColor: "cyan",
},
],
});
const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
name: "The Mandalorian",
subName: "A Star Wars Series",
description: "A lone bounty hunter in the outer reaches of the galaxy.",
date: randomDateBetween(start, end),
thumbnail: "https://image.tmdb.org/t/p/original/ztvm7C7hiUpS3CZRXFmJxljICzK.jpg",
mediaInformation: {
type: "tv",
seasonNumber: 1,
episodeNumber: 1,
},
links: [
{
href: "https://www.imdb.com/title/tt8111088/",
name: "IMDb",
color: "#f5c518",
isDark: false,
logo: "/images/apps/imdb.svg",
notificationColor: "blue",
},
],
});
function randomDateBetween(start: Date, end: Date): Date {
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
}

View 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",
};
}
}

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

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

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

View File

@@ -0,0 +1,97 @@
import { objectEntries } from "@homarr/common";
import type { IMediaRequestIntegration } from "../../interfaces/media-requests/media-request-integration";
import type { MediaInformation, MediaRequest, RequestStats, RequestUser } from "../../types";
import { MediaAvailability, MediaRequestStatus } 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 {
const values = objectEntries(MediaAvailability).filter(([key]) => typeof key === "number");
return values[Math.floor(Math.random() * values.length)]?.[1] ?? MediaAvailability.Available;
}
private static randomStatus(): MediaRequestStatus {
const values = objectEntries(MediaRequestStatus).filter(([key]) => typeof key === "number");
return values[Math.floor(Math.random() * values.length)]?.[1] ?? MediaRequestStatus.PendingApproval;
}
}

View File

@@ -0,0 +1,35 @@
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,
}
: null,
};
}
}

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

View File

@@ -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,
},
});
}
}

View 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}.`,
};
}
}

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

View File

@@ -0,0 +1,36 @@
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(),
memUsed: (4 * 1024 * 1024 * 1024).toString(), // 4 GB in bytes
memAvailable: (8 * 1024 * 1024 * 1024).toString(), // 8 GB in bytes
availablePkgUpdates: 0,
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",
},
],
});
}
}

View File

@@ -0,0 +1,119 @@
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 { 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 { 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,
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 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);
// 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);
}

View File

@@ -10,10 +10,11 @@ import { integrationTsdavHttpErrorHandler } 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 { CalendarEvent } from "../calendar-types";
import type { ICalendarIntegration } from "../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../interfaces/calendar/calendar-types";
@HandleIntegrationErrors([integrationTsdavHttpErrorHandler])
export class NextcloudIntegration extends Integration {
export class NextcloudIntegration extends Integration implements ICalendarIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.createCalendarClientAsync(input.dispatcher);
await client.login();

View File

@@ -1,13 +1,14 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import { Integration } from "../base/integration";
import type { IntegrationTestingInput } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { Notification } from "../interfaces/notifications/notification";
import { NotificationsIntegration } from "../interfaces/notifications/notifications-integration";
import type { Notification } from "../interfaces/notifications/notification-types";
import type { INotificationsIntegration } from "../interfaces/notifications/notifications-integration";
import { ntfyNotificationSchema } from "./ntfy-schema";
export class NTFYIntegration extends NotificationsIntegration {
export class NTFYIntegration extends Integration implements INotificationsIntegration {
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await input.fetchAsync(this.url("/v1/account"), { headers: this.getHeaders() });
return { success: true };

View File

@@ -9,7 +9,8 @@ import { Integration } from "../base/integration";
import type { SessionStore } from "../base/session-store";
import { createSessionStore } from "../base/session-store";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { HealthMonitoring } from "../types";
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
import type { SystemHealthMonitoring } from "../types";
import { cpuTempSchema, fileSystemSchema, smartSchema, systemInformationSchema } from "./openmediavault-types";
const localLogger = logger.child({ module: "OpenMediaVaultIntegration" });
@@ -18,7 +19,7 @@ type SessionStoreValue =
| { type: "header"; sessionId: string }
| { type: "cookie"; loginToken: string; sessionId: string };
export class OpenMediaVaultIntegration extends Integration {
export class OpenMediaVaultIntegration extends Integration implements ISystemHealthMonitoringIntegration {
private readonly sessionStore: SessionStore<SessionStoreValue>;
constructor(integration: IntegrationInput) {
@@ -26,7 +27,7 @@ export class OpenMediaVaultIntegration extends Integration {
this.sessionStore = createSessionStore(integration);
}
public async getSystemInfoAsync(): Promise<HealthMonitoring> {
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const systemResponses = await this.makeAuthenticatedRpcCallAsync("system", "getInformation");
const fileSystemResponse = await this.makeAuthenticatedRpcCallAsync(
"filesystemmgmt",

View File

@@ -8,8 +8,9 @@ import { Integration } from "../base/integration";
import type { ISearchableIntegration } from "../base/searchable-integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request";
import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request";
import type { IMediaRequestIntegration } from "../interfaces/media-requests/media-request-integration";
import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request-types";
import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request-types";
interface OverseerrSearchResult {
id: number;
@@ -23,7 +24,10 @@ interface OverseerrSearchResult {
/**
* Overseerr Integration. See https://api-docs.overseerr.dev
*/
export class OverseerrIntegration extends Integration implements ISearchableIntegration<OverseerrSearchResult> {
export class OverseerrIntegration
extends Integration
implements IMediaRequestIntegration, ISearchableIntegration<OverseerrSearchResult>
{
public async searchAsync(query: string) {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), {
headers: {

View File

@@ -8,10 +8,11 @@ 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 { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/session";
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { PlexResponse } from "./interface";
export class PlexIntegration extends Integration {
export class PlexIntegration extends Integration implements IMediaServerIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey");

View File

@@ -6,10 +6,11 @@ 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 { Indexer } from "../interfaces/indexer-manager/indexer";
import type { IIndexerManagerIntegration } from "../interfaces/indexer-manager/indexer-manager-integration";
import type { Indexer } from "../interfaces/indexer-manager/indexer-manager-types";
import { indexerResponseSchema, statusResponseSchema } from "./prowlarr-types";
export class ProwlarrIntegration extends Integration {
export class ProwlarrIntegration extends Integration implements IIndexerManagerIntegration {
public async getIndexersAsync(): Promise<Indexer[]> {
const apiKey = super.getSecretValue("apiKey");

View File

@@ -8,6 +8,7 @@ import { HandleIntegrationErrors } from "../base/errors/decorator";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IClusterHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
import { ProxmoxApiErrorHandler } from "./proxmox-error-handler";
import type {
ComputeResourceBase,
@@ -19,7 +20,7 @@ import type {
} from "./proxmox-types";
@HandleIntegrationErrors([new ProxmoxApiErrorHandler()])
export class ProxmoxIntegration extends Integration {
export class ProxmoxIntegration extends Integration implements IClusterHealthMonitoringIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const proxmox = this.getPromoxApi(input.fetchAsync);
await proxmox.nodes.$get();

View File

@@ -1,9 +1,9 @@
export * from "./calendar-types";
export * from "./interfaces/calendar/calendar-types";
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./interfaces/network-controller-summary/network-controller-summary-types";
export * from "./interfaces/health-monitoring/healt-monitoring";
export * from "./interfaces/indexer-manager/indexer";
export * from "./interfaces/media-requests/media-request";
export * from "./interfaces/health-monitoring/health-monitoring-types";
export * from "./interfaces/indexer-manager/indexer-manager-types";
export * from "./interfaces/media-requests/media-request-types";
export * from "./base/searchable-integration";
export * from "./homeassistant/homeassistant-types";
export * from "./proxmox/proxmox-types";

View File

@@ -2,12 +2,12 @@ import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { HealthMonitoring, ProxmoxClusterInfo } from "@homarr/integrations/types";
import type { ProxmoxClusterInfo, SystemHealthMonitoring } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
HealthMonitoring,
SystemHealthMonitoring,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox">,
Record<string, never>
>({
@@ -21,7 +21,7 @@ export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler<
ProxmoxClusterInfo,
"proxmox",
"proxmox" | "mock",
Record<string, never>
>({
async requestAsync(integration, _input) {

View File

@@ -5,6 +5,7 @@ import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
@@ -13,21 +14,25 @@ import { SystemHealthMonitoring } from "./system-health";
dayjs.extend(duration);
const isClusterIntegration = (integration: { kind: IntegrationKind }) =>
integration.kind === "proxmox" || integration.kind === "mock";
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const [integrations] = clientApi.integration.byIds.useSuspenseQuery(props.integrationIds);
const t = useI18n();
const proxmoxIntegrationId = integrations.find((integration) => integration.kind === "proxmox")?.id;
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
if (!proxmoxIntegrationId) {
if (!clusterIntegrationId) {
return <SystemHealthMonitoring {...props} />;
}
const otherIntegrationIds = integrations
// We want to have the mock integration also in the system tab, so we use it for both
.filter((integration) => integration.kind !== "proxmox")
.map((integration) => integration.id);
if (otherIntegrationIds.length === 0) {
return <ClusterHealthMonitoring {...props} integrationId={proxmoxIntegrationId} />;
return <ClusterHealthMonitoring {...props} integrationId={clusterIntegrationId} />;
}
return (
@@ -45,7 +50,7 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
<SystemHealthMonitoring {...props} integrationIds={otherIntegrationIds} />
</Tabs.Panel>
<Tabs.Panel value="cluster">
<ClusterHealthMonitoring integrationId={proxmoxIntegrationId} {...props} />
<ClusterHealthMonitoring integrationId={clusterIntegrationId} {...props} />
</Tabs.Panel>
</Tabs>
</ScrollArea>

View File

@@ -1,5 +1,7 @@
import { IconVideo } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -10,5 +12,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaServ
showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }),
}));
},
supportedIntegrations: ["jellyfin", "plex", "emby"],
supportedIntegrations: getIntegrationKindsByCategory("mediaService"),
}).withDynamicImport(() => import("./component"));

View File

@@ -2,6 +2,7 @@ import { IconTransform } from "@tabler/icons-react";
import { z } from "zod";
import { capitalize } from "@homarr/common";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
@@ -19,5 +20,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaTran
queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }),
}));
},
supportedIntegrations: ["tdarr"],
supportedIntegrations: getIntegrationKindsByCategory("mediaTranscoding"),
}).withDynamicImport(() => import("./component"));

3
pnpm-lock.yaml generated
View File

@@ -124,6 +124,9 @@ importers:
'@homarr/docker':
specifier: workspace:^0.1.0
version: link:../../packages/docker
'@homarr/env':
specifier: workspace:^0.1.0
version: link:../../packages/env
'@homarr/form':
specifier: workspace:^0.1.0
version: link:../../packages/form