Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

qBittorrent client maintenance #7474

Merged
merged 9 commits into from
Dec 12, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#### New Features

#### Improvements
- Improved qBittorrent client ([#7474](https://github.com/pymedusa/Medusa/pull/7474))

#### Fixes
- Fixed season pack downloads occurring even if not needed ([#7472](https://github.com/pymedusa/Medusa/pull/7472))
Expand Down Expand Up @@ -70,7 +71,7 @@
- Converted the footer to a Vue component ([#4520](https://github.com/pymedusa/Medusa/pull/4520))
- Converted Edit Show to a Vue SFC ([#4486](https://github.com/pymedusa/Medusa/pull/4486)
- Improved API v2 exception reporting on Python 2 ([#6931](https://github.com/pymedusa/Medusa/pull/6931))
- Added support for qbittorrent api v2. Required from qbittorrent version > 3.2.0. ([#7040](https://github.com/pymedusa/Medusa/pull/7040))
- Added support for qBittorrent API v2. Required from qBittorrent version 4.2.0. ([#7040](https://github.com/pymedusa/Medusa/pull/7040))
- Removed the forced search queue item in favor of the backlog search queue item. ([#6718](https://github.com/pymedusa/Medusa/pull/6718))
- Show Header: Improved visibility of local and global configured required and ignored words. ([#7085](https://github.com/pymedusa/Medusa/pull/7085))
- Reduced frequency of file system access when not strictly required ([#7102](https://github.com/pymedusa/Medusa/pull/7102))
Expand Down
143 changes: 86 additions & 57 deletions medusa/clients/torrent/qbittorrent.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@
from medusa.logger.adapters.style import BraceAdapter

from requests.auth import HTTPDigestAuth
from requests.compat import urljoin

log = BraceAdapter(logging.getLogger(__name__))
log.logger.addHandler(logging.NullHandler())


class APIUnavailableError(Exception):
"""Raised when the API version is not available."""


class QBittorrentAPI(GenericClient):
"""qBittorrent API class."""

Expand All @@ -29,47 +34,54 @@ def __init__(self, host=None, username=None, password=None):
:param password:
:type password: string
"""
super(QBittorrentAPI, self).__init__('qbittorrent', host, username, password)
super(QBittorrentAPI, self).__init__('qBittorrent', host, username, password)
self.url = self.host
# Auth for API v1.0.0 (qBittorrent v3.1.x and older)
self.session.auth = HTTPDigestAuth(self.username, self.password)

@property
def api(self):
"""Get API version."""
# Update the auth method to v2
self._get_auth = self._get_auth_v2
# Attempt to get API v2 version first
self.url = '{host}api/v2/app/webapiVersion'.format(host=self.host)
try:
version = self.session.get(self.url, verify=app.TORRENT_VERIFY_CERT,
cookies=self.session.cookies)
# Make sure version is using the (major, minor, release) format
version = list(map(int, version.text.split('.')))
if len(version) < 2:
version.append(0)
return tuple(version)
except (AttributeError, ValueError) as error:
log.error('{name}: Unable to get API version. Error: {error!r}',
{'name': self.name, 'error': error})

# Fall back to API v1
self._get_auth = self._get_auth_legacy
try:
self.url = '{host}version/api'.format(host=self.host)
version = int(self.session.get(self.url, verify=app.TORRENT_VERIFY_CERT).content)
# Convert old API versioning to new versioning (major, minor, release)
version = (1, version % 100, 0)
except Exception:
version = (1, 0, 0)
return version
self.api = None

def _get_auth(self):
"""Select between api v2 and legacy."""
return self._get_auth_v2() or self._get_auth_legacy()
"""Authenticate with the client using the most recent API version available for use."""
try:
auth = self._get_auth_v2()
version = 2
except APIUnavailableError:
auth = self._get_auth_legacy()
version = 1

# Authentication failed /or/ We already have the API version
if not auth or self.api:
return auth

# Get API version
if version == 2:
self.url = urljoin(self.host, 'api/v2/app/webapiVersion')
try:
response = self.session.get(self.url, verify=app.TORRENT_VERIFY_CERT)
if not response.text:
raise ValueError('Response from client is empty. [Status: {0}]'.format(response.status_code))
# Make sure version is using the (major, minor, release) format
version = tuple(map(int, response.text.split('.')))
# Fill up with zeros to get the correct format. e.g: (2, 3) => (2, 3, 0)
self.api = version + (0,) * (3 - len(version))
except (AttributeError, ValueError) as error:
log.error('{name}: Unable to get API version. Error: {error!r}',
{'name': self.name, 'error': error})
elif version == 1:
try:
self.url = urljoin(self.host, 'version/api')
version = int(self.session.get(self.url, verify=app.TORRENT_VERIFY_CERT).text)
# Convert old API versioning to new versioning (major, minor, release)
self.api = (1, version % 100, 0)
except Exception:
self.api = (1, 0, 0)

return auth

def _get_auth_v2(self):
"""Authenticate using the new method (API v2)."""
self.url = '{host}api/v2/auth/login'.format(host=self.host)
"""Authenticate using API v2."""
self.url = urljoin(self.host, 'api/v2/auth/login')
data = {
'username': self.username,
'password': self.password,
Expand All @@ -79,17 +91,31 @@ def _get_auth_v2(self):
except Exception:
return None

if self.response.status_code == 404:
return None
if self.response.status_code == 200:
if self.response.text == 'Fails.':
log.warning('{name}: Invalid Username or Password, check your config',
{'name': self.name})
return None

self.session.cookies = self.response.cookies
self.auth = self.response.content
# Successful log in
self.session.cookies = self.response.cookies
self.auth = self.response.text

return self.auth
return self.auth

if self.response.status_code == 404:
# API v2 is not available
raise APIUnavailableError()

if self.response.status_code == 403:
log.warning('{name}: Your IP address has been banned after too many failed authentication attempts.'
' Restart {name} to unban.',
{'name': self.name})
return None

def _get_auth_legacy(self):
"""Authenticate using the legacy method (API v1)."""
self.url = '{host}login'.format(host=self.host)
"""Authenticate using legacy API."""
self.url = urljoin(self.host, 'login')
data = {
'username': self.username,
'password': self.password,
Expand All @@ -99,22 +125,22 @@ def _get_auth_legacy(self):
except Exception:
return None

# Pre-API v1
# API v1.0.0 (qBittorrent v3.1.x and older)
if self.response.status_code == 404:
try:
self.response = self.session.get(self.host, verify=app.TORRENT_VERIFY_CERT)
except Exception:
return None

self.session.cookies = self.response.cookies
self.auth = self.response.content
self.auth = (self.response.status_code != 404) or None

return self.auth if not self.response.status_code == 404 else None
return self.auth

def _add_torrent_uri(self, result):

command = 'api/v2/torrents/add' if self.api >= (2, 0, 0) else 'command/download'
self.url = '{host}{command}'.format(host=self.host, command=command)
self.url = urljoin(self.host, command)
data = {
'urls': result.url,
}
Expand All @@ -123,7 +149,7 @@ def _add_torrent_uri(self, result):
def _add_torrent_file(self, result):

command = 'api/v2/torrents/add' if self.api >= (2, 0, 0) else 'command/upload'
self.url = '{host}{command}'.format(host=self.host, command=command)
self.url = urljoin(self.host, command)
files = {
'torrents': (
'{result}.torrent'.format(result=result.name),
Expand All @@ -140,27 +166,30 @@ def _set_torrent_label(self, result):

api = self.api
if api >= (2, 0, 0):
self.url = '{host}api/v2/torrents/setCategory'.format(host=self.host)
self.url = urljoin(self.host, 'api/v2/torrents/setCategory')
label_key = 'category'
elif api > (1, 6, 0):
label_key = 'Category' if api >= (1, 10, 0) else 'Label'
self.url = '{host}command/set{key}'.format(
host=self.host,
key=label_key,
)
self.url = urljoin(self.host, 'command/set' + label_key)

data = {
'hashes': result.hash.lower(),
label_key.lower(): label.replace(' ', '_'),
}
return self._request(method='post', data=data, cookies=self.session.cookies)
ok = self._request(method='post', data=data, cookies=self.session.cookies)

if self.response.status_code == 409:
log.warning('{name}: Unable to set torrent label. You need to create the label '
' in {name} first.', {'name': self.name})
ok = False

return ok

def _set_torrent_priority(self, result):

command = 'api/v2/torrents' if self.api >= (2, 0, 0) else 'command'
method = 'increase' if result.priority == 1 else 'decrease'
self.url = '{host}{command}/{method}Prio'.format(
host=self.host, command=command, method=method)
self.url = urljoin(self.host, '{command}/{method}Prio'.format(command=command, method=method))
data = {
'hashes': result.hash.lower(),
}
Expand All @@ -178,7 +207,7 @@ def _set_torrent_pause(self, result):
state = 'pause' if app.TORRENT_PAUSED else 'resume'
command = 'api/v2/torrents' if api >= (2, 0, 0) else 'command'
hashes_key = 'hashes' if self.api >= (1, 18, 0) else 'hash'
self.url = '{host}{command}/{state}'.format(host=self.host, command=command, state=state)
self.url = urljoin(self.host, '{command}/{state}'.format(command=command, state=state))
data = {
hashes_key: result.hash.lower(),
}
Expand All @@ -196,10 +225,10 @@ def remove_torrent(self, info_hash):
'hashes': info_hash.lower(),
}
if self.api >= (2, 0, 0):
self.url = '{host}api/v2/torrents/delete'.format(host=self.host)
self.url = urljoin(self.host, 'api/v2/torrents/delete')
data['deleteFiles'] = True
else:
self.url = '{host}command/deletePerm'.format(host=self.host)
self.url = urljoin(self.host, 'command/deletePerm')

return self._request(method='post', data=data, cookies=self.session.cookies)

Expand Down