Skip to content
Open
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
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM debian:buster-slim AS download
FROM debian:bookworm-slim AS download

RUN apt update && apt install -y wget
WORKDIR /deb
Expand All @@ -20,7 +20,7 @@ FROM scratch AS mount
COPY . /app

FROM python:3.9.11-slim-bullseye
MAINTAINER Alexander Puzynia <werwolf.by@gmail.com>
LABEL maintainer="Alexander Puzynia <werwolf.by@gmail.com>"

# For docker layers caching it is better to install Playwight first with all dependencies
COPY --from=download /deb /deb
Expand Down
156 changes: 65 additions & 91 deletions monitorrent/plugins/clients/qbittorrent.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
from __future__ import absolute_import, division, print_function, unicode_literals

import six
import time
from datetime import datetime

import six
from pytz import utc
from sqlalchemy import Column, Integer, String

from qbittorrentapi import Client
from sqlalchemy import Column, Integer, String

from monitorrent.db import Base, DBSession
from monitorrent.plugin_managers import register_plugin
from monitorrent.utils.bittorrent_ex import Torrent
from datetime import datetime


class QBittorrentCredentials(Base):
Expand All @@ -29,95 +25,88 @@ class QBittorrentCredentials(Base):

class QBittorrentClientPlugin(object):
name = "qbittorrent"
form = [{
'type': 'row',
'content': [{
'type': 'text',
'label': 'Host',
'model': 'host',
'flex': 80
}, {
'type': 'text',
'label': 'Port',
'model': 'port',
'flex': 20
}]
}, {
'type': 'row',
'content': [{
'type': 'text',
'label': 'Username',
'model': 'username',
'flex': 50
}, {
'type': 'password',
'label': 'Password',
'model': 'password',
'flex': 50
}]
}]
form = [
{
"type": "row",
"content": [
{"type": "text", "label": "Host", "model": "host", "flex": 80},
{"type": "text", "label": "Port", "model": "port", "flex": 20},
],
},
{
"type": "row",
"content": [
{"type": "text", "label": "Username", "model": "username", "flex": 50},
{
"type": "password",
"label": "Password",
"model": "password",
"flex": 50,
},
],
},
]
DEFAULT_PORT = 8080
SUPPORTED_FIELDS = ['download_dir']
SUPPORTED_FIELDS = ["download_dir"]
ADDRESS_FORMAT = "{0}:{1}"
_client = None

def __init__(self):
self._client = None

def get_client(self):
if not self._client:
self._client = self._get_client()
self._client = self._create_client()
return self._client

def _get_client(self):
def _create_client(self):
with DBSession() as db:
cred = db.query(QBittorrentCredentials).first()

if not cred:
return False

if not cred.port:
cred.port = self.DEFAULT_PORT
return None

address = self.ADDRESS_FORMAT.format(cred.host, cred.port)
port = cred.port or self.DEFAULT_PORT
address = self.ADDRESS_FORMAT.format(cred.host, port)

client = Client(host=address, username=cred.username, password=cred.password)
client.app_version()
return QBittorrentClientPlugin._decorate_post(client)
return Client(host=address, username=cred.username, password=cred.password)

def get_settings(self):
with DBSession() as db:
cred = db.query(QBittorrentCredentials).first()
if not cred:
return None
return {'host': cred.host, 'port': cred.port, 'username': cred.username}
return {"host": cred.host, "port": cred.port, "username": cred.username}

def set_settings(self, settings):
with DBSession() as db:
cred = db.query(QBittorrentCredentials).first()
if not cred:
cred = QBittorrentCredentials()
db.add(cred)
cred.host = settings['host']
cred.port = settings.get('port', None)
cred.username = settings.get('username', None)
cred.password = settings.get('password', None)
cred.host = settings["host"]
cred.port = settings.get("port", None)
cred.username = settings.get("username", None)
cred.password = settings.get("password", None)

def check_connection(self):
client = self.get_client()
client.app_version()
return True
if not client:
return False
try:
client.app_version()
return True
except Exception:
return False

def find_torrent(self, torrent_hash):
client = self.get_client()
if not client:
return False

torrents = client.torrents_info(hashes=[torrent_hash.lower()])
torrents = client.torrents_info(torrent_hashes=torrent_hash.lower())
if torrents:
time = torrents[0].info.added_on
result_date = datetime.fromtimestamp(time, utc)
return {
"name": torrents[0].name,
"date_added": result_date
}
torrent = torrents[0]
result_date = datetime.fromtimestamp(torrent.added_on, utc)
return {"name": torrent.name, "date_added": result_date}
return False

def get_download_dir(self):
Expand All @@ -129,51 +118,36 @@ def get_download_dir(self):
return six.text_type(result)

def add_torrent(self, torrent_content, torrent_settings):
"""
:type torrent_settings: clients.TopicSettings | None
"""
client = self.get_client()
if not client:
return False

savepath = None
auto_tmm = None
if torrent_settings is not None and torrent_settings.download_dir is not None:
savepath = torrent_settings.download_dir
auto_tmm = False
kwargs = {}
if torrent_settings and torrent_settings.download_dir:
kwargs["save_path"] = torrent_settings.download_dir
kwargs["use_auto_torrent_management"] = False

