Skip to content

Commit

Permalink
feat: add tdarr integration
Browse files Browse the repository at this point in the history
  • Loading branch information
manuel-rw committed Dec 14, 2024
1 parent cf9656d commit 1efcd38
Show file tree
Hide file tree
Showing 22 changed files with 996 additions and 1 deletion.
2 changes: 2 additions & 0 deletions packages/api/src/router/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,4 +26,5 @@ export const widgetRouter = createTRPCRouter({
rssFeed: rssFeedRouter,
indexerManager: indexerManagerRouter,
healthMonitoring: healthMonitoringRouter,
mediaTranscoding: mediaTranscodingRouter
});
23 changes: 23 additions & 0 deletions packages/api/src/router/widgets/media-transcoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";

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"))
.query(async ({ ctx }) => {
const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, { pageOffset: 0, pageSize: 10 });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });

return {
integrationId: ctx.integration.id,
data,
};
}),
});
2 changes: 2 additions & 0 deletions packages/cron-jobs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -29,6 +30,7 @@ export const jobGroup = createCronJobGroup({
indexerManager: indexerManagerJob,
healthMonitoring: healthMonitoringJob,
sessionCleanup: sessionCleanupJob,
mediaTranscoding: mediaTranscodingJob
});

export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
Expand Down
14 changes: 14 additions & 0 deletions packages/cron-jobs/src/jobs/integrations/media-transcoding.ts
Original file line number Diff line number Diff line change
@@ -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 }),
},
}),
);
10 changes: 9 additions & 1 deletion packages/definitions/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -217,4 +224,5 @@ export type IntegrationCategory =
| "torrent"
| "smartHomeServer"
| "indexerManager"
| "healthMonitoring";
| "healthMonitoring"
| "mediaTranscoding";
1 change: 1 addition & 0 deletions packages/definitions/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const widgetKinds = [
"downloads",
"mediaRequests-requestList",
"mediaRequests-requestStats",
"mediaTranscoding",
"rssFeed",
"bookmarks",
"indexerManager",
Expand Down
2 changes: 2 additions & 0 deletions packages/integrations/src/base/creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
import type { Integration, IntegrationInput } from "./integration";
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";

export const integrationCreator = <TKind extends keyof typeof integrationCreators>(
integration: IntegrationInput & { kind: TKind },
Expand Down Expand Up @@ -70,4 +71,5 @@ export const integrationCreators = {
lidarr: LidarrIntegration,
readarr: ReadarrIntegration,
dashDot: DashDotIntegration,
tdarr: TdarrIntegration
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;
3 changes: 3 additions & 0 deletions packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
16 changes: 16 additions & 0 deletions packages/integrations/src/interfaces/media-transcoding/queue.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
@@ -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[];
}[];
};
13 changes: 13 additions & 0 deletions packages/integrations/src/interfaces/media-transcoding/workers.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 172 additions & 0 deletions packages/integrations/src/media-transcoding/tdarr-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { z } from "@homarr/validation";

import { Integration } from "../base/integration";
import { TdarrQueue } from "../interfaces/media-transcoding/queue";
import type { TdarrStatistics } from "../interfaces/media-transcoding/statistics";
import { 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: 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: 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,
};
}
}
Loading

0 comments on commit 1efcd38

Please sign in to comment.