feat: downloads widget (#844)

Usenet and Torrent downloads in 1 widget.
sabNZBd, NzbGet, Deluge, qBitTorrent, and transmission support.
Columns can be reordered in Edit mode.
Sorting enabled.
Time uses Dayjs with auto translation.
Can pause/resume single items, clients, or all.
Can delete items (With option to delete assossiated files).
Clients list and details.
Include all filtering and processing for ratio from oldmarr torrent widget.
Invalidation of old data (older than 30 seconds) to show an integration is not responding anymore.

Misc (So many miscs):
Fixed validation error with multiText.
Fixed translation application for multiSelect to behave the same as select.
Added background to gitignore (I needed to add a background to visually test opacity, probably will in the future too)
Added setOptions to frontend components so potential updates made from the Dashboard can be saved.
Extracted background and border color to use in widgets.
humanFileSize function based on the si format (powers of 1024, not 1000).
Improved integrationCreatorByKind by @Meierschlumpf.
Changed integrationCreatorByKind to integrationCreator so it functions directly from the integration.
Added integrationCreatorFromSecrets to directly work with secrets from db.
Added getIntegrationKindsByCategory to get a list of integrations sharing categories.
Added IntegrationKindByCategory type to get the types possible for a category (Great to cast on integration.kind that isn't already properly limited/typed but for which we know the limitation)
Added a common AtLeastOneOf type. Applied to TKind and IntegrationSecretKind[] where it was already being used and Added to the getIntegrationKindsByCategory's output to be more freely used.
Added the Modify type, instead of omiting to then add again just to change a parameters type, use the modify instead. Applied code wide already.
Hook to get list of integration depending on permission level of user. (By @Meierschlumpf)
This commit is contained in:
Manuel
2024-09-11 17:30:21 +02:00
committed by GitHub
parent 3d4e607a9d
commit 2535192b2c
81 changed files with 4176 additions and 390 deletions

View File

@@ -1,6 +1,14 @@
import type { IntegrationKind } from "@homarr/definitions";
import { decryptSecret } from "@homarr/common/server";
import type { Modify } from "@homarr/common/types";
import type { Integration as DbIntegration } from "@homarr/db/schema/sqlite";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-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";
import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration";
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
@@ -11,15 +19,30 @@ import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
import type { Integration, IntegrationInput } from "./integration";
export const integrationCreatorByKind = <TKind extends keyof typeof integrationCreators>(
kind: TKind,
integration: IntegrationInput,
export const integrationCreator = <TKind extends keyof typeof integrationCreators>(
integration: IntegrationInput & { kind: TKind },
) => {
if (!(kind in integrationCreators)) {
throw new Error(`Unknown integration kind ${kind}. Did you forget to add it to the integration creator?`);
if (!(integration.kind in integrationCreators)) {
throw new Error(
`Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`,
);
}
return new integrationCreators[kind](integration) as InstanceType<(typeof integrationCreators)[TKind]>;
return new integrationCreators[integration.kind](integration) as InstanceType<(typeof integrationCreators)[TKind]>;
};
export const integrationCreatorFromSecrets = <TKind extends keyof typeof integrationCreators>(
integration: Modify<DbIntegration, { kind: TKind }> & {
secrets: { kind: IntegrationSecretKind; value: `${string}.${string}` }[];
},
) => {
return integrationCreator({
...integration,
decryptedSecrets: integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
});
};
export const integrationCreators = {
@@ -29,6 +52,11 @@ export const integrationCreators = {
jellyfin: JellyfinIntegration,
sonarr: SonarrIntegration,
radarr: RadarrIntegration,
sabNzbd: SabnzbdIntegration,
nzbGet: NzbGetIntegration,
qBittorrent: QBitTorrentIntegration,
deluge: DelugeIntegration,
transmission: TransmissionIntegration,
jellyseerr: JellyseerrIntegration,
overseerr: OverseerrIntegration,
prowlarr: ProwlarrIntegration,

View File

@@ -0,0 +1,116 @@
import { Deluge } from "@ctrl/deluge";
import dayjs from "dayjs";
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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
export class DelugeIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> {
const client = this.getClient();
await client.login();
}
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
const type = "torrent";
const client = this.getClient();
const {
stats: { download_rate, upload_rate },
torrents: rawTorrents,
} = (await client.listTorrents(["completed_time"])).result;
const torrents = Object.entries(rawTorrents).map(([id, torrent]) => ({
...(torrent as { completed_time: number } & typeof torrent),
id,
}));
const paused = torrents.find(({ state }) => DelugeIntegration.getTorrentState(state) !== "paused") === undefined;
const status: DownloadClientStatus = {
paused,
rates: {
down: Math.floor(download_rate),
up: Math.floor(upload_rate),
},
type,
};
const items = torrents.map((torrent): DownloadClientItem => {
const state = DelugeIntegration.getTorrentState(torrent.state);
return {
type,
id: torrent.id,
index: torrent.queue,
name: torrent.name,
size: torrent.total_wanted,
sent: torrent.total_uploaded,
downSpeed: torrent.progress !== 100 ? torrent.download_payload_rate : undefined,
upSpeed: torrent.upload_payload_rate,
time:
torrent.progress === 100
? Math.min((torrent.completed_time - dayjs().unix()) * 1000, -1)
: Math.max(torrent.eta * 1000, 0),
added: torrent.time_added * 1000,
state,
progress: torrent.progress / 100,
category: torrent.label,
};
});
return { status, items };
}
public async pauseQueueAsync() {
const client = this.getClient();
const store = (await client.listTorrents()).result.torrents;
await Promise.all(
Object.entries(store).map(async ([id]) => {
await client.pauseTorrent(id);
}),
);
}
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().pauseTorrent(id);
}
public async resumeQueueAsync() {
const client = this.getClient();
const store = (await client.listTorrents()).result.torrents;
await Promise.all(
Object.entries(store).map(async ([id]) => {
await client.resumeTorrent(id);
}),
);
}
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().resumeTorrent(id);
}
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
await this.getClient().removeTorrent(id, fromDisk);
}
private getClient() {
const baseUrl = new URL(this.integration.url).href;
return new Deluge({
baseUrl,
password: this.getSecretValue("password"),
});
}
private static getTorrentState(state: string): DownloadClientItem["state"] {
switch (state) {
case "Queued":
case "Checking":
case "Allocating":
case "Downloading":
return "leeching";
case "Seeding":
return "seeding";
case "Paused":
return "paused";
case "Error":
case "Moving":
default:
return "unknown";
}
}
}

View File

@@ -0,0 +1,123 @@
import dayjs from "dayjs";
import { rpcClient } from "typed-rpc";
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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
import type { NzbGetClient } from "./nzbget-types";
export class NzbGetIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> {
const client = this.getClient();
await client.version();
}
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
const type = "usenet";
const nzbGetClient = this.getClient();
const queue = await nzbGetClient.listgroups();
const history = await nzbGetClient.history();
const nzbGetStatus = await nzbGetClient.status();
const status: DownloadClientStatus = {
paused: nzbGetStatus.DownloadPaused,
rates: { down: nzbGetStatus.DownloadRate },
type,
};
const items = queue
.map((file): DownloadClientItem => {
const state = NzbGetIntegration.getUsenetQueueState(file.Status);
const time =
(file.RemainingSizeLo + file.RemainingSizeHi * Math.pow(2, 32)) / (nzbGetStatus.DownloadRate / 1000);
return {
type,
id: file.NZBID.toString(),
index: file.MaxPriority,
name: file.NZBName,
size: file.FileSizeLo + file.FileSizeHi * Math.pow(2, 32),
downSpeed: file.ActiveDownloads > 0 ? nzbGetStatus.DownloadRate : 0,
time: Number.isFinite(time) ? time : 0,
added: (dayjs().unix() - file.DownloadTimeSec) * 1000,
state,
progress: file.DownloadedSizeMB / file.FileSizeMB,
category: file.Category,
};
})
.concat(
history.map((file, index): DownloadClientItem => {
const state = NzbGetIntegration.getUsenetHistoryState(file.ScriptStatus);
return {
type,
id: file.NZBID.toString(),
index,
name: file.Name,
size: file.FileSizeLo + file.FileSizeHi * Math.pow(2, 32),
time: (dayjs().unix() - file.HistoryTime) * 1000,
added: (file.HistoryTime - file.DownloadTimeSec) * 1000,
state,
progress: 1,
category: file.Category,
};
}),
);
return { status, items };
}
public async pauseQueueAsync() {
await this.getClient().pausedownload();
}
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().editqueue("GroupPause", "", [Number(id)]);
}
public async resumeQueueAsync() {
await this.getClient().resumedownload();
}
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().editqueue("GroupResume", "", [Number(id)]);
}
public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise<void> {
const client = this.getClient();
if (fromDisk) {
const filesIds = (await client.listfiles(0, 0, Number(id))).map((value) => value.ID);
await this.getClient().editqueue("FileDelete", "", filesIds);
}
if (progress !== 1) {
await client.editqueue("GroupFinalDelete", "", [Number(id)]);
} else {
await client.editqueue("HistoryFinalDelete", "", [Number(id)]);
}
}
private getClient() {
const url = new URL(this.integration.url);
url.pathname += `${this.getSecretValue("username")}:${this.getSecretValue("password")}`;
url.pathname += url.pathname.endsWith("/") ? "jsonrpc" : "/jsonrpc";
return rpcClient<NzbGetClient>(url.toString());
}
private static getUsenetQueueState(status: string): DownloadClientItem["state"] {
switch (status) {
case "QUEUED":
return "queued";
case "PAUSED":
return "paused";
default:
return "downloading";
}
}
private static getUsenetHistoryState(status: string): DownloadClientItem["state"] {
switch (status) {
case "FAILURE":
return "failed";
case "SUCCESS":
return "completed";
default:
return "processing";
}
}
}

