Add Tdarr integration and widget (#1882)

This commit is contained in:
Janik Brüll
2024-04-18 23:01:20 +02:00
committed by GitHub
parent 39f416c6a9
commit 2b92c98975
17 changed files with 1219 additions and 4 deletions

View File

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

View File

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

View File

@@ -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<SelectItem[]>;

View File

@@ -85,7 +85,7 @@ export const WidgetsEditModal = ({
return (
<Stack>
{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<{
</Flex>
</Stack>
);
/* eslint-enable no-case-declarations */
default:
return null;

View File

@@ -63,7 +63,7 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => {
const handleEditClick = () => {
openContextModalGeneric<WidgetEditModalInnerProps>({
modal: 'integrationOptions',
title: <Title order={4}>{t('descriptor.settings.title')}</Title>,
title: t('descriptor.settings.title'),
innerProps: {
widgetId: widget.id,
widgetType: integration,

View File

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

View File

@@ -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<TdarrStatistics> => {
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<typeof getStatisticsSchema> = 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<TdarrWorker[]> => {
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<typeof getNodesResponseSchema> = 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<TdarrQueue> => {
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<typeof getStatusTableSchema> = 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<typeof getStatusTableSchema> = 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;
}

View File

@@ -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',

60
src/types/api/tdarr.ts Normal file
View File

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

View File

@@ -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 = {

View File

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

View File

@@ -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 <IconHeartbeat size={20} />;
}
const indicatorColor = statistics.failedHealthCheckCount
? 'red'
: statistics.stagedHealthCheckCount
? 'yellow'
: 'green';
return (
<HoverCard position="bottom" width={250} shadow="sm">
<HoverCard.Target>
<Indicator color={textColor(indicatorColor, colorScheme)} size={8} display="flex">
<IconHeartbeat size={20} />
</Indicator>
</HoverCard.Target>
<HoverCard.Dropdown bg={colorScheme === 'light' ? 'gray.2' : 'dark.8'}>
<Stack spacing="sm" align="center">
<Group spacing="xs">
<IconHeartbeat size={18} />
<Text size="sm">{t(`healthCheckStatus.title`)}</Text>
</Group>
<Divider
style={{
alignSelf: 'stretch',
}}
/>
<RingProgress
sections={[
{ value: statistics.stagedHealthCheckCount, color: textColor('yellow', colorScheme) },
{ value: statistics.totalHealthCheckCount, color: textColor('green', colorScheme) },
{ value: statistics.failedHealthCheckCount, color: textColor('red', colorScheme) },
]}
/>
<Group display="flex" w="100%">
<Stack style={{ flex: 1 }} spacing={0} align="center">
<Text size="xs" color={textColor('yellow', colorScheme)}>
{statistics.stagedHealthCheckCount}
</Text>
<Text size="xs">{t(`healthCheckStatus.queued`)}</Text>
</Stack>
<Stack style={{ flex: 1 }} spacing={0} align="center">
<Text size="xs" color={textColor('green', colorScheme)}>
{statistics.totalHealthCheckCount}
</Text>
<Text size="xs">{t(`healthCheckStatus.healthy`)}</Text>
</Stack>
<Stack style={{ flex: 1 }} spacing={0} align="center">
<Text size="xs" color={textColor('red', colorScheme)}>
{statistics.failedHealthCheckCount}
</Text>
<Text size="xs">{t(`healthCheckStatus.unhealthy`)}</Text>
</Stack>
</Group>
</Stack>
</HoverCard.Dropdown>
</HoverCard>
);
}
function textColor(color: MantineColor, theme: 'light' | 'dark') {
return `${color}.${theme === 'light' ? 8 : 5}`;
}

View File

@@ -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 (
<Group position="center">
<Alert
icon={<IconAlertCircle size={16} />}
my="lg"
title={t('error.title')}
color="red"
radius="md"
>
{t('error.message')}
<List>
{statistics.isError && (
<Code mt="sm" block>
{statistics.error.message}
</Code>
)}
{workers.isError && (
<Code mt="sm" block>
{workers.error.message}
</Code>
)}
{queue.isError && (
<Code mt="sm" block>
{queue.error.message}
</Code>
)}
</List>
</Alert>
</Group>
);
}
if (!app) {
return (
<Stack justify="center" h="100%">
<Center>
<Title order={3}>{t('noAppSelected')}</Title>
</Center>
</Stack>
);
}
const totalQueuePages = Math.ceil((queue.data?.totalCount || 1) / queuePageSize);
return (
<Stack spacing="xs" h="100%">
{view === 'workers' ? (
<WorkersPanel workers={workers.data} isLoading={workers.isLoading} />
) : view === 'queue' ? (
<QueuePanel queue={queue.data} isLoading={queue.isLoading} />
) : (
<StatisticsPanel statistics={statistics.data} isLoading={statistics.isLoading} />
)}
<Divider />
<Group spacing="xs">
<SegmentedControl
data={[
{
label: (
<Center>
<IconCpu2 size={18} />
<Text size="xs" ml={8}>
{t('tabs.workers')}
</Text>
</Center>
),
value: 'workers',
},
{
label: (
<Center>
<IconClipboardList size={18} />
<Text size="xs" ml={8}>
{t('tabs.queue')}
</Text>
</Center>
),
value: 'queue',
},
{
label: (
<Center>
<IconReportAnalytics size={18} />
<Text size="xs" ml={8}>
{t('tabs.statistics')}
</Text>
</Center>
),
value: 'statistics',
},
]}
value={view}
onChange={(value) => setView(viewSchema.parse(value))}
size="xs"
/>
{view === 'queue' && !!queue.data && (
<>
<Pagination.Root total={totalQueuePages} value={queuePage} onChange={setQueuePage} size="sm">
<Group spacing={5} position="center">
<Pagination.First disabled={queuePage === 1} />
<Pagination.Previous disabled={queuePage === 1} />
<Pagination.Next disabled={queuePage === totalQueuePages} />
<Pagination.Last disabled={queuePage === totalQueuePages} />
</Group>
</Pagination.Root>
<Text size="xs">
{t('views.queue.table.footer.currentIndex', {
start: queue.data.startIndex + 1,
end: queue.data.endIndex + 1,
total: queue.data.totalCount,
})}
</Text>
</>
)}
<Group spacing="xs" ml="auto">
{showHealthCheck && statistics.data && <HealthCheckStatus statistics={statistics.data} />}
{showAppIcon && (
<Tooltip label={app.name}>
<div>
<AppAvatar iconUrl={app.appearance.iconUrl} />
</div>
</Tooltip>
)}
</Group>
</Group>
</Stack>
);
}
const viewSchema = z.enum(['workers', 'queue', 'statistics']);
export default definition;

View File

@@ -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 <WidgetLoading />;
}
if (!queue?.array.length) {
return (
<Center
style={{ flex: '1' }}
>
<Title order={3}>{t('views.queue.table.empty')}</Title>
</Center>
);
}
return (
<ScrollArea style={{ flex: '1' }}>
<Table style={{ tableLayout: 'fixed' }}>
<thead>
<tr>
<th>{t('views.queue.table.header.name')}</th>
<th style={{ width: 80 }}>{t('views.queue.table.header.size')}</th>
</tr>
</thead>
<tbody>
{queue.array.map((item) => (
<tr key={item.id}>
<td>
<Group spacing="xs" noWrap>
<div>
{item.type === 'transcode' ? (
<Tooltip label={t('views.workers.table.tooltip.transcode')}>
<IconTransform size={14} />
</Tooltip>
) : (
<Tooltip label={t('views.workers.table.tooltip.healthCheck')}>
<IconHeartbeat size={14} />
</Tooltip>
)}
</div>
<Text lineClamp={1} size="xs">{item.filePath.split('\\').pop()?.split('/').pop() ?? item.filePath}</Text>
</Group>
</td>
<td>
<Text size="xs">{humanFileSize(item.fileSize)}</Text>
</td>
</tr>
))}
</tbody>
</Table>
</ScrollArea>
);
}

View File

@@ -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 <WidgetLoading />;
}
const allLibs = statistics?.pies.find((pie) => pie.libraryName === 'All');
if (!statistics || !allLibs) {
return (
<Center
style={{ flex: '1' }}
>
<Title order={3}>{t('views.statistics.empty')}</Title>
</Center>
);
}
return (
<Stack style={{ flex: '1' }} spacing="xs">
<Group
style={{
flex: 1,
}}
position="apart"
align="center"
noWrap
>
<Stack align="center" spacing={0}>
<RingProgress size={120} sections={toRingProgressSections(allLibs.transcodeStatus)} />
<Text size="xs">{t('views.statistics.pies.transcodes')}</Text>
</Stack>
<Grid gutter="xs">
<Grid.Col span={6}>
<StatBox
icon={<IconTransform size={18} />}
label={t('views.statistics.box.transcodes', {
value: statistics.totalTranscodeCount
})}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatBox
icon={<IconHeartbeat size={18} />}
label={t('views.statistics.box.healthChecks', {
value: statistics.totalHealthCheckCount
})}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatBox
icon={<IconFileDescription size={18} />}
label={t('views.statistics.box.files', {
value: statistics.totalFileCount
})}
/>
</Grid.Col>
<Grid.Col span={6}>
<StatBox
icon={<IconDatabaseHeart size={18} />}
label={t('views.statistics.box.spaceSaved', {
value: allLibs?.savedSpace ? humanFileSize(allLibs.savedSpace) : '-'
})}
/>
</Grid.Col>
</Grid>
<Stack align="center" spacing={0}>
<RingProgress
size={120}
sections={toRingProgressSections(allLibs.healthCheckStatus)}
/>
<Text size="xs">{t('views.statistics.pies.healthChecks')}</Text>
</Stack>
</Group>
<Group
style={{
flex: 1,
}}
position="apart"
align="center"
noWrap
>
<Stack align="center" spacing={0}>
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoCodecs)} />
<Text size="xs">{t('views.statistics.pies.videoCodecs')}</Text>
</Stack>
<Stack align="center" spacing={0}>
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoContainers)} />
<Text size="xs">{t('views.statistics.pies.videoContainers')}</Text>
</Stack>
<Stack align="center" spacing={0}>
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoResolutions)} />
<Text size="xs">{t('views.statistics.pies.videoResolutions')}</Text>
</Stack>
</Group>
</Stack>
);
}
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 (
<Box
sx={(theme) => ({
padding: theme.spacing.xs,
border: '1px solid',
borderRadius: theme.radius.md,
borderColor: theme.colorScheme === 'dark' ? theme.colors.dark[3] : theme.colors.gray[1],
})}
>
<Stack spacing="xs" align="center">
{icon}
<Text size="xs">
{label}
</Text>
</Stack>
</Box>
);
}

