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