View File

@@ -0,0 +1,42 @@
export interface NzbGetClient {
version: () => string;
status: () => NzbGetStatus;
listgroups: () => NzbGetGroup[];
history: () => NzbGetHistory[];
pausedownload: () => void;
resumedownload: () => void;
editqueue: (Command: string, Param: string, IDs: number[]) => void;
listfiles: (IDFrom: number, IDTo: number, NZBID: number) => { ID: number }[];
}
interface NzbGetStatus {
DownloadPaused: boolean;
DownloadRate: number;
}
interface NzbGetGroup {
Status: string;
NZBID: number;
MaxPriority: number;
NZBName: string;
FileSizeLo: number;
FileSizeHi: number;
ActiveDownloads: number;
RemainingSizeLo: number;
RemainingSizeHi: number;
DownloadTimeSec: number;
Category: string;
DownloadedSizeMB: number;
FileSizeMB: number;
}
interface NzbGetHistory {
ScriptStatus: string;
NZBID: number;
Name: string;
FileSizeLo: number;
FileSizeHi: number;
HistoryTime: number;
DownloadTimeSec: number;
Category: string;
}

View File

@@ -0,0 +1,112 @@
import { QBittorrent } from "@ctrl/qbittorrent";
import dayjs from "dayjs";
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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
export class QBitTorrentIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> {
const client = this.getClient();
await client.login();
}
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
const type = "torrent";
const client = this.getClient();
const torrents = await client.listTorrents();
const rates = torrents.reduce(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
{ down: 0, up: 0 },
);
const paused =
torrents.find(({ state }) => QBitTorrentIntegration.getTorrentState(state) !== "paused") === undefined;
const status: DownloadClientStatus = { paused, rates, type };
const items = torrents.map((torrent): DownloadClientItem => {
const state = QBitTorrentIntegration.getTorrentState(torrent.state);
return {
type,
id: torrent.hash,
index: torrent.priority,
name: torrent.name,
size: torrent.size,
sent: torrent.uploaded,
downSpeed: torrent.progress !== 1 ? torrent.dlspeed : undefined,
upSpeed: torrent.upspeed,
time:
torrent.progress === 1
? Math.min(torrent.completion_on * 1000 - dayjs().valueOf(), -1)
: torrent.eta === 8640000
? 0
: Math.max(torrent.eta * 1000, 0),
added: torrent.added_on * 1000,
state,
progress: torrent.progress,
category: torrent.category,
};
});
return { status, items };
}
public async pauseQueueAsync() {
await this.getClient().pauseTorrent("all");
}
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().pauseTorrent(id);
}
public async resumeQueueAsync() {
await this.getClient().resumeTorrent("all");
}
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().resumeTorrent(id);
}
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
await this.getClient().removeTorrent(id, fromDisk);
}
private getClient() {
const baseUrl = new URL(this.integration.url).href;
return new QBittorrent({
baseUrl,
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
});
}
private static getTorrentState(state: string): DownloadClientItem["state"] {
switch (state) {
case "allocating":
case "checkingDL":
case "downloading":
case "forcedDL":
case "forcedMetaDL":
case "metaDL":
case "queuedDL":
case "queuedForChecking":
return "leeching";
case "checkingUP":
case "forcedUP":
case "queuedUP":
case "uploading":
case "stalledUP":
return "seeding";
case "pausedDL":
case "pausedUP":
return "paused";
case "stalledDL":
return "stalled";
case "error":
case "checkingResumeData":
case "missingFiles":
case "moving":
case "unknown":
default:
return "unknown";
}
}
}

