Merge branch 'develop'
This commit is contained in:
+2
-1
@@ -7,6 +7,7 @@ import urllib
|
||||
|
||||
from couchpotato.core.helpers.request import getParams
|
||||
from couchpotato.core.logger import CPLog
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado.web import RequestHandler, asynchronous
|
||||
|
||||
|
||||
@@ -33,7 +34,7 @@ def run_async(func):
|
||||
def run_handler(route, kwargs, callback = None):
|
||||
try:
|
||||
res = api[route](**kwargs)
|
||||
callback(res, route)
|
||||
IOLoop.current().add_callback(callback, res, route)
|
||||
except:
|
||||
log.error('Failed doing api request "%s": %s', (route, traceback.format_exc()))
|
||||
callback({'success': False, 'error': 'Failed returning results'}, route)
|
||||
|
||||
@@ -20,14 +20,31 @@ class Blackhole(DownloaderBase):
|
||||
status_support = 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 = {}
|
||||
|
||||
directory = self.conf('directory')
|
||||
|
||||
# The folder needs to exist
|
||||
if not directory or not os.path.isdir(directory):
|
||||
log.error('No directory set for blackhole %s download.', data.get('protocol'))
|
||||
else:
|
||||
try:
|
||||
# Filedata can be empty, which probably means it a magnet link
|
||||
if not filedata or len(filedata) < 50:
|
||||
try:
|
||||
if data.get('protocol') == 'torrent_magnet':
|
||||
@@ -36,13 +53,16 @@ class Blackhole(DownloaderBase):
|
||||
except:
|
||||
log.error('Failed download torrent via magnet url: %s', traceback.format_exc())
|
||||
|
||||
# If it's still empty, don't know what to do!
|
||||
if not filedata or len(filedata) < 50:
|
||||
log.error('No nzb/torrent available: %s', data.get('url'))
|
||||
return False
|
||||
|
||||
# Create filename with imdb id and other nice stuff
|
||||
file_name = self.createFileName(data, filedata, media)
|
||||
full_path = os.path.join(directory, file_name)
|
||||
|
||||
# People want thinks nice and tidy, create a subdir
|
||||
if self.conf('create_subdir'):
|
||||
try:
|
||||
new_path = os.path.splitext(full_path)[0]
|
||||
@@ -53,6 +73,8 @@ class Blackhole(DownloaderBase):
|
||||
log.error('Couldnt create sub dir, reverting to old one: %s', full_path)
|
||||
|
||||
try:
|
||||
|
||||
# Make sure the file doesn't exist yet, no need in overwriting it
|
||||
if not os.path.isfile(full_path):
|
||||
log.info('Downloading %s to %s.', (data.get('protocol'), full_path))
|
||||
with open(full_path, 'wb') as f:
|
||||
@@ -74,6 +96,10 @@ class Blackhole(DownloaderBase):
|
||||
return False
|
||||
|
||||
def test(self):
|
||||
""" Test and see if the directory is writable
|
||||
:return: boolean
|
||||
"""
|
||||
|
||||
directory = self.conf('directory')
|
||||
if directory and os.path.isdir(directory):
|
||||
|
||||
@@ -88,6 +114,10 @@ class Blackhole(DownloaderBase):
|
||||
return False
|
||||
|
||||
def getEnabledProtocol(self):
|
||||
""" What protocols is this downloaded used for
|
||||
:return: list with protocols
|
||||
"""
|
||||
|
||||
if self.conf('use_for') == 'both':
|
||||
return super(Blackhole, self).getEnabledProtocol()
|
||||
elif self.conf('use_for') == 'torrent':
|
||||
@@ -96,6 +126,12 @@ class Blackhole(DownloaderBase):
|
||||
return ['nzb']
|
||||
|
||||
def isEnabled(self, manual = False, data = None):
|
||||
""" Check if protocol is used (and enabled)
|
||||
:param manual: The user has clicked to download a link through the webUI
|
||||
:param data: dict returned from provider
|
||||
Contains the release information
|
||||
:return: boolean
|
||||
"""
|
||||
if not data: data = {}
|
||||
for_protocol = ['both']
|
||||
if data and 'torrent' in data.get('protocol'):
|
||||
|
||||
@@ -25,6 +25,11 @@ class Deluge(DownloaderBase):
|
||||
drpc = None
|
||||
|
||||
def connect(self, reconnect = False):
|
||||
""" Connect to the delugeRPC, re-use connection when already available
|
||||
:param reconnect: force reconnect
|
||||
:return: DelugeRPC instance
|
||||
"""
|
||||
|
||||
# Load host from config and split out port.
|
||||
host = cleanHost(self.conf('host'), protocol = False).split(':')
|
||||
|
||||
@@ -42,6 +47,20 @@ class Deluge(DownloaderBase):
|
||||
return self.drpc
|
||||
|
||||
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 = {}
|
||||
|
||||
@@ -96,11 +115,21 @@ class Deluge(DownloaderBase):
|
||||
return self.downloadReturnId(remote_torrent)
|
||||
|
||||
def test(self):
|
||||
""" Check if connection works
|
||||
:return: bool
|
||||
"""
|
||||
if self.connect(True) and self.drpc.test():
|
||||
return True
|
||||
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 Deluge download status.')
|
||||
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
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
|
||||
|
||||
if not self.conf('apikey'):
|
||||
log.error('Config properties are not filled in correctly, API key is missing.')
|
||||
return False
|
||||
|
||||
self.hadouken_api = HadoukenAPI(host[0], port = host[1], api_key = self.conf('api_key'))
|
||||
|
||||
return True
|
||||
|
||||
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')
|
||||
|
||||
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['InfoHash'])
|
||||
torrent_files = []
|
||||
|
||||
save_path = torrent['SavePath']
|
||||
|
||||
# The 'Path' key for each file_item contains
|
||||
# the full path to the single file relative to the
|
||||
# torrents save path.
|
||||
|
||||
# For a single file torrent the result would be,
|
||||
# - Save path: "C:\Downloads"
|
||||
# - file_item['Path'] = "file1.iso"
|
||||
# Resulting path: "C:\Downloads\file1.iso"
|
||||
|
||||
# For a multi file torrent the result would be,
|
||||
# - Save path: "C:\Downloads"
|
||||
# - file_item['Path'] = "dirname/file1.iso"
|
||||
# Resulting path: "C:\Downloads\dirname/file1.iso"
|
||||
|
||||
for file_item in torrent_filelist:
|
||||
torrent_files.append(sp(os.path.join(save_path, file_item['Path'])))
|
||||
|
||||
release_downloads.append({
|
||||
'id': torrent['InfoHash'].upper(),
|
||||
'name': torrent['Name'],
|
||||
'status': self.get_torrent_status(torrent),
|
||||
'seed_ratio': self.get_seed_ratio(torrent),
|
||||
'original_status': torrent['State'],
|
||||
'timeleft': -1,
|
||||
'folder': sp(save_path if len(torrent_files == 1) else os.path.join(save_path, torrent['Name'])),
|
||||
'files': torrent_files
|
||||
})
|
||||
|
||||
return release_downloads
|
||||
|
||||
def get_seed_ratio(self, torrent):
|
||||
""" Returns the seed ratio for a given torrent.
|
||||
|
||||
Keyword arguments:
|
||||
torrent -- The torrent to calculate seed ratio for.
|
||||
"""
|
||||
|
||||
up = torrent['TotalUploadedBytes']
|
||||
down = torrent['TotalDownloadedBytes']
|
||||
|
||||
if up > 0 and down > 0:
|
||||
return up / down
|
||||
|
||||
return 0
|
||||
|
||||
def get_torrent_status(self, torrent):
|
||||
""" Returns the CouchPotato status for a given torrent.
|
||||
|
||||
Keyword arguments:
|
||||
torrent -- The torrent to translate status for.
|
||||
"""
|
||||
|
||||
if torrent['IsSeeding'] and torrent['IsFinished'] and torrent['Paused']:
|
||||
return 'completed'
|
||||
|
||||
if torrent['IsSeeding']:
|
||||
return 'seeding'
|
||||
|
||||
return 'busy'
|
||||
|
||||
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 HadoukenAPI(object):
|
||||
def __init__(self, host = 'localhost', port = 7890, api_key = None):
|
||||
self.url = 'http://' + str(host) + ':' + str(port)
|
||||
self.api_key = api_key
|
||||
self.requestId = 0;
|
||||
|
||||
self.opener = urllib2.build_opener()
|
||||
self.opener.addheaders = [('User-agent', 'couchpotato-hadouken-client/1.0'), ('Accept', 'application/json')]
|
||||
|
||||
if not api_key:
|
||||
log.error('API key missing.')
|
||||
|
||||
def add_file(self, filedata, torrent_params):
|
||||
""" Add a file to Hadouken with the specified parameters.
|
||||
|
||||
Keyword arguments:
|
||||
filedata -- The binary torrent data.
|
||||
torrent_params -- Additional parameters for the file.
|
||||
"""
|
||||
data = {
|
||||
'method': 'torrents.addFile',
|
||||
'params': [b64encode(filedata), torrent_params]
|
||||
}
|
||||
|
||||
return self._request(data)
|
||||
|
||||
def add_magnet_link(self, magnetLink, torrent_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.
|
||||
"""
|
||||
data = {
|
||||
'method': 'torrents.addUrl',
|
||||
'params': [magnetLink, torrent_params]
|
||||
}
|
||||
|
||||
return self._request(data)
|
||||
|
||||
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.
|
||||
"""
|
||||
data = {
|
||||
'method': 'torrents.getByInfoHashList',
|
||||
'params': [infoHashList]
|
||||
}
|
||||
|
||||
return self._request(data)
|
||||
|
||||
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.
|
||||
"""
|
||||
data = {
|
||||
'method': 'torrents.getFiles',
|
||||
'params': [infoHash]
|
||||
}
|
||||
|
||||
return self._request(data)
|
||||
|
||||
def get_version(self):
|
||||
""" Gets the version, commitish and build date of Hadouken. """
|
||||
data = {
|
||||
'method': 'core.getVersion',
|
||||
'params': None
|
||||
}
|
||||
|
||||
result = self._request(data)
|
||||
|
||||
if not result:
|
||||
return False
|
||||
|
||||
return result['Version']
|
||||
|
||||
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.
|
||||
"""
|
||||
data = {
|
||||
'method': 'torrents.pause',
|
||||
'params': [infoHash]
|
||||
}
|
||||
|
||||
if not pause:
|
||||
data['method'] = 'torrents.resume'
|
||||
|
||||
return self._request(data)
|
||||
|
||||
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.
|
||||
"""
|
||||
data = {
|
||||
'method': 'torrents.remove',
|
||||
'params': [infoHash, remove_data]
|
||||
}
|
||||
|
||||
return self._request(data)
|
||||
|
||||
|
||||
def _request(self, data):
|
||||
self.requestId += 1
|
||||
|
||||
data['jsonrpc'] = '2.0'
|
||||
data['id'] = self.requestId
|
||||
|
||||
request = urllib2.Request(self.url + '/jsonrpc', data = json.dumps(data))
|
||||
request.add_header('Authorization', 'Token ' + self.api_key)
|
||||
request.add_header('Content-Type', 'application/json')
|
||||
|
||||
try:
|
||||
f = self.opener.open(request)
|
||||
response = f.read()
|
||||
f.close()
|
||||
|
||||
obj = json.loads(response)
|
||||
|
||||
if not 'error' in obj.keys():
|
||||
return obj['result']
|
||||
|
||||
log.error('JSONRPC error, %s: %s', obj['error']['code'], obj['error']['message'])
|
||||
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('Invalid Hadouken API key, 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
|
||||
|
||||
|
||||
config = [{
|
||||
'name': 'hadouken',
|
||||
'groups': [
|
||||
{
|
||||
'tab': 'downloaders',
|
||||
'list': 'download_providers',
|
||||
'name': 'hadouken',
|
||||
'label': 'Hadouken',
|
||||
'description': 'Use <a href="http://www.hdkn.net">Hadouken</a> (>= v4.5.6) to download torrents.',
|
||||
'wizard': True,
|
||||
'options': [
|
||||
{
|
||||
'name': 'enabled',
|
||||
'default': 0,
|
||||
'type': 'enabler',
|
||||
'radio_group': 'torrent'
|
||||
},
|
||||
{
|
||||
'name': 'host',
|
||||
'default': 'localhost:7890'
|
||||
},
|
||||
{
|
||||
'name': 'api_key',
|
||||
'label': 'API key',
|
||||
'type': 'password'
|
||||
},
|
||||
{
|
||||
'name': 'label',
|
||||
'description': 'Label to add torrent as.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}]
|
||||
@@ -23,6 +23,20 @@ class NZBGet(DownloaderBase):
|
||||
rpc = 'xmlrpc'
|
||||
|
||||
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 = {}
|
||||
|
||||
@@ -71,6 +85,10 @@ class NZBGet(DownloaderBase):
|
||||
return False
|
||||
|
||||
def test(self):
|
||||
""" Check if connection works
|
||||
:return: bool
|
||||
"""
|
||||
|
||||
rpc = self.getRPC()
|
||||
|
||||
try:
|
||||
@@ -91,6 +109,13 @@ class NZBGet(DownloaderBase):
|
||||
return True
|
||||
|
||||
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 NZBGet download status.')
|
||||
|
||||
|
||||
@@ -24,6 +24,20 @@ class NZBVortex(DownloaderBase):
|
||||
session_id = None
|
||||
|
||||
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 = {}
|
||||
|
||||
@@ -45,6 +59,10 @@ class NZBVortex(DownloaderBase):
|
||||
return False
|
||||
|
||||
def test(self):
|
||||
""" Check if connection works
|
||||
:return: bool
|
||||
"""
|
||||
|
||||
try:
|
||||
login_result = self.login()
|
||||
except:
|
||||
@@ -53,6 +71,13 @@ class NZBVortex(DownloaderBase):
|
||||
return login_result
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
raw_statuses = self.call('nzb')
|
||||
|
||||
|
||||
@@ -19,6 +19,20 @@ class Pneumatic(DownloaderBase):
|
||||
status_support = 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 = {}
|
||||
|
||||
@@ -63,6 +77,10 @@ class Pneumatic(DownloaderBase):
|
||||
return False
|
||||
|
||||
def test(self):
|
||||
""" Check if connection works
|
||||
:return: bool
|
||||
"""
|
||||
|
||||
directory = self.conf('directory')
|
||||
if directory and os.path.isdir(directory):
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from .main import PutIO
|
||||
|
||||
|
||||
def autoload():
|
||||
return PutIO()
|
||||
|
||||
|
||||
config = [{
|
||||
'name': 'putio',
|
||||
'groups': [
|
||||
{
|
||||
'tab': 'downloaders',
|
||||
'list': 'download_providers',
|
||||
'name': 'putio',
|
||||
'label': 'put.io',
|
||||
'description': 'This will start a torrent download on <a href="http://put.io">Put.io</a>.',
|
||||
'wizard': True,
|
||||
'options': [
|
||||
{
|
||||
'name': 'enabled',
|
||||
'default': 0,
|
||||
'type': 'enabler',
|
||||
'radio_group': 'torrent',
|
||||
},
|
||||
{
|
||||
'name': 'oauth_token',
|
||||
'label': 'oauth_token',
|
||||
'description': 'This is the OAUTH_TOKEN from your putio API',
|
||||
'advanced': True,
|
||||
},
|
||||
{
|
||||
'name': 'callback_host',
|
||||
'description': 'External reachable url to CP so put.io can do it\'s thing',
|
||||
},
|
||||
{
|
||||
'name': 'download',
|
||||
'description': 'Set this to have CouchPotato download the file from Put.io',
|
||||
'type': 'bool',
|
||||
'default': 0,
|
||||
},
|
||||
{
|
||||
'name': 'delete_file',
|
||||
'description': ('Set this to remove the file from putio after sucessful download','Does nothing if you don\'t select download'),
|
||||
'type': 'bool',
|
||||
'default': 0,
|
||||
},
|
||||
{
|
||||
'name': 'download_dir',
|
||||
'type': 'directory',
|
||||
'label': 'Download Directory',
|
||||
'description': 'The Directory to download files to, does nothing if you don\'t select download',
|
||||
},
|
||||
{
|
||||
'name': 'manual',
|
||||
'default': 0,
|
||||
'type': 'bool',
|
||||
'advanced': True,
|
||||
'description': 'Disable this downloader for automated searches, but use it when I manually send a release.',
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}]
|
||||
@@ -0,0 +1,158 @@
|
||||
from couchpotato.api import addApiView
|
||||
from couchpotato.core.event import addEvent, fireEventAsync
|
||||
from couchpotato.core._base.downloader.main import DownloaderBase, ReleaseDownloadList
|
||||
from couchpotato.core.helpers.variable import cleanHost
|
||||
from couchpotato.core.logger import CPLog
|
||||
from couchpotato.environment import Env
|
||||
from pio import api as pio
|
||||
import datetime
|
||||
|
||||
log = CPLog(__name__)
|
||||
|
||||
autoload = 'Putiodownload'
|
||||
|
||||
|
||||
class PutIO(DownloaderBase):
|
||||
|
||||
protocol = ['torrent', 'torrent_magnet']
|
||||
downloading_list = []
|
||||
oauth_authenticate = 'https://api.couchpota.to/authorize/putio/'
|
||||
|
||||
def __init__(self):
|
||||
addApiView('downloader.putio.getfrom', self.getFromPutio, docs = {
|
||||
'desc': 'Allows you to download file from prom Put.io',
|
||||
})
|
||||
addApiView('downloader.putio.auth_url', self.getAuthorizationUrl)
|
||||
addApiView('downloader.putio.credentials', self.getCredentials)
|
||||
addEvent('putio.download', self.putioDownloader)
|
||||
|
||||
return super(PutIO, self).__init__()
|
||||
|
||||
def download(self, data = None, media = None, filedata = None):
|
||||
if not media: media = {}
|
||||
if not data: data = {}
|
||||
|
||||
log.info('Sending "%s" to put.io', data.get('name'))
|
||||
url = data.get('url')
|
||||
|
||||
client = pio.Client(self.conf('oauth_token'))
|
||||
# It might be possible to call getFromPutio from the renamer if we can then we don't need to do this.
|
||||
# Note callback_host is NOT our address, it's the internet host that putio can call too
|
||||
callbackurl = None
|
||||
if self.conf('download'):
|
||||
callbackurl = 'http://' + self.conf('callback_host') + '/' + '%sdownloader.putio.getfrom/' %Env.get('api_base'.strip('/'))
|
||||
resp = client.Transfer.add_url(url, callback_url = callbackurl)
|
||||
log.debug('resp is %s', resp.id);
|
||||
return self.downloadReturnId(resp.id)
|
||||
|
||||
def test(self):
|
||||
try:
|
||||
client = pio.Client(self.conf('oauth_token'))
|
||||
if client.File.list():
|
||||
return True
|
||||
except:
|
||||
log.info('Failed to get file listing, check OAUTH_TOKEN')
|
||||
return False
|
||||
|
||||
def getAuthorizationUrl(self, host = None, **kwargs):
|
||||
|
||||
callback_url = cleanHost(host) + '%sdownloader.putio.credentials/' % (Env.get('api_base').lstrip('/'))
|
||||
log.debug('callback_url is %s', callback_url)
|
||||
|
||||
target_url = self.oauth_authenticate + "?target=" + callback_url
|
||||
log.debug('target_url is %s', target_url)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'url': target_url,
|
||||
}
|
||||
|
||||
def getCredentials(self, **kwargs):
|
||||
try:
|
||||
oauth_token = kwargs.get('oauth')
|
||||
except:
|
||||
return 'redirect', Env.get('web_base') + 'settings/downloaders/'
|
||||
log.debug('oauth_token is: %s', oauth_token)
|
||||
self.conf('oauth_token', value = oauth_token);
|
||||
return 'redirect', Env.get('web_base') + 'settings/downloaders/'
|
||||
|
||||
def getAllDownloadStatus(self, ids):
|
||||
|
||||
log.debug('Checking putio download status.')
|
||||
client = pio.Client(self.conf('oauth_token'))
|
||||
|
||||
transfers = client.Transfer.list()
|
||||
|
||||
log.debug(transfers);
|
||||
release_downloads = ReleaseDownloadList(self)
|
||||
for t in transfers:
|
||||
if t.id in ids:
|
||||
|
||||
log.debug('downloading list is %s', self.downloading_list)
|
||||
if t.status == "COMPLETED" and self.conf('download') == False :
|
||||
status = 'completed'
|
||||
|
||||
# So check if we are trying to download something
|
||||
elif t.status == "COMPLETED" and self.conf('download') == True:
|
||||
# Assume we are done
|
||||
status = 'completed'
|
||||
if not self.downloading_list:
|
||||
now = datetime.datetime.utcnow()
|
||||
date_time = datetime.datetime.strptime(t.finished_at,"%Y-%m-%dT%H:%M:%S")
|
||||
# We need to make sure a race condition didn't happen
|
||||
if (now - date_time) < datetime.timedelta(minutes=5):
|
||||
# 5 minutes haven't passed so we wait
|
||||
status = 'busy'
|
||||
else:
|
||||
# If we have the file_id in the downloading_list mark it as busy
|
||||
if str(t.file_id) in self.downloading_list:
|
||||
status = 'busy'
|
||||
else:
|
||||
status = 'busy'
|
||||
release_downloads.append({
|
||||
'id' : t.id,
|
||||
'name': t.name,
|
||||
'status': status,
|
||||
'timeleft': t.estimated_time,
|
||||
})
|
||||
|
||||
return release_downloads
|
||||
|
||||
def putioDownloader(self, fid):
|
||||
|
||||
log.info('Put.io Real downloader called with file_id: %s',fid)
|
||||
client = pio.Client(self.conf('oauth_token'))
|
||||
|
||||
log.debug('About to get file List')
|
||||
files = client.File.list()
|
||||
downloaddir = self.conf('download_dir')
|
||||
|
||||
for f in files:
|
||||
if str(f.id) == str(fid):
|
||||
client.File.download(f, dest = downloaddir, delete_after_download = self.conf('delete_file'))
|
||||
# Once the download is complete we need to remove it from the running list.
|
||||
self.downloading_list.remove(fid)
|
||||
|
||||
return True
|
||||
|
||||
def getFromPutio(self, **kwargs):
|
||||
|
||||
try:
|
||||
file_id = str(kwargs.get('file_id'))
|
||||
except:
|
||||
return {
|
||||
'success' : False,
|
||||
}
|
||||
|
||||
log.info('Put.io Download has been called file_id is %s', file_id)
|
||||
if file_id not in self.downloading_list:
|
||||
self.downloading_list.append(file_id)
|
||||
fireEventAsync('putio.download',fid = file_id)
|
||||
return {
|
||||
'success': True,
|
||||
}
|
||||
|
||||
return {
|
||||
'success': False,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
var PutIODownloader = new Class({
|
||||
|
||||
initialize: function(){
|
||||
var self = this;
|
||||
|
||||
App.addEvent('loadSettings', self.addRegisterButton.bind(self));
|
||||
},
|
||||
|
||||
addRegisterButton: function(){
|
||||
var self = this;
|
||||
|
||||
var setting_page = App.getPage('Settings');
|
||||
setting_page.addEvent('create', function(){
|
||||
|
||||
var fieldset = setting_page.tabs.downloaders.groups.putio,
|
||||
l = window.location;
|
||||
|
||||
var putio_set = 0;
|
||||
fieldset.getElements('input[type=text]').each(function(el){
|
||||
putio_set += +(el.get('value') != '');
|
||||
});
|
||||
|
||||
new Element('.ctrlHolder').adopt(
|
||||
|
||||
// Unregister button
|
||||
(putio_set > 0) ?
|
||||
[
|
||||
self.unregister = new Element('a.button.red', {
|
||||
'text': 'Unregister "'+fieldset.getElement('input[name*=oauth_token]').get('value')+'"',
|
||||
'events': {
|
||||
'click': function(){
|
||||
fieldset.getElements('input[name*=oauth_token]').set('value', '').fireEvent('change');
|
||||
|
||||
self.unregister.destroy();
|
||||
self.unregister_or.destroy();
|
||||
}
|
||||
}
|
||||
}),
|
||||
self.unregister_or = new Element('span[text=or]')
|
||||
]
|
||||
: null,
|
||||
|
||||
// Register button
|
||||
new Element('a.button', {
|
||||
'text': putio_set > 0 ? 'Register a different account' : 'Register your put.io account',
|
||||
'events': {
|
||||
'click': function(){
|
||||
Api.request('downloader.putio.auth_url', {
|
||||
'data': {
|
||||
'host': l.protocol + '//' + l.hostname + (l.port ? ':' + l.port : '')
|
||||
},
|
||||
'onComplete': function(json){
|
||||
window.location = json.url;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
).inject(fieldset.getElement('.test_button'), 'before');
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
window.addEvent('domready', function(){
|
||||
new PutIODownloader();
|
||||
});
|
||||
@@ -41,12 +41,30 @@ class qBittorrent(DownloaderBase):
|
||||
return self.qb
|
||||
|
||||
def test(self):
|
||||
""" Check if connection works
|
||||
:return: bool
|
||||
"""
|
||||
|
||||
if self.connect():
|
||||
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 = {}
|
||||
|
||||
@@ -95,6 +113,14 @@ class qBittorrent(DownloaderBase):
|
||||
return 'busy'
|
||||
|
||||
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 qBittorrent download status.')
|
||||
|
||||
if not self.connect():
|
||||
|
||||
@@ -84,6 +84,10 @@ class rTorrent(DownloaderBase):
|
||||
return self.rt
|
||||
|
||||
def test(self):
|
||||
""" Check if connection works
|
||||
:return: bool
|
||||
"""
|
||||
|
||||
if self.connect(True):
|
||||
return True
|
||||
|
||||
@@ -94,6 +98,20 @@ class rTorrent(DownloaderBase):
|
||||
|
||||
|
||||
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 = {}
|
||||
|
||||
@@ -161,6 +179,14 @@ class rTorrent(DownloaderBase):
|
||||
return 'completed'
|
||||
|
||||
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 rTorrent download status.')
|
||||
|
||||
if not self.connect():
|
||||
|
||||
@@ -21,6 +21,21 @@ class Sabnzbd(DownloaderBase):
|
||||
protocol = ['nzb']
|
||||
|
||||
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 = {}
|
||||
|
||||
@@ -69,6 +84,11 @@ class Sabnzbd(DownloaderBase):
|
||||
return False
|
||||
|
||||
def test(self):
|
||||
""" Check if connection works
|
||||
Return message if an old version of SAB is used
|
||||
:return: bool
|
||||
"""
|
||||
|
||||
try:
|
||||
sab_data = self.call({
|
||||
'mode': 'version',
|
||||
@@ -89,6 +109,13 @@ class Sabnzbd(DownloaderBase):
|
||||
return True
|
||||
|
||||
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 SABnzbd download status.')
|
||||
|
||||
|
||||
@@ -19,6 +19,21 @@ class Synology(DownloaderBase):
|
||||
status_support = 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 = {}
|
||||
|
||||
@@ -50,6 +65,10 @@ class Synology(DownloaderBase):
|
||||
return self.downloadReturnId('') if response else False
|
||||
|
||||
def test(self):
|
||||
""" Check if connection works
|
||||
:return: bool
|
||||
"""
|
||||
|
||||
host = cleanHost(self.conf('host'), protocol = False).split(':')
|
||||
try:
|
||||
srpc = SynologyRPC(host[0], host[1], self.conf('username'), self.conf('password'))
|
||||
|
||||
@@ -34,6 +34,21 @@ class Transmission(DownloaderBase):
|
||||
return self.trpc
|
||||
|
||||
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 = {}
|
||||
|
||||
@@ -88,11 +103,22 @@ class Transmission(DownloaderBase):
|
||||
return self.downloadReturnId(data['hashString'])
|
||||
|
||||
def test(self):
|
||||
""" Check if connection works
|
||||
:return: bool
|
||||
"""
|
||||
|
||||
if self.connect() and self.trpc.get_session():
|
||||
return True
|
||||
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 Transmission download status.')
|
||||
|
||||
@@ -121,6 +147,8 @@ class Transmission(DownloaderBase):
|
||||
status = 'failed'
|
||||
elif torrent['status'] == 0 and torrent['percentDone'] == 1:
|
||||
status = 'completed'
|
||||
elif torrent['status'] == 16 and torrent['percentDone'] == 1:
|
||||
status = 'completed'
|
||||
elif torrent['status'] in [5, 6]:
|
||||
status = 'seeding'
|
||||
|
||||
|
||||
@@ -51,6 +51,21 @@ class uTorrent(DownloaderBase):
|
||||
return self.utorrent_api
|
||||
|
||||
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 = {}
|
||||
|
||||
@@ -120,6 +135,10 @@ class uTorrent(DownloaderBase):
|
||||
return self.downloadReturnId(torrent_hash)
|
||||
|
||||
def test(self):
|
||||
""" Check if connection works
|
||||
:return: bool
|
||||
"""
|
||||
|
||||
if self.connect():
|
||||
build_version = self.utorrent_api.get_build()
|
||||
if not build_version:
|
||||
@@ -131,6 +150,13 @@ class uTorrent(DownloaderBase):
|
||||
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 uTorrent download status.')
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import traceback
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from couchpotato.core.helpers.variable import tryInt
|
||||
from couchpotato.core.logger import CPLog
|
||||
from couchpotato.core.media._base.providers.torrent.base import TorrentProvider
|
||||
import six
|
||||
|
||||
|
||||
log = CPLog(__name__)
|
||||
|
||||
|
||||
class Base(TorrentProvider):
|
||||
|
||||
urls = {
|
||||
'test': 'https://www.torrentleech.org/',
|
||||
'login': 'https://www.torrentleech.org/user/account/login/',
|
||||
'login_check': 'https://torrentleech.org/user/messages',
|
||||
'detail': 'https://www.torrentleech.org/torrent/%s',
|
||||
'search': 'https://www.torrentleech.org/torrents/browse/index/query/%s/categories/%d',
|
||||
'download': 'https://www.torrentleech.org%s',
|
||||
}
|
||||
|
||||
http_time_between_calls = 1 # Seconds
|
||||
cat_backup_id = None
|
||||
|
||||
def _searchOnTitle(self, title, media, quality, results):
|
||||
|
||||
url = self.urls['search'] % self.buildUrl(title, media, quality)
|
||||
|
||||
data = self.getHTMLData(url)
|
||||
|
||||
if data:
|
||||
html = BeautifulSoup(data)
|
||||
|
||||
try:
|
||||
result_table = html.find('table', attrs = {'id': 'torrenttable'})
|
||||
if not result_table:
|
||||
return
|
||||
|
||||
entries = result_table.find_all('tr')
|
||||
|
||||
for result in entries[1:]:
|
||||
|
||||
link = result.find('td', attrs = {'class': 'name'}).find('a')
|
||||
url = result.find('td', attrs = {'class': 'quickdownload'}).find('a')
|
||||
details = result.find('td', attrs = {'class': 'name'}).find('a')
|
||||
|
||||
results.append({
|
||||
'id': link['href'].replace('/torrent/', ''),
|
||||
'name': six.text_type(link.string),
|
||||
'url': self.urls['download'] % url['href'],
|
||||
'detail_url': self.urls['download'] % details['href'],
|
||||
'size': self.parseSize(result.find_all('td')[4].string),
|
||||
'seeders': tryInt(result.find('td', attrs = {'class': 'seeders'}).string),
|
||||
'leechers': tryInt(result.find('td', attrs = {'class': 'leechers'}).string),
|
||||
})
|
||||
|
||||
except:
|
||||
log.error('Failed to parsing %s: %s', (self.getName(), traceback.format_exc()))
|
||||
|
||||
def getLoginParams(self):
|
||||
return {
|
||||
'username': self.conf('username'),
|
||||
'password': self.conf('password'),
|
||||
'remember_me': 'on',
|
||||
'login': 'submit',
|
||||
}
|
||||
|
||||
def loginSuccess(self, output):
|
||||
return '/user/account/logout' in output.lower() or 'welcome back' in output.lower()
|
||||
|
||||
loginCheckSuccess = loginSuccess
|
||||
|
||||
|
||||
config = [{
|
||||
'name': 'torrentleech',
|
||||
'groups': [
|
||||
{
|
||||
'tab': 'searcher',
|
||||
'list': 'torrent_providers',
|
||||
'name': 'TorrentLeech',
|
||||
'description': '<a href="http://torrentleech.org">TorrentLeech</a>',
|
||||
'wizard': True,
|
||||
'icon': 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAACHUlEQVR4AZVSO48SYRSdGTCBEMKzILLAWiybkKAGMZRUUJEoDZX7B9zsbuQPYEEjNLTQkYgJDwsoSaxspEBsCITXjjNAIKi8AkzceXgmbHQ1NJ5iMufmO9/9zrmXlCSJ+B8o75J8Pp/NZj0eTzweBy0Wi4PBYD6f12o1r9ebTCZx+22HcrnMsuxms7m6urTZ7LPZDMVYLBZ8ZV3yo8aq9Pq0wzCMTqe77dDv9y8uLyAWBH6xWOyL0K/56fcb+rrPgPZ6PZfLRe1fsl6vCUmGKIqoqNXqdDr9Dbjps9znUV0uTqdTjuPkDoVCIfcuJ4gizjMMm8u9vW+1nr04czqdK56c37CbKY9j2+1WEARZ0Gq1RFHAz2q1qlQqXxoN69HRcDjUarW8ZD6QUigUOnY8uKYH8N1sNkul9yiGw+F6vS4Rxn8EsodEIqHRaOSnq9T7ajQazWQycEIR1AEBYDabSZJyHDucJyegwWBQr9ebTCaKvHd4cCQANUU9evwQ1Ofz4YvUKUI43GE8HouSiFiNRhOowWBIpVLyHITJkuW3PwgAEf3pgIwxF5r+OplMEsk3CPT5szCMnY7EwUdhwUh/CXiej0Qi3idPz89fdrpdbsfBzH7S3Q9K5pP4c0sAKpVKoVAQGO1ut+t0OoFAQHkH2Da/3/+but3uarWK0ZMQoNdyucRutdttmqZxMTzY7XaYxsrgtUjEZrNhkSwWyy/0NCatZumrNQAAAABJRU5ErkJggg==',
|
||||
'options': [
|
||||
{
|
||||
'name': 'enabled',
|
||||
'type': 'enabler',
|
||||
'default': False,
|
||||
},
|
||||
{
|
||||
'name': 'username',
|
||||
'default': '',
|
||||
},
|
||||
{
|
||||
'name': 'password',
|
||||
'default': '',
|
||||
'type': 'password',
|
||||
},
|
||||
{
|
||||
'name': 'seed_ratio',
|
||||
'label': 'Seed ratio',
|
||||
'type': 'float',
|
||||
'default': 1,
|
||||
'description': 'Will not be (re)moved until this seed ratio is met.',
|
||||
},
|
||||
{
|
||||
'name': 'seed_time',
|
||||
'label': 'Seed time',
|
||||
'type': 'int',
|
||||
'default': 40,
|
||||
'description': 'Will not be (re)moved until this seed time (in hours) is met.',
|
||||
},
|
||||
{
|
||||
'name': 'extra_score',
|
||||
'advanced': True,
|
||||
'label': 'Extra Score',
|
||||
'type': 'int',
|
||||
'default': 20,
|
||||
'description': 'Starting score for each release found via this provider.',
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}]
|
||||
@@ -42,6 +42,7 @@ class Base(TorrentProvider):
|
||||
|
||||
link = result.find('span', attrs = {'class': 'torrent_name_link'}).parent
|
||||
url = result.find('td', attrs = {'class': 'torrent_td'}).find('a')
|
||||
size = result.find('td', attrs = {'class': 'size'}).contents[0].strip('\n ')
|
||||
tds = result.find_all('td')
|
||||
|
||||
results.append({
|
||||
@@ -49,7 +50,7 @@ class Base(TorrentProvider):
|
||||
'name': six.text_type(link.span.string).translate({ord(six.u('\xad')): None}),
|
||||
'url': self.urls['download'] % url['href'],
|
||||
'detail_url': self.urls['download'] % link['href'],
|
||||
'size': self.parseSize(result.find_all('td')[5].string),
|
||||
'size': self.parseSize(size),
|
||||
'seeders': tryInt(tds[len(tds)-2].string),
|
||||
'leechers': tryInt(tds[len(tds)-1].string),
|
||||
})
|
||||
|
||||
@@ -696,7 +696,7 @@ MA.Readd = new Class({
|
||||
|
||||
if(movie_done || snatched && snatched > 0)
|
||||
self.el = new Element('a.readd', {
|
||||
'title': 'Readd the movie and mark all previous snatched/downloaded as ignored',
|
||||
'title': 'Re-add the movie and mark all previous snatched/downloaded as ignored',
|
||||
'events': {
|
||||
'click': self.doReadd.bind(self)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from couchpotato.core.helpers.encoding import tryUrlencode
|
||||
from couchpotato.core.logger import CPLog
|
||||
from couchpotato.core.media._base.providers.torrent.torrentleech import Base
|
||||
from couchpotato.core.media.movie.providers.base import MovieProvider
|
||||
|
||||
log = CPLog(__name__)
|
||||
|
||||
autoload = 'TorrentLeech'
|
||||
|
||||
|
||||
class TorrentLeech(MovieProvider, Base):
|
||||
|
||||
cat_ids = [
|
||||
([13], ['720p', '1080p', 'bd50']),
|
||||
([8], ['cam']),
|
||||
([9], ['ts', 'tc']),
|
||||
([10], ['r5', 'scr']),
|
||||
([11], ['dvdrip']),
|
||||
([14], ['brrip']),
|
||||
([12], ['dvdr']),
|
||||
]
|
||||
|
||||
def buildUrl(self, title, media, quality):
|
||||
return (
|
||||
tryUrlencode(title.replace(':', '')),
|
||||
self.getCatId(quality)[0]
|
||||
)
|
||||
@@ -12,7 +12,7 @@ autoload = 'RottenTomatoes'
|
||||
|
||||
class RottenTomatoes(UserscriptBase):
|
||||
|
||||
includes = ['*://www.rottentomatoes.com/m/*/']
|
||||
includes = ['*://www.rottentomatoes.com/m/*']
|
||||
excludes = ['*://www.rottentomatoes.com/m/*/*/']
|
||||
|
||||
version = 2
|
||||
|
||||
@@ -35,6 +35,7 @@ class Renamer(Plugin):
|
||||
'desc': 'For the renamer to check for new files to rename in a folder',
|
||||
'params': {
|
||||
'async': {'desc': 'Optional: Set to 1 if you dont want to fire the renamer.scan asynchronous.'},
|
||||
'to_folder': {'desc': 'Optional: The folder to move releases to. Leave empty for default folder.'},
|
||||
'media_folder': {'desc': 'Optional: The folder of the media to scan. Keep empty for default renamer folder.'},
|
||||
'files': {'desc': 'Optional: Provide the release files if more releases are in the same media_folder, delimited with a \'|\'. Note that no dedicated release folder is expected for releases with one file.'},
|
||||
'base_folder': {'desc': 'Optional: The folder to find releases in. Leave empty for default folder.'},
|
||||
@@ -44,6 +45,13 @@ class Renamer(Plugin):
|
||||
},
|
||||
})
|
||||
|
||||
addApiView('renamer.progress', self.getProgress, docs = {
|
||||
'desc': 'Get the progress of current renamer scan',
|
||||
'return': {'type': 'object', 'example': """{
|
||||
'progress': False || True,
|
||||
}"""},
|
||||
})
|
||||
|
||||
addEvent('renamer.scan', self.scan)
|
||||
addEvent('renamer.check_snatched', self.checkSnatched)
|
||||
|
||||
@@ -67,11 +75,17 @@ class Renamer(Plugin):
|
||||
|
||||
return True
|
||||
|
||||
def getProgress(self, **kwargs):
|
||||
return {
|
||||
'progress': self.renaming_started
|
||||
}
|
||||
|
||||
def scanView(self, **kwargs):
|
||||
|
||||
async = tryInt(kwargs.get('async', 0))
|
||||
base_folder = kwargs.get('base_folder')
|
||||
media_folder = sp(kwargs.get('media_folder'))
|
||||
to_folder = kwargs.get('to_folder')
|
||||
|
||||
# Backwards compatibility, to be removed after a few versions :)
|
||||
if not media_folder:
|
||||
@@ -95,13 +109,13 @@ class Renamer(Plugin):
|
||||
})
|
||||
|
||||
fire_handle = fireEvent if not async else fireEventAsync
|
||||
fire_handle('renamer.scan', base_folder = base_folder, release_download = release_download)
|
||||
fire_handle('renamer.scan', base_folder = base_folder, release_download = release_download, to_folder = to_folder)
|
||||
|
||||
return {
|
||||
'success': True
|
||||
}
|
||||
|
||||
def scan(self, base_folder = None, release_download = None):
|
||||
def scan(self, base_folder = None, release_download = None, to_folder = None):
|
||||
if not release_download: release_download = {}
|
||||
|
||||
if self.isDisabled():
|
||||
@@ -115,7 +129,9 @@ class Renamer(Plugin):
|
||||
base_folder = sp(self.conf('from'))
|
||||
|
||||
from_folder = sp(self.conf('from'))
|
||||
to_folder = sp(self.conf('to'))
|
||||
|
||||
if not to_folder:
|
||||
to_folder = sp(self.conf('to'))
|
||||
|
||||
# Get media folder to process
|
||||
media_folder = sp(release_download.get('folder'))
|
||||
|
||||
@@ -157,7 +157,15 @@ class Settings(object):
|
||||
values[section] = {}
|
||||
for option in self.p.items(section):
|
||||
(option_name, option_value) = option
|
||||
|
||||
is_password = False
|
||||
try: is_password = self.types[section][option_name] == 'password'
|
||||
except: pass
|
||||
|
||||
values[section][option_name] = self.get(option_name, section)
|
||||
if is_password and values[section][option_name]:
|
||||
values[section][option_name] = len(values[section][option_name]) * '*'
|
||||
|
||||
return values
|
||||
|
||||
def save(self):
|
||||
|
||||
@@ -244,11 +244,13 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En
|
||||
|
||||
# Basic config
|
||||
host = Env.setting('host', default = '0.0.0.0')
|
||||
# app.debug = development
|
||||
host6 = Env.setting('host6', default = '::')
|
||||
|
||||
config = {
|
||||
'use_reloader': reloader,
|
||||
'port': tryInt(Env.setting('port', default = 5050)),
|
||||
'host': host if host and len(host) > 0 else '0.0.0.0',
|
||||
'host6': host6 if host6 and len(host6) > 0 else '::',
|
||||
'ssl_cert': Env.setting('ssl_cert', default = None),
|
||||
'ssl_key': Env.setting('ssl_key', default = None),
|
||||
}
|
||||
@@ -331,6 +333,7 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En
|
||||
while try_restart:
|
||||
try:
|
||||
server.listen(config['port'], config['host'])
|
||||
server.listen(config['port'], config['host6'])
|
||||
loop.start()
|
||||
server.close_all_connections()
|
||||
server.stop()
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Changed
|
||||
# Removed iso8601 library requirement
|
||||
# Added CP logging
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import webbrowser
|
||||
from urllib import urlencode
|
||||
from couchpotato import CPLog
|
||||
from dateutil.parser import parse
|
||||
|
||||
import requests
|
||||
|
||||
BASE_URL = 'https://api.put.io/v2'
|
||||
ACCESS_TOKEN_URL = 'https://api.put.io/v2/oauth2/access_token'
|
||||
AUTHENTICATION_URL = 'https://api.put.io/v2/oauth2/authenticate'
|
||||
|
||||
log = CPLog(__name__)
|
||||
|
||||
|
||||
class AuthHelper(object):
|
||||
|
||||
def __init__(self, client_id, client_secret, redirect_uri, type='code'):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.callback_url = redirect_uri
|
||||
self.type = type
|
||||
|
||||
@property
|
||||
def authentication_url(self):
|
||||
"""Redirect your users to here to authenticate them."""
|
||||
params = {
|
||||
'client_id': self.client_id,
|
||||
'response_type': self.type,
|
||||
'redirect_uri': self.callback_url
|
||||
}
|
||||
return AUTHENTICATION_URL + "?" + urlencode(params)
|
||||
|
||||
def open_authentication_url(self):
|
||||
webbrowser.open(self.authentication_url)
|
||||
|
||||
def get_access_token(self, code):
|
||||
params = {
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': self.callback_url,
|
||||
'code': code
|
||||
}
|
||||
response = requests.get(ACCESS_TOKEN_URL, params=params)
|
||||
log.debug(response)
|
||||
assert response.status_code == 200
|
||||
return response.json()['access_token']
|
||||
|
||||
|
||||
class Client(object):
|
||||
|
||||
def __init__(self, access_token):
|
||||
self.access_token = access_token
|
||||
self.session = requests.session()
|
||||
|
||||
# Keep resource classes as attributes of client.
|
||||
# Pass client to resource classes so resource object
|
||||
# can use the client.
|
||||
attributes = {'client': self}
|
||||
self.File = type('File', (_File,), attributes)
|
||||
self.Transfer = type('Transfer', (_Transfer,), attributes)
|
||||
self.Account = type('Account', (_Account,), attributes)
|
||||
|
||||
def request(self, path, method='GET', params=None, data=None, files=None,
|
||||
headers=None, raw=False, stream=False):
|
||||
"""
|
||||
Wrapper around requests.request()
|
||||
|
||||
Prepends BASE_URL to path.
|
||||
Inserts oauth_token to query params.
|
||||
Parses response as JSON and returns it.
|
||||
|
||||
"""
|
||||
if not params:
|
||||
params = {}
|
||||
|
||||
if not headers:
|
||||
headers = {}
|
||||
|
||||
# All requests must include oauth_token
|
||||
params['oauth_token'] = self.access_token
|
||||
|
||||
headers['Accept'] = 'application/json'
|
||||
|
||||
url = BASE_URL + path
|
||||
log.debug('url: %s', url)
|
||||
|
||||
response = self.session.request(
|
||||
method, url, params=params, data=data, files=files,
|
||||
headers=headers, allow_redirects=True, stream=stream)
|
||||
log.debug('response: %s', response)
|
||||
if raw:
|
||||
return response
|
||||
|
||||
log.debug('content: %s', response.content)
|
||||
try:
|
||||
response = json.loads(response.content)
|
||||
except ValueError:
|
||||
raise Exception('Server didn\'t send valid JSON:\n%s\n%s' % (
|
||||
response, response.content))
|
||||
|
||||
if response['status'] == 'ERROR':
|
||||
raise Exception(response['error_type'])
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class _BaseResource(object):
|
||||
|
||||
client = None
|
||||
|
||||
def __init__(self, resource_dict):
|
||||
"""Constructs the object from a dict."""
|
||||
# All resources must have id and name attributes
|
||||
self.id = None
|
||||
self.name = None
|
||||
self.__dict__.update(resource_dict)
|
||||
try:
|
||||
self.created_at = parse(self.created_at)
|
||||
except AttributeError:
|
||||
self.created_at = None
|
||||
|
||||
def __str__(self):
|
||||
return self.name.encode('utf-8')
|
||||
|
||||
def __repr__(self):
|
||||
# shorten name for display
|
||||
name = self.name[:17] + '...' if len(self.name) > 20 else self.name
|
||||
return '<%s id=%r, name="%r">' % (
|
||||
self.__class__.__name__, self.id, name)
|
||||
|
||||
|
||||
class _File(_BaseResource):
|
||||
|
||||
@classmethod
|
||||
def get(cls, id):
|
||||
d = cls.client.request('/files/%i' % id, method='GET')
|
||||
t = d['file']
|
||||
return cls(t)
|
||||
|
||||
@classmethod
|
||||
def list(cls, parent_id=0):
|
||||
d = cls.client.request('/files/list', params={'parent_id': parent_id})
|
||||
files = d['files']
|
||||
return [cls(f) for f in files]
|
||||
|
||||
@classmethod
|
||||
def upload(cls, path, name=None):
|
||||
with open(path) as f:
|
||||
if name:
|
||||
files = {'file': (name, f)}
|
||||
else:
|
||||
files = {'file': f}
|
||||
d = cls.client.request('/files/upload', method='POST', files=files)
|
||||
|
||||
f = d['file']
|
||||
return cls(f)
|
||||
|
||||
def dir(self):
|
||||
"""List the files under directory."""
|
||||
return self.list(parent_id=self.id)
|
||||
|
||||
def download(self, dest='.', delete_after_download=False):
|
||||
if self.content_type == 'application/x-directory':
|
||||
self._download_directory(dest, delete_after_download)
|
||||
else:
|
||||
self._download_file(dest, delete_after_download)
|
||||
|
||||
def _download_directory(self, dest='.', delete_after_download=False):
|
||||
name = self.name
|
||||
if isinstance(name, unicode):
|
||||
name = name.encode('utf-8', 'replace')
|
||||
|
||||
dest = os.path.join(dest, name)
|
||||
if not os.path.exists(dest):
|
||||
os.mkdir(dest)
|
||||
|
||||
for sub_file in self.dir():
|
||||
sub_file.download(dest, delete_after_download)
|
||||
|
||||
if delete_after_download:
|
||||
self.delete()
|
||||
|
||||
def _download_file(self, dest='.', delete_after_download=False):
|
||||
response = self.client.request(
|
||||
'/files/%s/download' % self.id, raw=True, stream=True)
|
||||
|
||||
filename = re.match(
|
||||
'attachment; filename=(.*)',
|
||||
response.headers['content-disposition']).groups()[0]
|
||||
# If file name has spaces, it must have quotes around.
|
||||
filename = filename.strip('"')
|
||||
|
||||
with open(os.path.join(dest, filename), 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=1024):
|
||||
if chunk: # filter out keep-alive new chunks
|
||||
f.write(chunk)
|
||||
f.flush()
|
||||
|
||||
if delete_after_download:
|
||||
self.delete()
|
||||
|
||||
def delete(self):
|
||||
return self.client.request('/files/delete', method='POST',
|
||||
data={'file_ids': str(self.id)})
|
||||
|
||||
def move(self, parent_id):
|
||||
return self.client.request('/files/move', method='POST',
|
||||
data={'file_ids': str(self.id), 'parent_id': str(parent_id)})
|
||||
|
||||
def rename(self, name):
|
||||
return self.client.request('/files/rename', method='POST',
|
||||
data={'file_id': str(self.id), 'name': str(name)})
|
||||
|
||||
|
||||
class _Transfer(_BaseResource):
|
||||
|
||||
@classmethod
|
||||
def list(cls):
|
||||
d = cls.client.request('/transfers/list')
|
||||
transfers = d['transfers']
|
||||
return [cls(t) for t in transfers]
|
||||
|
||||
@classmethod
|
||||
def get(cls, id):
|
||||
d = cls.client.request('/transfers/%i' % id, method='GET')
|
||||
t = d['transfer']
|
||||
return cls(t)
|
||||
|
||||
@classmethod
|
||||
def add_url(cls, url, parent_id=0, extract=False, callback_url=None):
|
||||
d = cls.client.request('/transfers/add', method='POST', data=dict(
|
||||
url=url, parent_id=parent_id, extract=extract,
|
||||
callback_url=callback_url))
|
||||
t = d['transfer']
|
||||
return cls(t)
|
||||
|
||||
@classmethod
|
||||
def add_torrent(cls, path, parent_id=0, extract=False, callback_url=None):
|
||||
with open(path) as f:
|
||||
files = {'file': f}
|
||||
d = cls.client.request('/files/upload', method='POST', files=files,
|
||||
data=dict(parent_id=parent_id,
|
||||
extract=extract,
|
||||
callback_url=callback_url))
|
||||
t = d['transfer']
|
||||
return cls(t)
|
||||
|
||||
@classmethod
|
||||
def clean(cls):
|
||||
return cls.client.request('/transfers/clean', method='POST')
|
||||
|
||||
|
||||
class _Account(_BaseResource):
|
||||
|
||||
@classmethod
|
||||
def info(cls):
|
||||
return cls.client.request('/account/info', method='GET')
|
||||
|
||||
@classmethod
|
||||
def settings(cls):
|
||||
return cls.client.request('/account/settings', method='GET')
|
||||
Reference in New Issue
Block a user