feat: add tdarr integration (#1657)

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Manuel
2024-12-15 15:53:02 +01:00
committed by GitHub
parent 6de74d9525
commit 032509e462
25 changed files with 996 additions and 2 deletions

View File

@@ -7,6 +7,7 @@ import { healthMonitoringRouter } from "./health-monitoring";
import { indexerManagerRouter } from "./indexer-manager";
import { mediaRequestsRouter } from "./media-requests";
import { mediaServerRouter } from "./media-server";
import { mediaTranscodingRouter } from "./media-transcoding";
import { notebookRouter } from "./notebook";
import { rssFeedRouter } from "./rssFeed";
import { smartHomeRouter } from "./smart-home";
@@ -25,4 +26,5 @@ export const widgetRouter = createTRPCRouter({
rssFeed: rssFeedRouter,
indexerManager: indexerManagerRouter,
healthMonitoring: healthMonitoringRouter,
mediaTranscoding: mediaTranscodingRouter,
});

View File

@@ -0,0 +1,28 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
import { validation } from "@homarr/validation";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaTranscoding"));
export const mediaTranscodingRouter = createTRPCRouter({
getDataAsync: publicProcedure
.unstable_concat(createIndexerManagerIntegrationMiddleware("query"))
.input(validation.common.paginated.pick({ page: true, pageSize: true }))
.query(async ({ ctx, input }) => {
const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
pageOffset: input.page,
pageSize: input.pageSize,
});
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: ctx.integration.id,
data,
};
}),
});

View File

@@ -8,6 +8,7 @@ import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests";
import { mediaServerJob } from "./jobs/integrations/media-server";
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
import { pingJob } from "./jobs/ping";
import type { RssFeed } from "./jobs/rss-feeds";
import { rssFeedsJob } from "./jobs/rss-feeds";
@@ -31,6 +32,7 @@ export const jobGroup = createCronJobGroup({
healthMonitoring: healthMonitoringJob,
sessionCleanup: sessionCleanupJob,
updateChecker: updateCheckerJob,
mediaTranscoding: mediaTranscodingJob,
});
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];

View File

@@ -0,0 +1,14 @@
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
import { createCronJob } from "../../lib";
export const mediaTranscodingJob = createCronJob("mediaTranscoding", EVERY_5_MINUTES).withCallback(
createRequestIntegrationJobHandler(mediaTranscodingRequestHandler.handler, {
widgetKinds: ["mediaTranscoding"],
getInput: {
mediaTranscoding: () => ({ pageOffset: 0, pageSize: 10 }),
},
}),
);

View File

@@ -151,6 +151,13 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/dashdot.png",
supportsSearch: false,
},
tdarr: {
name: "Tdarr",
secretKinds: [[]],
category: ["mediaTranscoding"],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/tdarr.png",
supportsSearch: false,
},
} as const satisfies Record<string, integrationDefinition>;
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
@@ -217,4 +224,5 @@ export type IntegrationCategory =
| "torrent"
| "smartHomeServer"
| "indexerManager"
| "healthMonitoring";
| "healthMonitoring"
| "mediaTranscoding";

View File

