From 5089a2ae6bf020b579c66d6d60b323f51086d766 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Wed, 1 Jul 2026 12:22:12 +0200 Subject: [PATCH] feat: add fileflows integration --- .../docs/integrations/fileflows/index.mdx | 36 ++++ .../docs/docs/integrations/fileflows/index.ts | 8 + .../src/docker-integration-match.ts | 1 + .../src/docs/homarr-docs-sitemap.ts | 2 + .../src/docs/integration-doc-slugs.ts | 1 + packages/definitions/src/integration.ts | 8 + packages/integrations/src/base/creator.ts | 2 + .../fileflows-integration.ts | 202 ++++++++++++++++++ .../fileflows-validation-schemas.ts | 98 +++++++++ 9 files changed, 358 insertions(+) create mode 100644 apps/docs/docs/integrations/fileflows/index.mdx create mode 100644 apps/docs/docs/integrations/fileflows/index.ts create mode 100644 packages/integrations/src/media-transcoding/fileflows-integration.ts create mode 100644 packages/integrations/src/media-transcoding/fileflows-validation-schemas.ts diff --git a/apps/docs/docs/integrations/fileflows/index.mdx b/apps/docs/docs/integrations/fileflows/index.mdx new file mode 100644 index 000000000..7349216bd --- /dev/null +++ b/apps/docs/docs/integrations/fileflows/index.mdx @@ -0,0 +1,36 @@ +--- +title: 'FileFlows' +description: "FileFlows is a distributed media processing system that allows you to encode, compress and process your media files across multiple nodes." +hide_title: true +--- + +import { IntegrationHeader } from '@site/src/components/integrations/header'; +import { IntegrationCapabilites } from '@site/src/components/integrations/widgets'; +import { AddingIntegration } from '@site/src/components/integrations/adding'; +import {IntegrationSecrets} from '@site/src/components/integrations/secrets'; +import { IconTransform } from "@tabler/icons-react"; +import { fileFlowsIntegration } from '.'; +import { mediaTranscodingWidget } from '@site/docs/widgets/media-transcoding'; + + + + +### Widgets & Capabilities + + +### Adding the integration + + +### Secrets + \ No newline at end of file diff --git a/apps/docs/docs/integrations/fileflows/index.ts b/apps/docs/docs/integrations/fileflows/index.ts new file mode 100644 index 000000000..33480620f --- /dev/null +++ b/apps/docs/docs/integrations/fileflows/index.ts @@ -0,0 +1,8 @@ +import { IntegrationDefinition } from "@site/src/types"; +export const fileFlowsIntegration: IntegrationDefinition = { + name: "FileFlows", + description: + "FileFlows is a distributed media processing system that allows you to encode, compress and process your media files across multiple nodes.", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/fileflows.png", + path: "../../integrations/fileflows", +}; \ No newline at end of file diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts index 9dcba12d4..174300f73 100644 --- a/packages/definitions/src/docker-integration-match.ts +++ b/packages/definitions/src/docker-integration-match.ts @@ -36,6 +36,7 @@ const integrationAliases: Partial> = linuxServerIO: ["linuxserver"], coolify: ["coolify"], truenas: ["truenas"], + fileFlows: ["fileflows"], }; export const matchIntegrationKind = (search: string): IntegrationKind | null => { diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index efc75332c..8bc3ed5c6 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -176,6 +176,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/docker-hub" | "/docs/integrations/docker" | "/docs/integrations/emby" + | "/docs/integrations/fileflows" | "/docs/integrations/github-containerregistry" | "/docs/integrations/github" | "/docs/integrations/gitlab" @@ -300,6 +301,7 @@ export type HomarrDocumentationPath = | "/docs/integrations/prowlarr" | "/docs/integrations/jellyfin" | "/docs/integrations/emby" + | "/docs/integrations/fileflows" | "/docs/integrations/plex" | "/docs/integrations/jellyseerr" | "/docs/integrations/seerr" diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts index 90c3cae8b..c666594cd 100644 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ b/packages/definitions/src/docs/integration-doc-slugs.ts @@ -27,6 +27,7 @@ export const integrationDocSlugs: Record = { dashDot: "dash-dot", glances: "glances", tdarr: "tdarr", + fileFlows: "fileflows", proxmox: "proxmox", nextcloud: "nextcloud", unifiController: "unifi-controller", diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index d6c3a2f58..fa8a66863 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -251,6 +251,14 @@ export const integrationDefs = { documentationUrl: createDocumentationLink("/docs/integrations/tdarr"), defaultPort: 8265, }, + fileFlows: { + name: "FileFlows", + secretKinds: [[], ["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/fileflows.png", + category: ["mediaTranscoding"], + documentationUrl: createDocumentationLink("/docs/integrations/fileflows"), + defaultPort: 5000, + }, proxmox: { name: "Proxmox", secretKinds: [["username", "tokenId", "apiKey", "realm"]], diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 13ed36537..6b2737516 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -33,6 +33,7 @@ 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 { FileFlowsIntegration } from "../media-transcoding/fileflows-integration"; import { MockIntegration } from "../mock/mock-integration"; import { NavidromeIntegration } from "../navidrome/navidrome-integration"; import { NextcloudIntegration } from "../nextcloud/nextcloud.integration"; @@ -109,6 +110,7 @@ export const integrationCreators = { readarr: ReadarrIntegration, dashDot: DashDotIntegration, tdarr: TdarrIntegration, + fileFlows: FileFlowsIntegration, proxmox: ProxmoxIntegration, emby: EmbyIntegration, nextcloud: NextcloudIntegration, diff --git a/packages/integrations/src/media-transcoding/fileflows-integration.ts b/packages/integrations/src/media-transcoding/fileflows-integration.ts new file mode 100644 index 000000000..c610d9474 --- /dev/null +++ b/packages/integrations/src/media-transcoding/fileflows-integration.ts @@ -0,0 +1,202 @@ +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import type { IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import { TestConnectionError } from "../base/test-connection/test-connection-error"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration"; +import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "../interfaces/media-transcoding/media-transcoding-types"; +import { + libraryStatusSchema, + recentlyFinishedSchema, + shrinkageGroupsSchema, + statusResponseSchema, + upcomingFilesSchema, + workersResponseSchema, +} from "./fileflows-validation-schemas"; + +export class FileFlowsIntegration extends Integration implements IMediaTranscodingIntegration { + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api/system/version"), { + method: "GET", + headers: { + accept: "application/json", + ...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}), + }, + }); + + if (!response.ok) return TestConnectionError.StatusResult(response); + + return { success: true }; + } + + public async getStatisticsAsync(): Promise { + const headerParams = { + accept: "application/json", + "Content-Type": "application/json", + ...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}), + }; + + const [statusData, libraryStatusData, shrinkageData] = await Promise.all([ + this.fetchStatusAsync(headerParams), + this.fetchLibraryStatusAsync(headerParams), + this.fetchShrinkageAsync(headerParams), + ]); + + const failedCount = libraryStatusData + .filter((item) => item.Status === 4) + .reduce((sum, item) => sum + item.Count, 0); + const processedCount = libraryStatusData + .filter((item) => item.Status === 3) + .reduce((sum, item) => sum + item.Count, 0); + const queuedCount = libraryStatusData + .filter((item) => item.Status === 1 || item.Status === 5) + .reduce((sum, item) => sum + item.Count, 0); + + const totalSavedSpace = Object.values(shrinkageData).reduce( + (sum, group) => sum + Math.max(0, group.OriginalSize - group.FinalSize), + 0, + ); + + const transcodeStatus = [ + { name: "Processed", value: statusData.processed }, + { name: "Queued", value: statusData.queue }, + { name: "Processing", value: statusData.processing }, + { name: "Failed", value: failedCount }, + ]; + + return { + libraryName: "All", + totalFileCount: processedCount + queuedCount + statusData.processing + failedCount, + totalTranscodeCount: statusData.processed, + totalHealthCheckCount: 0, + failedTranscodeCount: failedCount, + failedHealthCheckCount: 0, + stagedTranscodeCount: statusData.queue, + stagedHealthCheckCount: 0, + totalSavedSpace, + transcodeStatus, + healthCheckStatus: [], + videoCodecs: [], + videoContainers: [], + videoResolutions: [], + audioCodecs: [], + audioContainers: [], + }; + } + + public async getWorkersAsync(): Promise { + const headerParams = { + "Content-Type": "application/json", + ...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}), + }; + + const url = this.url("/api/worker"); + const response = await fetchWithTrustedCertificatesAsync(url, { + method: "GET", + headers: headerParams, + }); + + const workersData = await workersResponseSchema.parseAsync(await response.json()); + + return workersData.map((worker) => ({ + id: worker.Uid, + filePath: worker.RelativeFile ?? "", + fps: 0, + percentage: worker.CurrentPartPercent, + ETA: worker.ProcessingTime?.TotalSeconds + ? `${Math.round(worker.ProcessingTime.TotalSeconds)}s` + : "", + jobType: worker.CurrentPartName ?? "Processing", + status: "Processing", + step: worker.CurrentPartName ?? "", + originalSize: worker.LibraryFile?.OriginalSize ?? 0, + estimatedSize: null, + outputSize: null, + })); + } + + public async getQueueAsync(firstItemIndex: number, pageSize: number): Promise { + const headerParams = { + "Content-Type": "application/json", + ...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}), + }; + + const [upcomingItems, recentlyFinishedItems] = await Promise.all([ + this.fetchUpcomingAsync(headerParams), + this.fetchRecentlyFinishedAsync(headerParams), + ]); + + const queueArray = [ + ...upcomingItems.map((item) => ({ + id: item.Uid, + healthCheck: "", + transcode: item.Flow ?? "Default", + filePath: item.RelativePath ?? item.Name ?? "", + fileSize: item.OriginalSize ?? 0, + container: "", + codec: "", + resolution: "", + type: "transcode" as const, + })), + ...recentlyFinishedItems.map((item) => ({ + id: item.Uid, + healthCheck: "", + transcode: item.Flow ?? "Default", + filePath: item.RelativePath ?? item.Name ?? "", + fileSize: item.FinalSize ?? item.OriginalSize ?? 0, + container: "", + codec: "", + resolution: "", + type: "health-check" as const, + })), + ].slice(0, pageSize); + + return { + array: queueArray, + totalCount: upcomingItems.length + recentlyFinishedItems.length, + startIndex: firstItemIndex, + endIndex: firstItemIndex + queueArray.length - 1, + }; + } + + private async fetchStatusAsync(headerParams: Record) { + const response = await fetchWithTrustedCertificatesAsync(this.url("/api/status"), { + method: "GET", + headers: headerParams, + }); + return statusResponseSchema.parseAsync(await response.json()); + } + + private async fetchLibraryStatusAsync(headerParams: Record) { + const response = await fetchWithTrustedCertificatesAsync(this.url("/api/library-file/status"), { + method: "GET", + headers: headerParams, + }); + return libraryStatusSchema.parseAsync(await response.json()); + } + + private async fetchShrinkageAsync(headerParams: Record) { + const response = await fetchWithTrustedCertificatesAsync(this.url("/api/library-file/shrinkage-groups"), { + method: "GET", + headers: headerParams, + }); + return shrinkageGroupsSchema.parseAsync(await response.json()); + } + + private async fetchUpcomingAsync(headerParams: Record) { + const response = await fetchWithTrustedCertificatesAsync(this.url("/api/library-file/upcoming"), { + method: "GET", + headers: headerParams, + }); + return upcomingFilesSchema.parseAsync(await response.json()); + } + + private async fetchRecentlyFinishedAsync(headerParams: Record) { + const response = await fetchWithTrustedCertificatesAsync(this.url("/api/library-file/recently-finished"), { + method: "GET", + headers: headerParams, + }); + return recentlyFinishedSchema.parseAsync(await response.json()); + } +} diff --git a/packages/integrations/src/media-transcoding/fileflows-validation-schemas.ts b/packages/integrations/src/media-transcoding/fileflows-validation-schemas.ts new file mode 100644 index 000000000..9b225b045 --- /dev/null +++ b/packages/integrations/src/media-transcoding/fileflows-validation-schemas.ts @@ -0,0 +1,98 @@ +import { z } from "zod/v4"; + +export const statusResponseSchema = z.object({ + queue: z.number(), + processing: z.number(), + processed: z.number(), + processingFiles: z + .array( + z.object({ + name: z.string().nullable(), + relativePath: z.string().nullable(), + library: z.string().nullable(), + step: z.string().nullable(), + stepPercent: z.number(), + }), + ) + .optional() + .transform((arr) => arr ?? []), +}); + +export const libraryStatusSchema = z.array( + z.object({ + Name: z.string().nullable(), + Status: z.number(), + Count: z.number(), + }), +); + +export const shrinkageGroupsSchema = z.record( + z.string(), + z.object({ + Library: z.string().nullable(), + OriginalSize: z.number(), + FinalSize: z.number(), + Items: z.number(), + Difference: z.number().optional(), + }), +); + +export const workersResponseSchema = z.array( + z.object({ + Uid: z.string(), + NodeName: z.string().nullable(), + RelativeFile: z.string().nullable(), + CurrentPart: z.number(), + CurrentPartName: z.string().nullable(), + CurrentPartPercent: z.number(), + StartedAt: z.string(), + LastUpdate: z.string(), + ProcessingTime: z + .object({ + TotalSeconds: z.number().optional(), + }) + .optional(), + LibraryFile: z + .object({ + OriginalSize: z.number(), + }) + .optional(), + Library: z + .object({ + Name: z.string().nullable(), + }) + .optional(), + }), +); + +export const upcomingFilesSchema = z.array( + z.object({ + Uid: z.string(), + Name: z.string().nullable(), + RelativePath: z.string().nullable(), + OriginalSize: z.number().nullable(), + FinalSize: z.number().nullable(), + Status: z.number(), + Library: z.string().nullable(), + Flow: z.string().nullable(), + Node: z.string().nullable(), + ProcessingTime: z + .object({ + TotalSeconds: z.number().optional(), + }) + .optional(), + }), +); + +export const recentlyFinishedSchema = z.array( + z.object({ + Uid: z.string(), + Name: z.string().nullable(), + RelativePath: z.string().nullable(), + OriginalSize: z.number().nullable(), + FinalSize: z.number().nullable(), + Status: z.number(), + Library: z.string().nullable(), + Flow: z.string().nullable(), + }), +); \ No newline at end of file