mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-27 00:40:58 +01:00
feat: support aria2 integration (#2226)
This commit is contained in:
@@ -5,6 +5,7 @@ import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions
|
||||
|
||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||
import { DashDotIntegration } from "../dashdot/dashdot-integration";
|
||||
import { Aria2Integration } from "../download-client/aria2/aria2-integration";
|
||||
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
|
||||
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
|
||||
import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
|
||||
@@ -78,6 +79,7 @@ export const integrationCreators = {
|
||||
qBittorrent: QBitTorrentIntegration,
|
||||
deluge: DelugeIntegration,
|
||||
transmission: TransmissionIntegration,
|
||||
aria2: Aria2Integration,
|
||||
jellyseerr: JellyseerrIntegration,
|
||||
overseerr: OverseerrIntegration,
|
||||
prowlarr: ProwlarrIntegration,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import path from "path";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
|
||||
|
||||
import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data";
|
||||
import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
|
||||
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
|
||||
import type { Aria2Download, Aria2GetClient } from "./aria2-types";
|
||||
|
||||
export class Aria2Integration extends DownloadClientIntegration {
|
||||
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
|
||||
const client = this.getClient();
|
||||
const keys: (keyof Aria2Download)[] = [
|
||||
"bittorrent",
|
||||
"uploadLength",
|
||||
"uploadSpeed",
|
||||
"downloadSpeed",
|
||||
"totalLength",
|
||||
"completedLength",
|
||||
"files",
|
||||
"status",
|
||||
"gid",
|
||||
];
|
||||
const [activeDownloads, waitingDownloads, stoppedDownloads, globalStats] = await Promise.all([
|
||||
client.tellActive(),
|
||||
client.tellWaiting(0, 1000, keys),
|
||||
client.tellStopped(0, 1000, keys),
|
||||
client.getGlobalStat(),
|
||||
]);
|
||||
|
||||
const downloads = [...activeDownloads, ...waitingDownloads, ...stoppedDownloads];
|
||||
const allPaused = downloads.every((download) => download.status === "paused");
|
||||
|
||||
return {
|
||||
status: {
|
||||
types: ["torrent", "miscellaneous"],
|
||||
paused: allPaused,
|
||||
rates: {
|
||||
up: Number(globalStats.uploadSpeed),
|
||||
down: Number(globalStats.downloadSpeed),
|
||||
},
|
||||
},
|
||||
items: downloads.map((download, index) => {
|
||||
const totalSize = Number(download.totalLength);
|
||||
const completedSize = Number(download.completedLength);
|
||||
const progress = totalSize > 0 ? completedSize / totalSize : 0;
|
||||
|
||||
const itemName = download.bittorrent?.info?.name ?? path.basename(download.files[0]?.path ?? "Unknown");
|
||||
|
||||
return {
|
||||
index,
|
||||
id: download.gid,
|
||||
name: itemName,
|
||||
type: download.bittorrent ? "torrent" : "miscellaneous",
|
||||
size: totalSize,
|
||||
sent: Number(download.uploadLength),
|
||||
downSpeed: Number(download.downloadSpeed),
|
||||
upSpeed: Number(download.uploadSpeed),
|
||||
time: this.calculateEta(completedSize, totalSize, Number(download.downloadSpeed)),
|
||||
state: this.getState(download.status, Boolean(download.bittorrent)),
|
||||
category: [],
|
||||
progress,
|
||||
};
|
||||
}),
|
||||
} as DownloadClientJobsAndStatus;
|
||||
}
|
||||
public async pauseQueueAsync(): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.pauseAll();
|
||||
}
|
||||
public async pauseItemAsync(item: DownloadClientItem): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.pause(item.id);
|
||||
}
|
||||
public async resumeQueueAsync(): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.unpauseAll();
|
||||
}
|
||||
public async resumeItemAsync(item: DownloadClientItem): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.unpause(item.id);
|
||||
}
|
||||
public async deleteItemAsync(item: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
const client = this.getClient();
|
||||
// Note: Remove download file is not support by aria2, replace with forceremove
|
||||
|
||||
if (item.state in ["downloading", "leeching", "paused"]) {
|
||||
await (fromDisk ? client.remove(item.id) : client.forceRemove(item.id));
|
||||
} else {
|
||||
await client.removeDownloadResult(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
const client = this.getClient();
|
||||
await client.getVersion();
|
||||
}
|
||||
|
||||
private getClient() {
|
||||
const url = this.url("/jsonrpc");
|
||||
|
||||
return new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (target, method: keyof Aria2GetClient) => {
|
||||
return async (...args: Parameters<Aria2GetClient[typeof method]>) => {
|
||||
let params = [...args];
|
||||
if (this.hasSecretValue("apiKey")) {
|
||||
params = [`token:${this.getSecretValue("apiKey")}`, ...params];
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: btoa(["Homarr", Date.now().toString(), Math.random()].join(".")), // unique id per request
|
||||
method: `aria2.${method}`,
|
||||
params,
|
||||
});
|
||||
return await fetchWithTrustedCertificatesAsync(url, { method: "POST", body })
|
||||
.then(async (response) => {
|
||||
const responseBody = (await response.json()) as { result: ReturnType<Aria2GetClient[typeof method]> };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
return responseBody.result;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(error.message);
|
||||
} else {
|
||||
throw new Error("Error communicating with Aria2");
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
) as Aria2GetClient;
|
||||
}
|
||||
|
||||
private getState(aria2Status: Aria2Download["status"], isTorrent: boolean): DownloadClientItem["state"] {
|
||||
return isTorrent ? this.getTorrentState(aria2Status) : this.getNonTorrentState(aria2Status);
|
||||
}
|
||||
private getNonTorrentState(aria2Status: Aria2Download["status"]): DownloadClientItem["state"] {
|
||||
switch (aria2Status) {
|
||||
case "active":
|
||||
return "downloading";
|
||||
case "waiting":
|
||||
return "queued";
|
||||
case "paused":
|
||||
return "paused";
|
||||
case "complete":
|
||||
return "completed";
|
||||
case "error":
|
||||
return "failed";
|
||||
case "removed":
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
private getTorrentState(aria2Status: Aria2Download["status"]): DownloadClientItem["state"] {
|
||||
switch (aria2Status) {
|
||||
case "active":
|
||||
return "leeching";
|
||||
case "waiting":
|
||||
return "queued";
|
||||
case "paused":
|
||||
return "paused";
|
||||
case "complete":
|
||||
return "completed";
|
||||
case "error":
|
||||
return "failed";
|
||||
case "removed":
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
private calculateEta(completed: number, total: number, speed: number): number {
|
||||
if (speed === 0 || completed >= total) return 0;
|
||||
return Math.floor((total - completed) / speed) * 1000; // Convert to milliseconds
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export interface Aria2GetClient {
|
||||
getVersion: Aria2VoidFunc<Aria2GetVersion>;
|
||||
getGlobalStat: Aria2VoidFunc<Aria2GetGlobalStat>;
|
||||
|
||||
tellActive: Aria2VoidFunc<Aria2Download[]>;
|
||||
tellWaiting: Aria2VoidFunc<Aria2Download[], Aria2TellStatusListParams>;
|
||||
tellStopped: Aria2VoidFunc<Aria2Download[], Aria2TellStatusListParams>;
|
||||
tellStatus: Aria2GidFunc<Aria2Download, Aria2TellStatusListParams>;
|
||||
|
||||
pause: Aria2GidFunc<AriaGID>;
|
||||
pauseAll: Aria2VoidFunc<"OK">;
|
||||
unpause: Aria2GidFunc<AriaGID>;
|
||||
unpauseAll: Aria2VoidFunc<"OK">;
|
||||
remove: Aria2GidFunc<AriaGID>;
|
||||
forceRemove: Aria2GidFunc<AriaGID>;
|
||||
removeDownloadResult: Aria2GidFunc<"OK">;
|
||||
}
|
||||
type AriaGID = string;
|
||||
|
||||
type Aria2GidFunc<R = void, T extends unknown[] = []> = (gid: string, ...args: T) => Promise<R>;
|
||||
type Aria2VoidFunc<R = void, T extends unknown[] = []> = (...args: T) => Promise<R>;
|
||||
|
||||
type Aria2TellStatusListParams = [offset: number, num: number, keys?: [keyof Aria2Download] | (keyof Aria2Download)[]];
|
||||
|
||||
export interface Aria2GetVersion {
|
||||
enabledFeatures: string[];
|
||||
version: string;
|
||||
}
|
||||
export interface Aria2GetGlobalStat {
|
||||
downloadSpeed: string;
|
||||
uploadSpeed: string;
|
||||
numActive: string;
|
||||
numWaiting: string;
|
||||
numStopped: string;
|
||||
numStoppedTotal: string;
|
||||
}
|
||||
export interface Aria2Download {
|
||||
gid: AriaGID;
|
||||
status: "active" | "waiting" | "paused" | "error" | "complete" | "removed";
|
||||
totalLength: string;
|
||||
completedLength: string;
|
||||
uploadLength: string;
|
||||
bitfield: string;
|
||||
downloadSpeed: string;
|
||||
uploadSpeed: string;
|
||||
infoHash?: string;
|
||||
numSeeders?: string;
|
||||
seeder?: "true" | "false";
|
||||
pieceLength: string;
|
||||
numPieces: string;
|
||||
connections: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
followedBy?: AriaGID[];
|
||||
following?: AriaGID;
|
||||
belongsTo?: AriaGID;
|
||||
dir: string;
|
||||
files: {
|
||||
index: number;
|
||||
path: string;
|
||||
length: string;
|
||||
completedLength: string;
|
||||
selected: "true" | "false";
|
||||
uris: {
|
||||
status: "waiting" | "used";
|
||||
uri: string;
|
||||
}[];
|
||||
}[];
|
||||
bittorrent?: {
|
||||
announceList: string[];
|
||||
comment?: string | { "utf-8": string };
|
||||
creationDate?: number;
|
||||
mode?: "single" | "multi";
|
||||
info?: {
|
||||
name: string | { "utf-8": string };
|
||||
};
|
||||
verifiedLength?: number;
|
||||
verifyIntegrityPending?: boolean;
|
||||
};
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export class DelugeIntegration extends DownloadClientIntegration {
|
||||
down: Math.floor(download_rate),
|
||||
up: Math.floor(upload_rate),
|
||||
},
|
||||
type,
|
||||
types: [type],
|
||||
};
|
||||
const items = torrents.map((torrent): DownloadClientItem => {
|
||||
const state = DelugeIntegration.getTorrentState(torrent.state);
|
||||
|
||||
@@ -21,7 +21,7 @@ export class NzbGetIntegration extends DownloadClientIntegration {
|
||||
const status: DownloadClientStatus = {
|
||||
paused: nzbGetStatus.DownloadPaused,
|
||||
rates: { down: nzbGetStatus.DownloadRate },
|
||||
type,
|
||||
types: [type],
|
||||
};
|
||||
const items = queue
|
||||
.map((file): DownloadClientItem => {
|
||||
|
||||
@@ -24,7 +24,7 @@ export class QBitTorrentIntegration extends DownloadClientIntegration {
|
||||
);
|
||||
const paused =
|
||||
torrents.find(({ state }) => QBitTorrentIntegration.getTorrentState(state) !== "paused") === undefined;
|
||||
const status: DownloadClientStatus = { paused, rates, type };
|
||||
const status: DownloadClientStatus = { paused, rates, types: [type] };
|
||||
const items = torrents.map((torrent): DownloadClientItem => {
|
||||
const state = QBitTorrentIntegration.getTorrentState(torrent.state);
|
||||
return {
|
||||
|
||||
@@ -24,7 +24,7 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
|
||||
const status: DownloadClientStatus = {
|
||||
paused: queue.paused,
|
||||
rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps ()
|
||||
type,
|
||||
types: [type],
|
||||
};
|
||||
const items = queue.slots
|
||||
.map((slot): DownloadClientItem => {
|
||||
|
||||
@@ -24,7 +24,7 @@ export class TransmissionIntegration extends DownloadClientIntegration {
|
||||
);
|
||||
const paused =
|
||||
torrents.find(({ status }) => TransmissionIntegration.getTorrentState(status) !== "paused") === undefined;
|
||||
const status: DownloadClientStatus = { paused, rates, type };
|
||||
const status: DownloadClientStatus = { paused, rates, types: [type] };
|
||||
const items = torrents.map((torrent): DownloadClientItem => {
|
||||
const state = TransmissionIntegration.getTorrentState(torrent.status);
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
|
||||
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
|
||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||
export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration";
|
||||
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
||||
|
||||
@@ -20,8 +20,8 @@ export const downloadClientItemSchema = z.object({
|
||||
index: z.number(),
|
||||
/** Filename */
|
||||
name: z.string(),
|
||||
/** Torrent/Usenet identifier */
|
||||
type: z.enum(["torrent", "usenet"]),
|
||||
/** Download Client identifier */
|
||||
type: z.enum(["torrent", "usenet", "miscellaneous"]),
|
||||
/** Item size in Bytes */
|
||||
size: z.number(),
|
||||
/** Total uploaded in Bytes, only required for Torrent items */
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface DownloadClientStatus {
|
||||
down: number;
|
||||
up?: number;
|
||||
};
|
||||
type: "usenet" | "torrent";
|
||||
types: ("usenet" | "torrent" | "miscellaneous")[];
|
||||
}
|
||||
export interface ExtendedClientStatus {
|
||||
integration: Pick<Integration, "id" | "name" | "kind"> & { updatedAt: Date };
|
||||
|
||||
Reference in New Issue
Block a user