✨ Add Tdarr integration and widget (#1882)
This commit is contained in:
96
public/locales/en/modules/media-transcoding.json
Normal file
96
public/locales/en/modules/media-transcoding.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,11 @@ import { useTranslation } from 'next-i18next';
|
|||||||
import { highlight, languages } from 'prismjs';
|
import { highlight, languages } from 'prismjs';
|
||||||
import Editor from 'react-simple-code-editor';
|
import Editor from 'react-simple-code-editor';
|
||||||
import { useColorTheme } from '~/tools/color';
|
import { useColorTheme } from '~/tools/color';
|
||||||
import { BackgroundImageAttachment, BackgroundImageRepeat, BackgroundImageSize } from '~/types/settings';
|
import {
|
||||||
|
BackgroundImageAttachment,
|
||||||
|
BackgroundImageRepeat,
|
||||||
|
BackgroundImageSize,
|
||||||
|
} from '~/types/settings';
|
||||||
|
|
||||||
import { useBoardCustomizationFormContext } from '../form';
|
import { useBoardCustomizationFormContext } from '../form';
|
||||||
|
|
||||||
|
|||||||
@@ -202,5 +202,10 @@ export const availableIntegrations = [
|
|||||||
value: 'proxmox',
|
value: 'proxmox',
|
||||||
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/proxmox.png',
|
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/proxmox.png',
|
||||||
label: 'Proxmox',
|
label: 'Proxmox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'tdarr',
|
||||||
|
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png',
|
||||||
|
label: 'Tdarr',
|
||||||
}
|
}
|
||||||
] as const satisfies Readonly<SelectItem[]>;
|
] as const satisfies Readonly<SelectItem[]>;
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export const WidgetsEditModal = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
{items.map(([key, _], index) => {
|
{items.map(([key], index) => {
|
||||||
const option = (currentWidgetDefinition as any).options[key] as IWidgetOptionValue;
|
const option = (currentWidgetDefinition as any).options[key] as IWidgetOptionValue;
|
||||||
const value = moduleProperties[key] ?? option.defaultValue;
|
const value = moduleProperties[key] ?? option.defaultValue;
|
||||||
|
|
||||||
@@ -395,6 +395,7 @@ const WidgetOptionTypeSwitch: FC<{
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
||||||
/* eslint-enable no-case-declarations */
|
/* eslint-enable no-case-declarations */
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const WidgetsMenu = ({ integration, widget }: WidgetsMenuProps) => {
|
|||||||
const handleEditClick = () => {
|
const handleEditClick = () => {
|
||||||
openContextModalGeneric<WidgetEditModalInnerProps>({
|
openContextModalGeneric<WidgetEditModalInnerProps>({
|
||||||
modal: 'integrationOptions',
|
modal: 'integrationOptions',
|
||||||
title: <Title order={4}>{t('descriptor.settings.title')}</Title>,
|
title: t('descriptor.settings.title'),
|
||||||
innerProps: {
|
innerProps: {
|
||||||
widgetId: widget.id,
|
widgetId: widget.id,
|
||||||
widgetType: integration,
|
widgetType: integration,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { smartHomeEntityStateRouter } from './routers/smart-home/entity-state';
|
|||||||
import { usenetRouter } from './routers/usenet/router';
|
import { usenetRouter } from './routers/usenet/router';
|
||||||
import { userRouter } from './routers/user';
|
import { userRouter } from './routers/user';
|
||||||
import { weatherRouter } from './routers/weather';
|
import { weatherRouter } from './routers/weather';
|
||||||
|
import { tdarrRouter } from '~/server/api/routers/tdarr';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -51,6 +52,7 @@ export const rootRouter = createTRPCRouter({
|
|||||||
notebook: notebookRouter,
|
notebook: notebookRouter,
|
||||||
smartHomeEntityState: smartHomeEntityStateRouter,
|
smartHomeEntityState: smartHomeEntityStateRouter,
|
||||||
healthMonitoring: healthMonitoringRouter,
|
healthMonitoring: healthMonitoringRouter,
|
||||||
|
tdarr: tdarrRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
356
src/server/api/routers/tdarr.ts
Normal file
356
src/server/api/routers/tdarr.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ export const boardNamespaces = [
|
|||||||
'modules/notebook',
|
'modules/notebook',
|
||||||
'modules/smart-home/entity-state',
|
'modules/smart-home/entity-state',
|
||||||
'modules/smart-home/trigger-automation',
|
'modules/smart-home/trigger-automation',
|
||||||
|
'modules/media-transcoding',
|
||||||
'widgets/error-boundary',
|
'widgets/error-boundary',
|
||||||
'widgets/draggable-list',
|
'widgets/draggable-list',
|
||||||
'widgets/location',
|
'widgets/location',
|
||||||
|
|||||||
60
src/types/api/tdarr.ts
Normal file
60
src/types/api/tdarr.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -59,7 +59,8 @@ export type IntegrationType =
|
|||||||
| 'adGuardHome'
|
| 'adGuardHome'
|
||||||
| 'homeAssistant'
|
| 'homeAssistant'
|
||||||
| 'openmediavault'
|
| 'openmediavault'
|
||||||
| 'proxmox';
|
| 'proxmox'
|
||||||
|
| 'tdarr';
|
||||||
|
|
||||||
export type AppIntegrationType = {
|
export type AppIntegrationType = {
|
||||||
type: IntegrationType | null;
|
type: IntegrationType | null;
|
||||||
@@ -105,6 +106,7 @@ export const integrationFieldProperties: {
|
|||||||
homeAssistant: ['apiKey'],
|
homeAssistant: ['apiKey'],
|
||||||
openmediavault: ['username', 'password'],
|
openmediavault: ['username', 'password'],
|
||||||
proxmox: ['apiKey'],
|
proxmox: ['apiKey'],
|
||||||
|
tdarr: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IntegrationFieldDefinitionType = {
|
export type IntegrationFieldDefinitionType = {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import notebook from './notebook/NotebookWidgetTile';
|
|||||||
import rss from './rss/RssWidgetTile';
|
import rss from './rss/RssWidgetTile';
|
||||||
import smartHomeEntityState from './smart-home/entity-state/entity-state.widget';
|
import smartHomeEntityState from './smart-home/entity-state/entity-state.widget';
|
||||||
import smartHomeTriggerAutomation from './smart-home/trigger-automation/trigger-automation.widget';
|
import smartHomeTriggerAutomation from './smart-home/trigger-automation/trigger-automation.widget';
|
||||||
|
import mediaTranscoding from '~/widgets/media-transcoding/MediaTranscodingTile';
|
||||||
import torrent from './torrent/TorrentTile';
|
import torrent from './torrent/TorrentTile';
|
||||||
import usenet from './useNet/UseNetTile';
|
import usenet from './useNet/UseNetTile';
|
||||||
import videoStream from './video/VideoStreamTile';
|
import videoStream from './video/VideoStreamTile';
|
||||||
@@ -42,4 +43,5 @@ export default {
|
|||||||
'smart-home/entity-state': smartHomeEntityState,
|
'smart-home/entity-state': smartHomeEntityState,
|
||||||
'smart-home/trigger-automation': smartHomeTriggerAutomation,
|
'smart-home/trigger-automation': smartHomeTriggerAutomation,
|
||||||
'health-monitoring': healthMonitoring,
|
'health-monitoring': healthMonitoring,
|
||||||
|
'media-transcoding': mediaTranscoding,
|
||||||
};
|
};
|
||||||
|
|||||||
90
src/widgets/media-transcoding/HealthCheckStatus.tsx
Normal file
90
src/widgets/media-transcoding/HealthCheckStatus.tsx
Normal 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}`;
|
||||||
|
}
|
||||||
265
src/widgets/media-transcoding/MediaTranscodingTile.tsx
Normal file
265
src/widgets/media-transcoding/MediaTranscodingTile.tsx
Normal 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;
|
||||||
69
src/widgets/media-transcoding/QueuePanel.tsx
Normal file
69
src/widgets/media-transcoding/QueuePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
src/widgets/media-transcoding/StatisticsPanel.tsx
Normal file
167
src/widgets/media-transcoding/StatisticsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/widgets/media-transcoding/WorkersPanel.tsx
Normal file
94
src/widgets/media-transcoding/WorkersPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Icon } from '@tabler/icons-react';
|
import { Icon } from '@tabler/icons-react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { IntegrationType } from '~/types/app';
|
||||||
import { AreaType } from '~/types/area';
|
import { AreaType } from '~/types/area';
|
||||||
import { ShapeType } from '~/types/shape';
|
import { ShapeType } from '~/types/shape';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user