Skip to content

Commit

Permalink
Adds torrent injection for qBittorrent (#7)
Browse files Browse the repository at this point in the history
* WIP

* [WIP] Got a working prototype of qbit injection

* Changes after testing qbit auth

* Added tests for qbit

* Cleanup

* Added qbit to injection module

* Used client-provided content_path to determine source data location

* addressed TODOs
  • Loading branch information
moleculekayak authored Aug 5, 2024
1 parent 8a8294f commit 32159b0
Show file tree
Hide file tree
Showing 10 changed files with 341 additions and 31 deletions.
14 changes: 4 additions & 10 deletions src/clients/deluge.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import requests
from pathlib import Path

from ..filesystem import sane_join
from ..errors import TorrentClientError, TorrentClientAuthenticationError
from .torrent_client import TorrentClient
from requests.exceptions import RequestException
Expand Down Expand Up @@ -61,6 +62,7 @@ def get_torrent_info(self, infohash):
"complete": torrent_completed,
"label": torrent.get("label"),
"save_path": torrent["save_path"],
"content_path": sane_join(torrent["save_path"], torrent["name"]),
}

def inject_torrent(self, source_torrent_infohash, new_torrent_filepath, save_path_override=None):
Expand All @@ -80,7 +82,7 @@ def inject_torrent(self, source_torrent_infohash, new_torrent_filepath, save_pat
]

new_torrent_infohash = self.__wrap_request("core.add_torrent_file", params)
newtorrent_label = self.__determine_label(source_torrent_info)
newtorrent_label = self._determine_label(source_torrent_info)
self.__set_label(new_torrent_infohash, newtorrent_label)

return new_torrent_infohash
Expand All @@ -102,14 +104,6 @@ def __is_label_plugin_enabled(self):

return "Label" in response

def __determine_label(self, torrent_info):
current_label = torrent_info.get("label")

if not current_label or current_label == self.torrent_label:
return self.torrent_label

return f"{current_label}.{self.torrent_label}"

def __set_label(self, infohash, label):
if not self._label_plugin_enabled:
return
Expand Down Expand Up @@ -162,7 +156,7 @@ def __request(self, method, params=[]):
if "error" in json_response and json_response["error"]:
if json_response["error"]["code"] == self.ERROR_CODES["NO_AUTH"]:
raise TorrentClientAuthenticationError("Failed to authenticate with Deluge")
raise TorrentClientError(f"Deluge method {method} returned an error: {json_response['error']}")
raise TorrentClientError(f"Deluge method {method} returned an error: {json_response['error']['message']}")

return json_response["result"]

Expand Down
116 changes: 116 additions & 0 deletions src/clients/qbittorrent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import json
import requests
from pathlib import Path
from requests.structures import CaseInsensitiveDict

from ..filesystem import sane_join
from ..parser import get_bencoded_data, calculate_infohash
from ..errors import TorrentClientError, TorrentClientAuthenticationError
from .torrent_client import TorrentClient


class Qbittorrent(TorrentClient):
def __init__(self, qbit_url):
super().__init__()
self._qbit_url_parts = self._extract_credentials_from_url(qbit_url, "/api/v2")
self._qbit_cookie = None

def setup(self):
self.__authenticate()
return self

def get_torrent_info(self, infohash):
response = self.__wrap_request("torrents/info", data={"hashes": infohash})

if response:
parsed_response = json.loads(response)

if not parsed_response:
raise TorrentClientError(f"Torrent not found in client ({infohash})")

torrent = parsed_response[0]
torrent_completed = torrent["progress"] == 1.0 or torrent["state"] == "pausedUP" or torrent["completion_on"] > 0

return {
"complete": torrent_completed,
"label": torrent["category"],
"save_path": torrent["save_path"],
"content_path": torrent["content_path"],
}
else:
raise TorrentClientError("Client returned unexpected response")

