Skip to content

Commit

Permalink
feat(client/deluge): injection support for deluge torrent client (cro…
Browse files Browse the repository at this point in the history
…ss-seed#499)

# Deluge Client Integration

This PR adds support for injection to Deluge v2 torrent clients.

- Closes cross-seed#58 

### notes:
- requires RpcUrl uses extractCredential function
- uses JSON-RPC endpoint in deluge's webUI
- utilizes node-fetch library for sending calls
- bare function documentation
- logging and validation of config
- returns InjectionResults properly with appropriate handling

### todo:
- [x] add authentication vs connection errors (timeout/refuse/etc)
- [x] improve the label integration (it's crude atm)
- - [x] conform to current label usage behavior
- [x] remove axios (use node-fetch)
- [x] remove extra delugeWebPassword 
- - [x] get deluge's webUrl to be redacted in logging
- [x] more complete logging
- - [x] standardize logging messaging
- [x] transient auth errors handled with limited retry

---------

Co-authored-by: Michael Goodnow <[email protected]>
  • Loading branch information
zakkarry and mmgoodnow authored Nov 3, 2023
1 parent c1aa87d commit 2ec5205
Show file tree
Hide file tree
Showing 12 changed files with 335 additions and 19 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "cross-seed",
"version": "5.6.0",
"description": "Query Jackett for cross-seedable torrents",
"description": "Fully-automatic cross-seeding with Torznab",
"scripts": {
"test": "true",
"build": "tsc",
Expand Down
262 changes: 262 additions & 0 deletions src/clients/Deluge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { InjectionResult } from "../constants.js";
import { CrossSeedError } from "../errors.js";
import { Label, logger } from "../logger.js";
import { Metafile } from "../parseTorrent.js";
import { getRuntimeConfig } from "../runtimeConfig.js";
import { Searchee } from "../searchee.js";
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;
}
enum DelugeErrorCode {
NO_AUTH = 1,
BAD_METHOD = 2,
CALL_ERR = 3,
RPC_FAIL = 4,
BAD_JSON = 5,
}
interface TorrentInfo {
complete: boolean;
save_path?: string;
}

export default class Deluge implements TorrentClient {
private delugeCookie: string | null = null;
private delugeLabel = "cross-seed";
private isLabelEnabled: boolean;

/**
* validates the login and host for deluge webui
*/
async validateConfig(): Promise<void> {
await this.authenticate();
this.isLabelEnabled = await this.labelEnabled();
}

/**
* connects and authenticates to the webui
*/
private async authenticate(): Promise<void> {
const { delugeRpcUrl } = getRuntimeConfig();
const { href, password } = extractCredentialsFromUrl(
delugeRpcUrl
).unwrapOrThrow(
new CrossSeedError("delugeRpcUrl must be percent-encoded")
);
if (!password) {
throw new CrossSeedError(
"You need to define a password in the delugeRpcUrl. (e.g. http://:<PASSWORD>@localhost:8112)"
);
}
const response = await this.call("auth.login", [password], 0);
if (!response.result) {
throw new CrossSeedError(
`Reached Deluge, but failed to authenticate: ${href}`
);
}
}

/**
* ensures authentication and sends JSON-RPC calls to deluge
*/
private async call(method: string, params: object, retries = 1) {
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;
try {
response = await fetch(href, {
body: JSON.stringify({
...{ method: method, params: params },
id: Math.floor(Math.random() * 0x7fffffff),
}),
method: "POST",
headers: headers,
});
} catch (networkError) {
// @ts-expect-error needs es2022 target (tsconfig)
throw new Error(`Failed to connect to Deluge at ${href}`, {
cause: networkError,
});
}
try {
json = await response.json();
} catch (jsonParseError) {
throw new Error(
`Deluge method ${method} response was non-JSON ${jsonParseError}`
);
}
if (json?.error?.code === DelugeErrorCode.NO_AUTH && retries > 0) {
this.delugeCookie = null;
await this.authenticate();
if (this.delugeCookie) {
return this.call(method, params, 0);
} else {
throw new Error(
"Connection lost with Deluge. Reauthentication failed."
);
}
}
this.handleResponseHeaders(response.headers);
return json;
}

/**
* parses the set-cookie header and updates stored value
*/
private handleResponseHeaders(headers: Headers) {
if (headers.has("Set-Cookie")) {
this.delugeCookie = headers.get("Set-Cookie").split(";")[0];
}
}

/**
* checks enabled plugins for "Label"
* returns true if successful.
*/
private async labelEnabled() {
const enabledLabels = await this.call("core.get_enabled_plugins", []);
return enabledLabels?.result?.includes?.("Label");
}

/**
* if Label plugin is loaded, adds (if necessary)
* and sets the label based on torrent hash.
*/
private async setLabel(infoHash: string, label: string): Promise<void> {
if (this.isLabelEnabled) {
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]);
}
}
}

/**
* injects a torrent into deluge client
*/
async inject(
newTorrent: Metafile,
searchee: Searchee,
path?: string
): Promise<InjectionResult> {
try {
let torrentInfo: TorrentInfo;
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;
}
}

const params = this.formatData(
`${newTorrent.name}.cross-seed.torrent`,
newTorrent.encode().toString("base64"),
path ? path : torrentInfo.save_path,
!!searchee.infoHash
);
const addResult = await this.call("core.add_torrent_file", params);

