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:
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user