def inject_torrent(self, source_torrent_infohash, new_torrent_filepath, save_path_override=None):
source_torrent_info = self.get_torrent_info(source_torrent_infohash)
new_torrent_infohash = calculate_infohash(get_bencoded_data(new_torrent_filepath)).lower()
new_torrent_already_exists = self.__does_torrent_exist_in_client(new_torrent_infohash)

if new_torrent_already_exists:
raise TorrentClientError(f"New torrent already exists in client ({new_torrent_infohash})")

injection_filename = f"{Path(new_torrent_filepath).stem}.fertilizer.torrent"
torrents = {"torrents": (injection_filename, open(new_torrent_filepath, "rb"), "application/x-bittorrent")}
params = {
"autoTMM": False,
"category": self._determine_label(source_torrent_info),
"tags": self.torrent_label,
"savepath": save_path_override if save_path_override else source_torrent_info["save_path"],
}

self.__wrap_request("torrents/add", data=params, files=torrents)

return new_torrent_infohash

def __authenticate(self):
href, username, password = self._qbit_url_parts

try:
if username or password:
payload = {"username": username, "password": password}
else:
payload = {}

# This method specifically does not use the __wrap_request method
# because we want to avoid an infinite loop of re-authenticating
response = requests.post(f"{href}/auth/login", data=payload)
response.raise_for_status()
except requests.RequestException as e:
raise TorrentClientAuthenticationError(f"qBittorrent login failed: {e}")

self._qbit_cookie = response.cookies.get_dict().get("SID")
if not self._qbit_cookie:
raise TorrentClientAuthenticationError("qBittorrent login failed: Invalid username or password")

def __wrap_request(self, path, data=None, files=None):
try:
return self.__request(path, data, files)
except TorrentClientAuthenticationError:
self.__authenticate()
return self.__request(path, data, files)

def __request(self, path, data=None, files=None):
href, _username, _password = self._qbit_url_parts

try:
response = requests.post(
sane_join(href, path),
headers=CaseInsensitiveDict({"Cookie": f"SID={self._qbit_cookie}"}),
data=data,
files=files,
)

response.raise_for_status()

return response.text
except requests.RequestException as e:
if e.response.status_code == 403:
print(e.response.text)
raise TorrentClientAuthenticationError("Failed to authenticate with qBittorrent")

raise TorrentClientError(f"qBittorrent request to '{path}' failed: {e}")

def __does_torrent_exist_in_client(self, infohash):
try:
return bool(self.get_torrent_info(infohash))
except TorrentClientError:
return False
31 changes: 29 additions & 2 deletions src/clients/torrent_client.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,42 @@
import os
from urllib.parse import urlparse, unquote

from src.filesystem import sane_join


class TorrentClient:
def __init__(self):
self.torrent_label = "fertilizer"

def _extract_credentials_from_url(self, url):
def setup(self):
raise NotImplementedError

def get_torrent_info(self, *_args, **_kwargs):
raise NotImplementedError

def inject_torrent(self, *_args, **_kwargs):
raise NotImplementedError

def _extract_credentials_from_url(self, url, base_path=None):
parsed_url = urlparse(url)
username = unquote(parsed_url.username) if parsed_url.username else ""
password = unquote(parsed_url.password) if parsed_url.password else ""
origin = f"{parsed_url.scheme}://{parsed_url.hostname}:{parsed_url.port}"
href = origin + (parsed_url.path if parsed_url.path != "/" else "")

if base_path is not None:
href = sane_join(origin, os.path.normpath(base_path))
else:
href = sane_join(origin, (parsed_url.path if parsed_url.path != "/" else ""))

return href, username, password

def _determine_label(self, torrent_info):
current_label = torrent_info.get("label")

if not current_label:
return self.torrent_label

if current_label == self.torrent_label or current_label.endswith(f".{self.torrent_label}"):
return current_label

return f"{current_label}.{self.torrent_label}"
4 changes: 4 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ def server_port(self) -> str:
def deluge_rpc_url(self) -> str | None:
return self.__get_key("deluge_rpc_url", must_exist=False) or None

@property
def qbittorrent_url(self) -> str | None:
return self.__get_key("qbittorrent_url", must_exist=False) or None

