diff --git a/src/clients/Deluge.ts b/src/clients/Deluge.ts index 2977469f8..833a43ba2 100644 --- a/src/clients/Deluge.ts +++ b/src/clients/Deluge.ts @@ -8,12 +8,12 @@ import { TorrentClient } from "./TorrentClient.js"; import { extractCredentialsFromUrl } from "../utils.js"; import fetch, { Headers, Response } from "node-fetch"; -interface DelugeResponse { - error?: { - message?: string; - code?: DelugeErrorCode; - }; - result?: string | boolean; +interface TorrentInfo { + complete?: boolean; + save_path: string; + state?: string; + progress?: number; + label?: string; } enum DelugeErrorCode { NO_AUTH = 1, @@ -22,11 +22,23 @@ enum DelugeErrorCode { RPC_FAIL = 4, BAD_JSON = 5, } -interface TorrentInfo { - complete: boolean; - save_path: string; - label?: string; -} +type InjectData = [ + filename: string, + filedump: string, + options: { + add_paused: boolean; + seed_mode: boolean; + download_location: string; + } +]; +type WebHostList = [string, string, number, string][]; +type ErrorType = { message?: string; code?: DelugeErrorCode }; +type TorrentStatus = { torrents?: Record }; + +type DelugeJSON = { + result?: ResultType; + error?: ErrorType; +}; export default class Deluge implements TorrentClient { private delugeCookie: string | null = null; @@ -57,7 +69,11 @@ export default class Deluge implements TorrentClient { ); } try { - const authResponse = await this.call("auth.login", [password], 0); + const authResponse = await this.call( + "auth.login", + [password], + 0 + ); if (!authResponse.result) { throw new CrossSeedError( `Reached Deluge, but failed to authenticate: ${href}` @@ -66,19 +82,27 @@ export default class Deluge implements TorrentClient { } catch (networkError) { throw new CrossSeedError(networkError); } - const connectedResponse = await this.call("web.connected", [], 0); + const connectedResponse = await this.call( + "web.connected", + [], + 0 + ); if (!connectedResponse.result) { logger.warn( "Deluge WebUI disconnected from daemon...attempting to reconnect." ); - const webuiHostList = await this.call("web.get_hosts", [], 0); - const connectResponse = await this.call( + const webuiHostList = await this.call( + "web.get_hosts", + [], + 0 + ); + const connectResponse = await this.call( "web.connect", [webuiHostList.result[0][0]], 0 ); - if (connectResponse) { + if (!connectResponse.error) { logger.info("Deluge WebUI connected to the daemon."); } else { throw new CrossSeedError( @@ -91,22 +115,28 @@ export default class Deluge implements TorrentClient { /** * ensures authentication and sends JSON-RPC calls to deluge */ - private async call(method: string, params: object, retries = 1) { + private async call( + method: string, + params: unknown[], + retries = 1 + ): Promise> { const { delugeRpcUrl } = getRuntimeConfig(); const { href } = extractCredentialsFromUrl(delugeRpcUrl).unwrapOrThrow(); const headers = new Headers({ "Content-Type": "application/json" }); if (this.delugeCookie) headers.set("Cookie", this.delugeCookie); - let response: Response, json: DelugeResponse; + let response: Response, json: DelugeJSON; + const id = Math.floor(Math.random() * 0x7fffffff); try { response = await fetch(href, { body: JSON.stringify({ - ...{ method: method, params: params }, - id: Math.floor(Math.random() * 0x7fffffff), + method, + params, + id, }), method: "POST", - headers: headers, + headers, }); } catch (networkError) { // @ts-expect-error needs es2022 target (tsconfig) @@ -121,11 +151,11 @@ export default class Deluge implements TorrentClient { `Deluge method ${method} response was non-JSON ${jsonParseError}` ); } - if (json?.error?.code === DelugeErrorCode.NO_AUTH && retries > 0) { + if (json.error?.code === DelugeErrorCode.NO_AUTH && retries > 0) { this.delugeCookie = null; await this.authenticate(); if (this.delugeCookie) { - return this.call(method, params, 0); + return this.call(method, params, 0); } else { throw new Error( "Connection lost with Deluge. Reauthentication failed." @@ -150,8 +180,13 @@ export default class Deluge implements TorrentClient { * returns true if successful. */ private async labelEnabled() { - const enabledLabels = await this.call("core.get_enabled_plugins", []); - return enabledLabels?.result?.includes?.("Label"); + const enabledPlugins = await this.call( + "core.get_enabled_plugins", + [] + ); + return enabledPlugins.error + ? false + : enabledPlugins.result.includes("Label"); } /** @@ -160,13 +195,13 @@ export default class Deluge implements TorrentClient { */ private async setLabel(infoHash: string, label: string): Promise { if (this.isLabelEnabled) { - const setResult = await this.call("label.set_torrent", [ + const setResult = await this.call("label.set_torrent", [ infoHash, label, ]); - if (setResult?.error?.code == DelugeErrorCode.RPC_FAIL) { - await this.call("label.add", [label]); - await this.call("label.set_torrent", [infoHash, label]); + if (setResult.error?.code === DelugeErrorCode.RPC_FAIL) { + await this.call("label.add", [label]); + await this.call("label.set_torrent", [infoHash, label]); } } } @@ -182,19 +217,20 @@ export default class Deluge implements TorrentClient { try { let torrentInfo: TorrentInfo; const { duplicateCategories } = getRuntimeConfig(); + if (searchee.infoHash) { torrentInfo = await this.getTorrentInfo(searchee); if (!torrentInfo.complete) { - if ( - torrentInfo.save_path == "missing" && - !path && - !searchee.path - ) { - return InjectionResult.FAILURE; - } return InjectionResult.TORRENT_NOT_COMPLETE; } } + if (!path && (!searchee.infoHash || !torrentInfo)) { + logger.debug({ + label: Label.DELUGE, + message: `Injection failure: ${newTorrent.name} was missing critical data.`, + }); + return InjectionResult.FAILURE; + } const params = this.formatData( `${newTorrent.getFileSystemSafeName()}.cross-seed.torrent`, @@ -202,9 +238,11 @@ export default class Deluge implements TorrentClient { path ? path : torrentInfo.save_path, !!searchee.infoHash ); - const addResult = await this.call("core.add_torrent_file", params); - - if (addResult?.result) { + const addResult = await this.call( + "core.add_torrent_file", + params + ); + if (addResult.result) { const { dataCategory } = getRuntimeConfig(); await this.setLabel( newTorrent.infoHash, @@ -219,22 +257,36 @@ export default class Deluge implements TorrentClient { : this.delugeLabel ); return InjectionResult.SUCCESS; - } else if (addResult?.error?.message?.includes("already")) { + } else if (addResult.error.message.includes("already")) { return InjectionResult.ALREADY_EXISTS; - } else if (addResult?.error?.message) { + } else if (addResult.error.message) { logger.debug({ label: Label.DELUGE, message: `Injection failed: ${addResult.error.message}`, }); return InjectionResult.FAILURE; + } else { + logger.debug({ + label: Label.DELUGE, + message: `Unknown injection failure: ${newTorrent.name} (${newTorrent.infoHash})`, + }); + return InjectionResult.FAILURE; } } catch (injectResult) { - logger.error({ - label: Label.DELUGE, - message: `Injection failed: ${injectResult}`, - }); - logger.debug(injectResult); - return InjectionResult.FAILURE; + if (injectResult.includes("label.set_torrent")) { + logger.warning({ + label: Label.DELUGE, + message: `Labeling failure: ${newTorrent.name} (${newTorrent.infoHash})`, + }); + return InjectionResult.SUCCESS; + } else { + logger.error({ + label: Label.DELUGE, + message: `Injection failed: ${injectResult}`, + }); + logger.debug(injectResult); + return InjectionResult.FAILURE; + } } } @@ -246,7 +298,7 @@ export default class Deluge implements TorrentClient { filedump: string, path: string, isTorrent: boolean - ) { + ): InjectData { return [ filename, filedump, @@ -262,36 +314,45 @@ export default class Deluge implements TorrentClient { * returns information needed to complete/validate injection */ private async getTorrentInfo(searchee: Searchee): Promise { + if (!searchee.infoHash) { + throw new Error("Can't search a torrent without a infoHash"); + } + + let torrent: TorrentInfo; try { - let torrentLabel: string = undefined; const params = [ ["state", "progress", "save_path", "label"], { hash: searchee.infoHash }, ]; - const response = await this.call("web.update_ui", params); - if (response?.result?.torrents?.[searchee.infoHash] === undefined) { + + const response = await this.call( + "web.update_ui", + params + ); + + if (response.result.torrents) { + torrent = response.result.torrents?.[searchee.infoHash]; + } else { throw new Error( - `Torrent not found in client (${searchee.infoHash})` + "Client returned unexpected response (object missing)" ); } - if ( - this.isLabelEnabled && - response?.result?.torrents?.[searchee.infoHash]?.label - ?.length != 0 - ) { - torrentLabel = - response?.result?.torrents?.[searchee.infoHash]?.label; + if (torrent === undefined) { + throw new Error( + `Torrent not found in client (${searchee.infoHash})` + ); } const completedTorrent = - response?.result?.torrents?.[searchee.infoHash]?.state === - "Seeding" || - response?.result?.torrents?.[searchee.infoHash]?.progress === - 100; + torrent.state === "Seeding" || torrent.progress === 100; + const torrentLabel = + this.isLabelEnabled && torrent.label.length != 0 + ? torrent.label + : undefined; + return { complete: completedTorrent, - save_path: - response?.result?.torrents?.[searchee.infoHash]?.save_path, + save_path: torrent.save_path, label: torrentLabel, }; } catch (e) { @@ -300,7 +361,10 @@ export default class Deluge implements TorrentClient { message: `Failed to fetch torrent data: ${searchee.name} - (${searchee.infoHash})`, }); logger.debug(e); - return { complete: false, save_path: "missing" }; + // @ts-expect-error needs es2022 target (tsconfig) + throw new Error("web.update_ui: failed to fetch data from client", { + cause: e, + }); } } }