feat: add tdarr integration (#1657)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { healthMonitoringRouter } from "./health-monitoring";
|
|||||||
import { indexerManagerRouter } from "./indexer-manager";
|
import { indexerManagerRouter } from "./indexer-manager";
|
||||||
import { mediaRequestsRouter } from "./media-requests";
|
import { mediaRequestsRouter } from "./media-requests";
|
||||||
import { mediaServerRouter } from "./media-server";
|
import { mediaServerRouter } from "./media-server";
|
||||||
|
import { mediaTranscodingRouter } from "./media-transcoding";
|
||||||
import { notebookRouter } from "./notebook";
|
import { notebookRouter } from "./notebook";
|
||||||
import { rssFeedRouter } from "./rssFeed";
|
import { rssFeedRouter } from "./rssFeed";
|
||||||
import { smartHomeRouter } from "./smart-home";
|
import { smartHomeRouter } from "./smart-home";
|
||||||
@@ -25,4 +26,5 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
rssFeed: rssFeedRouter,
|
rssFeed: rssFeedRouter,
|
||||||
indexerManager: indexerManagerRouter,
|
indexerManager: indexerManagerRouter,
|
||||||
healthMonitoring: healthMonitoringRouter,
|
healthMonitoring: healthMonitoringRouter,
|
||||||
|
mediaTranscoding: mediaTranscodingRouter,
|
||||||
});
|
});
|
||||||
|
|||||||
28
packages/api/src/router/widgets/media-transcoding.ts
Normal file
28
packages/api/src/router/widgets/media-transcoding.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
|
|||||||
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
||||||
import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests";
|
import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/media-requests";
|
||||||
import { mediaServerJob } from "./jobs/integrations/media-server";
|
import { mediaServerJob } from "./jobs/integrations/media-server";
|
||||||
|
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
|
||||||
import { pingJob } from "./jobs/ping";
|
import { pingJob } from "./jobs/ping";
|
||||||
import type { RssFeed } from "./jobs/rss-feeds";
|
import type { RssFeed } from "./jobs/rss-feeds";
|
||||||
import { rssFeedsJob } from "./jobs/rss-feeds";
|
import { rssFeedsJob } from "./jobs/rss-feeds";
|
||||||
@@ -31,6 +32,7 @@ export const jobGroup = createCronJobGroup({
|
|||||||
healthMonitoring: healthMonitoringJob,
|
healthMonitoring: healthMonitoringJob,
|
||||||
sessionCleanup: sessionCleanupJob,
|
sessionCleanup: sessionCleanupJob,
|
||||||
updateChecker: updateCheckerJob,
|
updateChecker: updateCheckerJob,
|
||||||
|
mediaTranscoding: mediaTranscodingJob,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -151,6 +151,13 @@ export const integrationDefs = {
|
|||||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/dashdot.png",
|
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/dashdot.png",
|
||||||
supportsSearch: false,
|
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>;
|
} as const satisfies Record<string, integrationDefinition>;
|
||||||
|
|
||||||
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
||||||
@@ -217,4 +224,5 @@ export type IntegrationCategory =
|
|||||||
| "torrent"
|
| "torrent"
|
||||||
| "smartHomeServer"
|
| "smartHomeServer"
|
||||||
| "indexerManager"
|
| "indexerManager"
|
||||||
| "healthMonitoring";
|
| "healthMonitoring"
|
||||||
|
| "mediaTranscoding";
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const widgetKinds = [
|
|||||||
"downloads",
|
"downloads",
|
||||||
"mediaRequests-requestList",
|
"mediaRequests-requestList",
|
||||||
"mediaRequests-requestStats",
|
"mediaRequests-requestStats",
|
||||||
|
"mediaTranscoding",
|
||||||
"rssFeed",
|
"rssFeed",
|
||||||
"bookmarks",
|
"bookmarks",
|
||||||
"indexerManager",
|
"indexerManager",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration"
|
|||||||
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
|
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
|
||||||
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
|
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
|
||||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||||
|
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
|
||||||
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
|
||||||
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||||
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
||||||
@@ -70,4 +71,5 @@ export const integrationCreators = {
|
|||||||
lidarr: LidarrIntegration,
|
lidarr: LidarrIntegration,
|
||||||
readarr: ReadarrIntegration,
|
readarr: ReadarrIntegration,
|
||||||
dashDot: DashDotIntegration,
|
dashDot: DashDotIntegration,
|
||||||
|
tdarr: TdarrIntegration,
|
||||||
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;
|
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-moni
|
|||||||
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
|
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
|
||||||
export type { MediaRequestList, MediaRequestStats } 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 { 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
|
// Schemas
|
||||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
172
packages/integrations/src/media-transcoding/tdarr-integration.ts
Normal file
172
packages/integrations/src/media-transcoding/tdarr-integration.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
@@ -31,7 +31,7 @@ const mapping: Record<OldmarrIntegrationType, IntegrationKind | null> = {
|
|||||||
readarr: "readarr",
|
readarr: "readarr",
|
||||||
sabnzbd: "sabNzbd",
|
sabnzbd: "sabNzbd",
|
||||||
sonarr: "sonarr",
|
sonarr: "sonarr",
|
||||||
tdarr: null,
|
tdarr: "tdarr",
|
||||||
transmission: "transmission",
|
transmission: "transmission",
|
||||||
plex: "plex",
|
plex: "plex",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const widgetKindMapping = {
|
|||||||
indexerManager: "indexer-manager",
|
indexerManager: "indexer-manager",
|
||||||
bookmarks: "bookmark",
|
bookmarks: "bookmark",
|
||||||
healthMonitoring: "health-monitoring",
|
healthMonitoring: "health-monitoring",
|
||||||
|
mediaTranscoding: "media-transcoding",
|
||||||
} satisfies Record<WidgetKind, OldmarrWidgetDefinitions["id"] | null>;
|
} satisfies Record<WidgetKind, OldmarrWidgetDefinitions["id"] | null>;
|
||||||
// Use null for widgets that did not exist in oldmarr
|
// Use null for widgets that did not exist in oldmarr
|
||||||
// TODO: revert assignment so that only old widgets are needed in the object,
|
// TODO: revert assignment so that only old widgets are needed in the object,
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ const optionMapping: OptionMapping = {
|
|||||||
fahrenheit: (oldOptions) => oldOptions.fahrenheit,
|
fahrenheit: (oldOptions) => oldOptions.fahrenheit,
|
||||||
fileSystem: (oldOptions) => oldOptions.fileSystem,
|
fileSystem: (oldOptions) => oldOptions.fileSystem,
|
||||||
},
|
},
|
||||||
|
mediaTranscoding: {
|
||||||
|
defaultView: (oldOptions) => oldOptions.defaultView,
|
||||||
|
queuePageSize: (oldOptions) => oldOptions.queuePageSize,
|
||||||
|
},
|
||||||
app: null,
|
app: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
24
packages/request-handler/src/media-transcoding.ts
Normal file
24
packages/request-handler/src/media-transcoding.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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": {
|
"rssFeed": {
|
||||||
"name": "RSS feeds",
|
"name": "RSS feeds",
|
||||||
"description": "Monitor and display one or more generic RSS, ATOM or JSON feeds",
|
"description": "Monitor and display one or more generic RSS, ATOM or JSON feeds",
|
||||||
@@ -2272,6 +2331,9 @@
|
|||||||
},
|
},
|
||||||
"updateChecker": {
|
"updateChecker": {
|
||||||
"label": "Update checker"
|
"label": "Update checker"
|
||||||
|
},
|
||||||
|
"mediaTranscoding": {
|
||||||
|
"label": "Media transcoding"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import * as indexerManager from "./indexer-manager";
|
|||||||
import * as mediaRequestsList from "./media-requests/list";
|
import * as mediaRequestsList from "./media-requests/list";
|
||||||
import * as mediaRequestsStats from "./media-requests/stats";
|
import * as mediaRequestsStats from "./media-requests/stats";
|
||||||
import * as mediaServer from "./media-server";
|
import * as mediaServer from "./media-server";
|
||||||
|
import * as mediaTranscoding from "./media-transcoding";
|
||||||
import * as notebook from "./notebook";
|
import * as notebook from "./notebook";
|
||||||
import type { WidgetOptionDefinition } from "./options";
|
import type { WidgetOptionDefinition } from "./options";
|
||||||
import * as rssFeed from "./rssFeed";
|
import * as rssFeed from "./rssFeed";
|
||||||
@@ -52,6 +53,7 @@ export const widgetImports = {
|
|||||||
bookmarks,
|
bookmarks,
|
||||||
indexerManager,
|
indexerManager,
|
||||||
healthMonitoring,
|
healthMonitoring,
|
||||||
|
mediaTranscoding,
|
||||||
} satisfies WidgetImportRecord;
|
} satisfies WidgetImportRecord;
|
||||||
|
|
||||||
export type WidgetImports = typeof widgetImports;
|
export type WidgetImports = typeof widgetImports;
|
||||||
|
|||||||
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