@property
def inject_torrents(self) -> str | bool:
return self.__get_key("inject_torrents", must_exist=False) or False
Expand Down
7 changes: 7 additions & 0 deletions src/filesystem.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import os


def sane_join(*args: str) -> str:
path_parts = [part.lstrip(os.path.sep) for part in args[1:]]
path_parts.insert(0, args[0])

return os.path.join(*path_parts)


def mkdir_p(directory_path: str) -> str:
if not os.path.exists(directory_path):
os.makedirs(directory_path)
Expand Down
17 changes: 8 additions & 9 deletions src/injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@

from .errors import TorrentInjectionError
from .clients.deluge import Deluge
from .clients.qbittorrent import Qbittorrent
from .config import Config
from .parser import calculate_infohash, get_name, get_bencoded_data
from .parser import calculate_infohash, get_bencoded_data


class Injection:
Expand All @@ -19,7 +20,7 @@ def setup(self):

def inject_torrent(self, source_torrent_filepath, new_torrent_filepath, new_tracker):
source_torrent_data = get_bencoded_data(source_torrent_filepath)
source_torrent_file_or_dir = self.__determine_torrent_data_location(source_torrent_data)
source_torrent_file_or_dir = self.__determine_source_torrent_data_location(source_torrent_data)
output_location = self.__determine_output_location(source_torrent_file_or_dir, new_tracker)
self.__link_files_to_output_location(source_torrent_file_or_dir, output_location)
output_parent_directory = os.path.dirname(os.path.normpath(output_location))
Expand All @@ -37,20 +38,20 @@ def __validate_config(self, config: Config):
if not config.injection_link_directory:
raise TorrentInjectionError("No injection link directory specified in the config file.")

# NOTE: will add more checks here as more clients get added
if not config.deluge_rpc_url:
if (not config.deluge_rpc_url) and (not config.qbittorrent_url):
raise TorrentInjectionError("No torrent client configuration specified in the config file.")

return config

def __determine_torrent_client(self, config: Config):
# NOTE: will add more conditions here as more clients get added
if config.deluge_rpc_url:
return Deluge(config.deluge_rpc_url)
elif config.qbittorrent_url:
return Qbittorrent(config.qbittorrent_url)

# If the torrent is a single bare file, this returns the path _to that file_
# If the torrent is one or many files in a directory, this returns the topmost directory path
def __determine_torrent_data_location(self, torrent_data):
def __determine_source_torrent_data_location(self, torrent_data):
# Note on torrent file structures:
# --------
# From my testing, all torrents have a `name` stored at `[b"info"][b"name"]`. This appears to always
Expand All @@ -72,9 +73,7 @@ def __determine_torrent_data_location(self, torrent_data):
# See also: https://en.wikipedia.org/wiki/Torrent_file#File_struct
infohash = calculate_infohash(torrent_data)
torrent_info_from_client = self.client.get_torrent_info(infohash)
client_save_path = torrent_info_from_client["save_path"]
torrent_name = get_name(torrent_data).decode()
proposed_torrent_data_location = os.path.join(client_save_path, torrent_name)
proposed_torrent_data_location = torrent_info_from_client["content_path"]

if os.path.exists(proposed_torrent_data_location):
return proposed_torrent_data_location
Expand Down
3 changes: 2 additions & 1 deletion tests/clients/test_deluge.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def deluge_client():
@pytest.fixture
def torrent_info_response():
return {
"name": "foo.torrent",
"name": "foo",
"state": "Seeding",
"progress": 100.0,
"save_path": "/tmp/bar/",
Expand Down Expand Up @@ -131,6 +131,7 @@ def test_returns_torrent_details(self, api_url, deluge_client, torrent_info_resp
"complete": True,
"label": "fertilizer",
"save_path": "/tmp/bar/",
"content_path": "/tmp/bar/foo",
}

def test_raises_if_no_torrents_returned(self, api_url, deluge_client):
Expand Down
Loading

0 comments on commit 32159b0

Please sign in to comment.