diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 5dc6ab58..39a962ed 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -312,6 +312,7 @@ Transmission uTorrent (Beta) Deluge (Beta) + QBitTorrent
@@ -386,6 +387,26 @@
+
+ Note: Works with WebAPI Rev 11 and later (QBitTorrent 3.4.0 and later) +
+ + + usually http://localhost:8081 +
+
+ + +
+
+ + +
+
+ + +
+
@@ -2275,26 +2296,30 @@ if ($("#torrent_downloader_blackhole").is(":checked")) { - $("#transmission_options,#utorrent_options,#deluge_options").hide(); + $("#transmission_options,#utorrent_options,#deluge_options,#qbittorrent_options").hide(); $("#torrent_blackhole_options").show(); } if ($("#torrent_downloader_transmission").is(":checked")) { - $("#torrent_blackhole_options,#utorrent_options,#deluge_options").hide(); + $("#torrent_blackhole_options,#utorrent_options,#deluge_options,#qbittorrent_options").hide(); $("#transmission_options").show(); } if ($("#torrent_downloader_utorrent").is(":checked")) { - $("#torrent_blackhole_options,#transmission_options,#deluge_options").hide(); + $("#torrent_blackhole_options,#transmission_options,#deluge_options,#qbittorrent_options").hide(); $("#utorrent_options").show(); } + if ($("#torrent_downloader_qbittorrent").is(":checked")) + { + $("#torrent_blackhole_options,#transmission_options,#utorrent_options,#deluge_options").hide(); + $("#qbittorrent_options").show(); + } if ($("#torrent_downloader_deluge").is(":checked")) { - $("#torrent_blackhole_options,#transmission_options,#utorrent_options").hide(); + $("#torrent_blackhole_options,#transmission_options,#utorrent_options,#qbittorent_options").hide(); $("#deluge_options").show(); } - $('input[type=radio]').change(function(){ if ($("#preferred_bitrate").is(":checked")) { @@ -2330,19 +2355,23 @@ } if ($("#torrent_downloader_blackhole").is(":checked")) { - $("#transmission_options,#utorrent_options,#deluge_options").fadeOut("fast", function() { $("#torrent_blackhole_options").fadeIn() }); + $("#transmission_options,#utorrent_options,#deluge_options,#qbittorrent_options").fadeOut("fast", function() { $("#torrent_blackhole_options").fadeIn() }); } if ($("#torrent_downloader_transmission").is(":checked")) { - $("#torrent_blackhole_options,#utorrent_options,#deluge_options").fadeOut("fast", function() { $("#transmission_options").fadeIn() }); + $("#torrent_blackhole_options,#utorrent_options,#deluge_options,#qbittorrent_options").fadeOut("fast", function() { $("#transmission_options").fadeIn() }); } if ($("#torrent_downloader_utorrent").is(":checked")) { - $("#torrent_blackhole_options,#transmission_options,#deluge_options").fadeOut("fast", function() { $("#utorrent_options").fadeIn() }); + $("#torrent_blackhole_options,#transmission_options,#deluge_options,#qbittorrent_options").fadeOut("fast", function() { $("#utorrent_options").fadeIn() }); } + if ($("#torrent_downloader_qbittorrent").is(":checked")) + { + $("#torrent_blackhole_options,#transmission_options,#utorrent_options,#deluge_options").fadeOut("fast", function() { $("#qbittorrent_options").fadeIn() }); + } if ($("#torrent_downloader_deluge").is(":checked")) { - $("#torrent_blackhole_options,#utorrent_options,#transmission_options").fadeOut("fast", function() { $("#deluge_options").fadeIn() }); + $("#torrent_blackhole_options,#utorrent_options,#transmission_options,#qbittorrent_options").fadeOut("fast", function() { $("#deluge_options").fadeIn() }); } }); diff --git a/headphones/config.py b/headphones/config.py index 41f50ca9..4b37c2a1 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -232,6 +232,10 @@ _CONFIG_DEFINITIONS = { 'PUSHOVER_KEYS': (str, 'Pushover', ''), 'PUSHOVER_ONSNATCH': (int, 'Pushover', 0), 'PUSHOVER_PRIORITY': (int, 'Pushover', 0), + 'QBITTORRENT_HOST': (str, 'QBitTorrent', ''), + 'QBITTORRENT_LABEL': (str, 'QBitTorrent', ''), + 'QBITTORRENT_PASSWORD': (str, 'QBitTorrent', ''), + 'QBITTORRENT_USERNAME': (str, 'QBitTorrent', ''), 'RENAME_FILES': (int, 'General', 0), 'RENAME_UNPROCESSED': (bool_int, 'General', 1), 'RENAME_FROZEN': (bool_int, 'General', 1), diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index 2ddd247c..84116abc 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -26,7 +26,7 @@ from beets import autotag from beets import config as beetsconfig from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError from beetsplug import lyrics as beetslyrics -from headphones import notifiers, utorrent, transmission, deluge +from headphones import notifiers, utorrent, transmission, deluge, qbittorrent from headphones import db, albumart, librarysync from headphones import logger, helpers, request, mb, music_encoder from headphones import metadata @@ -452,7 +452,7 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, [albumid]) # Check if torrent has finished seeding - if headphones.CONFIG.TORRENT_DOWNLOADER == 1 or headphones.CONFIG.TORRENT_DOWNLOADER == 2: + if headphones.CONFIG.TORRENT_DOWNLOADER != 0: seed_snatched = myDB.action( 'SELECT * from snatched WHERE Status="Seed_Snatched" and AlbumID=?', [albumid]).fetchone() @@ -465,8 +465,10 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, torrent_removed = transmission.removeTorrent(hash, True) elif headphones.CONFIG.TORRENT_DOWNLOADER == 3: # Deluge torrent_removed = deluge.removeTorrent(hash, True) - else: + elif headphones.CONFIG.TORRENT_DOWNLOADER == 2: torrent_removed = utorrent.removeTorrent(hash, True) + else: + torrent_removed = qbittorrent.removeTorrent(hash, True) # Torrent removed, delete the snatched record, else update Status for scheduled job to check if torrent_removed: diff --git a/headphones/qbittorrent.py b/headphones/qbittorrent.py new file mode 100644 index 00000000..6fee8d71 --- /dev/null +++ b/headphones/qbittorrent.py @@ -0,0 +1,286 @@ +# This file is part of Headphones. +# +# Headphones is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Headphones is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Headphones. If not, see . + +import urllib +import urllib2 +import cookielib +import json +import os +import time +import mimetypes +import random +import string + +import headphones + +from headphones import logger +from collections import namedtuple + + +class qbittorrentclient(object): + + TOKEN_REGEX = "" + UTSetting = namedtuple("UTSetting", ["name", "int", "str", "access"]) + + def __init__(self, base_url=None, username=None, password=None,): + + host = headphones.CONFIG.QBITTORRENT_HOST + if not host.startswith('http'): + host = 'http://' + host + + if host.endswith('/'): + host = host[:-1] + + if host.endswith('/gui'): + host = host[:-4] + + self.base_url = host + self.username = headphones.CONFIG.QBITTORRENT_USERNAME + self.password = headphones.CONFIG.QBITTORRENT_PASSWORD + self.cookiejar = cookielib.CookieJar() + self.opener = self._make_opener() + self._get_sid(self.base_url, self.username, self.password) + + def _make_opener(self): + # create opener with cookie handler to carry QBitTorrent SID cookie + cookie_handler = urllib2.HTTPCookieProcessor(self.cookiejar) + handlers = [cookie_handler] + return urllib2.build_opener(*handlers) + + def _get_sid(self, base_url, username, password): + # login so we can capture SID cookie + login_data = urllib.urlencode({'username': username, 'password': password}) + try: + self.opener.open(base_url + '/login', login_data) + except urllib2.URLError as err: + logger.debug('Error getting SID. qBittorrent responded with error: ' + str(err.reason)) + return + for cookie in self.cookiejar: + logger.debug('login cookie: ' + cookie.name + ', value: ' + cookie.value) + return + + def _command(self, command, args=None, content_type=None, files=None): + logger.debug('QBittorrent WebAPI Command: %s' % command) + + url = self.base_url + '/' + command + + data = None + headers = dict() + if content_type == 'multipart/form-data': + data, headers = encode_multipart(args, files) + else: + if args: + data = urllib.urlencode(args) + if content_type: + headers['Content-Type'] = content_type + + logger.debug('%s' % json.dumps(headers, indent=4)) + logger.debug('%s' % data) + + request = urllib2.Request(url, data, headers) + try: + response = self.opener.open(request) + info = response.info() + if info: + if info.getheader('content-type'): + if info.getheader('content-type') == 'application/json': + resp = '' + for line in response: + resp = resp + line + logger.debug('response code: %s' % str(response.code)) + logger.debug('response: %s' % resp) + return response.code, json.loads(resp) + logger.debug('response code: %s' % str(response.code)) + return response.code, None + except urllib2.URLError as err: + logger.debug('Failed URL: %s' % url) + logger.debug('QBitTorrent webUI raised the following error: %s' % str(err)) + return None, None + + def _get_list(self, **args): + return self._command('query/torrents', args) + + def _get_settings(self): + status, value = self._command('query/preferences') + logger.debug('get_settings() returned %d items' % len(value)) + return value + + def get_savepath(self, hash): + logger.debug('qb.get_savepath(%s)' % hash) + status, torrentList = self._get_list() + for torrent in torrentList: + if torrent['hash']: + if torrent['hash'].upper() == hash.upper(): + return torrent['save_path'] + return None + + def start(self, hash): + logger.debug('qb.start(%s)' % hash) + args = {'hash': hash} + return self._command('command/resume', args, 'application/x-www-form-urlencoded') + + def pause(self, hash): + logger.debug('qb.pause(%s)' % hash) + args = {'hash': hash} + return self._command('command/pause', args, 'application/x-www-form-urlencoded') + + def getfiles(self, hash): + logger.debug('qb.getfiles(%s)' % hash) + return self._command('query/propertiesFiles/' + hash) + + def getprops(self, hash): + logger.debug('qb.getprops(%s)' % hash) + return self._command('query/propertiesGeneral/' + hash) + + def setprio(self, hash, priority): + logger.debug('qb.setprio(%s,%d)' % (hash, priority)) + args = {'hash': hash, 'priority': priority} + return self._command('command/setFilePrio', args, 'application/x-www-form-urlencoded') + + def remove(self, hash, remove_data=False): + logger.debug('qb.remove(%s,%s)' % (hash, remove_data)) + + args = {'hashes': hash} + if remove_data: + command = 'command/deletePerm' + else: + command = 'command/delete' + return self._command(command, args, 'application/x-www-form-urlencoded') + + +def removeTorrent(hash, remove_data=False): + + logger.debug('removeTorrent(%s,%s)' % (hash, remove_data)) + + qbclient = qbittorrentclient() + status, torrentList = qbclient._get_list() + for torrent in torrentList: + if torrent['hash'].upper() == hash.upper(): + if torrent['state'] == 'uploading' or torrent['state'] == 'stalledUP': + logger.info('%s has finished seeding, removing torrent and data' % torrent['name']) + qbclient.remove(hash, remove_data) + return True + else: + logger.info('%s has not finished seeding yet, torrent will not be removed, will try again on next run' % torrent['name']) + return False + return False + + +def addTorrent(link): + logger.debug('addTorrent(%s)' % link) + + qbclient = qbittorrentclient() + args = {'urls': link, 'savepath': headphones.CONFIG.DOWNLOAD_TORRENT_DIR} + if headphones.CONFIG.QBITTORRENT_LABEL: + args['category'] = headphones.CONFIG.QBITTORRENT_LABEL + + return qbclient._command('command/download', args, 'multipart/form-data') + + +def addFile(data): + logger.debug('addFile(data)') + + qbclient = qbittorrentclient() + files = {'torrents': {'filename': '', 'content': data}} + + return qbclient._command('command/upload', filelist=files) + + +def getFolder(hash): + logger.debug('getFolder(%s)' % hash) + + qbclient = qbittorrentclient() + + # Get Active Directory from settings + settings = qbclient._get_settings() + active_dir = settings['temp_path'] + + if not active_dir: + logger.error('Could not get "Keep incomplete torrents in:" directory from QBitTorrent settings, please ensure it is set') + return None + + # Get Torrent Folder Name + torrent_folder = qbclient.get_savepath(hash) + + # If there's no folder yet then it's probably a magnet, try until folder is populated + if torrent_folder == active_dir or not torrent_folder: + tries = 1 + while (torrent_folder == active_dir or torrent_folder is None) and tries <= 10: + tries += 1 + time.sleep(6) + torrent_folder = qbclient.get_savepath(hash) + + if torrent_folder == active_dir or not torrent_folder: + torrent_folder = qbclient.get_savepath(hash) + return torrent_folder + else: + if headphones.SYS_PLATFORM != "win32": + torrent_folder = torrent_folder.replace('\\', '/') + return os.path.basename(os.path.normpath(torrent_folder)) + + +_BOUNDARY_CHARS = string.digits + string.ascii_letters + + +# Taken from http://code.activestate.com/recipes/578668-encode-multipart-form-data-for-uploading-files-via/ +# "MIT License" which is compatible with GPL +def encode_multipart(args, files, boundary=None): + logger.debug('encode_multipart()') + + def escape_quote(s): + return s.replace('"', '\\"') + + if boundary is None: + boundary = ''.join(random.choice(_BOUNDARY_CHARS) for i in range(30)) + lines = [] + + if args: + for name, value in args.items(): + lines.extend(( + '--{0}'.format(boundary), + 'Content-Disposition: form-data; name="{0}"'.format(escape_quote(name)), + '', + str(value), + )) + logger.debug(''.join(lines)) + + if files: + for name, value in files.items(): + filename = value['filename'] + if 'mimetype' in value: + mimetype = value['mimetype'] + else: + mimetype = mimetypes.guess_type(filename)[0] or 'application/octet-stream' + lines.extend(( + '--{0}'.format(boundary), + 'Content-Disposition: form-data; name="{0}"; filename="{1}"'.format( + escape_quote(name), escape_quote(filename)), + 'Content-Type: {0}'.format(mimetype), + '', + value['content'], + )) + + lines.extend(( + '--{0}--'.format(boundary), + '', + )) + body = '\r\n'.join(lines) + + headers = { + 'Content-Type': 'multipart/form-data; boundary={0}'.format(boundary), + 'Content-Length': str(len(body)), + } + + return (body, headers) diff --git a/headphones/searcher.py b/headphones/searcher.py index 4b52216b..ba7040ae 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -35,7 +35,7 @@ from pygazelle import format as gazelleformat import headphones from headphones.common import USER_AGENT from headphones import logger, db, helpers, classes, sab, nzbget, request -from headphones import utorrent, transmission, notifiers, rutracker, deluge +from headphones import utorrent, transmission, notifiers, rutracker, deluge, qbittorrent from bencode import bencode, bdecode # Magnet to torrent services, for Black hole. Stolen from CouchPotato. @@ -981,7 +981,7 @@ def send_to_downloader(data, bestqual, album): except Exception as e: logger.error('Error sending torrent to Deluge: %s' % str(e)) - else: # if headphones.CONFIG.TORRENT_DOWNLOADER == 2: + elif headphones.CONFIG.TORRENT_DOWNLOADER == 2: logger.info("Sending torrent to uTorrent") # Add torrent @@ -1012,6 +1012,28 @@ def send_to_downloader(data, bestqual, album): seed_ratio = get_seed_ratio(bestqual[3]) if seed_ratio is not None: utorrent.setSeedRatio(torrentid, seed_ratio) + else: # if headphones.CONFIG.TORRENT_DOWNLOADER == 4: + logger.info("Sending torrent to QBiTorrent") + + # Add torrent + if bestqual[3] == 'rutracker.org': + qbittorrent.addFile(data) + else: + qbittorrent.addTorrent(bestqual[2]) + + # Get hash + torrentid = calculate_torrent_hash(bestqual[2], data) + if not torrentid: + logger.error('Torrent id could not be determined') + return + + # Get folder + folder_name = qbittorrent.getFolder(torrentid) + if folder_name: + logger.info('Torrent folder name: %s' % folder_name) + else: + logger.error('Torrent folder name could not be determined') + return myDB = db.DBConnection() myDB.action('UPDATE albums SET status = "Snatched" WHERE AlbumID=?', [album['AlbumID']]) diff --git a/headphones/webserve.py b/headphones/webserve.py index 278e512b..d10d6a3d 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1157,6 +1157,10 @@ class WebInterface(object): "nzbget_password": headphones.CONFIG.NZBGET_PASSWORD, "nzbget_category": headphones.CONFIG.NZBGET_CATEGORY, "nzbget_priority": headphones.CONFIG.NZBGET_PRIORITY, + "qbittorrent_host": headphones.CONFIG.QBITTORRENT_HOST, + "qbittorrent_username": headphones.CONFIG.QBITTORRENT_USERNAME, + "qbittorrent_password": headphones.CONFIG.QBITTORRENT_PASSWORD, + "qbittorrent_label": headphones.CONFIG.QBITTORRENT_LABEL, "transmission_host": headphones.CONFIG.TRANSMISSION_HOST, "transmission_username": headphones.CONFIG.TRANSMISSION_USERNAME, "transmission_password": headphones.CONFIG.TRANSMISSION_PASSWORD, @@ -1177,6 +1181,7 @@ class WebInterface(object): "torrent_downloader_transmission": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 1), "torrent_downloader_utorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 2), "torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3), + "torrent_downloader_qbittorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 4), "download_dir": headphones.CONFIG.DOWNLOAD_DIR, "use_blackhole": checked(headphones.CONFIG.BLACKHOLE), "blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR,