feat: add tdarr integration (#1657)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
115
packages/widgets/src/media-transcoding/component.tsx
Normal file
115
packages/widgets/src/media-transcoding/component.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Center, Divider, Group, Pagination, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||
import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { HealthCheckStatus } from "./health-check-status";
|
||||
import { QueuePanel } from "./panels/queue.panel";
|
||||
import { StatisticsPanel } from "./panels/statistics.panel";
|
||||
import { WorkersPanel } from "./panels/workers.panel";
|
||||
|
||||
type Views = "workers" | "queue" | "statistics";
|
||||
|
||||
export default function MediaTranscodingWidget({ integrationIds, options }: WidgetComponentProps<"mediaTranscoding">) {
|
||||
const [queuePage, setQueuePage] = useState(1);
|
||||
const queuePageSize = 10;
|
||||
const [transcodingData] = clientApi.widget.mediaTranscoding.getDataAsync.useSuspenseQuery(
|
||||
{
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
pageSize: queuePageSize,
|
||||
page: queuePage,
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
);
|
||||
|
||||
const [view, setView] = useState<Views>(options.defaultView);
|
||||
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
|
||||
|
||||
const t = useI18n("widget.mediaTranscoding");
|
||||
|
||||
return (
|
||||
<Stack gap={4} h="100%">
|
||||
{view === "workers" ? (
|
||||
<WorkersPanel workers={transcodingData.data.workers} />
|
||||
) : view === "queue" ? (
|
||||
<QueuePanel queue={transcodingData.data.queue} />
|
||||
) : (
|
||||
<StatisticsPanel statistics={transcodingData.data.statistics} />
|
||||
)}
|
||||
<Divider />
|
||||
<Group gap="xs" mb={4} ms={4} me={8}>
|
||||
<SegmentedControl
|
||||
data={[
|
||||
{
|
||||
label: (
|
||||
<Center>
|
||||
<IconCpu2 size={18} />
|
||||
<Text size="xs" ml={8}>
|
||||
{t("tab.workers")}
|
||||
</Text>
|
||||
</Center>
|
||||
),
|
||||
value: "workers",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Center>
|
||||
<IconClipboardList size={18} />
|
||||
<Text size="xs" ml={8}>
|
||||
{t("tab.queue")}
|
||||
</Text>
|
||||
</Center>
|
||||
),
|
||||
value: "queue",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Center>
|
||||
<IconReportAnalytics size={18} />
|
||||
<Text size="xs" ml={8}>
|
||||
{t("tab.statistics")}
|
||||
</Text>
|
||||
</Center>
|
||||
),
|
||||
value: "statistics",
|
||||
},
|
||||
]}
|
||||
value={view}
|
||||
onChange={(value) => setView(value as Views)}
|
||||
size="xs"
|
||||
/>
|
||||
{view === "queue" && (
|
||||
<>
|
||||
<Pagination.Root total={totalQueuePages} value={queuePage} onChange={setQueuePage} size="sm">
|
||||
<Group gap={5} justify="center">
|
||||
<Pagination.First disabled={transcodingData.data.queue.startIndex === 1} />
|
||||
<Pagination.Previous disabled={transcodingData.data.queue.startIndex === 1} />
|
||||
<Pagination.Next disabled={transcodingData.data.queue.startIndex === totalQueuePages} />
|
||||
<Pagination.Last disabled={transcodingData.data.queue.startIndex === totalQueuePages} />
|
||||
</Group>
|
||||
</Pagination.Root>
|
||||
<Text size="xs">
|
||||
{t("currentIndex", {
|
||||
start: transcodingData.data.queue.startIndex + 1,
|
||||
end: transcodingData.data.queue.endIndex + 1,
|
||||
total: transcodingData.data.queue.totalCount,
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Group gap="xs" ml="auto">
|
||||
<HealthCheckStatus statistics={transcodingData.data.statistics} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Divider, Group, HoverCard, Indicator, RingProgress, Stack, Text } from "@mantine/core";
|
||||
import { useColorScheme } from "@mantine/hooks";
|
||||
import { IconHeartbeat } from "@tabler/icons-react";
|
||||
|
||||
import type { TdarrStatistics } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface HealthCheckStatusProps {
|
||||
statistics: TdarrStatistics;
|
||||
}
|
||||
|
||||
export function HealthCheckStatus(props: HealthCheckStatusProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const t = useI18n("widget.mediaTranscoding.healthCheck");
|
||||
|
||||
const indicatorColor = props.statistics.failedHealthCheckCount
|
||||
? "red"
|
||||
: props.statistics.stagedHealthCheckCount
|
||||
? "yellow"
|
||||
: "green";
|
||||
|
||||
return (
|
||||
<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 gap="sm" align="center">
|
||||
<Group gap="xs">
|
||||
<IconHeartbeat size={18} />
|
||||
<Text size="sm">{t("title")}</Text>
|
||||
</Group>
|
||||
<Divider
|
||||
style={{
|
||||
alignSelf: "stretch",
|
||||
}}
|
||||
/>
|
||||
<RingProgress
|
||||
sections={[
|
||||
{ value: props.statistics.stagedHealthCheckCount, color: textColor("yellow", colorScheme) },
|
||||
{ value: props.statistics.totalHealthCheckCount, color: textColor("green", colorScheme) },
|
||||
{ value: props.statistics.failedHealthCheckCount, color: textColor("red", colorScheme) },
|
||||
]}
|
||||
/>
|
||||
<Group display="flex" w="100%">
|
||||
<Stack style={{ flex: 1 }} gap={0} align="center">
|
||||
<Text size="xs" c={textColor("yellow", colorScheme)}>
|
||||
{props.statistics.stagedHealthCheckCount}
|
||||
</Text>
|
||||
<Text size="xs">{t("queued")}</Text>
|
||||
</Stack>
|
||||
<Stack style={{ flex: 1 }} gap={0} align="center">
|
||||
<Text size="xs" c={textColor("green", colorScheme)}>
|
||||
{props.statistics.totalHealthCheckCount}
|
||||
</Text>
|
||||
<Text size="xs">{t("status.healthy")}</Text>
|
||||
</Stack>
|
||||
<Stack style={{ flex: 1 }} gap={0} align="center">
|
||||
<Text size="xs" c={textColor("red", colorScheme)}>
|
||||
{props.statistics.failedHealthCheckCount}
|
||||
</Text>
|
||||
<Text size="xs">{t("status.unhealthy")}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function textColor(color: MantineColor, theme: "light" | "dark") {
|
||||
return `${color}.${theme === "light" ? 8 : 5}`;
|
||||
}
|
||||
22
packages/widgets/src/media-transcoding/index.ts
Normal file
22
packages/widgets/src/media-transcoding/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { IconTransform } from "@tabler/icons-react";
|
||||
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
|
||||
icon: IconTransform,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
defaultView: factory.select({
|
||||
defaultValue: "statistics",
|
||||
options: [
|
||||
{ label: "Workers", value: "workers" },
|
||||
{ label: "Queue", value: "queue" },
|
||||
{ label: "Statistics", value: "statistics" },
|
||||
],
|
||||
}),
|
||||
queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }),
|
||||
})),
|
||||
supportedIntegrations: ["tdarr"],
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Center, Group, ScrollArea, Table, Text, Title, Tooltip } from "@mantine/core";
|
||||
import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import type { TdarrQueue } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface QueuePanelProps {
|
||||
queue: TdarrQueue;
|
||||
}
|
||||
|
||||
export function QueuePanel(props: QueuePanelProps) {
|
||||
const { queue } = props;
|
||||
|
||||
const t = useI18n("widget.mediaTranscoding.panel.queue");
|
||||
|
||||
if (queue.array.length === 0) {
|
||||
return (
|
||||
<Center style={{ flex: "1" }}>
|
||||
<Title order={3}>{t("empty")}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea style={{ flex: "1" }}>
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<Table.Thead>
|
||||
<tr>
|
||||
<th>{t("table.file")}</th>
|
||||
<th style={{ width: 80 }}>{t("table.size")}</th>
|
||||
</tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{queue.array.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<div>
|
||||
{item.type === "transcode" ? (
|
||||
<Tooltip label={t("table.transcode")}>
|
||||
<IconTransform size={14} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label={t("table.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>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type react from "react";
|
||||
import type { MantineColor, RingProgressProps } from "@mantine/core";
|
||||
import { Box, Center, Grid, Group, RingProgress, Stack, Text, Title, useMantineColorScheme } from "@mantine/core";
|
||||
import { IconDatabaseHeart, IconFileDescription, IconHeartbeat, IconTransform } from "@tabler/icons-react";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import type { TdarrPieSegment, TdarrStatistics } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
const PIE_COLORS: MantineColor[] = ["cyan", "grape", "gray", "orange", "pink"];
|
||||
|
||||
interface StatisticsPanelProps {
|
||||
statistics: TdarrStatistics;
|
||||
}
|
||||
|
||||
export function StatisticsPanel(props: StatisticsPanelProps) {
|
||||
const t = useI18n("widget.mediaTranscoding.panel.statistics");
|
||||
|
||||
const allLibs = props.statistics.pies.find((pie) => pie.libraryName === "All");
|
||||
|
||||
if (!allLibs) {
|
||||
return (
|
||||
<Center style={{ flex: "1" }}>
|
||||
<Title order={3}>{t("empty")}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack style={{ flex: "1" }} gap="xs">
|
||||
<Group
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
justify="apart"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Stack align="center" gap={0}>
|
||||
<RingProgress size={120} sections={toRingProgressSections(allLibs.transcodeStatus)} />
|
||||
<Text size="xs">{t("transcodes")}</Text>
|
||||
</Stack>
|
||||
<Grid gutter="xs">
|
||||
<Grid.Col span={6}>
|
||||
<StatBox
|
||||
icon={<IconTransform size={18} />}
|
||||
label={t("transcodesCount", {
|
||||
value: props.statistics.totalTranscodeCount,
|
||||
})}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<StatBox
|
||||
icon={<IconHeartbeat size={18} />}
|
||||
label={t("healthChecksCount", {
|
||||
value: props.statistics.totalHealthCheckCount,
|
||||
})}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<StatBox
|
||||
icon={<IconFileDescription size={18} />}
|
||||
label={t("filesCount", {
|
||||
value: props.statistics.totalFileCount,
|
||||
})}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<StatBox
|
||||
icon={<IconDatabaseHeart size={18} />}
|
||||
label={t("savedSpace", {
|
||||
value: humanFileSize(Math.floor(allLibs.savedSpace)),
|
||||
})}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Stack align="center" gap={0}>
|
||||
<RingProgress size={120} sections={toRingProgressSections(allLibs.healthCheckStatus)} />
|
||||
<Text size="xs">{t("healthChecks")}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
w="100%"
|
||||
>
|
||||
<Stack align="center" gap={0}>
|
||||
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoCodecs)} />
|
||||
<Text size="xs">{t("videoCodecs")}</Text>
|
||||
</Stack>
|
||||
<Stack align="center" gap={0}>
|
||||
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoContainers)} />
|
||||
<Text size="xs">{t("videoContainers")}</Text>
|
||||
</Stack>
|
||||
<Stack align="center" gap={0}>
|
||||
<RingProgress size={120} sections={toRingProgressSections(allLibs.videoResolutions)} />
|
||||
<Text size="xs">{t("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}`,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
color: PIE_COLORS[index % PIE_COLORS.length]!, // Ensures a valid color in the case that index > PIE_COLORS.length
|
||||
}));
|
||||
}
|
||||
|
||||
interface StatBoxProps {
|
||||
icon: react.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function StatBox(props: StatBoxProps) {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
return (
|
||||
<Box
|
||||
style={(theme) => ({
|
||||
padding: theme.spacing.xs,
|
||||
border: "1px solid",
|
||||
borderRadius: theme.radius.md,
|
||||
borderColor: colorScheme === "dark" ? theme.colors.dark[5] : theme.colors.gray[1],
|
||||
})}
|
||||
>
|
||||
<Stack gap="xs" align="center">
|
||||
{props.icon}
|
||||
<Text size="xs">{props.label}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Center, Group, Progress, ScrollArea, Table, Text, Title, Tooltip } from "@mantine/core";
|
||||
import { IconHeartbeat, IconTransform } from "@tabler/icons-react";
|
||||
|
||||
import type { TdarrWorker } from "@homarr/integrations";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface WorkersPanelProps {
|
||||
workers: TdarrWorker[];
|
||||
}
|
||||
|
||||
export function WorkersPanel(props: WorkersPanelProps) {
|
||||
const t = useI18n("widget.mediaTranscoding.panel.workers");
|
||||
|
||||
if (props.workers.length === 0) {
|
||||
return (
|
||||
<Center style={{ flex: "1" }}>
|
||||
<Title order={3}>{t("empty")}</Title>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea style={{ flex: "1" }}>
|
||||
<Table style={{ tableLayout: "fixed" }}>
|
||||
<Table.Thead>
|
||||
<tr>
|
||||
<th>{t("table.file")}</th>
|
||||
<th style={{ width: 60 }}>{t("table.eta")}</th>
|
||||
<th style={{ width: 175 }}>{t("table.progress")}</th>
|
||||
</tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{props.workers.map((worker) => (
|
||||
<tr key={worker.id}>
|
||||
<td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<div>
|
||||
{worker.jobType === "transcode" ? (
|
||||
<Tooltip label={t("table.transcode")}>
|
||||
<IconTransform size={14} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label={t("table.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 wrap="nowrap" gap="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>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user