View File

@@ -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 <WidgetLoading />;
}
if (!workers?.length) {
return (
<Center
style={{ flex: '1' }}
>
<Title order={3}>{t('views.workers.table.empty')}</Title>
</Center>
);
}
return (
<ScrollArea style={{ flex: '1' }}>
<Table style={{ tableLayout: 'fixed' }}>
<thead>
<tr>
<th>{t('views.workers.table.header.name')}</th>
<th style={{ width: 60 }}>{t('views.workers.table.header.eta')}</th>
<th style={{ width: 175 }}>{t('views.workers.table.header.progress')}</th>
</tr>
</thead>
<tbody>
{workers.map((worker) => (
<tr key={worker.id}>
<td>
<Group spacing="xs" noWrap>
<div>
{worker.jobType === 'transcode' ? (
<Tooltip label={t('views.workers.table.tooltip.transcode')}>
<IconTransform size={14} />
</Tooltip>
) : (
<Tooltip label={t('views.workers.table.tooltip.healthCheck')}>
<IconHeartbeat size={14} />
</Tooltip>
)}
</div>
<Text lineClamp={1} size="xs">{worker.filePath.split('\\').pop()?.split('/').pop() ?? worker.filePath}</Text>
</Group>
</td>
<td>
<Text size="xs">
{worker.ETA.startsWith('0:') ? worker.ETA.substring(2) : worker.ETA}
</Text>
</td>
<td>
<Group noWrap spacing="xs">
<Text size="xs">{worker.step}</Text>
<Progress
value={worker.percentage}
size="lg"
radius="xl"
style={{
flex: 1,
}}
/>
<Text size="xs">{Math.round(worker.percentage)}%</Text>
</Group>
</td>
</tr>
))}
</tbody>
</Table>
</ScrollArea>
);
}

View File

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