diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 7f030a4bb9..66650c821b 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -7,6 +7,7 @@ import { healthMonitoringRouter } from "./health-monitoring"; import { indexerManagerRouter } from "./indexer-manager"; import { mediaRequestsRouter } from "./media-requests"; import { mediaServerRouter } from "./media-server"; +import { mediaTranscodingRouter } from "./media-transcoding"; import { notebookRouter } from "./notebook"; import { rssFeedRouter } from "./rssFeed"; import { smartHomeRouter } from "./smart-home"; @@ -25,4 +26,5 @@ export const widgetRouter = createTRPCRouter({ rssFeed: rssFeedRouter, indexerManager: indexerManagerRouter, healthMonitoring: healthMonitoringRouter, + mediaTranscoding: mediaTranscodingRouter }); diff --git a/packages/api/src/router/widgets/media-transcoding.ts b/packages/api/src/router/widgets/media-transcoding.ts new file mode 100644 index 0000000000..8acf3114d9 --- /dev/null +++ b/packages/api/src/router/widgets/media-transcoding.ts @@ -0,0 +1,23 @@ +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding"; + +import type { IntegrationAction } from "../../middlewares/integration"; +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) => + createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaTranscoding")); + +export const mediaTranscodingRouter = createTRPCRouter({ + getDataAsync: publicProcedure + .unstable_concat(createIndexerManagerIntegrationMiddleware("query")) + .query(async ({ ctx }) => { + const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, { pageOffset: 0, pageSize: 10 }); + const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + + return { + integrationId: ctx.integration.id, + data, + }; + }), +}); diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index cf4132911c..fbffeef9b9 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -8,6 +8,7 @@ import { indexerManagerJob } from "./jobs/integrations/indexer-manager"; import { mediaOrganizerJob } from "./jobs/integrations/media-organizer"; import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests"; import { mediaServerJob } from "./jobs/integrations/media-server"; +import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding"; import { pingJob } from "./jobs/ping"; import type { RssFeed } from "./jobs/rss-feeds"; import { rssFeedsJob } from "./jobs/rss-feeds"; @@ -29,6 +30,7 @@ export const jobGroup = createCronJobGroup({ indexerManager: indexerManagerJob, healthMonitoring: healthMonitoringJob, sessionCleanup: sessionCleanupJob, + mediaTranscoding: mediaTranscodingJob }); export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number]; diff --git a/packages/cron-jobs/src/jobs/integrations/media-transcoding.ts b/packages/cron-jobs/src/jobs/integrations/media-transcoding.ts new file mode 100644 index 0000000000..c039dc2500 --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/media-transcoding.ts @@ -0,0 +1,14 @@ +import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions"; +import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler"; +import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding"; + +import { createCronJob } from "../../lib"; + +export const mediaTranscodingJob = createCronJob("mediaTranscoding", EVERY_5_MINUTES).withCallback( + createRequestIntegrationJobHandler(mediaTranscodingRequestHandler.handler, { + widgetKinds: ["mediaTranscoding"], + getInput: { + mediaTranscoding: () => ({ pageOffset: 0, pageSize: 10 }), + }, + }), +); 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/index.ts b/packages/integrations/src/index.ts index ea13ce13b8..baa5a99b02 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -28,6 +28,9 @@ export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-moni 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"; // Schemas export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items"; 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..689d693dfb --- /dev/null +++ b/packages/integrations/src/media-transcoding/tdarr-integration.ts @@ -0,0 +1,172 @@ +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", + headers: {'content-type': 'application/json'}, + 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", + headers: {'content-type': 'application/json'}, + }); + + 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", + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + data: { + 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", + headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + data: { + 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..47bec47483 --- /dev/null +++ b/packages/request-handler/src/media-transcoding.ts @@ -0,0 +1,26 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { integrationCreator } from "@homarr/integrations"; + +import type { TdarrQueue } from "../../integrations/src/interfaces/media-transcoding/queue"; +import type { TdarrStatistics } from "../../integrations/src/interfaces/media-transcoding/statistics"; +import type { TdarrWorker } from "../../integrations/src/interfaces/media-transcoding/workers"; +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const mediaTranscodingRequestHandler = createCachedIntegrationRequestHandler< + { queue: TdarrQueue; workers: TdarrWorker[]; statistics: TdarrStatistics }, + 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..0bd2ac4528 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1528,6 +1528,65 @@ } } }, + "mediaTranscoding": { + "name": "Media transcoding", + "description": "Statistics, current queue and worker status of your media transcoding", + "option": { + "defaultView": { + "label": "Default view" + }, + "queuePageSize": { + "label": "Queue page size" + } + }, + "tab": { + "workers": "Workers", + "queue": "Queue", + "statistics": "Statistics" + }, + "currentIndex": "{start}-{end} of {total}", + "healthCheck": { + "title": "Health check", + "queued": "Queued", + "status": { + "healthy": "Healthy", + "unhealthy": "Unhealthy" + } + }, + "panel": { + "statistics": { + "empty": "Empty", + "transcodes": "Transcodes", + "transcodesCount": "Transcodes: {value}", + "healthChecksCount": "Health checks: {value}", + "filesCount": "Files: {value}", + "savedSpace": "Saved space: {value}", + "healthChecks": "Health checks", + "videoCodecs": "Codecs", + "videoContainers": "Containers", + "videoResolutions": "Resolutions" + }, + "workers": { + "empty": "Empty", + "table": { + "file": "File", + "eta": "ETA", + "progress": "Progress", + "transcode": "Transcode", + "healthCheck": "Health check" + } + }, + "queue": { + "empty": "Empty", + "table": { + "file": "File", + "size": "Size", + "transcode": "Transcode", + "healthCheck": "Health check" + } + } + } + }, "rssFeed": { "name": "RSS feeds", "description": "Monitor and display one or more generic RSS, ATOM or JSON feeds", @@ -2153,6 +2212,9 @@ }, "sessionCleanup": { "label": "Session Cleanup" + }, + "mediaTranscoding": { + "label": "Media transcoding" } } }, 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..7c96d9c541 --- /dev/null +++ b/packages/widgets/src/media-transcoding/component.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { Center, Divider, Group, Pagination, SegmentedControl, Stack, Text } from "@mantine/core"; +import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react"; +import { useState } from "react"; + +import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; +import { HealthCheckStatus } from "./health-check-status"; +import { QueuePanel } from "./panels/queue.panel"; +import { StatisticsPanel } from "./panels/statistics.panel"; +import { WorkersPanel } from "./panels/workers.panel"; + +type Views = "workers" | "queue" | "statistics"; + +export default function MediaTranscodingWidget({ integrationIds, options }: WidgetComponentProps<"mediaTranscoding">) { + const [queuePage, setQueuePage] = useState(1); // TODO: pass this to the backend! + const [transcodingData] = clientApi.widget.mediaTranscoding.getDataAsync.useSuspenseQuery( + { + integrationId: integrationIds[0] ?? "", + }, + { + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + ); + + const [view, setView] = useState(options.defaultView); + + const queuePageSize = 10; + const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize); + + const t = useI18n("widget.mediaTranscoding"); + + return ( + + {view === "workers" ? ( + + ) : view === "queue" ? ( + + ) : ( + + )} + + + + + + {t("tab.workers")} + + + ), + value: "workers", + }, + { + label: ( +
+ + + {t("tab.queue")} + +
+ ), + value: "queue", + }, + { + label: ( +
+ + + {t("tab.statistics")} + +
+ ), + value: "statistics", + }, + ]} + value={view} + onChange={(value) => setView(value as Views)} + size="xs" + /> + {view === "queue" && ( + <> + + + + + + + + + + {t("currentIndex", { + start: transcodingData.data.queue.startIndex + 1, + end: transcodingData.data.queue.endIndex + 1, + total: transcodingData.data.queue.totalCount, + })} + + + )} + + + +
+
+ ); +} diff --git a/packages/widgets/src/media-transcoding/health-check-status.tsx b/packages/widgets/src/media-transcoding/health-check-status.tsx new file mode 100644 index 0000000000..fb0b9c7e02 --- /dev/null +++ b/packages/widgets/src/media-transcoding/health-check-status.tsx @@ -0,0 +1,76 @@ +import type { MantineColor } from "@mantine/core"; +import { Divider, Group, HoverCard, Indicator, RingProgress, Stack, Text } from "@mantine/core"; +import { useColorScheme } from "@mantine/hooks"; +import { IconHeartbeat } from "@tabler/icons-react"; + +import type { TdarrStatistics } from "@homarr/integrations"; +import { useI18n } from "@homarr/translation/client"; + +interface HealthCheckStatusProps { + statistics: TdarrStatistics; +} + +export function HealthCheckStatus(props: HealthCheckStatusProps) { + const colorScheme = useColorScheme(); + const t = useI18n("widget.mediaTranscoding.healthCheck"); + + const indicatorColor = props.statistics.failedHealthCheckCount + ? "red" + : props.statistics.stagedHealthCheckCount + ? "yellow" + : "green"; + + return ( + + + + + + + + + + + {t(`title`)} + + + + + + + {props.statistics.stagedHealthCheckCount} + + {t(`queued`)} + + + + {props.statistics.totalHealthCheckCount} + + {t(`status.healthy`)} + + + + {props.statistics.failedHealthCheckCount} + + {t(`status.unhealthy`)} + + + + + + ); +} + +function textColor(color: MantineColor, theme: "light" | "dark") { + return `${color}.${theme === "light" ? 8 : 5}`; +} diff --git a/packages/widgets/src/media-transcoding/index.ts b/packages/widgets/src/media-transcoding/index.ts new file mode 100644 index 0000000000..c8c6b13fb2 --- /dev/null +++ b/packages/widgets/src/media-transcoding/index.ts @@ -0,0 +1,22 @@ +import { IconTransform } from "@tabler/icons-react"; + +import { z } from "@homarr/validation"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", { + icon: IconTransform, + options: optionsBuilder.from((factory) => ({ + defaultView: factory.select({ + defaultValue: "statistics", + options: [ + { label: "Workers", value: "workers" }, + { label: "Queue", value: "queue" }, + { label: "Statistics", value: "statistics" }, + ], + }), + queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }), + })), + supportedIntegrations: ["tdarr"], +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-transcoding/panels/queue.panel.tsx b/packages/widgets/src/media-transcoding/panels/queue.panel.tsx new file mode 100644 index 0000000000..c6414936ed --- /dev/null +++ b/packages/widgets/src/media-transcoding/panels/queue.panel.tsx @@ -0,0 +1,63 @@ +import { humanFileSize } from '@homarr/common'; +import type { TdarrQueue } from '@homarr/integrations'; +import { useI18n } from '@homarr/translation/client'; +import { Center, Group, ScrollArea, Table, Text, Title, Tooltip } from '@mantine/core'; +import { IconHeartbeat, IconTransform } from '@tabler/icons-react'; + +interface QueuePanelProps { + queue: TdarrQueue; +} + +export function QueuePanel(props: QueuePanelProps) { + const { queue } = props; + + const t = useI18n('widget.mediaTranscoding.panel.queue'); + + if (queue.array.length == 0) { + return ( +
+ {t('empty')} +
+ ); + } + + return ( + + + + + + + + + + {queue.array.map((item) => ( + + + + + ))} + +
{t('table.file')}{t('table.size')}
+ +
+ {item.type === 'transcode' ? ( + + + + ) : ( + + + + )} +
+ {item.filePath.split('\\').pop()?.split('/').pop() ?? item.filePath} +
+
+ {humanFileSize(item.fileSize)} +
+
+ ); +} diff --git a/packages/widgets/src/media-transcoding/panels/statistics.panel.tsx b/packages/widgets/src/media-transcoding/panels/statistics.panel.tsx new file mode 100644 index 0000000000..5de3b90718 --- /dev/null +++ b/packages/widgets/src/media-transcoding/panels/statistics.panel.tsx @@ -0,0 +1,139 @@ +import type react from "react"; +import type { MantineColor, RingProgressProps } from "@mantine/core"; +import { Box, Center, Grid, Group, RingProgress, Stack, Text, Title, useMantineColorScheme } from "@mantine/core"; +import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react"; + +import { humanFileSize } from "@homarr/common"; +import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations"; +import { useI18n } from "@homarr/translation/client"; + +const PIE_COLORS: MantineColor[] = ["cyan", "grape", "gray", "orange", "pink"]; + +interface StatisticsPanelProps { + statistics: TdarrStatistics; +} + +export function StatisticsPanel(props: StatisticsPanelProps) { + const t = useI18n("widget.mediaTranscoding.panel.statistics"); + + const allLibs = props.statistics.pies.find((pie) => pie.libraryName === "All"); + + if (!allLibs) { + return ( +
+ {t("empty")} +
+ ); + } + + return ( + + + + + {t("transcodes")} + + + + } + label={t("transcodesCount", { + value: props.statistics.totalTranscodeCount, + })} + /> + + + } + label={t("healthChecksCount", { + value: props.statistics.totalHealthCheckCount, + })} + /> + + + } + label={t("filesCount", { + value: props.statistics.totalFileCount, + })} + /> + + + } + label={t("savedSpace", { + value: humanFileSize(Math.floor(allLibs.savedSpace)), + })} + /> + + + + + {t("healthChecks")} + + + + + + {t("videoCodecs")} + + + + {t("videoContainers")} + + + + {t("videoResolutions")} + + + + ); +} + +function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps["sections"] { + const total = segments.reduce((prev, curr) => prev + curr.value, 0); + return segments.map((segment, index) => ({ + value: (segment.value * 100) / total, + tooltip: `${segment.name}: ${segment.value}`, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + color: PIE_COLORS[index % PIE_COLORS.length]!, // Ensures a valid color in the case that index > PIE_COLORS.length + })); +} + +interface StatBoxProps { + icon: react.ReactNode; + label: string; +} + +function StatBox(props: StatBoxProps) { + const { colorScheme } = useMantineColorScheme(); + return ( + ({ + padding: theme.spacing.xs, + border: "1px solid", + borderRadius: theme.radius.md, + borderColor: colorScheme === "dark" ? theme.colors.dark[5] : theme.colors.gray[1], + })} + > + + {props.icon} + {props.label} + + + ); +} diff --git a/packages/widgets/src/media-transcoding/panels/workers.panel.tsx b/packages/widgets/src/media-transcoding/panels/workers.panel.tsx new file mode 100644 index 0000000000..3cabdbf64d --- /dev/null +++ b/packages/widgets/src/media-transcoding/panels/workers.panel.tsx @@ -0,0 +1,88 @@ +import type { TdarrWorker } from '@homarr/integrations'; +import { useI18n } from '@homarr/translation/client'; +import { + Center, + Group, + Progress, + ScrollArea, + Table, + Text, + Title, + Tooltip, +} from '@mantine/core'; +import { IconHeartbeat, IconTransform } from '@tabler/icons-react'; + +interface WorkersPanelProps { + workers: TdarrWorker[]; +} + +export function WorkersPanel(props: WorkersPanelProps) { + const { workers } = props; + + const t = useI18n('widget.mediaTranscoding.panel.workers'); + + if (workers.length === 0) { + return ( +
+ {t('empty')} +
+ ); + } + + return ( + + + + + + + + + + + {workers.map((worker) => ( + + + + + + ))} + +
{t('table.file')}{t('table.eta')}{t('table.progress')}
+ +
+ {worker.jobType === 'transcode' ? ( + + + + ) : ( + + + + )} +
+ {worker.filePath.split('\\').pop()?.split('/').pop() ?? worker.filePath} +
+
+ + {worker.ETA.startsWith('0:') ? worker.ETA.substring(2) : worker.ETA} + + + + {worker.step} + + {Math.round(worker.percentage)}% + +
+
+ ); +}