@@ -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,