View File

@@ -0,0 +1,149 @@
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
import { historySchema, queueSchema } from "./sabnzbd-schema";
dayjs.extend(duration);
export class SabnzbdIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> {
//This is the one call that uses the least amount of data while requiring the api key
await this.sabNzbApiCallAsync("translate", new URLSearchParams({ value: "ping" }));
}
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
const type = "usenet";
const { queue } = await queueSchema.parseAsync(await this.sabNzbApiCallAsync("queue"));
const { history } = await historySchema.parseAsync(await this.sabNzbApiCallAsync("history"));
const status: DownloadClientStatus = {
paused: queue.paused,
rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps ()
type,
};
const items = queue.slots
.map((slot): DownloadClientItem => {
const state = SabnzbdIntegration.getUsenetQueueState(slot.status);
const times = slot.timeleft.split(":").reverse();
const time = dayjs
.duration({
seconds: Number(times[0] ?? 0),
minutes: Number(times[1] ?? 0),
hours: Number(times[2] ?? 0),
days: Number(times[3] ?? 0),
})
.asMilliseconds();
return {
type,
id: slot.nzo_id,
index: slot.index,
name: slot.filename,
size: Math.ceil(parseFloat(slot.mb) * 1024 * 1024), //Actually rounded MiB
downSpeed: slot.index > 0 ? 0 : status.rates.down,
time,
//added: 0, <- Only part from all integrations that is missing the timestamp (or from which it could be inferred)
state,
progress: parseFloat(slot.percentage) / 100,
category: slot.cat,
};
})
.concat(
history.slots.map((slot, index): DownloadClientItem => {
const state = SabnzbdIntegration.getUsenetHistoryState(slot.status);
return {
type,
id: slot.nzo_id,
index,
name: slot.name,
size: slot.bytes,
time: slot.completed * 1000 - dayjs().valueOf(),
added: (slot.completed - slot.download_time - slot.postproc_time) * 1000,
state,
progress: 1,
category: slot.category,
};
}),
);
return { status, items };
}
public async pauseQueueAsync() {
await this.sabNzbApiCallAsync("pause");
}
public async pauseItemAsync({ id }: DownloadClientItem) {
await this.sabNzbApiCallAsync("queue", new URLSearchParams({ name: "pause", value: id }));
}
public async resumeQueueAsync() {
await this.sabNzbApiCallAsync("resume");
}
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.sabNzbApiCallAsync("queue", new URLSearchParams({ name: "resume", value: id }));
}
//Delete files prevented on completed files. https://github.com/sabnzbd/sabnzbd/issues/2754
//Works on all other in downloading and post-processing.
//Will stop working as soon as the finished files is moved to completed folder.
public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise<void> {
await this.sabNzbApiCallAsync(
progress !== 1 ? "queue" : "history",
new URLSearchParams({
name: "delete",
archive: fromDisk ? "0" : "1",
value: id,
del_files: fromDisk ? "1" : "0",
}),
);
}
private async sabNzbApiCallAsync(mode: string, searchParams?: URLSearchParams): Promise<unknown> {
const url = new URL("api", this.integration.url);
url.searchParams.append("output", "json");
url.searchParams.append("mode", mode);
searchParams?.forEach((value, key) => {
url.searchParams.append(key, value);
});
url.searchParams.append("apikey", this.getSecretValue("apiKey"));
return await fetch(url)
.then((response) => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json() as Promise<unknown>;
})
.catch((error) => {
if (error instanceof Error) {
throw new Error(error.message);
} else {
throw new Error("Error communicating with SABnzbd");
}
});
}
private static getUsenetQueueState(status: string): DownloadClientItem["state"] {
switch (status) {
case "Queued":
return "queued";
case "Paused":
return "paused";
default:
return "downloading";
}
}
private static getUsenetHistoryState(status: string): DownloadClientItem["state"] {
switch (status) {
case "Completed":
return "completed";
case "Failed":
return "failed";
default:
return "processing";
}
}
}