if (addResult?.result) {
const { dataCategory } = getRuntimeConfig();
await this.setLabel(
newTorrent.infoHash,
searchee.path ? dataCategory : this.delugeLabel
);
return InjectionResult.SUCCESS;
} else if (addResult?.error?.message?.includes("already")) {
return InjectionResult.ALREADY_EXISTS;
} else if (addResult?.error?.message) {
logger.debug({
label: Label.DELUGE,
message: `Injection failed: ${addResult.error.message}`,
});
return InjectionResult.FAILURE;
}
} catch (injectResult) {
logger.error({
label: Label.DELUGE,
message: `Injection failed: ${injectResult}`,
});
logger.debug(injectResult);
return InjectionResult.FAILURE;
}
}

/**
* formats the json for rpc calls to inject
*/
private formatData(
filename: string,
filedump: string,
path: string,
isTorrent: boolean
) {
return [
filename,
filedump,
{
add_paused: isTorrent ? false : !getRuntimeConfig().skipRecheck,
seed_mode: isTorrent ? true : getRuntimeConfig().skipRecheck,
download_location: path,
},
];
}

/**
* returns information needed to complete/validate injection
*/
private async getTorrentInfo(searchee: Searchee): Promise<TorrentInfo> {
try {
const params = [
["state", "progress", "save_path"],
{ hash: searchee.infoHash },
];

const response = await this.call("web.update_ui", params);
if (response?.result?.torrents?.[searchee.infoHash] === 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;
return {
complete: completedTorrent,
save_path:
response?.result?.torrents?.[searchee.infoHash]?.save_path,
};
} catch (e) {
logger.error({
label: Label.DELUGE,
message: `Failed to fetch torrent data: ${searchee.name} - (${searchee.infoHash})`,
});
logger.debug(e);
return { complete: false, save_path: "missing" };
}
}
}
5 changes: 4 additions & 1 deletion src/clients/TorrentClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Searchee } from "../searchee.js";
import QBittorrent from "./QBittorrent.js";
import RTorrent from "./RTorrent.js";
import Transmission from "./Transmission.js";
import Deluge from "./Deluge.js";

let activeClient: TorrentClient;

Expand All @@ -18,14 +19,16 @@ export interface TorrentClient {
}

function instantiateDownloadClient() {
const { rtorrentRpcUrl, qbittorrentUrl, transmissionRpcUrl } =
const { rtorrentRpcUrl, qbittorrentUrl, transmissionRpcUrl, delugeRpcUrl } =
getRuntimeConfig();
if (rtorrentRpcUrl) {
activeClient = new RTorrent();
} else if (qbittorrentUrl) {
activeClient = new QBittorrent();
} else if (transmissionRpcUrl) {
activeClient = new Transmission();
} else if (delugeRpcUrl) {
activeClient = new Deluge();
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ function createCommandWithSharedOptions(name, description) {
"The url of your Transmission RPC interface. Requires '-A inject'. See the docs for more information.",
fileConfig.transmissionRpcUrl
)
.option(
"--deluge-rpc-url <url>",
"The url of your Deluge JSON-RPC interface. Requires '-A inject'. See the docs for more information.",
fileConfig.delugeRpcUrl
)
.option(
"--duplicate-categories",
"Create and inject using categories with the same save paths as your normal categories",
Expand Down
30 changes: 24 additions & 6 deletions src/config.template.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,20 @@ module.exports = {
* List of Torznab URLs.
* For Jackett, click "Copy RSS feed"
* For Prowlarr, click on the indexer name and copy the Torznab Url, then append "?apikey=YOUR_PROWLARR_API_KEY"
* Wrap each URL in quotation marks, and separate them with commas.
* Wrap each URL in quotation marks, and separate them with commas, and surround the entire set in brackets.
*/
torznab: [],

/**
* To search with downloaded data, you can pass in directories to your downloaded torrent data
* to find matches rather using the torrent files themselves for matching.
* To search with downloaded data, you can pass in directories to your downloaded torrent
* data to find matches rather using the torrent files themselves for matching.
*
* If enabled, this needs to be surrounded by brackets. Windows users will need to use
* double backslash in all paths in this config.
* e.g.
* dataDirs: ["/path/here"],
* dataDirs: ["/path/here", "/other/path/here"],
* dataDirs: ["C:\\My Data\\Downloads"]
*/
dataDirs: undefined,

Expand Down Expand Up @@ -63,10 +70,12 @@ module.exports = {
maxDataDepth: 2,

/**
* Directory containing torrent files.
* Directory containing .torrent files.
* For qBittorrent, this is BT_Backup
* For rtorrent, this is your session directory
* as configured in your .rtorrent.rc file.
* For deluge, this is ~/.config/deluge/state.
* as configured in your .rtorrent.rc file.
* For Deluge, this is ~/.config/deluge/state.
* For Transmission, this would be ~/.config/transmission/torrents
*/
torrentDir: "/path/to/torrent/file/dir",

Expand Down Expand Up @@ -153,6 +162,15 @@ module.exports = {
*/
transmissionRpcUrl: undefined,

/**
* The url of your Deluge JSON-RPC interface.
* Usually ends with "/json".
* Only relevant with action: "inject".
* Supply your WebUI password as well
* "http://:password@localhost:8112/json"
*/
delugeRpcUrl: undefined,

/**
* qBittorrent-specific
* Whether to inject using categories with the same save paths as your normal categories.
Expand Down
Loading

0 comments on commit 2ec5205

Please sign in to comment.