diff --git a/public/locales/en/modules/media-transcoding.json b/public/locales/en/modules/media-transcoding.json new file mode 100644 index 00000000000..d391c54bf02 --- /dev/null +++ b/public/locales/en/modules/media-transcoding.json @@ -0,0 +1,96 @@ +{ + "descriptor": { + "name": "Media Transcoding", + "description": "Displays information about media transcoding", + "settings": { + "title": "Media Transcoding Settings", + "appId": { + "label": "Select an app" + }, + "defaultView": { + "label": "Default view", + "data": { + "workers": "Workers", + "queue": "Queue", + "statistics": "Statistics" + } + }, + "showHealthCheck": { + "label": "Show Health Check indicator" + }, + "showHealthChecksInQueue": { + "label": "Show Health Checks in queue" + }, + "queuePageSize": { + "label": "Queue: Items per page" + }, + "showAppIcon": { + "label": "Show app icon in the bottom right corner" + } + } + }, + "noAppSelected": "Please select an app in the widget settings", + "views": { + "workers": { + "table": { + "header": { + "name": "File", + "eta": "ETA", + "progress": "Progress" + }, + "empty": "Empty", + "tooltip": { + "transcode": "Transcode", + "healthCheck": "Health Check" + } + } + }, + "queue": { + "table": { + "header": { + "name": "File", + "size": "Size" + }, + "footer": { + "currentIndex": "{{start}}-{{end}} of {{total}}" + }, + "empty": "Empty", + "tooltip": { + "transcode": "Transcode", + "healthCheck": "Health Check" + } + } + }, + "statistics": { + "empty": "Empty", + "box": { + "transcodes": "Transcodes: {{value}}", + "healthChecks": "Health Checks: {{value}}", + "files": "Files: {{value}}", + "spaceSaved": "Saved: {{value}}" + }, + "pies": { + "transcodes": "Transcodes", + "healthChecks": "Health Checks", + "videoCodecs": "Codecs", + "videoContainers": "Containers", + "videoResolutions": "Resolutions" + } + } + }, + "error": { + "title": "Error", + "message": "An error occurred while fetching data from Tdarr." + }, + "tabs": { + "workers": "Workers", + "queue": "Queue", + "statistics": "Statistics" + }, + "healthCheckStatus": { + "title": "Health Check", + "queued": "Queued", + "healthy": "Healthy", + "unhealthy": "Unhealthy" + } +} diff --git a/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx b/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx index 4f1411124e8..ee595a55c2f 100644 --- a/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx +++ b/src/components/Board/Customize/Appearance/AppearanceCustomization.tsx @@ -16,7 +16,11 @@ import { useTranslation } from 'next-i18next'; import { highlight, languages } from 'prismjs'; import Editor from 'react-simple-code-editor'; import { useColorTheme } from '~/tools/color'; -import { BackgroundImageAttachment, BackgroundImageRepeat, BackgroundImageSize } from '~/types/settings'; +import { + BackgroundImageAttachment, + BackgroundImageRepeat, + BackgroundImageSize, +} from '~/types/settings'; import { useBoardCustomizationFormContext } from '../form'; diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx index f6273209274..515c4b46701 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx @@ -202,5 +202,10 @@ export const availableIntegrations = [ value: 'proxmox', image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/proxmox.png', label: 'Proxmox', + }, + { + value: 'tdarr', + image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png', + label: 'Tdarr', } ] as const satisfies Readonly; diff --git a/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx b/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx index 6b6e11e1a48..f2a2a15dc57 100644 --- a/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx +++ b/src/components/Dashboard/Tiles/Widgets/WidgetsEditModal.tsx @@ -85,7 +85,7 @@ export const WidgetsEditModal = ({ return ( - {items.map(([key, _], index) => { + {items.map(([key], index) => { const option = (currentWidgetDefinition as any).options[key] as IWidgetOptionValue; const value = moduleProperties[key] ?? option.defaultValue; @@ -395,6 +395,7 @@ const WidgetOptionTypeSwitch: FC<{ ); + /* eslint-enable no-case-declarations */ default: return null; diff --git a/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx b/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx index 88dfc827df0..1bab12d59d7 100644 --- a/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx +++ b/src/components/Dashboard/Tiles/Widgets/WidgetsMenu.tsx @@ -63,7 +63,7 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => { const handleEditClick = () => { openContextModalGeneric({ modal: 'integrationOptions', - title: {t('descriptor.settings.title')}, + title: t('descriptor.settings.title'), innerProps: { widgetId: widget.id, widgetType: integration, diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 053cc7d98f4..98f0ee68f55 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -22,6 +22,7 @@ import { smartHomeEntityStateRouter } from './routers/smart-home/entity-state'; import { usenetRouter } from './routers/usenet/router'; import { userRouter } from './routers/user'; import { weatherRouter } from './routers/weather'; +import { tdarrRouter } from '~/server/api/routers/tdarr'; /** * This is the primary router for your server. @@ -51,6 +52,7 @@ export const rootRouter = createTRPCRouter({ notebook: notebookRouter, smartHomeEntityState: smartHomeEntityStateRouter, healthMonitoring: healthMonitoringRouter, + tdarr: tdarrRouter, }); // export type definition of API diff --git a/src/server/api/routers/tdarr.ts b/src/server/api/routers/tdarr.ts new file mode 100644 index 00000000000..892464d0d51 --- /dev/null +++ b/src/server/api/routers/tdarr.ts @@ -0,0 +1,356 @@ +import { TRPCError } from '@trpc/server'; +import axios from 'axios'; +import { z } from 'zod'; +import { checkIntegrationsType } from '~/tools/client/app-properties'; +import { getConfig } from '~/tools/config/getConfig'; +import { ConfigAppType } from '~/types/app'; + +import { createTRPCRouter, publicProcedure } from '../trpc'; +import { TdarrQueue, TdarrStatistics, TdarrWorker } from '~/types/api/tdarr'; + +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(), + }) + ), + ]) + ), +}); + +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(), + }) + ), + }) +); + +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(), +}); + +export const tdarrRouter = createTRPCRouter({ + statistics: publicProcedure + .input(z.object({ + appId: z.string(), + configName: z.string(), + })) + .query(async ({ input }): Promise => { + const app = getTdarrApp(input.appId, input.configName); + const appUrl = new URL('api/v2/cruddb', app.url); + + const body = { + data: { + collection: 'StatisticsJSONDB', + mode: 'getById', + docID: 'statistics', + }, + }; + + const res = await axios.post(appUrl.toString(), body); + const data: z.infer = res.data; + + const zodRes = getStatisticsSchema.safeParse(data); + if (!zodRes.success) { + /* + * Tdarr's API is not documented and had to be reverse engineered. To account for mistakes in the type + * definitions, we assume the best case scenario and log any parsing errors to aid in fixing the types. + */ + console.error(zodRes.error); + } + + return { + totalFileCount: data.totalFileCount, + totalTranscodeCount: data.totalTranscodeCount, + totalHealthCheckCount: data.totalHealthCheckCount, + failedTranscodeCount: data.table3Count, + failedHealthCheckCount: data.table6Count, + stagedTranscodeCount: data.table1Count, + stagedHealthCheckCount: data.table4Count, + pies: data.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], + })), + }; + }), + + workers: publicProcedure + .input(z.object({ + appId: z.string(), + configName: z.string(), + })).query(async ({ input }): Promise => { + const app = getTdarrApp(input.appId, input.configName); + const appUrl = new URL('api/v2/get-nodes', app.url); + + const res = await axios.get(appUrl.toString()); + const data: z.infer = res.data; + + const zodRes = getNodesResponseSchema.safeParse(data); + if (!zodRes.success) { + /* + * Tdarr's API is not documented and had to be reverse engineered. To account for mistakes in the type + * definitions, we assume the best case scenario and log any parsing errors to aid in fixing the types. + */ + console.error(zodRes.error); + } + + const nodes = Object.values(data); + const workers = nodes.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, + })); + }), + + queue: publicProcedure + .input(z.object({ + appId: z.string(), + configName: z.string(), + showHealthChecksInQueue: z.boolean(), + pageSize: z.number(), + page: z.number(), + })) + .query(async ({ input }): Promise => { + const app = getTdarrApp(input.appId, input.configName); + + const appUrl = new URL('api/v2/client/status-tables', app.url); + + const { page, pageSize, showHealthChecksInQueue } = input; + + const firstItemIndex = page * pageSize; + + const transcodeQueueBody = { + data: { + start: firstItemIndex, + pageSize: pageSize, + filters: [], + sorts: [], + opts: { + table: 'table1', + }, + }, + }; + + const transcodeQueueRes = await axios.post(appUrl.toString(), transcodeQueueBody); + const transcodeQueueData: z.infer = transcodeQueueRes.data; + + const transcodeQueueZodRes = getStatusTableSchema.safeParse(transcodeQueueData); + if (!transcodeQueueZodRes.success) { + /* + * Tdarr's API is not documented and had to be reverse engineered. To account for mistakes in the type + * definitions, we assume the best case scenario and log any parsing errors to aid in fixing the types. + */ + console.error(transcodeQueueZodRes.error); + } + + const transcodeQueueResult = { + array: transcodeQueueData.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: transcodeQueueData.totalCount, + startIndex: firstItemIndex, + endIndex: firstItemIndex + transcodeQueueData.array.length - 1, + }; + + if (!showHealthChecksInQueue) { + return transcodeQueueResult; + } + + const healthCheckQueueBody = { + data: { + start: Math.max(firstItemIndex - transcodeQueueData.totalCount, 0), + pageSize: pageSize, + filters: [], + sorts: [], + opts: { + table: 'table4', + }, + }, + }; + + const healthCheckQueueRes = await axios.post(appUrl.toString(), healthCheckQueueBody); + const healthCheckQueueData: z.infer = healthCheckQueueRes.data; + + const healthCheckQueueZodRes = getStatusTableSchema.safeParse(healthCheckQueueData); + if (!healthCheckQueueZodRes.success) { + /* + * Tdarr's API is not documented and had to be reverse engineered. To account for mistakes in the type + * definitions, we assume the best case scenario and log any parsing errors to aid in fixing the types. + */ + console.error(healthCheckQueueZodRes.error); + } + + const healthCheckResultArray = healthCheckQueueData.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, + })); + + const combinedArray = [...transcodeQueueResult.array, ...healthCheckResultArray].slice( + 0, + pageSize + ); + + return { + array: combinedArray, + totalCount: transcodeQueueData.totalCount + healthCheckQueueData.totalCount, + startIndex: firstItemIndex, + endIndex: firstItemIndex + combinedArray.length - 1, + }; + }), +}); + +function getTdarrApp(appId: string, configName: string): ConfigAppType { + const config = getConfig(configName); + + const app = config.apps.find((x) => x.id === appId); + + if (!app) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `[Tdarr integration] App with ID "${appId}" could not be found.`, + }); + } + + if (!checkIntegrationsType(app.integration, ['tdarr'])) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `[Tdarr integration] App with ID "${appId}" is not using the Tdarr integration.`, + }); + } + + return app; +} diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index fe698859f4b..eeeeacc8d34 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -34,6 +34,7 @@ export const boardNamespaces = [ 'modules/notebook', 'modules/smart-home/entity-state', 'modules/smart-home/trigger-automation', + 'modules/media-transcoding', 'widgets/error-boundary', 'widgets/draggable-list', 'widgets/location', diff --git a/src/types/api/tdarr.ts b/src/types/api/tdarr.ts new file mode 100644 index 00000000000..751611b40d8 --- /dev/null +++ b/src/types/api/tdarr.ts @@ -0,0 +1,60 @@ +export type TdarrPieSegment = { + name: string; + value: number; +}; + +export type 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[]; + }[]; +}; + +export type 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; +}; + +export type 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; +}; \ No newline at end of file diff --git a/src/types/app.ts b/src/types/app.ts index c161060089c..60f0093e990 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -59,7 +59,8 @@ export type IntegrationType = | 'adGuardHome' | 'homeAssistant' | 'openmediavault' - | 'proxmox'; + | 'proxmox' + | 'tdarr'; export type AppIntegrationType = { type: IntegrationType | null; @@ -105,6 +106,7 @@ export const integrationFieldProperties: { homeAssistant: ['apiKey'], openmediavault: ['username', 'password'], proxmox: ['apiKey'], + tdarr: [], }; export type IntegrationFieldDefinitionType = { diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 1666722bf8b..69ed3a31617 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -15,6 +15,7 @@ import notebook from './notebook/NotebookWidgetTile'; import rss from './rss/RssWidgetTile'; import smartHomeEntityState from './smart-home/entity-state/entity-state.widget'; import smartHomeTriggerAutomation from './smart-home/trigger-automation/trigger-automation.widget'; +import mediaTranscoding from '~/widgets/media-transcoding/MediaTranscodingTile'; import torrent from './torrent/TorrentTile'; import usenet from './useNet/UseNetTile'; import videoStream from './video/VideoStreamTile'; @@ -42,4 +43,5 @@ export default { 'smart-home/entity-state': smartHomeEntityState, 'smart-home/trigger-automation': smartHomeTriggerAutomation, 'health-monitoring': healthMonitoring, + 'media-transcoding': mediaTranscoding, }; diff --git a/src/widgets/media-transcoding/HealthCheckStatus.tsx b/src/widgets/media-transcoding/HealthCheckStatus.tsx new file mode 100644 index 00000000000..4bf91baa8dc --- /dev/null +++ b/src/widgets/media-transcoding/HealthCheckStatus.tsx @@ -0,0 +1,90 @@ +import { + Divider, + Group, + HoverCard, + Indicator, + MantineColor, + RingProgress, + Stack, + Text, +} from '@mantine/core'; +import { IconHeartbeat } from '@tabler/icons-react'; +import { useTranslation } from 'next-i18next'; +import { useColorScheme } from '~/hooks/use-colorscheme'; + +import { TdarrStatistics } from '~/types/api/tdarr'; + +interface StatisticsBadgeProps { + statistics?: TdarrStatistics; +} + +export function HealthCheckStatus(props: StatisticsBadgeProps) { + const { statistics } = props; + + const { colorScheme } = useColorScheme(); + const { t } = useTranslation('modules/media-transcoding'); + + if (!statistics) { + return ; + } + + const indicatorColor = statistics.failedHealthCheckCount + ? 'red' + : statistics.stagedHealthCheckCount + ? 'yellow' + : 'green'; + + return ( + + + + + + + + + + + {t(`healthCheckStatus.title`)} + + + + + + + {statistics.stagedHealthCheckCount} + + {t(`healthCheckStatus.queued`)} + + + + {statistics.totalHealthCheckCount} + + {t(`healthCheckStatus.healthy`)} + + + + {statistics.failedHealthCheckCount} + + {t(`healthCheckStatus.unhealthy`)} + + + + + + ); +} + +function textColor(color: MantineColor, theme: 'light' | 'dark') { + return `${color}.${theme === 'light' ? 8 : 5}`; +} diff --git a/src/widgets/media-transcoding/MediaTranscodingTile.tsx b/src/widgets/media-transcoding/MediaTranscodingTile.tsx new file mode 100644 index 00000000000..b388e7e0c70 --- /dev/null +++ b/src/widgets/media-transcoding/MediaTranscodingTile.tsx @@ -0,0 +1,265 @@ +import { + Alert, + Center, + Code, + Divider, + Group, + List, + Pagination, + SegmentedControl, + Stack, + Text, + Title, + Tooltip, +} from '@mantine/core'; +import { + IconAlertCircle, + IconClipboardList, + IconCpu2, + IconReportAnalytics, IconTransform, +} from '@tabler/icons-react'; +import { useTranslation } from 'next-i18next'; +import { useState } from 'react'; +import { z } from 'zod'; +import { AppAvatar } from '~/components/AppAvatar'; +import { useConfigContext } from '~/config/provider'; +import { api } from '~/utils/api'; +import { HealthCheckStatus } from '~/widgets/media-transcoding/HealthCheckStatus'; +import { QueuePanel } from '~/widgets/media-transcoding/QueuePanel'; +import { StatisticsPanel } from '~/widgets/media-transcoding/StatisticsPanel'; +import { WorkersPanel } from '~/widgets/media-transcoding/WorkersPanel'; + +import { defineWidget } from '../helper'; +import { IWidget } from '../widgets'; + +const definition = defineWidget({ + id: 'media-transcoding', + icon: IconTransform, + options: { + defaultView: { + type: 'select', + data: [ + { + value: 'workers', + }, + { + value: 'queue', + }, + { + value: 'statistics', + }, + ], + defaultValue: 'workers', + }, + showHealthCheck: { + type: 'switch', + defaultValue: true, + }, + showHealthChecksInQueue: { + type: 'switch', + defaultValue: true, + }, + queuePageSize: { + type: 'number', + defaultValue: 10, + }, + showAppIcon: { + type: 'switch', + defaultValue: true, + }, + }, + gridstack: { + minWidth: 3, + minHeight: 2, + maxWidth: 12, + maxHeight: 6, + }, + component: MediaTranscodingTile, +}); + +export type TdarrWidget = IWidget<(typeof definition)['id'], typeof definition>; + +interface TdarrQueueTileProps { + widget: TdarrWidget; +} + +function MediaTranscodingTile({ widget }: TdarrQueueTileProps) { + const { t } = useTranslation('modules/media-transcoding'); + const { config, name: configName } = useConfigContext(); + + const appId = config?.apps.find( + (app) => app.integration.type === 'tdarr', + )?.id; + const app = config?.apps.find((app) => app.id === appId); + const { defaultView, showHealthCheck, showHealthChecksInQueue, queuePageSize, showAppIcon } = + widget.properties; + + const [view, setView] = useState<'workers' | 'queue' | 'statistics'>( + viewSchema.parse(defaultView) + ); + + const [queuePage, setQueuePage] = useState(1); + + const workers = api.tdarr.workers.useQuery( + { + appId: app?.id!, + configName: configName!, + }, + { enabled: !!app?.id && !!configName && view === 'workers', refetchInterval: 2000 } + ); + + const statistics = api.tdarr.statistics.useQuery( + { + appId: app?.id!, + configName: configName!, + }, + { enabled: !!app?.id && !!configName, refetchInterval: 10000 } + ); + + const queue = api.tdarr.queue.useQuery( + { + appId: app?.id!, + configName: configName!, + pageSize: queuePageSize, + page: queuePage - 1, + showHealthChecksInQueue, + }, + { + enabled: !!app?.id && !!configName && view === 'queue', + refetchInterval: 2000, + } + ); + + if (statistics.isError || workers.isError || queue.isError) { + return ( + + } + my="lg" + title={t('error.title')} + color="red" + radius="md" + > + {t('error.message')} + + {statistics.isError && ( + + {statistics.error.message} + + )} + {workers.isError && ( + + {workers.error.message} + + )} + {queue.isError && ( + + {queue.error.message} + + )} + + + + ); + } + + if (!app) { + return ( + +
+ {t('noAppSelected')} +
+
+ ); + } + + const totalQueuePages = Math.ceil((queue.data?.totalCount || 1) / queuePageSize); + + return ( + + {view === 'workers' ? ( + + ) : view === 'queue' ? ( + + ) : ( + + )} + + + + + + {t('tabs.workers')} + + + ), + value: 'workers', + }, + { + label: ( +
+ + + {t('tabs.queue')} + +
+ ), + value: 'queue', + }, + { + label: ( +
+ + + {t('tabs.statistics')} + +
+ ), + value: 'statistics', + }, + ]} + value={view} + onChange={(value) => setView(viewSchema.parse(value))} + size="xs" + /> + {view === 'queue' && !!queue.data && ( + <> + + + + + + + + + + {t('views.queue.table.footer.currentIndex', { + start: queue.data.startIndex + 1, + end: queue.data.endIndex + 1, + total: queue.data.totalCount, + })} + + + )} + + {showHealthCheck && statistics.data && } + {showAppIcon && ( + +
+ +
+
+ )} +
+
+
+ ); +} + +const viewSchema = z.enum(['workers', 'queue', 'statistics']); + +export default definition; diff --git a/src/widgets/media-transcoding/QueuePanel.tsx b/src/widgets/media-transcoding/QueuePanel.tsx new file mode 100644 index 00000000000..bb7a1c5ed7c --- /dev/null +++ b/src/widgets/media-transcoding/QueuePanel.tsx @@ -0,0 +1,69 @@ +import { Center, Group, ScrollArea, Table, Text, Title, Tooltip } from '@mantine/core'; +import { IconHeartbeat, IconTransform } from '@tabler/icons-react'; +import { useTranslation } from 'next-i18next'; +import { humanFileSize } from '~/tools/humanFileSize'; +import { WidgetLoading } from '~/widgets/loading'; +import { TdarrQueue } from '~/types/api/tdarr'; + +interface QueuePanelProps { + queue: TdarrQueue | undefined; + isLoading: boolean; +} + +export function QueuePanel(props: QueuePanelProps) { + const { queue, isLoading } = props; + + const { t } = useTranslation('modules/media-transcoding'); + + if (isLoading) { + return ; + } + + if (!queue?.array.length) { + return ( +
+ {t('views.queue.table.empty')} +
+ ); + } + + return ( + + + + + + + + + + {queue.array.map((item) => ( + + + + + ))} + +
{t('views.queue.table.header.name')}{t('views.queue.table.header.size')}
+ +
+ {item.type === 'transcode' ? ( + + + + ) : ( + + + + )} +
+ {item.filePath.split('\\').pop()?.split('/').pop() ?? item.filePath} +
+
+ {humanFileSize(item.fileSize)} +
+
+ ); +} diff --git a/src/widgets/media-transcoding/StatisticsPanel.tsx b/src/widgets/media-transcoding/StatisticsPanel.tsx new file mode 100644 index 00000000000..3a156436922 --- /dev/null +++ b/src/widgets/media-transcoding/StatisticsPanel.tsx @@ -0,0 +1,167 @@ +import { + Box, + Center, + Grid, + Group, + MantineColor, + RingProgress, + RingProgressProps, + Stack, + Text, + Title, +} from '@mantine/core'; +import { + IconDatabaseHeart, + IconFileDescription, + IconHeartbeat, + IconTransform, +} from '@tabler/icons-react'; +import { useTranslation } from 'next-i18next'; +import { ReactNode } from 'react'; +import { humanFileSize } from '~/tools/humanFileSize'; +import { WidgetLoading } from '~/widgets/loading'; +import { TdarrPieSegment, TdarrStatistics } from '~/types/api/tdarr'; + +const PIE_COLORS: MantineColor[] = ['cyan', 'grape', 'gray', 'orange', 'pink']; + +interface StatisticsPanelProps { + statistics: TdarrStatistics | undefined; + isLoading: boolean; +} + +export function StatisticsPanel(props: StatisticsPanelProps) { + const { statistics, isLoading } = props; + + const { t } = useTranslation('modules/media-transcoding'); + + if (isLoading) { + return ; + } + + const allLibs = statistics?.pies.find((pie) => pie.libraryName === 'All'); + + if (!statistics || !allLibs) { + return ( +
+ {t('views.statistics.empty')} +
+ ); + } + + return ( + + + + + {t('views.statistics.pies.transcodes')} + + + + } + label={t('views.statistics.box.transcodes', { + value: statistics.totalTranscodeCount + })} + /> + + + } + label={t('views.statistics.box.healthChecks', { + value: statistics.totalHealthCheckCount + })} + /> + + + } + label={t('views.statistics.box.files', { + value: statistics.totalFileCount + })} + /> + + + } + label={t('views.statistics.box.spaceSaved', { + value: allLibs?.savedSpace ? humanFileSize(allLibs.savedSpace) : '-' + })} + /> + + + + + {t('views.statistics.pies.healthChecks')} + + + + + + {t('views.statistics.pies.videoCodecs')} + + + + {t('views.statistics.pies.videoContainers')} + + + + {t('views.statistics.pies.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}`, + color: PIE_COLORS[index % PIE_COLORS.length], // Ensures a valid color in the case that index > PIE_COLORS.length + })); +} + +type StatBoxProps = { + icon: ReactNode; + label: string; +}; + +function StatBox(props: StatBoxProps) { + const { icon, label } = props; + return ( + ({ + padding: theme.spacing.xs, + border: '1px solid', + borderRadius: theme.radius.md, + borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[3] : theme.colors.gray[1], + })} + > + + {icon} + + {label} + + + + ); +} diff --git a/src/widgets/media-transcoding/WorkersPanel.tsx b/src/widgets/media-transcoding/WorkersPanel.tsx new file mode 100644 index 00000000000..b07250969ac --- /dev/null +++ b/src/widgets/media-transcoding/WorkersPanel.tsx @@ -0,0 +1,94 @@ +import { + Center, + Group, + Progress, + ScrollArea, + Table, + Text, + Title, + Tooltip, +} from '@mantine/core'; +import { IconHeartbeat, IconTransform } from '@tabler/icons-react'; +import { useTranslation } from 'next-i18next'; +import { WidgetLoading } from '~/widgets/loading'; +import { TdarrWorker } from '~/types/api/tdarr'; + +interface WorkersPanelProps { + workers: TdarrWorker[] | undefined; + isLoading: boolean; +} + +export function WorkersPanel(props: WorkersPanelProps) { + const { workers, isLoading } = props; + + const { t } = useTranslation('modules/media-transcoding'); + + if (isLoading) { + return ; + } + + if (!workers?.length) { + return ( +
+ {t('views.workers.table.empty')} +
+ ); + } + + return ( + + + + + + + + + + + {workers.map((worker) => ( + + + + + + ))} + +
{t('views.workers.table.header.name')}{t('views.workers.table.header.eta')}{t('views.workers.table.header.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)}% + +
+
+ ); +} diff --git a/src/widgets/widgets.ts b/src/widgets/widgets.ts index b77a9251c10..5cff6af0ae5 100644 --- a/src/widgets/widgets.ts +++ b/src/widgets/widgets.ts @@ -8,6 +8,7 @@ import { } from '@mantine/core'; import { Icon } from '@tabler/icons-react'; import React from 'react'; +import { IntegrationType } from '~/types/app'; import { AreaType } from '~/types/area'; import { ShapeType } from '~/types/shape';