View File

@@ -0,0 +1,121 @@
import { z } from "@homarr/validation";
export const queueSchema = z.object({
queue: z.object({
status: z.string(),
speedlimit: z.string(),
speedlimit_abs: z.string(),
paused: z.boolean(),
noofslots_total: z.number(),
noofslots: z.number(),
limit: z.number(),
start: z.number(),
timeleft: z.string(),
speed: z.string(),
kbpersec: z.string(),
size: z.string(),
sizeleft: z.string(),
mb: z.string(),
mbleft: z.string(),
slots: z.array(
z.object({
status: z.string(),
index: z.number(),
password: z.string(),
avg_age: z.string(),
script: z.string(),
has_rating: z.boolean().optional(),
mb: z.string(),
mbleft: z.string(),
mbmissing: z.string(),
size: z.string(),
sizeleft: z.string(),
filename: z.string(),
labels: z.array(z.string().or(z.null())).or(z.null()).optional(),
priority: z
.number()
.or(z.string())
.transform((priority) => (typeof priority === "number" ? priority : parseInt(priority))),
cat: z.string(),
timeleft: z.string(),
percentage: z.string(),
nzo_id: z.string(),
unpackopts: z.string(),
}),
),
categories: z.array(z.string()).or(z.null()).optional(),
scripts: z.array(z.string()).or(z.null()).optional(),
diskspace1: z.string(),
diskspace2: z.string(),
diskspacetotal1: z.string(),
diskspacetotal2: z.string(),
diskspace1_norm: z.string(),
diskspace2_norm: z.string(),
have_warnings: z.string(),
pause_int: z.string(),
loadavg: z.string().optional(),
left_quota: z.string(),
version: z.string(),
finish: z.number(),
cache_art: z.string(),
cache_size: z.string(),
finishaction: z.null().optional(),
paused_all: z.boolean(),
quota: z.string(),
have_quota: z.boolean(),
queue_details: z.string().optional(),
}),
});
export const historySchema = z.object({
history: z.object({
noofslots: z.number(),
day_size: z.string(),
week_size: z.string(),
month_size: z.string(),
total_size: z.string(),
last_history_update: z.number(),
slots: z.array(
z.object({
action_line: z.string(),
series: z.string().or(z.null()).optional(),
script_log: z.string().optional(),
meta: z.null().optional(),
fail_message: z.string(),
loaded: z.boolean(),
id: z.number().optional(),
size: z.string(),
category: z.string(),
pp: z.string(),
retry: z.number(),
script: z.string(),
nzb_name: z.string(),
download_time: z.number(),
storage: z.string(),
has_rating: z.boolean().optional(),
status: z.string(),
script_line: z.string(),
completed: z.number(),
nzo_id: z.string(),
downloaded: z.number(),
report: z.string(),
password: z.string().or(z.null()).optional(),
path: z.string(),
postproc_time: z.number(),
name: z.string(),
url: z.string().or(z.null()).optional(),
md5sum: z.string(),
bytes: z.number(),
url_info: z.string(),
stage_log: z
.array(
z.object({
name: z.string(),
actions: z.array(z.string()).or(z.null()).optional(),
}),
)
.optional(),
}),
),
}),
});

