-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adds torrent injection for qBittorrent (#7)
* 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
1 parent
8a8294f
commit 32159b0
Showing
10 changed files
with
341 additions
and
31 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.