You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
586 lines
17 KiB
586 lines
17 KiB
from base64 import b16encode, b32decode, b64encode
|
|
from distutils.version import LooseVersion
|
|
from hashlib import sha1
|
|
import httplib
|
|
import json
|
|
import os
|
|
import re
|
|
import urllib2
|
|
|
|
from couchpotato.core._base.downloader.main import DownloaderBase, ReleaseDownloadList
|
|
from couchpotato.core.helpers.encoding import isInt, sp
|
|
from couchpotato.core.helpers.variable import cleanHost
|
|
from couchpotato.core.logger import CPLog
|
|
from bencode import bencode as benc, bdecode
|
|
|
|
|
|
log = CPLog(__name__)
|
|
|
|
autoload = 'Hadouken'
|
|
|
|
|
|
class Hadouken(DownloaderBase):
|
|
protocol = ['torrent', 'torrent_magnet']
|
|
hadouken_api = None
|
|
|
|
def connect(self):
|
|
# Load host from config and split out port.
|
|
host = cleanHost(self.conf('host'), protocol = False).split(':')
|
|
|
|
if not isInt(host[1]):
|
|
log.error('Config properties are not filled in correctly, port is missing.')
|
|
return False
|
|
|
|
# This is where v4 and v5 begin to differ
|
|
if(self.conf('version') == 'v4'):
|
|
if not self.conf('api_key'):
|
|
log.error('Config properties are not filled in correctly, API key is missing.')
|
|
return False
|
|
|
|
url = 'http://' + str(host[0]) + ':' + str(host[1]) + '/jsonrpc'
|
|
client = JsonRpcClient(url, 'Token ' + self.conf('api_key'))
|
|
self.hadouken_api = HadoukenAPIv4(client)
|
|
|
|
return True
|
|
else:
|
|
auth_type = self.conf('auth_type')
|
|
header = None
|
|
|
|
if auth_type == 'api_key':
|
|
header = 'Token ' + self.conf('api_key')
|
|
elif auth_type == 'user_pass':
|
|
header = 'Basic ' + b64encode(self.conf('auth_user') + ':' + self.conf('auth_pass'))
|
|
|
|
url = 'http://' + str(host[0]) + ':' + str(host[1]) + '/api'
|
|
client = JsonRpcClient(url, header)
|
|
self.hadouken_api = HadoukenAPIv5(client)
|
|
|
|
return True
|
|
|
|
return False
|
|
|
|
def download(self, data = None, media = None, filedata = None):
|
|
""" Send a torrent/nzb file to the downloader
|
|
|
|
:param data: dict returned from provider
|
|
Contains the release information
|
|
:param media: media dict with information
|
|
Used for creating the filename when possible
|
|
:param filedata: downloaded torrent/nzb filedata
|
|
The file gets downloaded in the searcher and send to this function
|
|
This is done to have failed checking before using the downloader, so the downloader
|
|
doesn't need to worry about that
|
|
:return: boolean
|
|
One faile returns false, but the downloaded should log his own errors
|
|
"""
|
|
|
|
if not media: media = {}
|
|
if not data: data = {}
|
|
|
|
log.debug("Sending '%s' (%s) to Hadouken.", (data.get('name'), data.get('protocol')))
|
|
|
|
if not self.connect():
|
|
return False
|
|
|
|
torrent_params = {}
|
|
|
|
if self.conf('label'):
|
|
torrent_params['label'] = self.conf('label')
|
|
# Set the tags array since that is what v5 expects.
|
|
torrent_params['tags'] = [self.conf('label')]
|
|
|
|
torrent_filename = self.createFileName(data, filedata, media)
|
|
|
|
if data.get('protocol') == 'torrent_magnet':
|
|
torrent_hash = re.findall('urn:btih:([\w]{32,40})', data.get('url'))[0].upper()
|
|
torrent_params['trackers'] = self.torrent_trackers
|
|
torrent_params['name'] = torrent_filename
|
|
else:
|
|
info = bdecode(filedata)['info']
|
|
torrent_hash = sha1(benc(info)).hexdigest().upper()
|
|
|
|
# Convert base 32 to hex
|
|
if len(torrent_hash) == 32:
|
|
torrent_hash = b16encode(b32decode(torrent_hash))
|
|
|
|
# Send request to Hadouken
|
|
if data.get('protocol') == 'torrent_magnet':
|
|
self.hadouken_api.add_magnet_link(data.get('url'), torrent_params)
|
|
else:
|
|
self.hadouken_api.add_file(filedata, torrent_params)
|
|
|
|
return self.downloadReturnId(torrent_hash)
|
|
|
|
def test(self):
|
|
""" Tests the given host:port and API key """
|
|
|
|
if not self.connect():
|
|
return False
|
|
|
|
version = self.hadouken_api.get_version()
|
|
|
|
if not version:
|
|
log.error('Could not get Hadouken version.')
|
|
return False
|
|
|
|
# The minimum required version of Hadouken is 4.5.6.
|
|
if LooseVersion(version) >= LooseVersion('4.5.6'):
|
|
return True
|
|
|
|
log.error('Hadouken v4.5.6 (or newer) required. Found v%s', version)
|
|
return False
|
|
|
|
def getAllDownloadStatus(self, ids):
|
|
""" Get status of all active downloads
|
|
|
|
:param ids: list of (mixed) downloader ids
|
|
Used to match the releases for this downloader as there could be
|
|
other downloaders active that it should ignore
|
|
:return: list of releases
|
|
"""
|
|
|
|
log.debug('Checking Hadouken download status.')
|
|
|
|
if not self.connect():
|
|
return []
|
|
|
|
release_downloads = ReleaseDownloadList(self)
|
|
queue = self.hadouken_api.get_by_hash_list(ids)
|
|
|
|
if not queue:
|
|
return []
|
|
|
|
for torrent in queue:
|
|
if torrent is None:
|
|
continue
|
|
|
|
torrent_filelist = self.hadouken_api.get_files_by_hash(torrent.info_hash)
|
|
torrent_files = []
|
|
|
|
for file_item in torrent_filelist:
|
|
torrent_files.append(sp(os.path.join(torrent.save_path, file_item)))
|
|
|
|
release_downloads.append({
|
|
'id': torrent.info_hash.upper(),
|
|
'name': torrent.name,
|
|
'status': torrent.get_status(),
|
|
'seed_ratio': torrent.get_seed_ratio(),
|
|
'original_status': torrent.state,
|
|
'timeleft': -1,
|
|
'folder': sp(torrent.save_path if len(torrent_files == 1) else os.path.join(torrent.save_path, torrent.name)),
|
|
'files': torrent_files
|
|
})
|
|
|
|
return release_downloads
|
|
|
|
def pause(self, release_download, pause = True):
|
|
""" Pauses or resumes the torrent specified by the ID field
|
|
in release_download.
|
|
|
|
Keyword arguments:
|
|
release_download -- The CouchPotato release_download to pause/resume.
|
|
pause -- Boolean indicating whether to pause or resume.
|
|
"""
|
|
|
|
if not self.connect():
|
|
return False
|
|
|
|
return self.hadouken_api.pause(release_download['id'], pause)
|
|
|
|
def removeFailed(self, release_download):
|
|
""" Removes a failed torrent and also remove the data associated with it.
|
|
|
|
Keyword arguments:
|
|
release_download -- The CouchPotato release_download to remove.
|
|
"""
|
|
|
|
log.info('%s failed downloading, deleting...', release_download['name'])
|
|
|
|
if not self.connect():
|
|
return False
|
|
|
|
return self.hadouken_api.remove(release_download['id'], remove_data = True)
|
|
|
|
def processComplete(self, release_download, delete_files = False):
|
|
""" Removes the completed torrent from Hadouken and optionally removes the data
|
|
associated with it.
|
|
|
|
Keyword arguments:
|
|
release_download -- The CouchPotato release_download to remove.
|
|
delete_files: Boolean indicating whether to remove the associated data.
|
|
"""
|
|
|
|
log.debug('Requesting Hadouken to remove the torrent %s%s.',
|
|
(release_download['name'], ' and cleanup the downloaded files' if delete_files else ''))
|
|
|
|
if not self.connect():
|
|
return False
|
|
|
|
return self.hadouken_api.remove(release_download['id'], remove_data = delete_files)
|
|
|
|
|
|
class JsonRpcClient(object):
|
|
def __init__(self, url, auth_header = None):
|
|
self.url = url
|
|
self.requestId = 0
|
|
|
|
self.opener = urllib2.build_opener()
|
|
self.opener.addheaders = [
|
|
('User-Agent', 'couchpotato-hadouken-client/1.0'),
|
|
('Accept', 'application/json'),
|
|
('Content-Type', 'application/json')
|
|
]
|
|
|
|
if auth_header:
|
|
self.opener.addheaders.append(('Authorization', auth_header))
|
|
|
|
def invoke(self, method, params):
|
|
self.requestId += 1
|
|
|
|
data = {
|
|
'jsonrpc': '2.0',
|
|
'id': self.requestId,
|
|
'method': method,
|
|
'params': params
|
|
}
|
|
|
|
request = urllib2.Request(self.url, data = json.dumps(data))
|
|
|
|
try:
|
|
f = self.opener.open(request)
|
|
response = f.read()
|
|
f.close()
|
|
|
|
obj = json.loads(response)
|
|
|
|
if 'error' in obj.keys():
|
|
log.error('JSONRPC error, %s: %s', (obj['error']['code'], obj['error']['message']))
|
|
return False
|
|
|
|
if 'result' in obj.keys():
|
|
return obj['result']
|
|
|
|
return True
|
|
except httplib.InvalidURL as err:
|
|
log.error('Invalid Hadouken host, check your config %s', err)
|
|
except urllib2.HTTPError as err:
|
|
if err.code == 401:
|
|
log.error('Could not authenticate, check your config')
|
|
else:
|
|
log.error('Hadouken HTTPError: %s', err)
|
|
except urllib2.URLError as err:
|
|
log.error('Unable to connect to Hadouken %s', err)
|
|
|
|
return False
|
|
|
|
|
|
class HadoukenAPI(object):
|
|
def __init__(self, rpc_client):
|
|
self.rpc = rpc_client
|
|
|
|
if not rpc_client:
|
|
log.error('No JSONRPC client specified.')
|
|
|
|
def add_file(self, data, params):
|
|
""" Add a file to Hadouken with the specified parameters.
|
|
|
|
Keyword arguments:
|
|
filedata -- The binary torrent data.
|
|
torrent_params -- Additional parameters for the file.
|
|
"""
|
|
pass
|
|
|
|
def add_magnet_link(self, link, params):
|
|
""" Add a magnet link to Hadouken with the specified parameters.
|
|
|
|
Keyword arguments:
|
|
magnetLink -- The magnet link to send.
|
|
torrent_params -- Additional parameters for the magnet link.
|
|
"""
|
|
pass
|
|
|
|
def get_by_hash_list(self, infoHashList):
|
|
""" Gets a list of torrents filtered by the given info hash list.
|
|
|
|
Keyword arguments:
|
|
infoHashList -- A list of info hashes.
|
|
"""
|
|
pass
|
|
|
|
def get_files_by_hash(self, infoHash):
|
|
""" Gets a list of files for the torrent identified by the
|
|
given info hash.
|
|
|
|
Keyword arguments:
|
|
infoHash -- The info hash of the torrent to return files for.
|
|
"""
|
|
pass
|
|
|
|
def get_version(self):
|
|
""" Gets the version, commitish and build date of Hadouken. """
|
|
pass
|
|
|
|
def pause(self, infoHash, pause):
|
|
""" Pauses/unpauses the torrent identified by the given info hash.
|
|
|
|
Keyword arguments:
|
|
infoHash -- The info hash of the torrent to operate on.
|
|
pause -- If true, pauses the torrent. Otherwise resumes.
|
|
"""
|
|
pass
|
|
|
|
def remove(self, infoHash, remove_data = False):
|
|
""" Removes the torrent identified by the given info hash and
|
|
optionally removes the data as well.
|
|
|
|
Keyword arguments:
|
|
infoHash -- The info hash of the torrent to remove.
|
|
remove_data -- If true, removes the data associated with the torrent.
|
|
"""
|
|
pass
|
|
|
|
|
|
class TorrentItem(object):
|
|
@property
|
|
def info_hash(self):
|
|
pass
|
|
|
|
@property
|
|
def save_path(self):
|
|
pass
|
|
|
|
@property
|
|
def name(self):
|
|
pass
|
|
|
|
@property
|
|
def state(self):
|
|
pass
|
|
|
|
def get_status(self):
|
|
""" Returns the CouchPotato status for a given torrent."""
|
|
pass
|
|
|
|
def get_seed_ratio(self):
|
|
""" Returns the seed ratio for a given torrent."""
|
|
pass
|
|
|
|
|
|
class TorrentItemv5(TorrentItem):
|
|
def __init__(self, obj):
|
|
self.obj = obj
|
|
|
|
def info_hash(self):
|
|
return self.obj['infoHash']
|
|
|
|
def save_path(self):
|
|
return self.obj['savePath']
|
|
|
|
def name(self):
|
|
return self.obj['name']
|
|
|
|
def state(self):
|
|
return self.obj['state']
|
|
|
|
def get_status(self):
|
|
if self.obj['isSeeding'] and self.obj['isFinished'] and self.obj['isPaused']:
|
|
return 'completed'
|
|
|
|
if self.obj['isSeeding']:
|
|
return 'seeding'
|
|
|
|
return 'busy'
|
|
|
|
def get_seed_ratio(self):
|
|
up = self.obj['uploadedBytesTotal']
|
|
down = self.obj['downloadedBytesTotal']
|
|
|
|
if up > 0 and down > 0:
|
|
return up / down
|
|
|
|
return 0
|
|
|
|
|
|
class HadoukenAPIv5(HadoukenAPI):
|
|
def add_file(self, data, params):
|
|
return self.rpc.invoke('session.addTorrentFile', [b64encode(data), params])
|
|
|
|
def add_magnet_link(self, link, params):
|
|
return self.rpc.invoke('session.addTorrentUri', [link, params])
|
|
|
|
def get_by_hash_list(self, infoHashList):
|
|
torrents = self.rpc.invoke('session.getTorrents')
|
|
result = []
|
|
|
|
for torrent in torrents.values():
|
|
if torrent['infoHash'] in infoHashList:
|
|
result.append(TorrentItemv5(torrent))
|
|
|
|
return result
|
|
|
|
def get_files_by_hash(self, infoHash):
|
|
files = self.rpc.invoke('torrent.getFiles', [infoHash])
|
|
result = []
|
|
|
|
for file in files:
|
|
result.append(file['path'])
|
|
|
|
return result
|
|
|
|
def get_version(self):
|
|
result = self.rpc.invoke('core.getSystemInfo', None)
|
|
|
|
if not result:
|
|
return False
|
|
|
|
return result['versions']['hadouken']
|
|
|
|
def pause(self, infoHash, pause):
|
|
if pause:
|
|
return self.rpc.invoke('torrent.pause', [infoHash])
|
|
|
|
return self.rpc.invoke('torrent.resume', [infoHash])
|
|
|
|
def remove(self, infoHash, remove_data = False):
|
|
return self.rpc.invoke('session.removeTorrent', [infoHash, remove_data])
|
|
|
|
|
|
class TorrentItemv4(TorrentItem):
|
|
def __init__(self, obj):
|
|
self.obj = obj
|
|
|
|
def info_hash(self):
|
|
return self.obj['InfoHash']
|
|
|
|
def save_path(self):
|
|
return self.obj['SavePath']
|
|
|
|
def name(self):
|
|
return self.obj['Name']
|
|
|
|
def state(self):
|
|
return self.obj['State']
|
|
|
|
def get_status(self):
|
|
if self.obj['IsSeeding'] and self.obj['IsFinished'] and self.obj['Paused']:
|
|
return 'completed'
|
|
|
|
if self.obj['IsSeeding']:
|
|
return 'seeding'
|
|
|
|
return 'busy'
|
|
|
|
def get_seed_ratio(self):
|
|
up = self.obj['TotalUploadedBytes']
|
|
down = self.obj['TotalDownloadedBytes']
|
|
|
|
if up > 0 and down > 0:
|
|
return up / down
|
|
|
|
return 0
|
|
|
|
|
|
class HadoukenAPIv4(object):
|
|
def add_file(self, data, params):
|
|
return self.rpc.invoke('torrents.addFile', [b64encode(data), params])
|
|
|
|
def add_magnet_link(self, link, params):
|
|
return self.rpc.invoke('torrents.addUrl', [link, params])
|
|
|
|
def get_by_hash_list(self, infoHashList):
|
|
torrents = self.rpc.invoke('torrents.getByInfoHashList', [infoHashList])
|
|
result = []
|
|
|
|
for torrent in torrents:
|
|
result.append(TorrentItemv4(torrent))
|
|
|
|
return result
|
|
|
|
def get_files_by_hash(self, infoHash):
|
|
files = self.rpc.invoke('torrents.getFiles', [infoHash])
|
|
result = []
|
|
|
|
for file in files:
|
|
result.append(file['Path'])
|
|
|
|
return result
|
|
|
|
def get_version(self):
|
|
result = self.rpc.invoke('core.getVersion', None)
|
|
|
|
if not result:
|
|
return False
|
|
|
|
return result['Version']
|
|
|
|
def pause(self, infoHash, pause):
|
|
if pause:
|
|
return self.rpc.invoke('torrents.pause', [infoHash])
|
|
|
|
return self.rpc.invoke('torrents.resume', [infoHash])
|
|
|
|
def remove(self, infoHash, remove_data = False):
|
|
return self.rpc.invoke('torrents.remove', [infoHash, remove_data])
|
|
|
|
|
|
config = [{
|
|
'name': 'hadouken',
|
|
'groups': [
|
|
{
|
|
'tab': 'downloaders',
|
|
'list': 'download_providers',
|
|
'name': 'hadouken',
|
|
'label': 'Hadouken',
|
|
'description': 'Use <a href="http://www.hdkn.net" target="_blank">Hadouken</a> (>= v4.5.6) to download torrents.',
|
|
'wizard': True,
|
|
'options': [
|
|
{
|
|
'name': 'enabled',
|
|
'default': 0,
|
|
'type': 'enabler',
|
|
'radio_group': 'torrent'
|
|
},
|
|
{
|
|
'name': 'version',
|
|
'label': 'Version',
|
|
'type': 'dropdown',
|
|
'default': 'v4',
|
|
'values': [('v4.x', 'v4'), ('v5.x', 'v5')],
|
|
'description': 'Hadouken version.',
|
|
},
|
|
{
|
|
'name': 'host',
|
|
'default': 'localhost:7890'
|
|
},
|
|
{
|
|
'name': 'auth_type',
|
|
'label': 'Auth. type',
|
|
'type': 'dropdown',
|
|
'default': 'api_key',
|
|
'values': [('None', 'none'), ('API key/Token', 'api_key'), ('Username/Password', 'user_pass')],
|
|
'description': 'Type of authentication',
|
|
},
|
|
{
|
|
'name': 'api_key',
|
|
'label': 'API key (v4)/Token (v5)',
|
|
'type': 'password'
|
|
},
|
|
{
|
|
'name': 'auth_user',
|
|
'label': 'Username',
|
|
'description': '(only for v5)'
|
|
},
|
|
{
|
|
'name': 'auth_pass',
|
|
'label': 'Password',
|
|
'type': 'password',
|
|
'description': '(only for v5)'
|
|
},
|
|
{
|
|
'name': 'label',
|
|
'description': 'Label to add torrent as.'
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}]
|
|
|