View File

@@ -0,0 +1,99 @@
import { Transmission } from "@ctrl/transmission";
import dayjs from "dayjs";
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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
export class TransmissionIntegration extends DownloadClientIntegration {
public async testConnectionAsync(): Promise<void> {
await this.getClient().getSession();
}
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
const type = "torrent";
const client = this.getClient();
const { torrents } = (await client.listTorrents()).arguments;
const rates = torrents.reduce(
({ down, up }, { rateDownload, rateUpload }) => ({ down: down + rateDownload, up: up + rateUpload }),
{ down: 0, up: 0 },
);
const paused =
torrents.find(({ status }) => TransmissionIntegration.getTorrentState(status) !== "paused") === undefined;
const status: DownloadClientStatus = { paused, rates, type };
const items = torrents.map((torrent): DownloadClientItem => {
const state = TransmissionIntegration.getTorrentState(torrent.status);
return {
type,
id: torrent.hashString,
index: torrent.queuePosition,
name: torrent.name,
size: torrent.totalSize,
sent: torrent.uploadedEver,
downSpeed: torrent.percentDone !== 1 ? torrent.rateDownload : undefined,
upSpeed: torrent.rateUpload,
time:
torrent.percentDone === 1
? Math.min(torrent.doneDate * 1000 - dayjs().valueOf(), -1)
: Math.max(torrent.eta * 1000, 0),
added: torrent.addedDate * 1000,
state,
progress: torrent.percentDone,
category: torrent.labels,
};
});
return { status, items };
}
public async pauseQueueAsync() {
const client = this.getClient();
const ids = (await client.listTorrents()).arguments.torrents.map(({ hashString }) => hashString);
await this.getClient().pauseTorrent(ids);
}
public async pauseItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().pauseTorrent(id);
}
public async resumeQueueAsync() {
const client = this.getClient();
const ids = (await client.listTorrents()).arguments.torrents.map(({ hashString }) => hashString);
await this.getClient().resumeTorrent(ids);
}
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
await this.getClient().resumeTorrent(id);
}
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
await this.getClient().removeTorrent(id, fromDisk);
}
private getClient() {
const baseUrl = new URL(this.integration.url).href;
return new Transmission({
baseUrl,
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
});
}
private static getTorrentState(status: number): DownloadClientItem["state"] {
switch (status) {
case 0:
return "paused";
case 1:
case 3:
return "stalled";
case 2:
case 4:
return "leeching";
case 5:
case 6:
return "seeding";
default:
return "unknown";
}
}
}

