feat: add tdarr integration (#1657)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
Reference in New Issue
Block a user