@@ -14,6 +14,7 @@ export const widgetKinds = [
"downloads",
"mediaRequests-requestList",
"mediaRequests-requestStats",
"mediaTranscoding",
"rssFeed",
"bookmarks",
"indexerManager",

View File

@@ -17,6 +17,7 @@ import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration"
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
@@ -70,4 +71,5 @@ export const integrationCreators = {
lidarr: LidarrIntegration,
readarr: ReadarrIntegration,
dashDot: DashDotIntegration,
tdarr: TdarrIntegration,
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;

View File

@@ -28,6 +28,9 @@ export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-moni
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request";
export type { StreamSession } from "./interfaces/media-server/session";
export type { TdarrQueue } from "./interfaces/media-transcoding/queue";
export type { TdarrPieSegment, TdarrStatistics } from "./interfaces/media-transcoding/statistics";
export type { TdarrWorker } from "./interfaces/media-transcoding/workers";
// Schemas
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";

View File

@@ -0,0 +1,16 @@
export interface TdarrQueue {
array: {
id: string;
healthCheck: string;
transcode: string;
filePath: string;
fileSize: number;
container: string;
codec: string;
resolution: string;
type: "transcode" | "health-check";
}[];
totalCount: number;
startIndex: number;
endIndex: number;
}

View File

@@ -0,0 +1,29 @@
export interface TdarrPieSegment {
name: string;
value: number;
}
export interface TdarrStatistics {
totalFileCount: number;
totalTranscodeCount: number;
totalHealthCheckCount: number;
failedTranscodeCount: number;
failedHealthCheckCount: number;
stagedTranscodeCount: number;
stagedHealthCheckCount: number;
pies: {
libraryName: string;
libraryId: string;
totalFiles: number;
totalTranscodes: number;
savedSpace: number;
totalHealthChecks: number;
transcodeStatus: TdarrPieSegment[];
healthCheckStatus: TdarrPieSegment[];
videoCodecs: TdarrPieSegment[];
videoContainers: TdarrPieSegment[];
videoResolutions: TdarrPieSegment[];
audioCodecs: TdarrPieSegment[];
audioContainers: TdarrPieSegment[];
}[];
}

View File

@@ -0,0 +1,13 @@
export interface TdarrWorker {
id: string;
filePath: string;
fps: number;
percentage: number;
ETA: string;
jobType: string;
status: string;
step: string;
originalSize: number;
estimatedSize: number | null;
outputSize: number | null;
}

View File

@@ -0,0 +1,172 @@
import { z } from "@homarr/validation";
import { Integration } from "../base/integration";
import type { TdarrQueue } from "../interfaces/media-transcoding/queue";
import type { TdarrStatistics } from "../interfaces/media-transcoding/statistics";
import type { TdarrWorker } from "../interfaces/media-transcoding/workers";
import { getNodesResponseSchema, getStatisticsSchema, getStatusTableSchema } from "./tdarr-validation-schemas";
export class TdarrIntegration extends Integration {
public async testConnectionAsync(): Promise<void> {
const url = this.url("/api/v2/status");
const response = await fetch(url);
if (response.status !== 200) {
throw new Error(`Unexpected status code: ${response.status}`);
}
await z.object({ status: z.string() }).parseAsync(await response.json());
}
public async getStatisticsAsync(): Promise<TdarrStatistics> {
const url = this.url("/api/v2/cruddb");
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
data: {
collection: "StatisticsJSONDB",
mode: "getById",
docID: "statistics",
},
}),
});
const statisticsData = await getStatisticsSchema.parseAsync(await response.json());
return {
totalFileCount: statisticsData.totalFileCount,
totalTranscodeCount: statisticsData.totalTranscodeCount,
totalHealthCheckCount: statisticsData.totalHealthCheckCount,
failedTranscodeCount: statisticsData.table3Count,
failedHealthCheckCount: statisticsData.table6Count,
stagedTranscodeCount: statisticsData.table1Count,
stagedHealthCheckCount: statisticsData.table4Count,
pies: statisticsData.pies.map((pie) => ({
libraryName: pie[0],
libraryId: pie[1],
totalFiles: pie[2],
totalTranscodes: pie[3],
savedSpace: pie[4] * 1_000_000_000, // file_size is in GB, convert to bytes,
totalHealthChecks: pie[5],
transcodeStatus: pie[6],
healthCheckStatus: pie[7],
videoCodecs: pie[8],
videoContainers: pie[9],
videoResolutions: pie[10],
audioCodecs: pie[11],
audioContainers: pie[12],
})),
};
}
public async getWorkersAsync(): Promise<TdarrWorker[]> {
const url = this.url("/api/v2/get-nodes");
const response = await fetch(url, {
method: "GET",
headers: { "content-type": "application/json" },
});
const nodesData = await getNodesResponseSchema.parseAsync(await response.json());
const workers = Object.values(nodesData).flatMap((node) => {
return Object.values(node.workers);
});
return workers.map((worker) => ({
id: worker._id,
filePath: worker.file,
fps: worker.fps,
percentage: worker.percentage,
ETA: worker.ETA,
jobType: worker.job.type,
status: worker.status,
step: worker.lastPluginDetails?.number ?? "",
originalSize: worker.originalfileSizeInGbytes * 1_000_000_000, // file_size is in GB, convert to bytes,
estimatedSize: worker.estSize ? worker.estSize * 1_000_000_000 : null, // file_size is in GB, convert to bytes,
outputSize: worker.outputFileSizeInGbytes ? worker.outputFileSizeInGbytes * 1_000_000_000 : null, // file_size is in GB, convert to bytes,
}));
}
public async getQueueAsync(firstItemIndex: number, pageSize: number): Promise<TdarrQueue> {
const transcodingQueue = await this.getTranscodingQueueAsync(firstItemIndex, pageSize);
const healthChecks = await this.getHealthCheckDataAsync(firstItemIndex, pageSize, transcodingQueue.totalCount);
const combinedArray = [...transcodingQueue.array, ...healthChecks.array].slice(0, pageSize);
return {
array: combinedArray,
totalCount: transcodingQueue.totalCount + healthChecks.totalCount,
startIndex: firstItemIndex,
endIndex: firstItemIndex + combinedArray.length - 1,
};
}
private async getTranscodingQueueAsync(firstItemIndex: number, pageSize: number) {
const url = this.url("/api/v2/client/status-tables");
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
data: {
start: firstItemIndex,
pageSize,
filters: [],
sorts: [],
opts: { table: "table1" },
},
}),
});
const transcodesQueueData = await getStatusTableSchema.parseAsync(await response.json());
return {
array: transcodesQueueData.array.map((item) => ({
id: item._id,
healthCheck: item.HealthCheck,
transcode: item.TranscodeDecisionMaker,
filePath: item.file,
fileSize: item.file_size * 1_000_000, // file_size is in MB, convert to bytes
container: item.container,
codec: item.video_codec_name,
resolution: item.video_resolution,
type: "transcode" as const,
})),
totalCount: transcodesQueueData.totalCount,
startIndex: firstItemIndex,
endIndex: firstItemIndex + transcodesQueueData.array.length - 1,
};
}
private async getHealthCheckDataAsync(firstItemIndex: number, pageSize: number, totalQueueCount: number) {
const url = this.url("/api/v2/client/status-tables");
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
data: {
start: Math.max(firstItemIndex - totalQueueCount, 0),
pageSize,
filters: [],
sorts: [],
opts: {
table: "table4",
},
},
}),
});
const healthCheckData = await getStatusTableSchema.parseAsync(await response.json());
return {
array: healthCheckData.array.map((item) => ({
id: item._id,
healthCheck: item.HealthCheck,
transcode: item.TranscodeDecisionMaker,
filePath: item.file,
fileSize: item.file_size * 1_000_000, // file_size is in MB, convert to bytes
container: item.container,
codec: item.video_codec_name,
resolution: item.video_resolution,
type: "health-check" as const,
})),
totalCount: healthCheckData.totalCount,
};
}
}

