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:
@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?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>
|
<requires>
|
||||||
<import addon="xbmc.python" version="2.19.0"/>
|
<import addon="xbmc.python" version="2.19.0"/>
|
||||||
</requires>
|
</requires>
|
||||||
|
|||||||
2
lib/libsonic/__init__.py
Normal file → Executable file
2
lib/libsonic/__init__.py
Normal file → Executable file
@ -29,4 +29,4 @@ print conn.ping()
|
|||||||
|
|
||||||
from connection import *
|
from connection import *
|
||||||
|
|
||||||
__version__ = '0.3.4'
|
__version__ = '0.5.1'
|
||||||
313
lib/libsonic/connection.py
Normal file → Executable file
313
lib/libsonic/connection.py
Normal file → Executable 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/>
|
along with py-sonic. If not, see <http://www.gnu.org/licenses/>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from base64 import b64encode
|
|
||||||
from urllib import urlencode
|
from urllib import urlencode
|
||||||
from .errors import *
|
from .errors import *
|
||||||
from pprint import pprint
|
from pprint import pprint
|
||||||
from cStringIO import StringIO
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class HTTPSConnectionChain(httplib.HTTPSConnection):
|
class HTTPSConnectionChain(httplib.HTTPSConnection):
|
||||||
_preferred_ssl_protos = (
|
_preferred_ssl_protos = sorted([ p for p in dir(ssl)
|
||||||
('TLSv1' , ssl.PROTOCOL_TLSv1) ,
|
if p.startswith('PROTOCOL_') ], reverse=True)
|
||||||
('SSLv3' , ssl.PROTOCOL_SSLv3) ,
|
|
||||||
('SSLv23' , ssl.PROTOCOL_SSLv23) ,
|
|
||||||
)
|
|
||||||
_ssl_working_proto = None
|
_ssl_working_proto = None
|
||||||
|
|
||||||
def connect(self):
|
def _create_sock(self):
|
||||||
sock = socket.create_connection((self.host, self.port), self.timeout)
|
sock = socket.create_connection((self.host, self.port), self.timeout)
|
||||||
if self._tunnel_host:
|
if self._tunnel_host:
|
||||||
self.sock = sock
|
self.sock = sock
|
||||||
self._tunnel()
|
self._tunnel()
|
||||||
|
return sock
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
if self._ssl_working_proto is not None:
|
if self._ssl_working_proto is not None:
|
||||||
# If we have a working proto, let's use that straight away
|
# If we have a working proto, let's use that straight away
|
||||||
logger.debug("Using known working proto: '%s'",
|
logger.debug("Using known working proto: '%s'",
|
||||||
self._ssl_working_proto)
|
self._ssl_working_proto)
|
||||||
|
sock = self._create_sock()
|
||||||
self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file,
|
self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file,
|
||||||
ssl_version=self._ssl_working_proto)
|
ssl_version=self._ssl_working_proto)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Try connecting via the different SSL protos in preference order
|
# 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:
|
try:
|
||||||
self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file,
|
self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file,
|
||||||
ssl_version=proto)
|
ssl_version=proto)
|
||||||
except:
|
except:
|
||||||
pass
|
sock.close()
|
||||||
else:
|
else:
|
||||||
# Cache the working ssl version
|
# Cache the working ssl version
|
||||||
HTTPSConnectionChain._ssl_working_proto = proto
|
HTTPSConnectionChain._ssl_working_proto = proto
|
||||||
@ -91,8 +96,9 @@ class PysHTTPRedirectHandler(urllib2.HTTPRedirectHandler):
|
|||||||
raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp)
|
raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp)
|
||||||
|
|
||||||
class Connection(object):
|
class Connection(object):
|
||||||
def __init__(self , baseUrl , username , password , port=4040 ,
|
def __init__(self, baseUrl, username=None, password=None, port=4040,
|
||||||
serverPath='/rest' , appName='py-sonic' , apiVersion=API_VERSION):
|
serverPath='/rest', appName='py-sonic', apiVersion=API_VERSION,
|
||||||
|
insecure=False, useNetrc=None):
|
||||||
"""
|
"""
|
||||||
This will create a connection to your subsonic server
|
This will create a connection to your subsonic server
|
||||||
|
|
||||||
@ -116,8 +122,12 @@ class Connection(object):
|
|||||||
baseUrl = "https://mydomain.com"
|
baseUrl = "https://mydomain.com"
|
||||||
port = 8080
|
port = 8080
|
||||||
serverPath = "/path/to/subsonic/rest"
|
serverPath = "/path/to/subsonic/rest"
|
||||||
username:str The username to use for the connection
|
username:str The username to use for the connection. This
|
||||||
password:str The password to use for the connection
|
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
|
port:int The port number to connect on. The default for
|
||||||
unencrypted subsonic connections is 4040
|
unencrypted subsonic connections is 4040
|
||||||
serverPath:str The base resource path for the subsonic views.
|
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.
|
to find the Subsonic version -> API version table.
|
||||||
This is useful if you are connecting to an older
|
This is useful if you are connecting to an older
|
||||||
version of Subsonic.
|
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._baseUrl = baseUrl
|
||||||
|
self._hostname = baseUrl.split('://')[1].strip()
|
||||||
self._username = username
|
self._username = username
|
||||||
self._rawPass = password
|
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._port = int(port)
|
||||||
self._apiVersion = apiVersion
|
self._apiVersion = apiVersion
|
||||||
self._appName = appName
|
self._appName = appName
|
||||||
self._serverPath = serverPath.strip('/')
|
self._serverPath = serverPath.strip('/')
|
||||||
|
self._insecure = insecure
|
||||||
self._opener = self._getOpener(self._username, self._rawPass)
|
self._opener = self._getOpener(self._username, self._rawPass)
|
||||||
|
|
||||||
# Properties
|
# Properties
|
||||||
@ -181,6 +208,10 @@ class Connection(object):
|
|||||||
self._serverPath = path.strip('/')
|
self._serverPath = path.strip('/')
|
||||||
serverPath = property(lambda s: s._serverPath, setServerPath)
|
serverPath = property(lambda s: s._serverPath, setServerPath)
|
||||||
|
|
||||||
|
def setInsecure(self, insecure):
|
||||||
|
self._insecure = insecure
|
||||||
|
insecure = property(lambda s: s._insecure, setInsecure)
|
||||||
|
|
||||||
# API methods
|
# API methods
|
||||||
def ping(self):
|
def ping(self):
|
||||||
"""
|
"""
|
||||||
@ -199,7 +230,8 @@ class Connection(object):
|
|||||||
if res['status'] == 'ok':
|
if res['status'] == 'ok':
|
||||||
return True
|
return True
|
||||||
elif res['status'] == 'failed':
|
elif res['status'] == 'failed':
|
||||||
raise getExcByCode(res['error']['code'])
|
exc = getExcByCode(res['error']['code'])
|
||||||
|
raise exc(res['error']['message'])
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def getLicense(self):
|
def getLicense(self):
|
||||||
@ -299,7 +331,8 @@ class Connection(object):
|
|||||||
artists for the given folder ID from
|
artists for the given folder ID from
|
||||||
the getMusicFolders call
|
the getMusicFolders call
|
||||||
ifModifiedSince:int If specified, return a result if the artist
|
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:
|
Returns a dict like the following:
|
||||||
|
|
||||||
@ -327,6 +360,7 @@ class Connection(object):
|
|||||||
req = self._getRequest(viewName, q)
|
req = self._getRequest(viewName, q)
|
||||||
res = self._doInfoReq(req)
|
res = self._doInfoReq(req)
|
||||||
self._checkStatus(res)
|
self._checkStatus(res)
|
||||||
|
self._fixLastModified(res)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def getMusicDirectory(self, mid):
|
def getMusicDirectory(self, mid):
|
||||||
@ -426,7 +460,7 @@ class Connection(object):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
def search2(self, query, artistCount=20, artistOffset=0, albumCount=20,
|
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
|
since: 1.4.0
|
||||||
|
|
||||||
@ -440,6 +474,8 @@ class Connection(object):
|
|||||||
albumOffset:int Search offset for albums (for paging) [default: 0]
|
albumOffset:int Search offset for albums (for paging) [default: 0]
|
||||||
songCount:int Max number of songs to return [default: 20]
|
songCount:int Max number of songs to return [default: 20]
|
||||||
songOffset:int Search offset for songs (for paging) [default: 0]
|
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:
|
Returns a dict like the following:
|
||||||
|
|
||||||
@ -476,10 +512,10 @@ class Connection(object):
|
|||||||
methodName = 'search2'
|
methodName = 'search2'
|
||||||
viewName = '%s.view' % methodName
|
viewName = '%s.view' % methodName
|
||||||
|
|
||||||
q = {'query': query , 'artistCount': artistCount ,
|
q = self._getQueryDict({'query': query, 'artistCount': artistCount,
|
||||||
'artistOffset': artistOffset, 'albumCount': albumCount,
|
'artistOffset': artistOffset, 'albumCount': albumCount,
|
||||||
'albumOffset': albumOffset, 'songCount': songCount,
|
'albumOffset': albumOffset, 'songCount': songCount,
|
||||||
'songOffset': songOffset}
|
'songOffset': songOffset, 'musicFolderId': musicFolderId})
|
||||||
|
|
||||||
req = self._getRequest(viewName, q)
|
req = self._getRequest(viewName, q)
|
||||||
res = self._doInfoReq(req)
|
res = self._doInfoReq(req)
|
||||||
@ -487,7 +523,7 @@ class Connection(object):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
def search3(self, query, artistCount=20, artistOffset=0, albumCount=20,
|
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
|
since: 1.8.0
|
||||||
|
|
||||||
@ -501,6 +537,8 @@ class Connection(object):
|
|||||||
albumOffset:int Search offset for albums (for paging) [default: 0]
|
albumOffset:int Search offset for albums (for paging) [default: 0]
|
||||||
songCount:int Max number of songs to return [default: 20]
|
songCount:int Max number of songs to return [default: 20]
|
||||||
songOffset:int Search offset for songs (for paging) [default: 0]
|
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":
|
Returns a dict like the following (search for "Tune Yards":
|
||||||
{u'searchResult3': {u'album': [{u'artist': u'Tune-Yards',
|
{u'searchResult3': {u'album': [{u'artist': u'Tune-Yards',
|
||||||
@ -552,10 +590,10 @@ class Connection(object):
|
|||||||
methodName = 'search3'
|
methodName = 'search3'
|
||||||
viewName = '%s.view' % methodName
|
viewName = '%s.view' % methodName
|
||||||
|
|
||||||
q = {'query': query , 'artistCount': artistCount ,
|
q = self._getQueryDict({'query': query, 'artistCount': artistCount,
|
||||||
'artistOffset': artistOffset, 'albumCount': albumCount,
|
'artistOffset': artistOffset, 'albumCount': albumCount,
|
||||||
'albumOffset': albumOffset, 'songCount': songCount,
|
'albumOffset': albumOffset, 'songCount': songCount,
|
||||||
'songOffset': songOffset}
|
'songOffset': songOffset, 'musicFolderId': musicFolderId})
|
||||||
|
|
||||||
req = self._getRequest(viewName, q)
|
req = self._getRequest(viewName, q)
|
||||||
res = self._doInfoReq(req)
|
res = self._doInfoReq(req)
|
||||||
@ -921,7 +959,8 @@ class Connection(object):
|
|||||||
ldapAuthenticated=False, adminRole=False, settingsRole=True,
|
ldapAuthenticated=False, adminRole=False, settingsRole=True,
|
||||||
streamRole=True, jukeboxRole=False, downloadRole=False,
|
streamRole=True, jukeboxRole=False, downloadRole=False,
|
||||||
uploadRole=False, playlistRole=False, coverArtRole=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
|
since: 1.1.0
|
||||||
|
|
||||||
@ -932,6 +971,7 @@ class Connection(object):
|
|||||||
password:str The password for the new user
|
password:str The password for the new user
|
||||||
email:str The email of the new user
|
email:str The email of the new user
|
||||||
<For info on the boolean roles, see http://subsonic.org for more info>
|
<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:
|
Returns a dict like the following:
|
||||||
|
|
||||||
@ -943,13 +983,16 @@ class Connection(object):
|
|||||||
viewName = '%s.view' % methodName
|
viewName = '%s.view' % methodName
|
||||||
hexPass = 'enc:%s' % self._hexEnc(password)
|
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,
|
'ldapAuthenticated': ldapAuthenticated, 'adminRole': adminRole,
|
||||||
'settingsRole': settingsRole, 'streamRole': streamRole,
|
'settingsRole': settingsRole, 'streamRole': streamRole,
|
||||||
'jukeboxRole': jukeboxRole, 'downloadRole': downloadRole,
|
'jukeboxRole': jukeboxRole, 'downloadRole': downloadRole,
|
||||||
'uploadRole': uploadRole, 'playlistRole': playlistRole,
|
'uploadRole': uploadRole, 'playlistRole': playlistRole,
|
||||||
'coverArtRole': coverArtRole, 'commentRole': commentRole,
|
'coverArtRole': coverArtRole, 'commentRole': commentRole,
|
||||||
'podcastRole': podcastRole , 'shareRole': shareRole}
|
'podcastRole': podcastRole, 'shareRole': shareRole,
|
||||||
|
'musicFolderId': musicFolderId
|
||||||
|
})
|
||||||
|
|
||||||
req = self._getRequest(viewName, q)
|
req = self._getRequest(viewName, q)
|
||||||
res = self._doInfoReq(req)
|
res = self._doInfoReq(req)
|
||||||
@ -960,13 +1003,17 @@ class Connection(object):
|
|||||||
ldapAuthenticated=False, adminRole=False, settingsRole=True,
|
ldapAuthenticated=False, adminRole=False, settingsRole=True,
|
||||||
streamRole=True, jukeboxRole=False, downloadRole=False,
|
streamRole=True, jukeboxRole=False, downloadRole=False,
|
||||||
uploadRole=False, playlistRole=False, coverArtRole=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
|
since 1.10.1
|
||||||
|
|
||||||
Modifies an existing Subsonic user.
|
Modifies an existing Subsonic user.
|
||||||
|
|
||||||
username:str The username of the user to update.
|
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
|
All other args are the same as create user and you can update
|
||||||
whatever item you wish to update for the given username.
|
whatever item you wish to update for the given username.
|
||||||
@ -988,7 +1035,9 @@ class Connection(object):
|
|||||||
'jukeboxRole': jukeboxRole, 'downloadRole': downloadRole,
|
'jukeboxRole': jukeboxRole, 'downloadRole': downloadRole,
|
||||||
'uploadRole': uploadRole, 'playlistRole': playlistRole,
|
'uploadRole': uploadRole, 'playlistRole': playlistRole,
|
||||||
'coverArtRole': coverArtRole, 'commentRole': commentRole,
|
'coverArtRole': coverArtRole, 'commentRole': commentRole,
|
||||||
'podcastRole': podcastRole , 'shareRole': shareRole})
|
'podcastRole': podcastRole, 'shareRole': shareRole,
|
||||||
|
'musicFolderId': musicFolderId, 'maxBitRate': maxBitRate
|
||||||
|
})
|
||||||
req = self._getRequest(viewName, q)
|
req = self._getRequest(viewName, q)
|
||||||
res = self._doInfoReq(req)
|
res = self._doInfoReq(req)
|
||||||
self._checkStatus(res)
|
self._checkStatus(res)
|
||||||
@ -1732,10 +1781,13 @@ class Connection(object):
|
|||||||
self._checkStatus(res)
|
self._checkStatus(res)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def getStarred(self):
|
def getStarred(self, musicFolderId=None):
|
||||||
"""
|
"""
|
||||||
since 1.8.0
|
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 starred songs, albums and artists
|
||||||
|
|
||||||
Returns a dict like the following:
|
Returns a dict like the following:
|
||||||
@ -1797,15 +1849,22 @@ class Connection(object):
|
|||||||
methodName = 'getStarred'
|
methodName = 'getStarred'
|
||||||
viewName = '%s.view' % methodName
|
viewName = '%s.view' % methodName
|
||||||
|
|
||||||
req = self._getRequest(viewName)
|
q = {}
|
||||||
|
if musicFolderId:
|
||||||
|
q['musicFolderId'] = musicFolderId
|
||||||
|
|
||||||
|
req = self._getRequest(viewName, q)
|
||||||
res = self._doInfoReq(req)
|
res = self._doInfoReq(req)
|
||||||
self._checkStatus(res)
|
self._checkStatus(res)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def getStarred2(self):
|
def getStarred2(self, musicFolderId=None):
|
||||||
"""
|
"""
|
||||||
since 1.8.0
|
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(),
|
Returns starred songs, albums and artists like getStarred(),
|
||||||
but this uses ID3 tags for organization
|
but this uses ID3 tags for organization
|
||||||
|
|
||||||
@ -1816,7 +1875,11 @@ class Connection(object):
|
|||||||
methodName = 'getStarred2'
|
methodName = 'getStarred2'
|
||||||
viewName = '%s.view' % methodName
|
viewName = '%s.view' % methodName
|
||||||
|
|
||||||
req = self._getRequest(viewName)
|
q = {}
|
||||||
|
if musicFolderId:
|
||||||
|
q['musicFolderId'] = musicFolderId
|
||||||
|
|
||||||
|
req = self._getRequest(viewName, q)
|
||||||
res = self._doInfoReq(req)
|
res = self._doInfoReq(req)
|
||||||
self._checkStatus(res)
|
self._checkStatus(res)
|
||||||
return res
|
return res
|
||||||
@ -1969,7 +2032,7 @@ class Connection(object):
|
|||||||
self._checkStatus(res)
|
self._checkStatus(res)
|
||||||
return 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
|
since 1.9.0
|
||||||
|
|
||||||
@ -1979,14 +2042,17 @@ class Connection(object):
|
|||||||
count:int The maximum number of songs to return. Max is 500
|
count:int The maximum number of songs to return. Max is 500
|
||||||
default: 10
|
default: 10
|
||||||
offset:int The offset if you are paging. default: 0
|
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'
|
methodName = 'getGenres'
|
||||||
viewName = '%s.view' % methodName
|
viewName = '%s.view' % methodName
|
||||||
|
|
||||||
q = {'genre': genre ,
|
q = self._getQueryDict({'genre': genre,
|
||||||
'count': count,
|
'count': count,
|
||||||
'offset': offset,
|
'offset': offset,
|
||||||
}
|
'musicFolderId': musicFolderId,
|
||||||
|
})
|
||||||
|
|
||||||
req = self._getRequest(viewName, q)
|
req = self._getRequest(viewName, q)
|
||||||
res = self._doInfoReq(req)
|
res = self._doInfoReq(req)
|
||||||
@ -2281,6 +2347,89 @@ class Connection(object):
|
|||||||
self._checkStatus(res)
|
self._checkStatus(res)
|
||||||
return 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):
|
def scanMediaFolders(self):
|
||||||
"""
|
"""
|
||||||
This is not an officially supported method of the API
|
This is not an officially supported method of the API
|
||||||
@ -2326,12 +2475,16 @@ class Connection(object):
|
|||||||
res_msg = res.msg.lower()
|
res_msg = res.msg.lower()
|
||||||
return res_msg == 'ok'
|
return res_msg == 'ok'
|
||||||
|
|
||||||
|
#
|
||||||
# Private internal methods
|
# Private internal methods
|
||||||
|
#
|
||||||
def _getOpener(self, username, passwd):
|
def _getOpener(self, username, passwd):
|
||||||
creds = b64encode('%s:%s' % (username , passwd))
|
# Context is only relevent in >= python 2.7.9
|
||||||
opener = urllib2.build_opener(PysHTTPRedirectHandler ,
|
https_chain = HTTPSHandlerChain()
|
||||||
HTTPSHandlerChain)
|
if sys.version_info[:3] >= (2, 7, 9) and self._insecure:
|
||||||
opener.addheaders = [('Authorization' , 'Basic %s' % creds)]
|
https_chain = HTTPSHandlerChain(
|
||||||
|
context=ssl._create_unverified_context())
|
||||||
|
opener = urllib2.build_opener(PysHTTPRedirectHandler, https_chain)
|
||||||
return opener
|
return opener
|
||||||
|
|
||||||
def _getQueryDict(self, d):
|
def _getQueryDict(self, d):
|
||||||
@ -2343,12 +2496,26 @@ class Connection(object):
|
|||||||
del d[k]
|
del d[k]
|
||||||
return d
|
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={}):
|
def _getRequest(self, viewName, query={}):
|
||||||
qstring = {'f': 'json' , 'v': self._apiVersion , 'c': self._appName}
|
qdict = self._getBaseQdict()
|
||||||
qstring.update(query)
|
qdict.update(query)
|
||||||
url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath,
|
url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath,
|
||||||
viewName)
|
viewName)
|
||||||
req = urllib2.Request(url , urlencode(qstring))
|
req = urllib2.Request(url, urlencode(qdict))
|
||||||
return req
|
return req
|
||||||
|
|
||||||
def _getRequestWithList(self, viewName, listName, alist, query={}):
|
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
|
Like _getRequest, but allows appending a number of items with the
|
||||||
same key (listName). This bypasses the limitation of urlencode()
|
same key (listName). This bypasses the limitation of urlencode()
|
||||||
"""
|
"""
|
||||||
qstring = {'f': 'json' , 'v': self._apiVersion , 'c': self._appName}
|
qdict = self._getBaseQdict()
|
||||||
qstring.update(query)
|
qdict.update(query)
|
||||||
url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath,
|
url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath,
|
||||||
viewName)
|
viewName)
|
||||||
data = StringIO()
|
data = StringIO()
|
||||||
data.write(urlencode(qstring))
|
data.write(urlencode(qdict))
|
||||||
for i in alist:
|
for i in alist:
|
||||||
data.write('&%s' % urlencode({listName: i}))
|
data.write('&%s' % urlencode({listName: i}))
|
||||||
req = urllib2.Request(url, data.getvalue())
|
req = urllib2.Request(url, data.getvalue())
|
||||||
@ -2377,12 +2544,12 @@ class Connection(object):
|
|||||||
listMap:dict A mapping of listName to a list of entries
|
listMap:dict A mapping of listName to a list of entries
|
||||||
query:dict The normal query dict
|
query:dict The normal query dict
|
||||||
"""
|
"""
|
||||||
qstring = {'f': 'json' , 'v': self._apiVersion , 'c': self._appName}
|
qdict = self._getBaseQdict()
|
||||||
qstring.update(query)
|
qdict.update(query)
|
||||||
url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath,
|
url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath,
|
||||||
viewName)
|
viewName)
|
||||||
data = StringIO()
|
data = StringIO()
|
||||||
data.write(urlencode(qstring))
|
data.write(urlencode(qdict))
|
||||||
for k, l in listMap.iteritems():
|
for k, l in listMap.iteritems():
|
||||||
for i in l:
|
for i in l:
|
||||||
data.write('&%s' % urlencode({k: i}))
|
data.write('&%s' % urlencode({k: i}))
|
||||||
@ -2439,3 +2606,53 @@ class Connection(object):
|
|||||||
"""
|
"""
|
||||||
return urllib2.splithost(self._serverPath)[1].split('/')[0]
|
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
0
lib/libsonic/errors.py
Normal file → Executable file
Reference in New Issue
Block a user