diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 8c9645be94..26700f340a 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -151,6 +151,13 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/dashdot.png", supportsSearch: false, }, + tdarr: { + name: "Tdarr", + secretKinds: [[]], + category: ["mediaTranscoding"], + iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/tdarr.png", + supportsSearch: false + } } as const satisfies Record; export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf; @@ -217,4 +224,5 @@ export type IntegrationCategory = | "torrent" | "smartHomeServer" | "indexerManager" - | "healthMonitoring"; + | "healthMonitoring" + | "mediaTranscoding"; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 0f68c6577e..633a1004ae 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -14,6 +14,7 @@ export const widgetKinds = [ "downloads", "mediaRequests-requestList", "mediaRequests-requestStats", + "mediaTranscoding", "rssFeed", "bookmarks", "indexerManager", diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 54643e4dbf..304ed8808b 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -23,6 +23,7 @@ import { PiHoleIntegration } from "../pi-hole/pi-hole-integration"; import { PlexIntegration } from "../plex/plex-integration"; import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; import type { Integration, IntegrationInput } from "./integration"; +import { TdarrIntegration } from "../media-transcoding/tdarr-integration"; export const integrationCreator = ( integration: IntegrationInput & { kind: TKind }, @@ -70,4 +71,5 @@ export const integrationCreators = { lidarr: LidarrIntegration, readarr: ReadarrIntegration, dashDot: DashDotIntegration, + tdarr: TdarrIntegration } satisfies Record Integration>; diff --git a/packages/integrations/src/interfaces/media-transcoding/queue.ts b/packages/integrations/src/interfaces/media-transcoding/queue.ts new file mode 100644 index 0000000000..6fcb55836d --- /dev/null +++ b/packages/integrations/src/interfaces/media-transcoding/queue.ts @@ -0,0 +1,16 @@ +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; +}; diff --git a/packages/integrations/src/interfaces/media-transcoding/statistics.ts b/packages/integrations/src/interfaces/media-transcoding/statistics.ts new file mode 100644 index 0000000000..76a0ba74a5 --- /dev/null +++ b/packages/integrations/src/interfaces/media-transcoding/statistics.ts @@ -0,0 +1,29 @@ +export interface TdarrPieSegment { + name: string; + value: number; + }; + +export interface TdarrStatistics { + totalFileCount: number; + totalTranscodeCount: number; + totalHealthCheckCount: number; + failedTranscodeCount: number; + failedHealthCheckCount: number; + stagedTranscodeCount: number; + stagedHealthCheckCount: number; + pies: { + libraryName: string; + libraryId: string; + totalFiles: number; + totalTranscodes: number; + savedSpace: number; + totalHealthChecks: number; + transcodeStatus: TdarrPieSegment[]; + healthCheckStatus: TdarrPieSegment[]; + videoCodecs: TdarrPieSegment[]; + videoContainers: TdarrPieSegment[]; + videoResolutions: TdarrPieSegment[]; + audioCodecs: TdarrPieSegment[]; + audioContainers: TdarrPieSegment[]; + }[]; + }; \ No newline at end of file diff --git a/packages/integrations/src/interfaces/media-transcoding/workers.ts b/packages/integrations/src/interfaces/media-transcoding/workers.ts new file mode 100644 index 0000000000..89c4133e72 --- /dev/null +++ b/packages/integrations/src/interfaces/media-transcoding/workers.ts @@ -0,0 +1,13 @@ +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; + }; \ No newline at end of file diff --git a/packages/integrations/src/media-transcoding/tdarr-integration.ts b/packages/integrations/src/media-transcoding/tdarr-integration.ts new file mode 100644 index 0000000000..b66dd60ebc --- /dev/null +++ b/packages/integrations/src/media-transcoding/tdarr-integration.ts @@ -0,0 +1,165 @@ +import { z } from "@homarr/validation"; + +import { Integration } from "../base/integration"; +import { TdarrQueue } from "../interfaces/media-transcoding/queue"; +import type { TdarrStatistics } from "../interfaces/media-transcoding/statistics"; +import { TdarrWorker } from "../interfaces/media-transcoding/workers"; +import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } from "./tdarr-validation-schemas"; + +export class TdarrIntegration extends Integration { + public async testConnectionAsync(): Promise { + const url = this.url("/api/v2/status"); + const response = await fetch(url); + if (response.status !== 200) { + throw new Error(`Unexpected status code: ${response.status}`); + } + + await z.object({ status: z.string() }).parseAsync(await response.json()); + } + + public async getStatisticsAsync(): Promise { + const url = this.url("/api/v2/cruddb"); + const response = await fetch(url, { + method: "POST", + body: JSON.stringify({ + data: { + collection: "StatisticsJSONDB", + mode: "getById", + docId: "statistics", + }, + }), + }); + + const statisticsData = await getStatisticsSchema.parseAsync(await response.json()); + + return { + totalFileCount: statisticsData.totalFileCount, + totalTranscodeCount: statisticsData.totalTranscodeCount, + totalHealthCheckCount: statisticsData.totalHealthCheckCount, + failedTranscodeCount: statisticsData.table3Count, + failedHealthCheckCount: statisticsData.table6Count, + stagedTranscodeCount: statisticsData.table1Count, + stagedHealthCheckCount: statisticsData.table4Count, + pies: statisticsData.pies.map((pie) => ({ + libraryName: pie[0], + libraryId: pie[1], + totalFiles: pie[2], + totalTranscodes: pie[3], + savedSpace: pie[4] * 1_000_000_000, // file_size is in GB, convert to bytes, + totalHealthChecks: pie[5], + transcodeStatus: pie[6], + healthCheckStatus: pie[7], + videoCodecs: pie[8], + videoContainers: pie[9], + videoResolutions: pie[10], + audioCodecs: pie[11], + audioContainers: pie[12], + })), + }; + } + + public async getWorkersAsync(): Promise { + const url = this.url("/api/v2/get-nodes"); + const response = await fetch(url, { + method: "GET", + }); + + 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 { + 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 response = await fetch(url, { + method: "POST", + body: JSON.stringify({ + start: firstItemIndex, + pageSize: 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: item.file_size * 1_000_000, // file_size is in MB, convert to bytes + 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 response = await fetch(url, { + method: "POST", + body: JSON.stringify({ + start: Math.max(firstItemIndex - totalQueueCount, 0), + pageSize: 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: item.file_size * 1_000_000, // file_size is in MB, convert to bytes + container: item.container, + codec: item.video_codec_name, + resolution: item.video_resolution, + type: "health check" as const, + })), + totalCount: healthCheckData.totalCount, + }; + } +} diff --git a/packages/integrations/src/media-transcoding/tdarr-validation-schemas.ts b/packages/integrations/src/media-transcoding/tdarr-validation-schemas.ts new file mode 100644 index 0000000000..aabb61cc7d --- /dev/null +++ b/packages/integrations/src/media-transcoding/tdarr-validation-schemas.ts @@ -0,0 +1,118 @@ +import { z } from "@homarr/validation"; + +export const getStatisticsSchema = z.object({ + totalFileCount: z.number(), + totalTranscodeCount: z.number(), + totalHealthCheckCount: z.number(), + table3Count: z.number(), + table6Count: z.number(), + table1Count: z.number(), + table4Count: z.number(), + pies: z.array( + z.tuple([ + z.string(), // Library Name + z.string(), // Library ID + z.number(), // File count + z.number(), // Number of transcodes + z.number(), // Space saved (in GB) + z.number(), // Number of health checks + z.array( + z.object({ + // Transcode Status (Pie segments) + name: z.string(), + value: z.number(), + }), + ), + z.array( + z.object({ + // Health Status (Pie segments) + name: z.string(), + value: z.number(), + }), + ), + z.array( + z.object({ + // Video files - Codecs (Pie segments) + name: z.string(), + value: z.number(), + }), + ), + z.array( + z.object({ + // Video files - Containers (Pie segments) + name: z.string(), + value: z.number(), + }), + ), + z.array( + z.object({ + // Video files - Resolutions (Pie segments) + name: z.string(), + value: z.number(), + }), + ), + z.array( + z.object({ + // Audio files - Codecs (Pie segments) + name: z.string(), + value: z.number(), + }), + ), + z.array( + z.object({ + // Audio files - Containers (Pie segments) + name: z.string(), + value: z.number(), + }), + ), + ]), + ), +}); + +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(), +}); diff --git a/packages/request-handler/src/media-transcoding.ts b/packages/request-handler/src/media-transcoding.ts new file mode 100644 index 0000000000..8e4456da78 --- /dev/null +++ b/packages/request-handler/src/media-transcoding.ts @@ -0,0 +1,23 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { integrationCreator } from "@homarr/integrations"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const mediaTranscodingRequestHandler = createCachedIntegrationRequestHandler< + unknown, + IntegrationKindByCategory<"mediaTranscoding">, + { pageOffset: number, pageSize: number } +>({ + queryKey: "mediaTranscoding", + cacheDuration: dayjs.duration(5, "minutes"), + async requestAsync(integration, input) { + const integrationInstance = integrationCreator(integration); + return { + queue: await integrationInstance.getQueueAsync(input.pageOffset, input.pageSize), + workers: await integrationInstance.getWorkersAsync(), + statistics: await integrationInstance.getStatisticsAsync(), + }; + }, +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 3ad0c7e859..4f089f08da 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1528,6 +1528,10 @@ } } }, + "mediaTranscoding": { + "name": "Media transcoding", + "description": "Statistics, current queue and worker status of your media transcoding" + }, "rssFeed": { "name": "RSS feeds", "description": "Monitor and display one or more generic RSS, ATOM or JSON feeds", diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index e2d83563ab..4156800330 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -28,6 +28,7 @@ import * as smartHomeEntityState from "./smart-home/entity-state"; import * as smartHomeExecuteAutomation from "./smart-home/execute-automation"; import * as video from "./video"; import * as weather from "./weather"; +import * as mediaTranscoding from "./media-transcoding"; export type { WidgetDefinition } from "./definition"; export type { WidgetComponentProps }; @@ -52,6 +53,7 @@ export const widgetImports = { bookmarks, indexerManager, healthMonitoring, + mediaTranscoding } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports; diff --git a/packages/widgets/src/media-transcoding/component.tsx b/packages/widgets/src/media-transcoding/component.tsx new file mode 100644 index 0000000000..e7025c5f50 --- /dev/null +++ b/packages/widgets/src/media-transcoding/component.tsx @@ -0,0 +1,5 @@ +import type { WidgetComponentProps } from "../definition"; + +export default function MediaTranscodingWidget({ integrationIds }: WidgetComponentProps<"mediaTranscoding">) { + return <>test!; +} diff --git a/packages/widgets/src/media-transcoding/index.ts b/packages/widgets/src/media-transcoding/index.ts new file mode 100644 index 0000000000..4c24022f44 --- /dev/null +++ b/packages/widgets/src/media-transcoding/index.ts @@ -0,0 +1,9 @@ +import { IconTransform } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; + +export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", { + icon: IconTransform, + options: {}, + supportedIntegrations: ["tdarr"], +}).withDynamicImport(() => import("./component"));