View File

@@ -0,0 +1,118 @@
import { z } from "@homarr/validation";
export const getStatisticsSchema = z.object({
totalFileCount: z.number(),
totalTranscodeCount: z.number(),
totalHealthCheckCount: z.number(),
table3Count: z.number(),
table6Count: z.number(),
table1Count: z.number(),
table4Count: z.number(),
pies: z.array(
z.tuple([
z.string(), // Library Name
z.string(), // Library ID
z.number(), // File count
z.number(), // Number of transcodes
z.number(), // Space saved (in GB)
z.number(), // Number of health checks
z.array(
z.object({
// Transcode Status (Pie segments)
name: z.string(),
value: z.number(),
}),
),
z.array(
z.object({
// Health Status (Pie segments)
name: z.string(),
value: z.number(),
}),
),
z.array(
z.object({
// Video files - Codecs (Pie segments)
name: z.string(),
value: z.number(),
}),
),
z.array(
z.object({
// Video files - Containers (Pie segments)
name: z.string(),
value: z.number(),
}),
),
z.array(
z.object({
// Video files - Resolutions (Pie segments)
name: z.string(),
value: z.number(),
}),
),
z.array(
z.object({
// Audio files - Codecs (Pie segments)
name: z.string(),
value: z.number(),
}),
),
z.array(
z.object({
// Audio files - Containers (Pie segments)
name: z.string(),
value: z.number(),
}),
),
]),
),
});
export const getNodesResponseSchema = z.record(
z.string(),
z.object({
_id: z.string(),
nodeName: z.string(),
nodePaused: z.boolean(),
workers: z.record(
z.string(),
z.object({
_id: z.string(),
file: z.string(),
fps: z.number(),
percentage: z.number(),
ETA: z.string(),
job: z.object({
type: z.string(),
}),
status: z.string(),
lastPluginDetails: z
.object({
number: z.string().optional(),
})
.optional(),
originalfileSizeInGbytes: z.number(),
estSize: z.number().optional(),
outputFileSizeInGbytes: z.number().optional(),
workerType: z.string(),
}),
),
}),
);
export const getStatusTableSchema = z.object({
array: z.array(
z.object({
_id: z.string(),
HealthCheck: z.string(),
TranscodeDecisionMaker: z.string(),
file: z.string(),
file_size: z.number(),
container: z.string(),
video_codec_name: z.string(),
video_resolution: z.string(),
}),
),
totalCount: z.number(),
});

