diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 7f030a4bb..5960587eb 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 000000000..d6d1ce7e8 --- /dev/null +++ b/packages/api/src/router/widgets/media-transcoding.ts @@ -0,0 +1,28 @@ +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding"; +import { validation } from "@homarr/validation"; + +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")) + .input(validation.common.paginated.pick({ page: true, pageSize: true })) + .query(async ({ ctx, input }) => { + const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, { + pageOffset: input.page, + pageSize: input.pageSize, + }); + 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 45ddd4505..4bb6e0bf5 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"; @@ -31,6 +32,7 @@ export const jobGroup = createCronJobGroup({ healthMonitoring: healthMonitoringJob, sessionCleanup: sessionCleanupJob, updateChecker: updateCheckerJob, + 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 000000000..c039dc250 --- /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 8c9645be9..0856fa830 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 0f68c6577..633a1004a 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 54643e4db..82e54d98d 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -17,6 +17,7 @@ import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration" import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration"; import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration"; import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"; +import { TdarrIntegration } from "../media-transcoding/tdarr-integration"; import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; import { OverseerrIntegration } from "../overseerr/overseerr-integration"; import { PiHoleIntegration } from "../pi-hole/pi-hole-integration"; @@ -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 ea13ce13b..baa5a99b0 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 000000000..1b85dddc9 --- /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 000000000..f16d39ae4 --- /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[]; + }[]; +} 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 000000000..9adaed716 --- /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; +} 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 000000000..c602b8da0 --- /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 type { TdarrQueue } from "../interfaces/media-transcoding/queue"; +import type { TdarrStatistics } from "../interfaces/media-transcoding/statistics"; +import type { 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, + 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, + 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 000000000..aabb61cc7 --- /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/old-import/src/mappers/map-integration.ts b/packages/old-import/src/mappers/map-integration.ts index b51dae2dd..e4274adb5 100644 --- a/packages/old-import/src/mappers/map-integration.ts +++ b/packages/old-import/src/mappers/map-integration.ts @@ -31,7 +31,7 @@ const mapping: Record = { readarr: "readarr", sabnzbd: "sabNzbd", sonarr: "sonarr", - tdarr: null, + tdarr: "tdarr", transmission: "transmission", plex: "plex", }; diff --git a/packages/old-import/src/widgets/definitions/index.ts b/packages/old-import/src/widgets/definitions/index.ts index d574e0a37..5aaa9684f 100644 --- a/packages/old-import/src/widgets/definitions/index.ts +++ b/packages/old-import/src/widgets/definitions/index.ts @@ -68,6 +68,7 @@ export const widgetKindMapping = { indexerManager: "indexer-manager", bookmarks: "bookmark", healthMonitoring: "health-monitoring", + mediaTranscoding: "media-transcoding", } satisfies Record; // Use null for widgets that did not exist in oldmarr // TODO: revert assignment so that only old widgets are needed in the object, diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts index a95c8a5d9..078a4ccd5 100644 --- a/packages/old-import/src/widgets/options.ts +++ b/packages/old-import/src/widgets/options.ts @@ -129,6 +129,10 @@ const optionMapping: OptionMapping = { fahrenheit: (oldOptions) => oldOptions.fahrenheit, fileSystem: (oldOptions) => oldOptions.fileSystem, }, + mediaTranscoding: { + defaultView: (oldOptions) => oldOptions.defaultView, + queuePageSize: (oldOptions) => oldOptions.queuePageSize, + }, app: null, }; diff --git a/packages/request-handler/src/media-transcoding.ts b/packages/request-handler/src/media-transcoding.ts new file mode 100644 index 000000000..05446bdd6 --- /dev/null +++ b/packages/request-handler/src/media-transcoding.ts @@ -0,0 +1,24 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { integrationCreator } from "@homarr/integrations"; +import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "@homarr/integrations"; + +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 75d42785b..8997a4deb 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1643,6 +1643,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", @@ -2272,6 +2331,9 @@ }, "updateChecker": { "label": "Update checker" + }, + "mediaTranscoding": { + "label": "Media transcoding" } } }, diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index e2d83563a..7fc8f97ff 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -21,6 +21,7 @@ import * as indexerManager from "./indexer-manager"; import * as mediaRequestsList from "./media-requests/list"; import * as mediaRequestsStats from "./media-requests/stats"; import * as mediaServer from "./media-server"; +import * as mediaTranscoding from "./media-transcoding"; import * as notebook from "./notebook"; import type { WidgetOptionDefinition } from "./options"; import * as rssFeed from "./rssFeed"; @@ -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 000000000..10342ec6a --- /dev/null +++ b/packages/widgets/src/media-transcoding/component.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState } from "react"; +import { Center, Divider, Group, Pagination, SegmentedControl, Stack, Text } from "@mantine/core"; +import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-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); + const queuePageSize = 10; + const [transcodingData] = clientApi.widget.mediaTranscoding.getDataAsync.useSuspenseQuery( + { + integrationId: integrationIds[0] ?? "", + pageSize: queuePageSize, + page: queuePage, + }, + { + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + ); + + const [view, setView] = useState(options.defaultView); + 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 000000000..6491f6289 --- /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 000000000..c8c6b13fb --- /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 000000000..9d4d6a680 --- /dev/null +++ b/packages/widgets/src/media-transcoding/panels/queue.panel.tsx @@ -0,0 +1,64 @@ +import { Center, Group, ScrollArea, Table, Text, Title, Tooltip } from "@mantine/core"; +import { IconHeartbeat, IconTransform } from "@tabler/icons-react"; + +import { humanFileSize } from "@homarr/common"; +import type { TdarrQueue } from "@homarr/integrations"; +import { useI18n } from "@homarr/translation/client"; + +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 000000000..93da0c0ce --- /dev/null +++ b/packages/widgets/src/media-transcoding/panels/statistics.panel.tsx @@ -0,0 +1,140 @@ +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 000000000..961ac5d7c --- /dev/null +++ b/packages/widgets/src/media-transcoding/panels/workers.panel.tsx @@ -0,0 +1,76 @@ +import { Center, Group, Progress, ScrollArea, Table, Text, Title, Tooltip } from "@mantine/core"; +import { IconHeartbeat, IconTransform } from "@tabler/icons-react"; + +import type { TdarrWorker } from "@homarr/integrations"; +import { useI18n } from "@homarr/translation/client"; + +interface WorkersPanelProps { + workers: TdarrWorker[]; +} + +export function WorkersPanel(props: WorkersPanelProps) { + const t = useI18n("widget.mediaTranscoding.panel.workers"); + + if (props.workers.length === 0) { + return ( +
+ {t("empty")} +
+ ); + } + + return ( + + + + + + + + + + + {props.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)}% + +
+
+ ); +}