Merge pull request #2822 from dsm1212/qbittorrent

Add qbittorrent downloader support
This commit is contained in:
AdeHub
2017-01-21 09:35:36 +13:00
committed by GitHub
6 changed files with 362 additions and 14 deletions

View File

@@ -312,6 +312,7 @@
<input type="radio" name="torrent_downloader" id="torrent_downloader_transmission" value="1" ${config['torrent_downloader_transmission']}> Transmission
<input type="radio" name="torrent_downloader" id="torrent_downloader_utorrent" value="2" ${config['torrent_downloader_utorrent']}> uTorrent (Beta)
<input type="radio" name="torrent_downloader" id="torrent_downloader_deluge" value="3" ${config['torrent_downloader_deluge']}> Deluge (Beta)
<input type="radio" name="torrent_downloader" id="torrent_downloader_qbittorrent" value="4" ${config['torrent_downloader_qbittorrent']}> QBitTorrent
</fieldset>
<fieldset id="torrent_blackhole_options">
<div class="row">
@@ -386,6 +387,26 @@
<input type="text" name="utorrent_label" value="${config['utorrent_label']}" size="30">
</div>
</fieldset>
<fieldset id="qbittorrent_options">
<small class="heading"><i class="fa fa-info-circle"></i> Note: Works with WebAPI Rev 11 and later (QBitTorrent 3.4.0 and later) </small>
<div class="row">
<label>QBitTorrent Host</label>
<input type="text" name="qbittorrent_host" value="${config['qbittorrent_host']}" size="30">
<small>usually http://localhost:8081</small>
</div>
<div class="row">
<label>QBitTorrent Username</label>
<input type="text" name="qbittorrent_username" value="${config['qbittorrent_username']}" size="30">
</div>
<div class="row">
<label>QBitTorrent Password</label>
<input type="password" name="qbittorrent_password" value="${config['qbittorrent_password']}" size="30">
</div>
<div class="row">
<label>QBitTorrent Label</label>
<input type="text" name="qbittorrent_label" value="${config['qbittorrent_label']}" size="30">
</div>
</fieldset>
<fieldset id="deluge_options">
<div class="row">
<label>Deluge WebUI Host and Port</label>
@@ -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() });
}
});

View File

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

View File

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

286
headphones/qbittorrent.py Normal file
View File

@@ -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 <http://www.gnu.org/licenses/>.
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 = "<div id='token' style='display:none;'>([^<>]+)</div>"
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)

View File

@@ -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']])

View File

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