View File

@@ -31,7 +31,7 @@ const mapping: Record<OldmarrIntegrationType, IntegrationKind | null> = {
readarr: "readarr",
sabnzbd: "sabNzbd",
sonarr: "sonarr",
tdarr: null,
tdarr: "tdarr",
transmission: "transmission",
plex: "plex",
};

View File

@@ -68,6 +68,7 @@ export const widgetKindMapping = {
indexerManager: "indexer-manager",
bookmarks: "bookmark",
healthMonitoring: "health-monitoring",
mediaTranscoding: "media-transcoding",
} satisfies Record<WidgetKind, OldmarrWidgetDefinitions["id"] | null>;
// Use null for widgets that did not exist in oldmarr
// TODO: revert assignment so that only old widgets are needed in the object,

View File

@@ -129,6 +129,10 @@ const optionMapping: OptionMapping = {
fahrenheit: (oldOptions) => oldOptions.fahrenheit,
fileSystem: (oldOptions) => oldOptions.fileSystem,
},
mediaTranscoding: {
defaultView: (oldOptions) => oldOptions.defaultView,
queuePageSize: (oldOptions) => oldOptions.queuePageSize,
},
app: null,
};

View File

@@ -0,0 +1,24 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { integrationCreator } from "@homarr/integrations";
import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const mediaTranscodingRequestHandler = createCachedIntegrationRequestHandler<
{ queue: TdarrQueue; workers: TdarrWorker[]; statistics: TdarrStatistics },
IntegrationKindByCategory<"mediaTranscoding">,
{ pageOffset: number; pageSize: number }
>({
queryKey: "mediaTranscoding",
cacheDuration: dayjs.duration(5, "minutes"),
async requestAsync(integration, input) {
const integrationInstance = integrationCreator(integration);
return {
queue: await integrationInstance.getQueueAsync(input.pageOffset, input.pageSize),
workers: await integrationInstance.getWorkersAsync(),
statistics: await integrationInstance.getStatisticsAsync(),
};
},
});

View File

@@ -1643,6 +1643,65 @@
}
}
},
"mediaTranscoding": {
"name": "Media transcoding",
"description": "Statistics, current queue and worker status of your media transcoding",
"option": {
"defaultView": {
"label": "Default view"
},
"queuePageSize": {
"label": "Queue page size"
}
},
"tab": {
"workers": "Workers",
"queue": "Queue",
"statistics": "Statistics"
},
"currentIndex": "{start}-{end} of {total}",
"healthCheck": {
"title": "Health check",
"queued": "Queued",
"status": {
"healthy": "Healthy",
"unhealthy": "Unhealthy"
}
},
"panel": {
"statistics": {
"empty": "Empty",
"transcodes": "Transcodes",
"transcodesCount": "Transcodes: {value}",
"healthChecksCount": "Health checks: {value}",
"filesCount": "Files: {value}",
"savedSpace": "Saved space: {value}",
"healthChecks": "Health checks",
"videoCodecs": "Codecs",
"videoContainers": "Containers",
"videoResolutions": "Resolutions"
},
"workers": {
"empty": "Empty",
"table": {
"file": "File",
"eta": "ETA",
"progress": "Progress",
"transcode": "Transcode",
"healthCheck": "Health check"
}
},
"queue": {
"empty": "Empty",
"table": {
"file": "File",
"size": "Size",
"transcode": "Transcode",
"healthCheck": "Health check"
}
}
}
},
"rssFeed": {
"name": "RSS feeds",
"description": "Monitor and display one or more generic RSS, ATOM or JSON feeds",
@@ -2272,6 +2331,9 @@
},
"updateChecker": {
"label": "Update checker"
},
"mediaTranscoding": {
"label": "Media transcoding"
}
}
},

View File

@@ -21,6 +21,7 @@ import * as indexerManager from "./indexer-manager";
import * as mediaRequestsList from "./media-requests/list";
import * as mediaRequestsStats from "./media-requests/stats";
import * as mediaServer from "./media-server";
import * as mediaTranscoding from "./media-transcoding";
import * as notebook from "./notebook";
import type { WidgetOptionDefinition } from "./options";
import * as rssFeed from "./rssFeed";
@@ -52,6 +53,7 @@ export const widgetImports = {
bookmarks,
indexerManager,
healthMonitoring,
mediaTranscoding,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;

View 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>
);
}

View File

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

View 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"));

View File

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

View File

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

View File

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