diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 4439c5dfe..2a5d3897e 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -36,6 +36,7 @@ export type HomarrDocumentationPath = | "/blog/tags/update" | "/blog/tags/version" | "/blog/translations" + | "/carbon-test" | "/search" | "/docs/tags" | "/docs/tags/active-directory" diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index c29c89f06..39cd04802 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -73,6 +73,14 @@ export const integrationDefs = { category: ["downloadClient", "torrent", "miscellaneous"], documentationUrl: createDocumentationLink("/docs/integrations/aria2"), }, + slskd: { + name: "Slskd", + secretKinds: [["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/slskd.svg", + category: ["downloadClient", "miscellaneous"], + // @ts-expect-error - docs page will be created when integration is merged + documentationUrl: createDocumentationLink("/docs/integrations/slskd"), + }, sonarr: { name: "Sonarr", secretKinds: [["apiKey"]], diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 3a48e2c42..60d22895b 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -10,6 +10,7 @@ import { DelugeIntegration } from "../download-client/deluge/deluge-integration" import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration"; import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration"; import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration"; +import { SlskdIntegration } from "../download-client/slskd/slskd-integration"; import { TransmissionIntegration } from "../download-client/transmission/transmission-integration"; import { EmbyIntegration } from "../emby/emby-integration"; import { GitHubContainerRegistryIntegration } from "../github-container-registry/github-container-registry-integration"; @@ -78,6 +79,7 @@ export const integrationCreators = { qBittorrent: QBitTorrentIntegration, deluge: DelugeIntegration, transmission: TransmissionIntegration, + slskd: SlskdIntegration, aria2: Aria2Integration, jellyseerr: JellyseerrIntegration, overseerr: OverseerrIntegration, diff --git a/packages/integrations/src/download-client/slskd/slskd-integration.ts b/packages/integrations/src/download-client/slskd/slskd-integration.ts new file mode 100644 index 000000000..91c7b55ff --- /dev/null +++ b/packages/integrations/src/download-client/slskd/slskd-integration.ts @@ -0,0 +1,244 @@ +import dayjs from "dayjs"; +import type { RequestInit } from "undici"; + +import { ResponseError } from "@homarr/common/server"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; + +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 { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; +import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; +import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; + +interface SlskdUserAndDownloads { + directories: { + files: { + id: string; + filename: string; + size: number; + percentComplete: number; + averageSpeed: number; + /** + * Time in hh:mm:ss format. + * Optional, undefined when aborted or errored. + */ + remainingTime?: string; + + /** + * A bit-wise combination of the state enums. + */ + state: string; + + /** + * ISO date when the file was requested at. + * "enqueued" and "started" are different things. + */ + requestedAt: string; + }[]; + }[]; +} + +const logger = createLogger({ module: "slskd-integration" }); + +/** + * Implementation of the Slskd application for Soulseek https://github.com/slskd/slskd + * Slskd integration cannot pause or stop downloads, it can only list downloads. + */ +export class SlskdIntegration extends Integration implements IDownloadClientIntegration { + // eslint-disable-next-line no-restricted-syntax + public pauseQueueAsync(): Promise { + return Promise.resolve(); + } + + // eslint-disable-next-line no-restricted-syntax + public pauseItemAsync(_: DownloadClientItem): Promise { + return Promise.resolve(); + } + + // eslint-disable-next-line no-restricted-syntax + public resumeQueueAsync(): Promise { + return Promise.resolve(); + } + + // eslint-disable-next-line no-restricted-syntax + public resumeItemAsync(_: DownloadClientItem): Promise { + return Promise.resolve(); + } + + // eslint-disable-next-line no-restricted-syntax,id-length + public deleteItemAsync(_: DownloadClientItem, _2: boolean): Promise { + return Promise.resolve(); + } + + protected async testingAsync(input: IntegrationTestingInput): Promise { + const response = await input.fetchAsync(this.url("/api/v0/server"), { + headers: { + "X-API-Key": this.getSecretValue("apiKey"), + }, + }); + + if (!response.ok) { + return TestConnectionError.StatusResult({ + status: response.status, + url: response.url, + }); + } + + await response.json(); + + return { + success: true, + }; + } + + public async getClientJobsAndStatusAsync(input: { limit: number }): Promise { + const downloads = await this.getDownloadsAsync(); + const flatFiles = downloads.flatMap((user) => user.directories).flatMap((directory) => directory.files); + + return { + status: { + paused: false, // slskd cannot be paused + rates: { + down: flatFiles.reduce((acc, file) => acc + file.averageSpeed, 0), + up: 0, // slskd does not upload except sharing which isn't what we want to show in downloads + }, + types: ["miscellaneous"], + }, + items: flatFiles.slice(0, input.limit).map((file, index) => { + const remainingTimeParts = file.remainingTime ? file.remainingTime.split(":") : null; + // The file name returned from API includes the full remote path, which is not relevant for Homarr. + // We remove everything before the last slash to beautify the file name. + const actualFileName = file.filename.split("\\").at(-1); + + return { + id: file.id, + name: actualFileName ?? file.filename, + size: file.size, + time: remainingTimeParts + ? dayjs + .duration({ + hours: Number(remainingTimeParts[0]), + minutes: Number(remainingTimeParts[1]), + seconds: Number(remainingTimeParts[2]), + }) + .as("milliseconds") + : 0, + progress: file.percentComplete / 100, + type: "miscellaneous", + state: this.getDownloadState(file), + added: dayjs(file.requestedAt).unix() * 1000, + index, + }; + }), + }; + } + + private async getDownloadsAsync(): Promise { + const response = await this.makeRequestAsync("/api/v0/transfers/downloads", { method: "GET" }); + return response as SlskdUserAndDownloads[]; + } + + private async makeRequestAsync( + path: `/${string}`, + options: RequestInit & { timeout?: number } = {}, + ): Promise { + const response = await fetchWithTrustedCertificatesAsync(this.url(path), { + ...options, + headers: { + "X-API-Key": this.getSecretValue("apiKey"), + }, + }); + + if (!response.ok) { + throw new ResponseError(response); + } + + const contentType = response.headers.get("content-type"); + if (contentType?.includes("application/json")) { + return response.json(); + } + + return response.text(); + } + + private getDownloadState( + item: SlskdUserAndDownloads["directories"][number]["files"][number], + ): DownloadClientItem["state"] { + try { + const mappedStates = parseTransferStateArray(item.state); + + if (mappedStates.includes(TransferState.InProgress)) { + return "downloading"; + } + + if ( + mappedStates.includes(TransferState.Errored) || + mappedStates.includes(TransferState.Aborted) || + mappedStates.includes(TransferState.TimedOut) || + mappedStates.includes(TransferState.Rejected) || + mappedStates.includes(TransferState.Cancelled) + ) { + return "failed"; + } + + if (mappedStates.includes(TransferState.Queued) || mappedStates.includes(TransferState.Initializing)) { + return "processing"; + } + + // "completed" must come last, since above enums are bit-wise combined even if they failed + if (mappedStates.includes(TransferState.Completed) || mappedStates.includes(TransferState.Succeeded)) { + return "completed"; + } + + logger.warn( + `Unable to map the SLSKD transfer state enum '${mappedStates.join(",")}'. Will fall back to 'unknown'`, + ); + + return "unknown"; + } catch (error) { + logger.error( + new ErrorWithMetadata( + `Unable to map the SLSKD transfer states '${item.state}' due to an exception in the translation. Will fall back to 'unknown'. This is likely a bug.`, + undefined, + { + cause: error, + }, + ), + ); + return "unknown"; + } + } +} + +/** + * Copy of the transfer states of SLSKD, copied from the following file: + * https://github.com/slskd/slskd/blob/86dc6072a28876abfdeb1f5dbb7dfc99ea8e1805/src/slskd/Core/Data/Migrations/Z04012025_TransferStateMigration.cs#L217-L234 + */ +export enum TransferState { + None, + Requested, + Queued, + Initializing, + InProgress, + Completed, + Succeeded, + Cancelled, + TimedOut, + Errored, + Rejected, + Aborted, +} + +export function parseTransferStateArray(csv: string, separator = ","): TransferState[] { + const parts = csv + .split(separator) + .map((part) => part.trim()) + .filter(Boolean); + + // @ts-expect-error use string to index + return parts.map((part) => TransferState[part] as TransferState); +} diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 7592a1b0f..da748fc57 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -5,6 +5,7 @@ export { DelugeIntegration } from "./download-client/deluge/deluge-integration"; export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration"; export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration"; export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration"; +export { SlskdIntegration } from "./download-client/slskd/slskd-integration"; export { TransmissionIntegration } from "./download-client/transmission/transmission-integration"; export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration"; export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";