res = client.torrents_add(save_path=savepath, use_auto_torrent_management=auto_tmm, torrent_contents=[('file.torrent', torrent_content)])
if 'Ok' in res:
result = client.torrents_add(torrent_files=torrent_content, **kwargs)

if result == "Ok.":
torrent = Torrent(torrent_content)
torrent_hash = torrent.info_hash
for i in range(0, 10):
found = self.find_torrent(torrent_hash)
if found:

for _ in range(10):
if self.find_torrent(torrent_hash):
return True
time.sleep(1)
return True

return res
return False

def remove_torrent(self, torrent_hash):
client = self.get_client()
if not client:
return False

client.torrents_delete(hashes=[torrent_hash.lower()])
client.torrents_delete(delete_files=False, torrent_hashes=torrent_hash.lower())
return True

@staticmethod
def _decorate_post(client):
def _post_decorator(func):
def _post_wrapper(*args, **kwargs):
if 'torrent_contents' in kwargs:
kwargs['files'] = kwargs['torrent_contents']
del kwargs['torrent_contents']
return func(*args, **kwargs)
return _post_wrapper

client._post = _post_decorator(client._post)
return client


register_plugin('client', 'qbittorrent', QBittorrentClientPlugin())
register_plugin("client", "qbittorrent", QBittorrentClientPlugin())
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ SQLAlchemy-Enum34==1.0.1
transmissionrpc==0.11
beautifulsoup4==4.7.1
deluge-client==1.7.0
qbittorrent-api==2023.3.44
qbittorrent-api==2025.7.0
feedparser==6.0.8
alembic==1.0.8
falcon==1.4.1
Expand Down
31 changes: 6 additions & 25 deletions tests/plugins/clients/test_qbittorrent.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,14 @@ def test_find_torrent(self, qbittorrent_client):
settings = self.DEFAULT_SETTINGS
plugin.set_settings(settings)

torrent_info = new('torrent', {'name': 'Torrent 1', 'info': new('info', {'added_on': date_added.astimezone(pytz.utc).timestamp()})})
torrent_info = new('torrent', {'name': 'Torrent 1', 'added_on': date_added.astimezone(pytz.utc).timestamp()})
client.torrents_info.return_value = [torrent_info]

torrent = plugin.find_torrent(torrent_hash)

self.assertEqual({'name': 'Torrent 1', 'date_added': date_added.astimezone(pytz.utc)}, torrent)

client.torrents_info.assert_called_once_with(hashes=[torrent_hash.lower()])
client.torrents_info.assert_called_once_with(torrent_hashes=torrent_hash.lower())

@patch('monitorrent.plugins.clients.qbittorrent.Client')
def test_find_torrent_failed(self, qbittorrent_client):
Expand All @@ -91,7 +91,7 @@ def test_find_torrent_failed(self, qbittorrent_client):
@patch('monitorrent.plugins.clients.qbittorrent.Client')
def test_find_torrent_no_settings(self, qbittorrent_client):
client = qbittorrent_client.return_value

torrent_hash = "8347DD6415598A7409DFC3D1AB95078F959BFB93"
plugin = QBittorrentClientPlugin()

Expand All @@ -112,9 +112,7 @@ def test_add_torrent_success(self, qbittorrent_client):
torrent_info = [
new('torrent', {
'name': 'Hell.On.Wheels.S05E02.720p.WEB.rus.LostFilm.TV.mp4',
'info': new('info', {
"added_on": 1616424630
})
"added_on": 1616424630
})
]
client.torrents_info.return_value = torrent_info
Expand All @@ -133,9 +131,7 @@ def test_add_torrent_with_settings_success(self, qbittorrent_client):
torrent_info = [
new('torrent', {
'name': 'Hell.On.Wheels.S05E02.720p.WEB.rus.LostFilm.TV.mp4',
'info': new('info', {
"added_on": 1616424630
})
"added_on": 1616424630
})
]
client.torrents_info.return_value = torrent_info
Expand Down Expand Up @@ -164,7 +160,7 @@ def test_remove_torrent_success(self, qbittorrent_client):
torrent = b'torrent'
self.assertTrue(plugin.remove_torrent(torrent))

client.torrents_delete.assert_called_once_with(hashes=[torrent.lower()])
client.torrents_delete.assert_called_once_with(delete_files=False, torrent_hashes=torrent.lower())

@patch('monitorrent.plugins.clients.qbittorrent.Client')
def test_get_download_dir_success(self, qbittorrent_client):
Expand All @@ -181,18 +177,3 @@ def test_get_download_dir_success(self, qbittorrent_client):
assert plugin.get_download_dir() == u'/mnt/media/downloads'

client.app_default_save_path.assert_called_once()

def test_decorate_post_method(self):
client = QBittorrentPluginTest.ClassWithPostMethod()
client = QBittorrentClientPlugin._decorate_post(client)

(args, kwargs) = client._post(torrent_contents=[('file.torrent', b'torrent')])

assert len(args) == 0
assert len(kwargs) == 1
assert 'files' in kwargs
assert kwargs['files'] == [('file.torrent', b'torrent')]

class ClassWithPostMethod(object):
def _post(self, *args, **kwargs):
return (args, kwargs)