@ -14,63 +14,442 @@
# You should have received a copy of the GNU General Public License
# along with SickGear. If not, see <http://www.gnu.org/licenses/>.
import re
import time
import urllib
from . generic import GenericClient
from . . import helpers , logger
import sickbeard
from sickbeard import helpers
from sickbeard . clients . generic import GenericClient
from requests . exceptions import HTTPError
from six import string_types
class QbittorrentAPI ( GenericClient ) :
def __init__ ( self , host = None , username = None , password = None ) :
super ( QbittorrentAPI , self ) . __init__ ( ' qBittorrent ' , host , username , password )
self . url = self . host
self . session . headers . update ( { ' Origin ' : self . host } )
self . api_ns = None
def _get_auth ( self ) :
def _active_state ( self , ids = None ) :
"""
Fetch state of items , return items that are actually downloading or seeding
: param ids : Optional id ( s ) to get state info for . None to get all
: type ids : list , string or None
: return : Zero or more object ( s ) assigned with state ` down ` loading or ` seed ` ing
: rtype : list
"""
downloaded = ( lambda item : float ( item . get ( ' progress ' ) or 0 ) * ( item . get ( ' size ' ) or 0 ) ) # bytes
wanted = ( lambda item : item . get ( ' priority ' ) ) # wanted will == tally/downloaded if all files are selected
base_state = ( lambda t , gp , f : dict (
id = t [ ' hash ' ] , title = t [ ' name ' ] , total_size = gp . get ( ' total_size ' ) or 0 ,
added_ts = gp . get ( ' addition_date ' ) , last_completed_ts = gp . get ( ' completion_date ' ) ,
last_started_ts = None , seed_elapsed_secs = gp . get ( ' seeding_time ' ) ,
wanted_size = sum ( map ( lambda tf : wanted ( tf ) and tf . get ( ' size ' ) or 0 , f ) ) or None ,
wanted_down = sum ( map ( lambda tf : wanted ( tf ) and downloaded ( tf ) or 0 , f ) ) or None ,
tally_down = sum ( map ( lambda tf : downloaded ( tf ) or 0 , f ) ) or None ,
tally_up = gp . get ( ' total_uploaded ' ) ,
state = ' done ' if ' pausedUP ' == t . get ( ' state ' ) else ( ' down ' , ' seed ' ) [ ' up ' in t . get ( ' state ' ) . lower ( ) ]
) )
file_list = ( lambda ti : self . _client_request (
( ' torrents/files ' , ' query/propertiesFiles/ %s ' % ti [ ' hash ' ] ) [ not self . api_ns ] ,
params = ( { ' hash ' : ti [ ' hash ' ] } , { } ) [ not self . api_ns ] , json = True ) or { } )
valid_stat = ( lambda ti : not self . _ignore_state ( ti )
and sum ( map ( lambda tf : wanted ( tf ) and downloaded ( tf ) or 0 , file_list ( ti ) ) ) )
result = map ( lambda t : base_state ( t , self . _tinf ( t [ ' hash ' ] ) [ 0 ] , file_list ( t ) ) ,
filter ( lambda t : re . search ( ' (?i)queue|stall|(up|down)load|pausedUP ' , t [ ' state ' ] ) and valid_stat ( t ) ,
self . _tinf ( ids , False ) ) )
self . auth = ( 6 < self . api_version ( ) and
' Ok ' in helpers . getURL ( ' %s login ' % self . host , session = self . session ,
post_data = { ' username ' : self . username , ' password ' : self . password } ) )
return self . auth
return result
def _tinf ( self , ids = None , use_props = True , err = False ) :
"""
Fetch client task information
: param ids : Optional id ( s ) to get task info for . None to get all task info
: type ids : list or None
: param use_props : Optional override forces retrieval of torrents info instead of torrent generic properties
: type ids : Boolean
: param err : Optional return error dict instead of empty array
: type err : Boolean
: return : Zero or more task object ( s ) from response
: rtype : list
"""
result = [ ]
rids = ( ids if isinstance ( ids , ( list , type ( None ) ) ) else [ x . strip ( ) for x in ids . split ( ' , ' ) ] ) or [ None ]
getinfo = use_props and None is not ids
params = { }
cmd = ( ' torrents/info ' , ' query/torrents ' ) [ not self . api_ns ]
if not getinfo :
label = sickbeard . TORRENT_LABEL . replace ( ' ' , ' _ ' )
if label and not ids :
params [ ' category ' ] = label
for rid in rids :
if getinfo :
if self . api_ns :
cmd = ' torrents/properties '
params [ ' hash ' ] = rid
else :
cmd = ' query/propertiesGeneral/ %s ' % rid
elif rid :
params [ ' hashes ' ] = rid
try :
tasks = self . _client_request ( cmd , params = params , timeout = 60 , json = True )
result + = tasks and ( isinstance ( tasks , list ) and tasks or ( isinstance ( tasks , dict ) and [ tasks ] ) ) \
or ( [ ] , [ { ' state ' : ' error ' , ' hash ' : rid } ] ) [ err ]
except ( BaseException , Exception ) :
if getinfo :
result + = [ dict ( error = True , id = rid ) ]
for t in filter ( lambda d : isinstance ( d . get ( ' name ' ) , string_types ) and d . get ( ' name ' ) , ( result , [ ] ) [ getinfo ] ) :
t [ ' name ' ] = urllib . unquote_plus ( t . get ( ' name ' ) )
return result
def _set_torrent_pause ( self , search_result ) :
"""
Set torrent as paused used for the " add as paused " feature ( overridden class function )
: param search_result : A populated search result object
: type search_result : TorrentSearchResult
: return : Success or Falsy if fail
: rtype : bool
"""
if not sickbeard . TORRENT_PAUSED :
return super ( QbittorrentAPI , self ) . _set_torrent_pause ( search_result )
return True is self . _pause_torrent ( search_result . hash )
def _set_torrent_label ( self , search_result ) :
if not sickbeard . TORRENT_LABEL . replace ( ' ' , ' _ ' ) :
return super ( QbittorrentAPI , self ) . _set_torrent_label ( search_result )
return True is self . _label_torrent ( search_result . hash )
def _set_torrent_priority ( self , search_result ) :
if 1 != search_result . priority :
return super ( QbittorrentAPI , self ) . _set_torrent_priority ( search_result )
return True is self . _maxpri_torrent ( search_result . hash )
@staticmethod
def _ignore_state ( task ) :
return bool ( re . search ( r ' (?i)error ' , task . get ( ' state ' ) or ' ' ) )
def _maxpri_torrent ( self , ids ) :
"""
Set maximal priority in queue to torrent task
: param ids : ID ( s ) to promote
: type ids : list or string
: return : True / Falsy if success / failure else Id ( s ) that failed to be changed
: rtype : bool or list
"""
def _maxpri_filter ( t ) :
mark_fail = True
if not self . _ignore_state ( t ) :
if 1 > = t . get ( ' priority ' ) :
return not mark_fail
params = { ' hashes ' : t . get ( ' hash ' ) }
post_data = None
if not self . api_ns :
post_data = params
params = None
response = self . _client_request (
' %s /topPrio ' % ( ' torrents ' , ' command ' ) [ not self . api_ns ] ,
params = params , post_data = post_data , raise_status_code = True )
if True is response :
task = self . _tinf ( t . get ( ' hash ' ) , use_props = False , err = True ) [ 0 ]
return 1 < task . get ( ' priority ' ) or self . _ignore_state ( task ) # then mark fail
elif isinstance ( response , string_types ) and ' queueing ' in response . lower ( ) :
logger . log ( ' %s : %s ' % ( self . name , response ) , logger . ERROR )
return not mark_fail
return mark_fail
return self . _action ( ' topPrio ' , ids , lambda t : _maxpri_filter ( t ) )
def _label_torrent ( self , ids ) :
"""
Set label / category to torrent task
: param ids : ID ( s ) to change
: type ids : list or string
: return : True / Falsy if success / failure else Id ( s ) that failed to be changed
: rtype : bool or list
"""
def _label_filter ( t ) :
mark_fail = True
if not self . _ignore_state ( t ) :
label = sickbeard . TORRENT_LABEL . replace ( ' ' , ' _ ' )
if label in t . get ( ' category ' ) :
return not mark_fail
response = self . _client_request (
' %s /setCategory ' % ( ' torrents ' , ' command ' ) [ not self . api_ns ] ,
post_data = { ' hashes ' : t . get ( ' hash ' ) , ' category ' : label , ' label ' : label } , raise_status_code = True )
if True is response :
task = self . _tinf ( t . get ( ' hash ' ) , use_props = False , err = True ) [ 0 ]
return label not in task . get ( ' category ' ) or self . _ignore_state ( task ) # then mark fail
elif isinstance ( response , string_types ) and ' incorrect ' in response . lower ( ) :
logger . log ( ' %s : %s . " %s " isn \' t known to qB ' % ( self . name , response , label ) , logger . ERROR )
return not mark_fail
return mark_fail
return self . _action ( ' label ' , ids , lambda t : _label_filter ( t ) )
def _pause_torrent ( self , ids ) :
"""
Pause item ( s )
: param ids : Id ( s ) to pause
: type ids : list or string
: return : True / Falsy if success / failure else Id ( s ) that failed to be paused
: rtype : bool or list
"""
def _pause_filter ( t ) :
mark_fail = True
if not self . _ignore_state ( t ) :
if ' paused ' in t . get ( ' state ' ) :
return not mark_fail
if True is self . _client_request (
' %s /pause ' % ( ' torrents ' , ' command ' ) [ not self . api_ns ] ,
post_data = { ' hash ' + ( ' es ' , ' ' ) [ not self . api_ns ] : t . get ( ' hash ' ) } ) :
task = self . _tinf ( t . get ( ' hash ' ) , use_props = False , err = True ) [ 0 ]
return ' paused ' not in task . get ( ' state ' ) or self . _ignore_state ( task ) # then mark fail
return mark_fail
# check task state stability, and call pause where not paused
sample_size = 10
iv = 0.5
states = [ ]
for i in range ( 0 , sample_size ) :
states + = [ self . _tinf ( ids , False ) [ 0 ] [ ' state ' ] ]
if ' paused ' not in states [ - 1 ] :
self . _action ( ' pause ' , ids , lambda t : _pause_filter ( t ) )
break
time . sleep ( iv )
# as precaution, if was unstable, do another pass
sample_size = 10
iterations = int ( ( 5 + sample_size ) * iv * ( 1 / iv ) ) # timeout, ought never happen
while 1 != len ( set ( states ) ) and iterations :
for i in range ( 0 , sample_size ) :
states + = [ self . _tinf ( ids , False ) [ 0 ] [ ' state ' ] ]
if ' paused ' not in states [ - 1 ] and True is not self . _action ( ' pause ' , ids , lambda t : _pause_filter ( t ) ) :
time . sleep ( iv )
iterations - = 1
if iterations :
continue
iterations = None
break
states = states [ - sample_size : ]
return ' paused ' in states [ - 1 ]
def _resume_torrent ( self , ids ) :
"""
Resume task ( s ) in client
: param ids : Id ( s ) to act on
: type ids : list or string
: return : True if success , Id ( s ) that could not be resumed , else Falsy if failure
: rtype : bool or list
"""
return self . _perform_task (
' resume ' , ids ,
lambda t : self . _ignore_state ( t ) or
( ' paused ' in t . get ( ' state ' ) ) and
True is not self . _client_request (
' %s /resume ' % ( ' torrents ' , ' command ' ) [ not self . api_ns ] ,
post_data = { ' hash ' + ( ' es ' , ' ' ) [ not self . api_ns ] : t . get ( ' hash ' ) } ) )
def _delete_torrent ( self , ids ) :
"""
Delete task ( s ) from client
: param ids : Id ( s ) to act on
: type ids : list or string
: return : True if success , Id ( s ) that could not be deleted , else Falsy if failure
: rtype : bool or list
"""
return self . _perform_task (
' delete ' , ids ,
lambda t : self . _ignore_state ( t ) or
True is not self . _client_request (
( ' torrents/delete ' , ' command/deletePerm ' ) [ not self . api_ns ] ,
post_data = dict ( [ ( ' hashes ' , t . get ( ' hash ' ) ) ] + ( [ ( ' deleteFiles ' , True ) ] , [ ] ) [ not self . api_ns ] ) ) ,
pause_first = True )
def _perform_task ( self , method , ids , filter_func , pause_first = False ) :
"""
Set up and send a method to client
: param method : Either ` resume ` or ` delete `
: type method : string
: param ids : Id ( s ) to perform method on
: type ids : list or string
: param filter_func : Call back function passed to _action that will filter tasks as failed or erroneous
: type Function
: param pause_first : True if task should be paused prior to invoking method
: type Boolean
: return : True if success , Id ( s ) that could not be acted upon , else Falsy if failure
: rtype : Boolean or list
"""
if isinstance ( ids , ( string_types , list ) ) :
rids = ids if isinstance ( ids , list ) else map ( lambda x : x . strip ( ) , ids . split ( ' , ' ) )
result = pause_first and self . _pause_torrent ( rids ) # get items not paused
result = ( isinstance ( result , list ) and result or [ ] )
for t_id in list ( set ( rids ) - ( isinstance ( result , list ) and set ( result ) or set ( ) ) ) : # perform on paused ids
if True is not self . _action ( method , t_id , filter_func ) :
result + = [ t_id ] # failed item
def api_version ( self ) :
return result or True
return helpers . tryInt ( helpers . getURL ( ' %s version/api ' % self . host , session = self . session ) )
def _action ( self , act , ids , filter_func ) :
def _post_api ( self , cmd = ' ' , * * kwargs ) :
if isinstance ( ids , ( string_types , list ) ) :
item = dict ( fail = [ ] , ignore = [ ] )
for task in filter ( filter_func , self . _tinf ( ids , use_props = False , err = True ) ) :
item [ ( ' fail ' , ' ignore ' ) [ self . _ignore_state ( task ) ] ] + = [ task . get ( ' hash ' ) ]
return helpers . getURL ( ' %s command/ %s ' % ( self . host , cmd ) , session = self . session , * * kwargs ) in ( ' ' , ' Ok. ' )
# retry items that are not acted on
retry_ids = item [ ' fail ' ]
tries = ( 1 , 3 , 5 , 10 , 15 , 15 , 30 , 60 )
i = 0
while retry_ids :
for i in tries :
logger . log ( ' %s : retry %s %s item(s) in %s s ' % ( self . name , act , len ( item [ ' fail ' ] ) , i ) , logger . DEBUG )
time . sleep ( i )
item [ ' fail ' ] = [ ]
for task in filter ( filter_func , self . _tinf ( retry_ids , use_props = False , err = True ) ) :
item [ ( ' fail ' , ' ignore ' ) [ self . _ignore_state ( task ) ] ] + = [ task . get ( ' hash ' ) ]
def _add_torrent ( self , cmd , * * kwargs ) :
if not item [ ' fail ' ] :
retry_ids = None
break
retry_ids = item [ ' fail ' ]
else :
if max ( tries ) == i :
logger . log ( ' %s : failed to %s %s item(s) after %s tries over %s mins, aborted ' %
( self . name , act , len ( item [ ' fail ' ] ) , len ( tries ) , sum ( tries ) / 60 ) , logger . DEBUG )
return ( item [ ' fail ' ] + item [ ' ignore ' ] ) or True
def _add_torrent_uri ( self , search_result ) :
"""
Add magnet to client ( overridden class function )
: param search_result : A populated search result object
: type search_result : TorrentSearchResult
: return : True if created , else Falsy if nothing created
: rtype : Boolean or None
"""
return search_result and self . _add_torrent ( ' download ' , search_result ) or False
def _add_torrent_file ( self , search_result ) :
"""
Add file to client ( overridden class function )
: param search_result : A populated search result object
: type search_result : TorrentSearchResult
: return : True if created , else Falsy if nothing created
: rtype : Boolean or None
"""
return search_result and self . _add_torrent ( ' upload ' , search_result ) or False
def _add_torrent ( self , cmd , data ) :
"""
Create client task
: param cmd : Command for client API v6 , converted up for newer API
: type cmd : String
: param data : A populated search result object
: type data : TorrentSearchResult
: return : True if created , else Falsy if nothing created
: rtype : Boolean or None
"""
if self . _tinf ( data . hash ) :
logger . log ( ' Could not create task, the hash is already in use ' , logger . ERROR )
return
label = sickbeard . TORRENT_LABEL . replace ( ' ' , ' _ ' )
label_dict = { ' label ' : label , ' category ' : label , ' savepath ' : sickbeard . TORRENT_PATH }
if ' post_data ' in kwargs :
kwargs [ ' post_data ' ] . update ( label_dict )
params = dict (
( [ ( ' category ' , label ) , ( ' label ' , label ) ] , [ ] ) [ not label ]
+ ( [ ( ' paused ' , ( ' false ' , ' true ' ) [ bool ( sickbeard . TORRENT_PAUSED ) ] ) ] , [ ] ) [ not sickbeard . TORRENT_PAUSED ]
+ ( [ ( ' savepath ' , sickbeard . TORRENT_PATH ) ] , [ ] ) [ not sickbeard . TORRENT_PATH ]
)
if ' download ' == cmd :
params . update ( dict ( urls = data . url ) )
kwargs = dict ( post_data = params )
else :
kwargs . update ( { ' post_data ' : label_dict } )
return self . _post_api ( cmd , * * kwargs )
kwargs = dict ( post_data = params , files = { ' torrents ' : ( ' %s .torrent ' % data . name , data . content ) } )
def _add_torrent_uri ( self , result ) :
task_stamp = int ( sickbeard . sbdatetime . sbdatetime . now ( ) . totimestamp ( default = 0 ) )
response = self . _client_request ( ( ' torrents/add ' , ' command/ %s ' % cmd ) [ not self . api_ns ] , * * kwargs )
return self . _add_torrent ( ' download ' , post_data = { ' urls ' : result . url } )
if True is response :
for s in ( 1 , 3 , 5 , 10 , 15 , 30 , 60 ) :
if filter ( lambda t : task_stamp < = t [ ' addition_date ' ] , self . _tinf ( data . hash ) ) :
return data . hash
time . sleep ( s )
return True
def _add_torrent_file ( self , result ) :
def api_found ( self ) :
return self . _add_torrent ( ' upload ' , files = { ' torrents ' : ( ' %s .torrent ' % result . name , result . content ) } )
try :
v = self . _client_request ( ' app/webapiVersion ' ) . split ( ' . ' )
return ( 2 , 0 ) < tuple ( [ helpers . tryInt ( x ) for x in ' . ' . join ( v + [ ' 0 ' ] * ( 4 - len ( v ) ) ) . split ( ' . ' ) ] )
except AttributeError :
return 6 < helpers . tryInt ( self . _client_request ( ' version/api ' ) )
###
# An issue in qB can lead to actions being ignored during the initial period after a file is added.
# Therefore, actions that need to be applied to existing items will be disabled unless fixed.
###
# def _set_torrent_priority(self, result):
#
# return self._post_api('%screasePrio' % ('de', 'in')[1 == result.priority], post_data={'hashes': result.hash})
def _client_request ( self , cmd = ' ' , * * kwargs ) :
"""
Send a request to client
: param cmd : Api task to invoke
: type cmd : string_types
: param kwargs : keyword arguments to pass thru to helpers getURL function
: type kwargs : mixed
: return : JSON decoded response dict , True if success and no response body , Text error or None if failure ,
: rtype : Dict , Boolean , String or None
"""
authless = bool ( re . search ( ' (?i)login|version ' , cmd ) )
if authless or self . auth :
if not authless and not self . _get_auth ( ) :
logger . log ( ' %s : Authentication failed ' % self . name , logger . ERROR )
return
# def _set_torrent_pause(self, result):
#
# return self._post_api(('resume', 'pause')[sickbeard.TORRENT_PAUSED], post_data={'hash': result.hash})
# self._log_request_details('%s%s' % (self.api_ns, cmd.strip('/')), **kwargs)
response = None
try :
response = helpers . getURL ( ' %s %s %s ' % ( self . host , self . api_ns , cmd . strip ( ' / ' ) ) ,
session = self . session , * * kwargs )
except HTTPError as e :
if e . response . status_code in ( 409 , 403 ) :
response = e . response . text
except ( StandardError , Exception ) :
pass
if isinstance ( response , string_types ) :
if response [ 0 : 3 ] . lower ( ) in ( ' ' , ' ok. ' ) :
return True
elif response [ 0 : 4 ] . lower ( ) == ' fail ' :
return False
return response
def _get_auth ( self ) :
"""
Authenticate with client ( overridden class function )
: return : True on success , or False on failure
: rtype : Boolean
"""
post_data = dict ( username = self . username , password = self . password )
self . api_ns = ' api/v2/ '
response = self . _client_request ( ' auth/login ' , post_data = post_data , raise_status_code = True )
if isinstance ( response , string_types ) and ' banned ' in response . lower ( ) :
logger . log ( ' %s : %s ' % ( self . name , response ) , logger . ERROR )
response = False
elif not response :
self . api_ns = ' '
response = self . _client_request ( ' login ' , post_data = post_data )
self . auth = response and self . api_found ( )
return self . auth
api = QbittorrentAPI ( )