View File

@@ -2,18 +2,31 @@
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration";
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
export { DelugeIntegration } from "./download-client/deluge/deluge-integration";
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
// Types
export type { IntegrationInput } from "./base/integration";
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
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";
// Schemas
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
// Helpers
export { integrationCreatorByKind } from "./base/creator";
export { integrationCreator, integrationCreatorFromSecrets } from "./base/creator";
export { IntegrationTestConnectionError } from "./base/test-connection-error";

View File

@@ -0,0 +1,7 @@
import type { DownloadClientItem } from "./download-client-items";
import type { DownloadClientStatus } from "./download-client-status";
export interface DownloadClientJobsAndStatus {
status: DownloadClientStatus;
items: DownloadClientItem[];
}

View File

@@ -0,0 +1,18 @@
import { Integration } from "../../base/integration";
import type { DownloadClientJobsAndStatus } from "./download-client-data";
import type { DownloadClientItem } from "./download-client-items";
export abstract class DownloadClientIntegration extends Integration {
/** Get download client's status and list of all of it's items */
public abstract getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus>;
/** Pauses the client or all of it's items */
public abstract pauseQueueAsync(): Promise<void>;
/** Pause a single item using it's ID */
public abstract pauseItemAsync(item: DownloadClientItem): Promise<void>;
/** Resumes the client or all of it's items */
public abstract resumeQueueAsync(): Promise<void>;
/** Resume a single item using it's ID */
public abstract resumeItemAsync(item: DownloadClientItem): Promise<void>;
/** Delete an entry on the client or a file from disk */
public abstract deleteItemAsync(item: DownloadClientItem, fromDisk: boolean): Promise<void>;
}

