update libsonic to v0.5.1

makes plugin work again on Kodi Krypton (fixes issue
https://github.com/basilfx/plugin.audio.subsonic/issues/3)
This commit is contained in:
gordielachance
2016-09-19 23:30:48 +02:00
parent 797a5f2223
commit fb01d0eaa9
4 changed files with 488 additions and 271 deletions

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.audio.subsonic" name="Subsonic" version="2.0.0" provider-name="BasilFX">
<addon id="plugin.audio.subsonic" name="Subsonic" version="2.0.1" provider-name="BasilFX">
<requires>
<import addon="xbmc.python" version="2.19.0"/>
</requires>

2
lib/libsonic/__init__.py Normal file → Executable file
View File

@ -29,4 +29,4 @@ print conn.ping()
from connection import *
__version__ = '0.3.4'
__version__ = '0.5.1'

313
lib/libsonic/connection.py Normal file → Executable file
View File

@ -15,44 +15,49 @@ You should have received a copy of the GNU General Public License
along with py-sonic. If not, see <http://www.gnu.org/licenses/>
"""
from base64 import b64encode
from urllib import urlencode
from .errors import *
from pprint import pprint
from cStringIO import StringIO
import json , urllib2, httplib, logging, socket, ssl
from netrc import netrc
from hashlib import md5
import json, urllib2, httplib, logging, socket, ssl, sys, os
API_VERSION = '1.11.0'
API_VERSION = '1.13.0'
logger = logging.getLogger(__name__)
class HTTPSConnectionChain(httplib.HTTPSConnection):
_preferred_ssl_protos = (
('TLSv1' , ssl.PROTOCOL_TLSv1) ,
('SSLv3' , ssl.PROTOCOL_SSLv3) ,
('SSLv23' , ssl.PROTOCOL_SSLv23) ,
)
_preferred_ssl_protos = sorted([ p for p in dir(ssl)
if p.startswith('PROTOCOL_') ], reverse=True)
_ssl_working_proto = None
def connect(self):
def _create_sock(self):
sock = socket.create_connection((self.host, self.port), self.timeout)
if self._tunnel_host:
self.sock = sock
self._tunnel()
return sock
def connect(self):
if self._ssl_working_proto is not None:
# If we have a working proto, let's use that straight away
logger.debug("Using known working proto: '%s'",
self._ssl_working_proto)
sock = self._create_sock()
self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file,
ssl_version=self._ssl_working_proto)
return
# Try connecting via the different SSL protos in preference order
for proto_name , proto in self._preferred_ssl_protos:
for proto_name in self._preferred_ssl_protos:
sock = self._create_sock()
proto = getattr(ssl, proto_name, None)
try:
self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file,
ssl_version=proto)
except:
pass
sock.close()
else:
# Cache the working ssl version
HTTPSConnectionChain._ssl_working_proto = proto
@ -91,8 +96,9 @@ class PysHTTPRedirectHandler(urllib2.HTTPRedirectHandler):
raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp)
class Connection(object):
def __init__(self , baseUrl , username , password , port=4040 ,
serverPath='/rest' , appName='py-sonic' , apiVersion=API_VERSION):
def __init__(self, baseUrl, username=None, password=None, port=4040,
serverPath='/rest', appName='py-sonic', apiVersion=API_VERSION,
insecure=False, useNetrc=None):
"""
This will create a connection to your subsonic server
@ -116,8 +122,12 @@ class Connection(object):
baseUrl = "https://mydomain.com"
port = 8080
serverPath = "/path/to/subsonic/rest"
username:str The username to use for the connection
password:str The password to use for the connection
username:str The username to use for the connection. This
can be None if `useNetrc' is True (and you
have a valid entry in your netrc file)
password:str The password to use for the connection. This
can be None if `useNetrc' is True (and you
have a valid entry in your netrc file)
port:int The port number to connect on. The default for
unencrypted subsonic connections is 4040
serverPath:str The base resource path for the subsonic views.
@ -140,14 +150,31 @@ class Connection(object):
to find the Subsonic version -> API version table.
This is useful if you are connecting to an older
version of Subsonic.
insecure:bool This will allow you to use self signed
certificates when connecting if set to True.
useNetrc:str|bool You can either specify a specific netrc
formatted file or True to use your default
netrc file ($HOME/.netrc).
"""
self._baseUrl = baseUrl
self._hostname = baseUrl.split('://')[1].strip()
self._username = username
self._rawPass = password
self._netrc = None
if useNetrc is not None:
self._process_netrc(useNetrc)
elif username is None or password is None:
raise CredentialError('You must specify either a username/password '
'combination or "useNetrc" must be either True or a string '
'representing a path to a netrc file')
self._port = int(port)
self._apiVersion = apiVersion
self._appName = appName
self._serverPath = serverPath.strip('/')
self._insecure = insecure
self._opener = self._getOpener(self._username, self._rawPass)
# Properties
@ -181,6 +208,10 @@ class Connection(object):
self._serverPath = path.strip('/')
serverPath = property(lambda s: s._serverPath, setServerPath)
def setInsecure(self, insecure):
self._insecure = insecure
insecure = property(lambda s: s._insecure, setInsecure)
# API methods
def ping(self):
"""
@ -199,7 +230,8 @@ class Connection(object):
if res['status'] == 'ok':
return True
elif res['status'] == 'failed':
raise getExcByCode(res['error']['code'])
exc = getExcByCode(res['error']['code'])
raise exc(res['error']['message'])
return False
def getLicense(self):
@ -299,7 +331,8 @@ class Connection(object):
artists for the given folder ID from
the getMusicFolders call
ifModifiedSince:int If specified, return a result if the artist
collection has changed since the given time
collection has changed since the given
unix timestamp
Returns a dict like the following:
@ -327,6 +360,7 @@ class Connection(object):
req = self._getRequest(viewName, q)
res = self._doInfoReq(req)
self._checkStatus(res)
self._fixLastModified(res)
return res
def getMusicDirectory(self, mid):
@ -426,7 +460,7 @@ class Connection(object):
return res
def search2(self, query, artistCount=20, artistOffset=0, albumCount=20,
albumOffset=0 , songCount=20 , songOffset=0):
albumOffset=0, songCount=20, songOffset=0, musicFolderId=None):
"""
since: 1.4.0
@ -440,6 +474,8 @@ class Connection(object):
albumOffset:int Search offset for albums (for paging) [default: 0]
songCount:int Max number of songs to return [default: 20]
songOffset:int Search offset for songs (for paging) [default: 0]
musicFolderId:int Only return results from the music folder
with the given ID. See getMusicFolders
Returns a dict like the following:
@ -476,10 +512,10 @@ class Connection(object):
methodName = 'search2'
viewName = '%s.view' % methodName
q = {'query': query , 'artistCount': artistCount ,
q = self._getQueryDict({'query': query, 'artistCount': artistCount,
'artistOffset': artistOffset, 'albumCount': albumCount,
'albumOffset': albumOffset, 'songCount': songCount,
'songOffset': songOffset}
'songOffset': songOffset, 'musicFolderId': musicFolderId})
req = self._getRequest(viewName, q)
res = self._doInfoReq(req)
@ -487,7 +523,7 @@ class Connection(object):
return res
def search3(self, query, artistCount=20, artistOffset=0, albumCount=20,
albumOffset=0 , songCount=20 , songOffset=0):
albumOffset=0, songCount=20, songOffset=0, musicFolderId=None):
"""
since: 1.8.0
@ -501,6 +537,8 @@ class Connection(object):
albumOffset:int Search offset for albums (for paging) [default: 0]
songCount:int Max number of songs to return [default: 20]
songOffset:int Search offset for songs (for paging) [default: 0]
musicFolderId:int Only return results from the music folder
with the given ID. See getMusicFolders
Returns a dict like the following (search for "Tune Yards":
{u'searchResult3': {u'album': [{u'artist': u'Tune-Yards',
@ -552,10 +590,10 @@ class Connection(object):
methodName = 'search3'
viewName = '%s.view' % methodName
q = {'query': query , 'artistCount': artistCount ,
q = self._getQueryDict({'query': query, 'artistCount': artistCount,
'artistOffset': artistOffset, 'albumCount': albumCount,
'albumOffset': albumOffset, 'songCount': songCount,
'songOffset': songOffset}
'songOffset': songOffset, 'musicFolderId': musicFolderId})
req = self._getRequest(viewName, q)
res = self._doInfoReq(req)
@ -921,7 +959,8 @@ class Connection(object):
ldapAuthenticated=False, adminRole=False, settingsRole=True,
streamRole=True, jukeboxRole=False, downloadRole=False,
uploadRole=False, playlistRole=False, coverArtRole=False,
commentRole=False , podcastRole=False , shareRole=False):
commentRole=False, podcastRole=False, shareRole=False,
musicFolderId=None):
"""
since: 1.1.0
@ -932,6 +971,7 @@ class Connection(object):
password:str The password for the new user
email:str The email of the new user
<For info on the boolean roles, see http://subsonic.org for more info>
musicFolderId:int These are the only folders the user has access to
Returns a dict like the following:
@ -943,13 +983,16 @@ class Connection(object):
viewName = '%s.view' % methodName
hexPass = 'enc:%s' % self._hexEnc(password)
q = {'username': username , 'password': hexPass , 'email': email ,
q = self._getQueryDict({
'username': username, 'password': hexPass, 'email': email,
'ldapAuthenticated': ldapAuthenticated, 'adminRole': adminRole,
'settingsRole': settingsRole, 'streamRole': streamRole,
'jukeboxRole': jukeboxRole, 'downloadRole': downloadRole,
'uploadRole': uploadRole, 'playlistRole': playlistRole,
'coverArtRole': coverArtRole, 'commentRole': commentRole,
'podcastRole': podcastRole , 'shareRole': shareRole}
'podcastRole': podcastRole, 'shareRole': shareRole,
'musicFolderId': musicFolderId
})
req = self._getRequest(viewName, q)
res = self._doInfoReq(req)
@ -960,13 +1003,17 @@ class Connection(object):
ldapAuthenticated=False, adminRole=False, settingsRole=True,
streamRole=True, jukeboxRole=False, downloadRole=False,
uploadRole=False, playlistRole=False, coverArtRole=False,
commentRole=False , podcastRole=False , shareRole=False):
commentRole=False, podcastRole=False, shareRole=False,
musicFolderId=None, maxBitRate=0):
"""
since 1.10.1
Modifies an existing Subsonic user.
username:str The username of the user to update.
musicFolderId:int Only return results from the music folder
with the given ID. See getMusicFolders
maxBitRate:int The max bitrate for the user. 0 is unlimited
All other args are the same as create user and you can update
whatever item you wish to update for the given username.
@ -988,7 +1035,9 @@ class Connection(object):
'jukeboxRole': jukeboxRole, 'downloadRole': downloadRole,
'uploadRole': uploadRole, 'playlistRole': playlistRole,
'coverArtRole': coverArtRole, 'commentRole': commentRole,
'podcastRole': podcastRole , 'shareRole': shareRole})
'podcastRole': podcastRole, 'shareRole': shareRole,
'musicFolderId': musicFolderId, 'maxBitRate': maxBitRate
})
req = self._getRequest(viewName, q)
res = self._doInfoReq(req)
self._checkStatus(res)
@ -1732,10 +1781,13 @@ class Connection(object):
self._checkStatus(res)
return res
def getStarred(self):
def getStarred(self, musicFolderId=None):
"""
since 1.8.0
musicFolderId:int Only return results from the music folder
with the given ID. See getMusicFolders
Returns starred songs, albums and artists
Returns a dict like the following:
@ -1797,15 +1849,22 @@ class Connection(object):
methodName = 'getStarred'
viewName = '%s.view' % methodName
req = self._getRequest(viewName)
q = {}
if musicFolderId:
q['musicFolderId'] = musicFolderId
req = self._getRequest(viewName, q)
res = self._doInfoReq(req)
self._checkStatus(res)
return res
def getStarred2(self):
def getStarred2(self, musicFolderId=None):
"""
since 1.8.0
musicFolderId:int Only return results from the music folder
with the given ID. See getMusicFolders
Returns starred songs, albums and artists like getStarred(),
but this uses ID3 tags for organization
@ -1816,7 +1875,11 @@ class Connection(object):
methodName = 'getStarred2'
viewName = '%s.view' % methodName
req = self._getRequest(viewName)
q = {}
if musicFolderId:
q['musicFolderId'] = musicFolderId
req = self._getRequest(viewName, q)
res = self._doInfoReq(req)
self._checkStatus(res)
return res
@ -1969,7 +2032,7 @@ class Connection(object):
self._checkStatus(res)
return res
def getSongsByGenre(self , genre , count=10 , offset=0):
def getSongsByGenre(self, genre, count=10, offset=0, musicFolderId=None):
"""
since 1.9.0
@ -1979,14 +2042,17 @@ class Connection(object):
count:int The maximum number of songs to return. Max is 500
default: 10
offset:int The offset if you are paging. default: 0
musicFolderId:int Only return results from the music folder
with the given ID. See getMusicFolders
"""
methodName = 'getGenres'
viewName = '%s.view' % methodName
q = {'genre': genre ,
q = self._getQueryDict({'genre': genre,
'count': count,
'offset': offset,
}
'musicFolderId': musicFolderId,
})
req = self._getRequest(viewName, q)
res = self._doInfoReq(req)
@ -2281,6 +2347,89 @@ class Connection(object):
self._checkStatus(res)
return res
def savePlayQueue(self, qids, current=None, position=None):
"""
since 1.12.0
qid:list[int] The list of song ids in the play queue
current:int The id of the current playing song
position:int The position, in milliseconds, within the current
playing song
Saves the state of the play queue for this user. This includes
the tracks in the play queue, the currently playing track, and
the position within this track. Typically used to allow a user to
move between different clients/apps while retaining the same play
queue (for instance when listening to an audio book).
"""
methodName = 'savePlayQueue'
viewName = '%s.view' % methodName
if not isinstance(qids, (tuple, list)):
qids = [qids]
q = self._getQueryDict({'current': current, 'position': position})
req = self._getRequestWithLists(viewName, {'id': qids}, q)
res = self._doInfoReq(req)
self._checkStatus(res)
return res
def getPlayQueue(self):
"""
since 1.12.0
Returns the state of the play queue for this user (as set by
savePlayQueue). This includes the tracks in the play queue,
the currently playing track, and the position within this track.
Typically used to allow a user to move between different
clients/apps while retaining the same play queue (for instance
when listening to an audio book).
"""
methodName = 'getPlayQueue'
viewName = '%s.view' % methodName
req = self._getRequest(viewName)
res = self._doInfoReq(req)
self._checkStatus(res)
return res
def getTopSongs(self, artist, count=50):
"""
since 1.13.0
Returns the top songs for a given artist
artist:str The artist to get songs for
count:int The number of songs to return
"""
methodName = 'getTopSongs'
viewName = '%s.view' % methodName
q = {'artist': artist, 'count': count}
req = self._getRequest(viewName, q)
res = self._doInfoReq(req)
self._checkStatus(res)
return res
def getNewestPodcasts(self, count=20):
"""
since 1.13.0
Returns the most recently published Podcast episodes
count:int The number of episodes to return
"""
methodName = 'getNewestPodcasts'
viewName = '%s.view' % methodName
q = {'count': count}
req = self._getRequest(viewName, q)
res = self._doInfoReq(req)
self._checkStatus(res)
return res
def scanMediaFolders(self):
"""
This is not an officially supported method of the API
@ -2326,12 +2475,16 @@ class Connection(object):
res_msg = res.msg.lower()
return res_msg == 'ok'
#
# Private internal methods
#
def _getOpener(self, username, passwd):
creds = b64encode('%s:%s' % (username , passwd))
opener = urllib2.build_opener(PysHTTPRedirectHandler ,
HTTPSHandlerChain)
opener.addheaders = [('Authorization' , 'Basic %s' % creds)]
# Context is only relevent in >= python 2.7.9
https_chain = HTTPSHandlerChain()
if sys.version_info[:3] >= (2, 7, 9) and self._insecure:
https_chain = HTTPSHandlerChain(
context=ssl._create_unverified_context())
opener = urllib2.build_opener(PysHTTPRedirectHandler, https_chain)
return opener
def _getQueryDict(self, d):
@ -2343,12 +2496,26 @@ class Connection(object):
del d[k]
return d
def _getBaseQdict(self):
salt = self._getSalt()
token = md5(self._rawPass + salt).hexdigest()
qdict = {
'f': 'json',
'v': self._apiVersion,
'c': self._appName,
'u': self._username,
's': salt,
't': token,
}
return qdict
def _getRequest(self, viewName, query={}):
qstring = {'f': 'json' , 'v': self._apiVersion , 'c': self._appName}
qstring.update(query)
qdict = self._getBaseQdict()
qdict.update(query)
url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath,
viewName)
req = urllib2.Request(url , urlencode(qstring))
req = urllib2.Request(url, urlencode(qdict))
return req
def _getRequestWithList(self, viewName, listName, alist, query={}):
@ -2356,12 +2523,12 @@ class Connection(object):
Like _getRequest, but allows appending a number of items with the
same key (listName). This bypasses the limitation of urlencode()
"""
qstring = {'f': 'json' , 'v': self._apiVersion , 'c': self._appName}
qstring.update(query)
qdict = self._getBaseQdict()
qdict.update(query)
url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath,
viewName)
data = StringIO()
data.write(urlencode(qstring))
data.write(urlencode(qdict))
for i in alist:
data.write('&%s' % urlencode({listName: i}))
req = urllib2.Request(url, data.getvalue())
@ -2377,12 +2544,12 @@ class Connection(object):
listMap:dict A mapping of listName to a list of entries
query:dict The normal query dict
"""
qstring = {'f': 'json' , 'v': self._apiVersion , 'c': self._appName}
qstring.update(query)
qdict = self._getBaseQdict()
qdict.update(query)
url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath,
viewName)
data = StringIO()
data.write(urlencode(qstring))
data.write(urlencode(qdict))
for k, l in listMap.iteritems():
for i in l:
data.write('&%s' % urlencode({k: i}))
@ -2439,3 +2606,53 @@ class Connection(object):
"""
return urllib2.splithost(self._serverPath)[1].split('/')[0]
def _fixLastModified(self, data):
"""
This will recursively walk through a data structure and look for
a dict key/value pair where the key is "lastModified" and change
the shitty java millisecond timestamp to a real unix timestamp
of SECONDS since the unix epoch. JAVA SUCKS!
"""
if isinstance(data, dict):
for k, v in data.items():
if k == 'lastModified':
data[k] = long(v) / 1000.0
return
elif isinstance(v, (tuple, list, dict)):
return self._fixLastModified(v)
elif isinstance(data, (list, tuple)):
for item in data:
if isinstance(item, (list, tuple, dict)):
return self._fixLastModified(item)
def _process_netrc(self, use_netrc):
"""
The use_netrc var is either a boolean, which means we should use
the user's default netrc, or a string specifying a path to a
netrc formatted file
use_netrc:bool|str Either set to True to use the user's default
netrc file or a string specifying a specific
netrc file to use
"""
if not use_netrc:
raise CredentialError('useNetrc must be either a boolean "True" '
'or a string representing a path to a netrc file, '
'not {0}'.format(repr(use_netrc)))
if isinstance(use_netrc, bool) and use_netrc:
self._netrc = netrc()
else:
# This should be a string specifying a path to a netrc file
self._netrc = netrc(os.path.expanduser(use_netrc))
auth = self._netrc.authenticators(self._hostname)
if not auth:
raise CredentialError('No machine entry found for {0} in '
'your netrc file'.format(self._hostname))
# If we get here, we have credentials
self._username = auth[0]
self._rawPass = auth[2]
def _getSalt(self, length=12):
salt = md5(os.urandom(100)).hexdigest()
return salt[:length]

0
lib/libsonic/errors.py Normal file → Executable file
View File