View File

@@ -0,0 +1,56 @@
import type { Integration } from "@homarr/db/schema/sqlite";
import { z } from "@homarr/validation";
const usenetQueueState = ["downloading", "queued", "paused"] as const;
const usenetHistoryState = ["completed", "failed", "processing"] as const;
const torrentState = ["leeching", "stalled", "paused", "seeding"] as const;
/**
* DownloadClientItem
* Description:
* Normalized interface for downloading clients for Usenet and
* Torrents alike, using common properties and few extra optionals
* from each.
*/
export const downloadClientItemSchema = z.object({
/** Unique Identifier provided by client */
id: z.string(),
/** Position in queue */
index: z.number(),
/** Filename */
name: z.string(),
/** Torrent/Usenet identifier */
type: z.enum(["torrent", "usenet"]),
/** Item size in Bytes */
size: z.number(),
/** Total uploaded in Bytes, only required for Torrent items */
sent: z.number().optional(),
/** Download speed in Bytes/s, only required if not complete
* (Says 0 only if it should be downloading but isn't) */
downSpeed: z.number().optional(),
/** Upload speed in Bytes/s, only required for Torrent items */
upSpeed: z.number().optional(),
/** Positive = eta (until completion, 0 meaning infinite), Negative = time since completion, in milliseconds*/
time: z.number(),
/** Unix timestamp in milliseconds when the item was added to the client */
added: z.number().optional(),
/** Status message, mostly as information to display and not for logic */
state: z.enum(["unknown", ...usenetQueueState, ...usenetHistoryState, ...torrentState]),
/** Progress expressed between 0 and 1, can infer completion from progress === 1 */
progress: z.number().min(0).max(1),
/** Category given to the item */
category: z.string().or(z.array(z.string())).optional(),
});
export type DownloadClientItem = z.infer<typeof downloadClientItemSchema>;
export type ExtendedDownloadClientItem = {
integration: Integration;
received: number;
ratio?: number;
actions?: {
resume: () => void;
pause: () => void;
delete: ({ fromDisk }: { fromDisk: boolean }) => void;
};
} & DownloadClientItem;

View File

@@ -0,0 +1,23 @@
import type { Integration } from "@homarr/db/schema/sqlite";
export interface DownloadClientStatus {
/** If client is considered paused */
paused: boolean;
/** Download/Upload speeds for the client */
rates: {
down: number;
up?: number;
};
type: "usenet" | "torrent";
}
export interface ExtendedClientStatus {
integration: Integration;
interact: boolean;
status?: {
/** To derive from current items */
totalDown?: number;
/** To derive from current items */
totalUp?: number;
ratio?: number;
} & DownloadClientStatus;
}