""" This file is part of py-sonic. py-sonic is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. py-sonic is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with py-sonic. If not, see """ from libsonic.errors import * from netrc import netrc from hashlib import md5 import urllib.request import urllib.error from http import client as http_client from urllib.parse import urlencode from io import StringIO import json import logging import socket import ssl import sys import os import xbmc API_VERSION = '1.16.1' logger = logging.getLogger(__name__) class HTTPSConnectionChain(http_client.HTTPSConnection): 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): sock = self._create_sock() try: self.sock = ssl.create_default_context().wrap_socket(sock, server_hostname=self.host) except: sock.close() class HTTPSHandlerChain(urllib.request.HTTPSHandler): def https_open(self, req): return self.do_open(HTTPSConnectionChain, req) # install opener urllib.request.install_opener(urllib.request.build_opener(HTTPSHandlerChain())) class PysHTTPRedirectHandler(urllib.request.HTTPRedirectHandler): """ This class is used to override the default behavior of the HTTPRedirectHandler, which does *not* redirect POST data """ def redirect_request(self, req, fp, code, msg, headers, newurl): m = req.get_method() if (code in (301, 302, 303, 307) and m in ("GET", "HEAD") or code in (301, 302, 303) and m == "POST"): newurl = newurl.replace(' ', '%20') newheaders = dict((k, v) for k, v in list(req.headers.items()) if k.lower() not in ("content-length", "content-type") ) data = None if req.data: data = req.data return urllib.request.Request(newurl, data=data, headers=newheaders, origin_req_host=req.origin_req_host, unverifiable=True) else: raise urllib.error.HTTPError( req.get_full_url(), code, msg, headers, fp, ) class Connection(object): def __init__(self, baseUrl, username=None, password=None, port=4040, serverPath='/rest', appName='py-sonic', apiVersion=API_VERSION, insecure=False, useNetrc=None, legacyAuth=False, useGET=True): """ This will create a connection to your subsonic server baseUrl:str The base url for your server. Be sure to use "https" for SSL connections. If you are using a port other than the default 4040, be sure to specify that with the port argument. Do *not* append it here. ex: http://subsonic.example.com If you are running subsonic under a different path, specify that with the "serverPath" arg, *not* here. For example, if your subsonic lives at: https://mydomain.com:8080/path/to/subsonic/rest You would set the following: baseUrl = "https://mydomain.com" port = 8080 serverPath = "/path/to/subsonic/rest" 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. This is useful if you have your subsonic server behind a proxy and the path that you are proxying is different from the default of '/rest'. Ex: serverPath='/path/to/subs' The full url that would be built then would be (assuming defaults and using "example.com" and you are using the "ping" view): http://example.com:4040/path/to/subs/ping.view appName:str The name of your application. apiVersion:str The API version you wish to use for your application. Subsonic will throw an error if you try to use/send an api version higher than what the server supports. See the Subsonic API docs 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). legacyAuth:bool Use pre-1.13.0 API version authentication useGET:bool Use a GET request instead of the default POST request. This is not recommended as request URLs can get very long with some API calls """ self._baseUrl = baseUrl self._hostname = baseUrl.split('://')[1].strip() self._username = username self._rawPass = password self._legacyAuth = legacyAuth self._useGET = useGET 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 def setBaseUrl(self, url): self._baseUrl = url self._opener = self._getOpener(self._username, self._rawPass) baseUrl = property(lambda s: s._baseUrl, setBaseUrl) def setPort(self, port): self._port = int(port) port = property(lambda s: s._port, setPort) def setUsername(self, username): self._username = username self._opener = self._getOpener(self._username, self._rawPass) username = property(lambda s: s._username, setUsername) def setPassword(self, password): self._rawPass = password # Redo the opener with the new creds self._opener = self._getOpener(self._username, self._rawPass) password = property(lambda s: s._rawPass, setPassword) apiVersion = property(lambda s: s._apiVersion) def setAppName(self, appName): self._appName = appName appName = property(lambda s: s._appName, setAppName) def setServerPath(self, path): 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) def setLegacyAuth(self, lauth): self._legacyAuth = lauth legacyAuth = property(lambda s: s._legacyAuth, setLegacyAuth) def setGET(self, get): self._useGET = get useGET = property(lambda s: s._useGET, setGET) # API methods def ping(self): """ since: 1.0.0 Returns a boolean True if the server is alive, False otherwise """ methodName = 'ping' viewName = '%s.view' % methodName req = self._getRequest(viewName) try: res = self._doInfoReq(req) except: return False if res['status'] == 'ok': return True elif res['status'] == 'failed': exc = getExcByCode(res['error']['code']) raise exc(res['error']['message']) return False def getLicense(self): """ since: 1.0.0 Gets details related to the software license Returns a dict like the following: {u'license': {u'date': u'2010-05-21T11:14:39', u'email': u'email@example.com', u'key': u'12345678901234567890123456789012', u'valid': True}, u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getLicense' viewName = '%s.view' % methodName req = self._getRequest(viewName) res = self._doInfoReq(req) self._checkStatus(res) return res def getScanStatus(self): """ since: 1.15.0 returns the current status for media library scanning. takes no extra parameters. returns a dict like the following: {'status': 'ok', 'version': '1.15.0', 'scanstatus': {'scanning': true, 'count': 4680}} 'count' is the total number of items to be scanned """ methodName = 'getScanStatus' viewName = '%s.view' % methodName req = self._getRequest(viewName) res = self._doInfoReq(req) self._checkStatus(res) return res def startScan(self): """ since: 1.15.0 Initiates a rescan of the media libraries. Takes no extra parameters. returns a dict like the following: {'status': 'ok', 'version': '1.15.0', 'scanstatus': {'scanning': true, 'count': 0}} 'scanning' changes to false when a scan is complete 'count' starts a 0 and ends at the total number of items scanned """ methodName = 'startScan' viewName = '%s.view' % methodName req = self._getRequest(viewName) res = self._doInfoReq(req) self._checkStatus(res) return res def getMusicFolders(self): """ since: 1.0.0 Returns all configured music folders Returns a dict like the following: {u'musicFolders': {u'musicFolder': [{u'id': 0, u'name': u'folder1'}, {u'id': 1, u'name': u'folder2'}, {u'id': 2, u'name': u'folder3'}]}, u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getMusicFolders' viewName = '%s.view' % methodName req = self._getRequest(viewName) res = self._doInfoReq(req) self._checkStatus(res) return res def getNowPlaying(self): """ since: 1.0.0 Returns what is currently being played by all users Returns a dict like the following: {u'nowPlaying': {u'entry': {u'album': u"Jazz 'Round Midnight 12", u'artist': u'Astrud Gilberto', u'bitRate': 172, u'contentType': u'audio/mpeg', u'coverArt': u'98349284', u'duration': 325, u'genre': u'Jazz', u'id': u'2424324', u'isDir': False, u'isVideo': False, u'minutesAgo': 0, u'parent': u'542352', u'path': u"Astrud Gilberto/Jazz 'Round Midnight 12/01 - The Girl From Ipanema.mp3", u'playerId': 1, u'size': 7004089, u'suffix': u'mp3', u'title': u'The Girl From Ipanema', u'track': 1, u'username': u'user1', u'year': 1996}}, u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getNowPlaying' viewName = '%s.view' % methodName req = self._getRequest(viewName) res = self._doInfoReq(req) self._checkStatus(res) return res def getIndexes(self, musicFolderId=None, ifModifiedSince=0): """ since: 1.0.0 Returns an indexed structure of all artists musicFolderId:int If this is specified, it will only return 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 unix timestamp Returns a dict like the following: {u'indexes': {u'index': [{u'artist': [{u'id': u'29834728934', u'name': u'A Perfect Circle'}, {u'id': u'238472893', u'name': u'A Small Good Thing'}, {u'id': u'9327842983', u'name': u'A Tribe Called Quest'}, {u'id': u'29348729874', u'name': u'A-Teens, The'}, {u'id': u'298472938', u'name': u'ABA STRUCTURE'}], u'lastModified': 1303318347000L}, u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getIndexes' viewName = '%s.view' % methodName q = self._getQueryDict({'musicFolderId': musicFolderId, 'ifModifiedSince': self._ts2milli(ifModifiedSince)}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) self._fixLastModified(res) return res def getMusicDirectory(self, mid): """ since: 1.0.0 Returns a listing of all files in a music directory. Typically used to get a list of albums for an artist or list of songs for an album. mid:str The string ID value which uniquely identifies the folder. Obtained via calls to getIndexes or getMusicDirectory. REQUIRED Returns a dict like the following: {u'directory': {u'child': [{u'artist': u'A Tribe Called Quest', u'coverArt': u'223484', u'id': u'329084', u'isDir': True, u'parent': u'234823940', u'title': u'Beats, Rhymes And Life'}, {u'artist': u'A Tribe Called Quest', u'coverArt': u'234823794', u'id': u'238472893', u'isDir': True, u'parent': u'2308472938', u'title': u'Midnight Marauders'}, {u'artist': u'A Tribe Called Quest', u'coverArt': u'39284792374', u'id': u'983274892', u'isDir': True, u'parent': u'9823749', u'title': u"People's Instinctive Travels And The Paths Of Rhythm"}, {u'artist': u'A Tribe Called Quest', u'coverArt': u'289347293', u'id': u'3894723934', u'isDir': True, u'parent': u'9832942', u'title': u'The Anthology'}, {u'artist': u'A Tribe Called Quest', u'coverArt': u'923847923', u'id': u'29834729', u'isDir': True, u'parent': u'2934872893', u'title': u'The Love Movement'}, {u'artist': u'A Tribe Called Quest', u'coverArt': u'9238742893', u'id': u'238947293', u'isDir': True, u'parent': u'9432878492', u'title': u'The Low End Theory'}], u'id': u'329847293', u'name': u'A Tribe Called Quest'}, u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getMusicDirectory' viewName = '%s.view' % methodName req = self._getRequest(viewName, {'id': mid}) res = self._doInfoReq(req) self._checkStatus(res) return res def search(self, artist=None, album=None, title=None, any=None, count=20, offset=0, newerThan=None): """ since: 1.0.0 DEPRECATED SINCE API 1.4.0! USE search2() INSTEAD! Returns a listing of files matching the given search criteria. Supports paging with offset artist:str Search for artist album:str Search for album title:str Search for title of song any:str Search all fields count:int Max number of results to return [default: 20] offset:int Search result offset. For paging [default: 0] newerThan:int Return matches newer than this timestamp """ if artist == album == title == any == None: raise ArgumentError('Invalid search. You must supply search ' 'criteria') methodName = 'search' viewName = '%s.view' % methodName q = self._getQueryDict({'artist': artist, 'album': album, 'title': title, 'any': any, 'count': count, 'offset': offset, 'newerThan': self._ts2milli(newerThan)}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def search2(self, query, artistCount=20, artistOffset=0, albumCount=20, albumOffset=0, songCount=20, songOffset=0, musicFolderId=None): """ since: 1.4.0 Returns albums, artists and songs matching the given search criteria. Supports paging through the result. query:str The search query artistCount:int Max number of artists to return [default: 20] artistOffset:int Search offset for artists (for paging) [default: 0] albumCount:int Max number of albums to return [default: 20] 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: {u'searchResult2': {u'album': [{u'artist': u'A Tribe Called Quest', u'coverArt': u'289347', u'id': u'32487298', u'isDir': True, u'parent': u'98374289', u'title': u'The Love Movement'}], u'artist': [{u'id': u'2947839', u'name': u'A Tribe Called Quest'}, {u'id': u'239847239', u'name': u'Tribe'}], u'song': [{u'album': u'Beats, Rhymes And Life', u'artist': u'A Tribe Called Quest', u'bitRate': 224, u'contentType': u'audio/mpeg', u'coverArt': u'329847', u'duration': 148, u'genre': u'default', u'id': u'3928472893', u'isDir': False, u'isVideo': False, u'parent': u'23984728394', u'path': u'A Tribe Called Quest/Beats, Rhymes And Life/A Tribe Called Quest - Beats, Rhymes And Life - 03 - Motivators.mp3', u'size': 4171913, u'suffix': u'mp3', u'title': u'Motivators', u'track': 3}]}, u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'search2' viewName = '%s.view' % methodName q = self._getQueryDict({'query': query, 'artistCount': artistCount, 'artistOffset': artistOffset, 'albumCount': albumCount, 'albumOffset': albumOffset, 'songCount': songCount, 'songOffset': songOffset, 'musicFolderId': musicFolderId}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def search3(self, query, artistCount=20, artistOffset=0, albumCount=20, albumOffset=0, songCount=20, songOffset=0, musicFolderId=None): """ since: 1.8.0 Works the same way as search2, but uses ID3 tags for organization query:str The search query artistCount:int Max number of artists to return [default: 20] artistOffset:int Search offset for artists (for paging) [default: 0] albumCount:int Max number of albums to return [default: 20] 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', u'artistId': 1, u'coverArt': u'al-7', u'created': u'2012-01-30T12:35:33', u'duration': 3229, u'id': 7, u'name': u'Bird-Brains', u'songCount': 13}, {u'artist': u'Tune-Yards', u'artistId': 1, u'coverArt': u'al-8', u'created': u'2011-03-22T15:08:00', u'duration': 2531, u'id': 8, u'name': u'W H O K I L L', u'songCount': 10}], u'artist': {u'albumCount': 2, u'coverArt': u'ar-1', u'id': 1, u'name': u'Tune-Yards'}, u'song': [{u'album': u'Bird-Brains', u'albumId': 7, u'artist': u'Tune-Yards', u'artistId': 1, u'bitRate': 160, u'contentType': u'audio/mpeg', u'coverArt': 105, u'created': u'2012-01-30T12:35:33', u'duration': 328, u'genre': u'Lo-Fi', u'id': 107, u'isDir': False, u'isVideo': False, u'parent': 105, u'path': u'Tune Yards/Bird-Brains/10-tune-yards-fiya.mp3', u'size': 6588498, u'suffix': u'mp3', u'title': u'Fiya', u'track': 10, u'type': u'music', u'year': 2009}]}, u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'search3' viewName = '%s.view' % methodName q = self._getQueryDict({'query': query, 'artistCount': artistCount, 'artistOffset': artistOffset, 'albumCount': albumCount, 'albumOffset': albumOffset, 'songCount': songCount, 'songOffset': songOffset, 'musicFolderId': musicFolderId}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getPlaylists(self, username=None): """ since: 1.0.0 Returns the ID and name of all saved playlists The "username" option was added in 1.8.0. username:str If specified, return playlists for this user rather than for the authenticated user. The authenticated user must have admin role if this parameter is used Returns a dict like the following: {u'playlists': {u'playlist': [{u'id': u'62656174732e6d3375', u'name': u'beats'}, {u'id': u'766172696574792e6d3375', u'name': u'variety'}]}, u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getPlaylists' viewName = '%s.view' % methodName q = self._getQueryDict({'username': username}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getPlaylist(self, pid): """ since: 1.0.0 Returns a listing of files in a saved playlist id:str The ID of the playlist as returned in getPlaylists() Returns a dict like the following: {u'playlist': {u'entry': {u'album': u'The Essential Bob Dylan', u'artist': u'Bob Dylan', u'bitRate': 32, u'contentType': u'audio/mpeg', u'coverArt': u'2983478293', u'duration': 984, u'genre': u'Classic Rock', u'id': u'982739428', u'isDir': False, u'isVideo': False, u'parent': u'98327428974', u'path': u"Bob Dylan/Essential Bob Dylan Disc 1/Bob Dylan - The Essential Bob Dylan - 03 - The Times They Are A-Changin'.mp3", u'size': 3921899, u'suffix': u'mp3', u'title': u"The Times They Are A-Changin'", u'track': 3}, u'id': u'44796c616e2e6d3375', u'name': u'Dylan'}, u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getPlaylist' viewName = '%s.view' % methodName req = self._getRequest(viewName, {'id': pid}) res = self._doInfoReq(req) self._checkStatus(res) return res def createPlaylist(self, playlistId=None, name=None, songIds=[]): """ since: 1.2.0 Creates OR updates a playlist. If updating the list, the playlistId is required. If creating a list, the name is required. playlistId:str The ID of the playlist to UPDATE name:str The name of the playlist to CREATE songIds:list The list of songIds to populate the list with in either create or update mode. Note that this list will replace the existing list if updating Returns a dict like the following: {u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'createPlaylist' viewName = '%s.view' % methodName if playlistId == name == None: raise ArgumentError('You must supply either a playlistId or a name') if playlistId is not None and name is not None: raise ArgumentError('You can only supply either a playlistId ' 'OR a name, not both') q = self._getQueryDict({'playlistId': playlistId, 'name': name}) req = self._getRequestWithList(viewName, 'songId', songIds, q) res = self._doInfoReq(req) self._checkStatus(res) return res def deletePlaylist(self, pid): """ since: 1.2.0 Deletes a saved playlist pid:str ID of the playlist to delete, as obtained by getPlaylists Returns a dict like the following: """ methodName = 'deletePlaylist' viewName = '%s.view' % methodName req = self._getRequest(viewName, {'id': pid}) res = self._doInfoReq(req) self._checkStatus(res) return res def download(self, sid): """ since: 1.0.0 Downloads a given music file. sid:str The ID of the music file to download. Returns the file-like object for reading or raises an exception on error """ methodName = 'download' viewName = '%s.view' % methodName req = self._getRequest(viewName, {'id': sid}) res = self._doBinReq(req) if isinstance(res, dict): self._checkStatus(res) return res def stream(self, sid, maxBitRate=0, tformat=None, timeOffset=None, size=None, estimateContentLength=False, converted=False): """ since: 1.0.0 Downloads a given music file. sid:str The ID of the music file to download. maxBitRate:int (since: 1.2.0) If specified, the server will attempt to limit the bitrate to this value, in kilobits per second. If set to zero (default), no limit is imposed. Legal values are: 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256 and 320. tformat:str (since: 1.6.0) Specifies the target format (e.g. "mp3" or "flv") in case there are multiple applicable transcodings (since: 1.9.0) You can use the special value "raw" to disable transcoding timeOffset:int (since: 1.6.0) Only applicable to video streaming. Start the stream at the given offset (in seconds) into the video size:str (since: 1.6.0) The requested video size in WxH, for instance 640x480 estimateContentLength:bool (since: 1.8.0) If set to True, the HTTP Content-Length header will be set to an estimated value for trancoded media converted:bool (since: 1.14.0) Only applicable to video streaming. Subsonic can optimize videos for streaming by converting them to MP4. If a conversion exists for the video in question, then setting this parameter to "true" will cause the converted video to be returned instead of the original. Returns the file-like object for reading or raises an exception on error """ methodName = 'stream' viewName = '%s.view' % methodName q = self._getQueryDict({'id': sid, 'maxBitRate': maxBitRate, 'format': tformat, 'timeOffset': timeOffset, 'size': size, 'estimateContentLength': estimateContentLength, 'converted': converted}) req = self._getRequest(viewName, q) res = self._doBinReq(req) if isinstance(res, dict): self._checkStatus(res) return res def streamUrl(self, sid, maxBitRate=0, tformat=None, timeOffset=None, size=None, estimateContentLength=False, converted=False): """ since: 1.0.0 Downloads a given music file. sid:str The ID of the music file to download. maxBitRate:int (since: 1.2.0) If specified, the server will attempt to limit the bitrate to this value, in kilobits per second. If set to zero (default), no limit is imposed. Legal values are: 0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256 and 320. tformat:str (since: 1.6.0) Specifies the target format (e.g. "mp3" or "flv") in case there are multiple applicable transcodings (since: 1.9.0) You can use the special value "raw" to disable transcoding timeOffset:int (since: 1.6.0) Only applicable to video streaming. Start the stream at the given offset (in seconds) into the video size:str (since: 1.6.0) The requested video size in WxH, for instance 640x480 estimateContentLength:bool (since: 1.8.0) If set to True, the HTTP Content-Length header will be set to an estimated value for trancoded media converted:bool (since: 1.14.0) Only applicable to video streaming. Subsonic can optimize videos for streaming by converting them to MP4. If a conversion exists for the video in question, then setting this parameter to "true" will cause the converted video to be returned instead of the original. Returns the file-like object for reading or raises an exception on error """ methodName = 'stream' viewName = '%s.view' % methodName q = self._getQueryDict({'id': sid, 'maxBitRate': maxBitRate, 'format': tformat, 'timeOffset': timeOffset, 'size': size, 'estimateContentLength': estimateContentLength, 'converted': converted}) req = self._getRequest(viewName, q) xbmc.log("Requesting %s"%str(req.full_url),xbmc.LOGINFO) res = self._doBinReq(req) if isinstance(res, dict): self._checkStatus(res) return req.full_url def getCoverArt(self, aid, size=None): """ since: 1.0.0 Returns a cover art image aid:str ID string for the cover art image to download size:int If specified, scale image to this size Returns the file-like object for reading or raises an exception on error """ methodName = 'getCoverArt' viewName = '%s.view' % methodName q = self._getQueryDict({'id': aid, 'size': size}) req = self._getRequest(viewName, q) res = self._doBinReq(req) if isinstance(res, dict): self._checkStatus(res) return res def getCoverArtUrl(self, aid, size=None): """ since: 1.0.0 Returns a cover art image aid:str ID string for the cover art image to download size:int If specified, scale image to this size Returns the file-like object for reading or raises an exception on error """ methodName = 'getCoverArt' viewName = '%s.view' % methodName q = self._getQueryDict({'id': aid, 'size': size}) req = self._getRequest(viewName, q) res = self._doBinReq(req) if isinstance(res, dict): self._checkStatus(res) return req.full_url def scrobble(self, sid, submission=True, listenTime=None): """ since: 1.5.0 "Scrobbles" a given music file on last.fm. Requires that the user has set this up. Since 1.8.0 you may specify multiple id (and optionally time) parameters to scrobble multiple files. Since 1.11.0 this method will also update the play count and last played timestamp for the song and album. It will also make the song appear in the "Now playing" page in the web app, and appear in the list of songs returned by getNowPlaying sid:str The ID of the file to scrobble submission:bool Whether this is a "submission" or a "now playing" notification listenTime:int (Since 1.8.0) The time (unix timestamp) at which the song was listened to. Returns a dict like the following: {u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'scrobble' viewName = '%s.view' % methodName q = self._getQueryDict({'id': sid, 'submission': submission, 'time': self._ts2milli(listenTime)}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def changePassword(self, username, password): """ since: 1.1.0 Changes the password of an existing Subsonic user. Note that the user performing this must have admin privileges username:str The username whose password is being changed password:str The new password of the user Returns a dict like the following: {u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'changePassword' viewName = '%s.view' % methodName hexPass = 'enc:%s' % self._hexEnc(password) # There seems to be an issue with some subsonic implementations # not recognizing the "enc:" precursor to the encoded password and # encodes the whole "enc:" as the password. Weird. #q = {'username': username, 'password': hexPass.lower()} q = {'username': username, 'password': password} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getUser(self, username): """ since: 1.3.0 Get details about a given user, including which auth roles it has. Can be used to enable/disable certain features in the client, such as jukebox control username:str The username to retrieve. You can only retrieve your own user unless you have admin privs. Returns a dict like the following: {u'status': u'ok', u'user': {u'adminRole': False, u'commentRole': False, u'coverArtRole': False, u'downloadRole': True, u'jukeboxRole': False, u'playlistRole': True, u'podcastRole': False, u'settingsRole': True, u'streamRole': True, u'uploadRole': True, u'username': u'test'}, u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getUser' viewName = '%s.view' % methodName q = {'username': username} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getUsers(self): """ since 1.8.0 Gets a list of users returns a dict like the following {u'status': u'ok', u'users': {u'user': [{u'adminRole': True, u'commentRole': True, u'coverArtRole': True, u'downloadRole': True, u'jukeboxRole': True, u'playlistRole': True, u'podcastRole': True, u'scrobblingEnabled': True, u'settingsRole': True, u'shareRole': True, u'streamRole': True, u'uploadRole': True, u'username': u'user1'}, ... ... ]}, u'version': u'1.10.2', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getUsers' viewName = '%s.view' % methodName req = self._getRequest(viewName) res = self._doInfoReq(req) self._checkStatus(res) return res def createUser(self, username, password, email, ldapAuthenticated=False, adminRole=False, settingsRole=True, streamRole=True, jukeboxRole=False, downloadRole=False, uploadRole=False, playlistRole=False, coverArtRole=False, commentRole=False, podcastRole=False, shareRole=False, videoConversionRole=False, musicFolderId=None): """ since: 1.1.0 Creates a new subsonic user, using the parameters defined. See the documentation at http://subsonic.org for more info on all the roles. username:str The username of the new user password:str The password for the new user email:str The email of the new user musicFolderId:int These are the only folders the user has access to Returns a dict like the following: {u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'createUser' viewName = '%s.view' % methodName hexPass = 'enc:%s' % self._hexEnc(password) 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, 'videoConversionRole': videoConversionRole, 'musicFolderId': musicFolderId }) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def updateUser(self, username, password=None, email=None, ldapAuthenticated=False, adminRole=False, settingsRole=True, streamRole=True, jukeboxRole=False, downloadRole=False, uploadRole=False, playlistRole=False, coverArtRole=False, commentRole=False, podcastRole=False, shareRole=False, videoConversionRole=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. Returns a dict like the following: {u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'updateUser' viewName = '%s.view' % methodName if password is not None: password = 'enc:%s' % self._hexEnc(password) q = self._getQueryDict({'username': username, 'password': password, '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, 'videoConversionRole': videoConversionRole, 'musicFolderId': musicFolderId, 'maxBitRate': maxBitRate }) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def deleteUser(self, username): """ since: 1.3.0 Deletes an existing Subsonic user. Of course, you must have admin rights for this. username:str The username of the user to delete Returns a dict like the following: {u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'deleteUser' viewName = '%s.view' % methodName q = {'username': username} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getChatMessages(self, since=1): """ since: 1.2.0 Returns the current visible (non-expired) chat messages. since:int Only return messages newer than this timestamp NOTE: All times returned are in MILLISECONDS since the Epoch, not seconds! Returns a dict like the following: {u'chatMessages': {u'chatMessage': {u'message': u'testing 123', u'time': 1303411919872L, u'username': u'admin'}}, u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getChatMessages' viewName = '%s.view' % methodName q = {'since': self._ts2milli(since)} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def addChatMessage(self, message): """ since: 1.2.0 Adds a message to the chat log message:str The message to add Returns a dict like the following: {u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'addChatMessage' viewName = '%s.view' % methodName q = {'message': message} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getAlbumList(self, ltype, size=10, offset=0, fromYear=None, toYear=None, genre=None, musicFolderId=None): """ since: 1.2.0 Returns a list of random, newest, highest rated etc. albums. Similar to the album lists on the home page of the Subsonic web interface ltype:str The list type. Must be one of the following: random, newest, highest, frequent, recent, (since 1.8.0 -> )starred, alphabeticalByName, alphabeticalByArtist Since 1.10.1 you can use byYear and byGenre to list albums in a given year range or genre. size:int The number of albums to return. Max 500 offset:int The list offset. Use for paging. Max 5000 fromYear:int If you specify the ltype as "byYear", you *must* specify fromYear toYear:int If you specify the ltype as "byYear", you *must* specify toYear genre:str The name of the genre e.g. "Rock". You must specify genre if you set the ltype to "byGenre" musicFolderId:str Only return albums in the music folder with the given ID. See getMusicFolders() Returns a dict like the following: {u'albumList': {u'album': [{u'artist': u'Hank Williams', u'id': u'3264928374', u'isDir': True, u'parent': u'9238479283', u'title': u'The Original Singles Collection...Plus'}, {u'artist': u'Freundeskreis', u'coverArt': u'9823749823', u'id': u'23492834', u'isDir': True, u'parent': u'9827492374', u'title': u'Quadratur des Kreises'}]}, u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getAlbumList' viewName = '%s.view' % methodName q = self._getQueryDict({'type': ltype, 'size': size, 'offset': offset, 'fromYear': fromYear, 'toYear': toYear, 'genre': genre, 'musicFolderId': musicFolderId}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getAlbumList2(self, ltype, size=10, offset=0, fromYear=None, toYear=None, genre=None): """ since 1.8.0 Returns a list of random, newest, highest rated etc. albums. This is similar to getAlbumList, but uses ID3 tags for organization ltype:str The list type. Must be one of the following: random, newest, highest, frequent, recent, (since 1.8.0 -> )starred, alphabeticalByName, alphabeticalByArtist Since 1.10.1 you can use byYear and byGenre to list albums in a given year range or genre. size:int The number of albums to return. Max 500 offset:int The list offset. Use for paging. Max 5000 fromYear:int If you specify the ltype as "byYear", you *must* specify fromYear toYear:int If you specify the ltype as "byYear", you *must* specify toYear genre:str The name of the genre e.g. "Rock". You must specify genre if you set the ltype to "byGenre" Returns a dict like the following: {u'albumList2': {u'album': [{u'artist': u'Massive Attack', u'artistId': 0, u'coverArt': u'al-0', u'created': u'2009-08-28T10:00:44', u'duration': 3762, u'id': 0, u'name': u'100th Window', u'songCount': 9}, {u'artist': u'Massive Attack', u'artistId': 0, u'coverArt': u'al-5', u'created': u'2003-11-03T22:00:00', u'duration': 2715, u'id': 5, u'name': u'Blue Lines', u'songCount': 9}]}, u'status': u'ok', u'version': u'1.8.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getAlbumList2' viewName = '%s.view' % methodName q = self._getQueryDict({'type': ltype, 'size': size, 'offset': offset, 'fromYear': fromYear, 'toYear': toYear, 'genre': genre}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getRandomSongs(self, size=10, genre=None, fromYear=None, toYear=None, musicFolderId=None): """ since 1.2.0 Returns random songs matching the given criteria size:int The max number of songs to return. Max 500 genre:str Only return songs from this genre fromYear:int Only return songs after or in this year toYear:int Only return songs before or in this year musicFolderId:str Only return songs in the music folder with the given ID. See getMusicFolders Returns a dict like the following: {u'randomSongs': {u'song': [{u'album': u'1998 EP - Airbag (How Am I Driving)', u'artist': u'Radiohead', u'bitRate': 320, u'contentType': u'audio/mpeg', u'duration': 129, u'id': u'9284728934', u'isDir': False, u'isVideo': False, u'parent': u'983249823', u'path': u'Radiohead/1998 EP - Airbag (How Am I Driving)/06 - Melatonin.mp3', u'size': 5177469, u'suffix': u'mp3', u'title': u'Melatonin'}, {u'album': u'Mezmerize', u'artist': u'System Of A Down', u'bitRate': 214, u'contentType': u'audio/mpeg', u'coverArt': u'23849372894', u'duration': 176, u'id': u'28937492834', u'isDir': False, u'isVideo': False, u'parent': u'92837492837', u'path': u'System Of A Down/Mesmerize/10 - System Of A Down - Old School Hollywood.mp3', u'size': 4751360, u'suffix': u'mp3', u'title': u'Old School Hollywood', u'track': 10}]}, u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getRandomSongs' viewName = '%s.view' % methodName q = self._getQueryDict({'size': size, 'genre': genre, 'fromYear': fromYear, 'toYear': toYear, 'musicFolderId': musicFolderId}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getLyrics(self, artist=None, title=None): """ since: 1.2.0 Searches for and returns lyrics for a given song artist:str The artist name title:str The song title Returns a dict like the following for getLyrics('Bob Dylan', 'Blowin in the wind'): {u'lyrics': {u'artist': u'Bob Dylan', u'content': u"How many roads must a man walk down", u'title': u"Blowin' in the Wind"}, u'status': u'ok', u'version': u'1.5.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getLyrics' viewName = '%s.view' % methodName q = self._getQueryDict({'artist': artist, 'title': title}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def jukeboxControl(self, action, index=None, sids=[], gain=None, offset=None): """ since: 1.2.0 NOTE: Some options were added as of API version 1.7.0 Controls the jukebox, i.e., playback directly on the server's audio hardware. Note: The user must be authorized to control the jukebox action:str The operation to perform. Must be one of: get, start, stop, skip, add, clear, remove, shuffle, setGain, status (added in API 1.7.0), set (added in API 1.7.0) index:int Used by skip and remove. Zero-based index of the song to skip to or remove. sids:str Used by "add" and "set". ID of song to add to the jukebox playlist. Use multiple id parameters to add many songs in the same request. Whether you are passing one song or many into this, this parameter MUST be a list gain:float Used by setGain to control the playback volume. A float value between 0.0 and 1.0 offset:int (added in API 1.7.0) Used by "skip". Start playing this many seconds into the track. """ methodName = 'jukeboxControl' viewName = '%s.view' % methodName q = self._getQueryDict({'action': action, 'index': index, 'gain': gain, 'offset': offset}) req = None if action == 'add': # We have to deal with the sids if not (isinstance(sids, list) or isinstance(sids, tuple)): raise ArgumentError('If you are adding songs, "sids" must ' 'be a list or tuple!') req = self._getRequestWithList(viewName, 'id', sids, q) else: req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getPodcasts(self, incEpisodes=True, pid=None): """ since: 1.6.0 Returns all podcast channels the server subscribes to and their episodes. incEpisodes:bool (since: 1.9.0) Whether to include Podcast episodes in the returned result. pid:str (since: 1.9.0) If specified, only return the Podcast channel with this ID. Returns a dict like the following: {u'status': u'ok', u'version': u'1.6.0', u'xmlns': u'http://subsonic.org/restapi', u'podcasts': {u'channel': {u'description': u"Dr Chris Smith...", u'episode': [{u'album': u'Dr Karl and the Naked Scientist', u'artist': u'BBC Radio 5 live', u'bitRate': 64, u'contentType': u'audio/mpeg', u'coverArt': u'2f6f7074', u'description': u'Dr Karl answers all your science related questions.', u'duration': 2902, u'genre': u'Podcast', u'id': 0, u'isDir': False, u'isVideo': False, u'parent': u'2f6f70742f737562736f6e69632f706f6463617374732f4472204b61726c20616e6420746865204e616b656420536369656e74697374', u'publishDate': u'2011-08-17 22:06:00.0', u'size': 23313059, u'status': u'completed', u'streamId': u'2f6f70742f737562736f6e69632f706f6463617374732f4472204b61726c20616e6420746865204e616b656420536369656e746973742f64726b61726c5f32303131303831382d30343036612e6d7033', u'suffix': u'mp3', u'title': u'DrKarl: Peppermints, Chillies & Receptors', u'year': 2011}, {u'description': u'which is warmer, a bath with bubbles in it or one without? Just one of the stranger science stories tackled this week by Dr Chris Smith and the Naked Scientists!', u'id': 1, u'publishDate': u'2011-08-14 21:05:00.0', u'status': u'skipped', u'title': u'DrKarl: how many bubbles in your bath? 15 AUG 11'}, ... {u'description': u'Dr Karl joins Rhod to answer all your science questions', u'id': 9, u'publishDate': u'2011-07-06 22:12:00.0', u'status': u'skipped', u'title': u'DrKarl: 8 Jul 11 The Strange Sound of the MRI Scanner'}], u'id': 0, u'status': u'completed', u'title': u'Dr Karl and the Naked Scientist', u'url': u'http://downloads.bbc.co.uk/podcasts/fivelive/drkarl/rss.xml'}} } See also: http://subsonic.svn.sourceforge.net/viewvc/subsonic/trunk/subsonic-main/src/main/webapp/xsd/podcasts_example_1.xml?view=markup """ methodName = 'getPodcasts' viewName = '%s.view' % methodName q = self._getQueryDict({'includeEpisodes': incEpisodes, 'id': pid}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getShares(self): """ since: 1.6.0 Returns information about shared media this user is allowed to manage Note that entry can be either a single dict or a list of dicts Returns a dict like the following: {u'status': u'ok', u'version': u'1.6.0', u'xmlns': u'http://subsonic.org/restapi', u'shares': {u'share': [ {u'created': u'2011-08-18T10:01:35', u'entry': {u'artist': u'Alice In Chains', u'coverArt': u'2f66696c65732f6d7033732f412d4d2f416c69636520496e20436861696e732f416c69636520496e20436861696e732f636f7665722e6a7067', u'id': u'2f66696c65732f6d7033732f412d4d2f416c69636520496e20436861696e732f416c69636520496e20436861696e73', u'isDir': True, u'parent': u'2f66696c65732f6d7033732f412d4d2f416c69636520496e20436861696e73', u'title': u'Alice In Chains'}, u'expires': u'2012-08-18T10:01:35', u'id': 0, u'url': u'http://crustymonkey.subsonic.org/share/BuLbF', u'username': u'admin', u'visitCount': 0 }]} } """ methodName = 'getShares' viewName = '%s.view' % methodName req = self._getRequest(viewName) res = self._doInfoReq(req) self._checkStatus(res) return res def createShare(self, shids=[], description=None, expires=None): """ since: 1.6.0 Creates a public URL that can be used by anyone to stream music or video from the Subsonic server. The URL is short and suitable for posting on Facebook, Twitter etc. Note: The user must be authorized to share (see Settings > Users > User is allowed to share files with anyone). shids:list[str] A list of ids of songs, albums or videos to share. description:str A description that will be displayed to people visiting the shared media (optional). expires:float A timestamp pertaining to the time at which this should expire (optional) This returns a structure like you would get back from getShares() containing just your new share. """ methodName = 'createShare' viewName = '%s.view' % methodName q = self._getQueryDict({'description': description, 'expires': self._ts2milli(expires)}) req = self._getRequestWithList(viewName, 'id', shids, q) res = self._doInfoReq(req) self._checkStatus(res) return res def updateShare(self, shid, description=None, expires=None): """ since: 1.6.0 Updates the description and/or expiration date for an existing share shid:str The id of the share to update description:str The new description for the share (optional). expires:float The new timestamp for the expiration time of this share (optional). """ methodName = 'updateShare' viewName = '%s.view' % methodName q = self._getQueryDict({'id': shid, 'description': description, expires: self._ts2milli(expires)}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def deleteShare(self, shid): """ since: 1.6.0 Deletes an existing share shid:str The id of the share to delete Returns a standard response dict """ methodName = 'deleteShare' viewName = '%s.view' % methodName q = self._getQueryDict({'id': shid}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def setRating(self, id, rating): """ since: 1.6.0 Sets the rating for a music file id:str The id of the item (song/artist/album) to rate rating:int The rating between 1 and 5 (inclusive), or 0 to remove the rating Returns a standard response dict """ methodName = 'setRating' viewName = '%s.view' % methodName try: rating = int(rating) except: raise ArgumentError('Rating must be an integer between 0 and 5: ' '%r' % rating) if rating < 0 or rating > 5: raise ArgumentError('Rating must be an integer between 0 and 5: ' '%r' % rating) q = self._getQueryDict({'id': id, 'rating': rating}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getArtists(self): """ since 1.8.0 Similar to getIndexes(), but this method uses the ID3 tags to determine the artist Returns a dict like the following: {u'artists': {u'index': [{u'artist': {u'albumCount': 7, u'coverArt': u'ar-0', u'id': 0, u'name': u'Massive Attack'}, u'name': u'M'}, {u'artist': {u'albumCount': 2, u'coverArt': u'ar-1', u'id': 1, u'name': u'Tune-Yards'}, u'name': u'T'}]}, u'status': u'ok', u'version': u'1.8.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getArtists' viewName = '%s.view' % methodName req = self._getRequest(viewName) res = self._doInfoReq(req) self._checkStatus(res) return res def getArtist(self, id): """ since 1.8.0 Returns the info (albums) for an artist. This method uses the ID3 tags for organization id:str The artist ID Returns a dict like the following: {u'artist': {u'album': [{u'artist': u'Tune-Yards', u'artistId': 1, u'coverArt': u'al-7', u'created': u'2012-01-30T12:35:33', u'duration': 3229, u'id': 7, u'name': u'Bird-Brains', u'songCount': 13}, {u'artist': u'Tune-Yards', u'artistId': 1, u'coverArt': u'al-8', u'created': u'2011-03-22T15:08:00', u'duration': 2531, u'id': 8, u'name': u'W H O K I L L', u'songCount': 10}], u'albumCount': 2, u'coverArt': u'ar-1', u'id': 1, u'name': u'Tune-Yards'}, u'status': u'ok', u'version': u'1.8.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getArtist' viewName = '%s.view' % methodName q = self._getQueryDict({'id': id}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getAlbum(self, id): """ since 1.8.0 Returns the info and songs for an album. This method uses the ID3 tags for organization id:str The album ID Returns a dict like the following: {u'album': {u'artist': u'Massive Attack', u'artistId': 0, u'coverArt': u'al-0', u'created': u'2009-08-28T10:00:44', u'duration': 3762, u'id': 0, u'name': u'100th Window', u'song': [{u'album': u'100th Window', u'albumId': 0, u'artist': u'Massive Attack', u'artistId': 0, u'bitRate': 192, u'contentType': u'audio/mpeg', u'coverArt': 2, u'created': u'2009-08-28T10:00:57', u'duration': 341, u'genre': u'Rock', u'id': 14, u'isDir': False, u'isVideo': False, u'parent': 2, u'path': u'Massive Attack/100th Window/01 - Future Proof.mp3', u'size': 8184445, u'suffix': u'mp3', u'title': u'Future Proof', u'track': 1, u'type': u'music', u'year': 2003}], u'songCount': 9}, u'status': u'ok', u'version': u'1.8.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getAlbum' viewName = '%s.view' % methodName q = self._getQueryDict({'id': id}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getSong(self, id): """ since 1.8.0 Returns the info for a song. This method uses the ID3 tags for organization id:str The song ID Returns a dict like the following: {u'song': {u'album': u'W H O K I L L', u'albumId': 8, u'artist': u'Tune-Yards', u'artistId': 1, u'bitRate': 320, u'contentType': u'audio/mpeg', u'coverArt': 106, u'created': u'2011-03-22T15:08:00', u'discNumber': 1, u'duration': 192, u'genre': u'Indie Rock', u'id': 120, u'isDir': False, u'isVideo': False, u'parent': 106, u'path': u'Tune Yards/Who Kill/10 Killa.mp3', u'size': 7692656, u'suffix': u'mp3', u'title': u'Killa', u'track': 10, u'type': u'music', u'year': 2011}, u'status': u'ok', u'version': u'1.8.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getSong' viewName = '%s.view' % methodName q = self._getQueryDict({'id': id}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getVideos(self): """ since 1.8.0 Returns all video files Returns a dict like the following: {u'status': u'ok', u'version': u'1.8.0', u'videos': {u'video': {u'bitRate': 384, u'contentType': u'video/x-matroska', u'created': u'2012-08-26T13:36:44', u'duration': 1301, u'id': 130, u'isDir': False, u'isVideo': True, u'path': u'South Park - 16x07 - Cartman Finds Love.mkv', u'size': 287309613, u'suffix': u'mkv', u'title': u'South Park - 16x07 - Cartman Finds Love', u'transcodedContentType': u'video/x-flv', u'transcodedSuffix': u'flv'}}, u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getVideos' viewName = '%s.view' % methodName req = self._getRequest(viewName) res = self._doInfoReq(req) self._checkStatus(res) return res 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: {u'starred': {u'album': {u'album': u'Bird-Brains', u'artist': u'Tune-Yards', u'coverArt': 105, u'created': u'2012-01-30T13:16:58', u'id': 105, u'isDir': True, u'parent': 104, u'starred': u'2012-08-26T13:18:34', u'title': u'Bird-Brains'}, u'song': [{u'album': u'Mezzanine', u'albumId': 4, u'artist': u'Massive Attack', u'artistId': 0, u'bitRate': 256, u'contentType': u'audio/mpeg', u'coverArt': 6, u'created': u'2009-06-15T07:48:28', u'duration': 298, u'genre': u'Dub', u'id': 72, u'isDir': False, u'isVideo': False, u'parent': 6, u'path': u'Massive Attack/Mezzanine/Massive Attack_02_mezzanine.mp3', u'size': 9564160, u'starred': u'2012-08-26T13:19:26', u'suffix': u'mp3', u'title': u'Risingson', u'track': 2, u'type': u'music'}, {u'album': u'Mezzanine', u'albumId': 4, u'artist': u'Massive Attack', u'artistId': 0, u'bitRate': 256, u'contentType': u'audio/mpeg', u'coverArt': 6, u'created': u'2009-06-15T07:48:25', u'duration': 380, u'genre': u'Dub', u'id': 71, u'isDir': False, u'isVideo': False, u'parent': 6, u'path': u'Massive Attack/Mezzanine/Massive Attack_01_mezzanine.mp3', u'size': 12179456, u'starred': u'2012-08-26T13:19:03', u'suffix': u'mp3', u'title': u'Angel', u'track': 1, u'type': u'music'}]}, u'status': u'ok', u'version': u'1.8.0', u'xmlns': u'http://subsonic.org/restapi'} """ methodName = 'getStarred' viewName = '%s.view' % methodName q = {} if musicFolderId: q['musicFolderId'] = musicFolderId req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res 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 Returns a dict like the following: **See the output from getStarred()** """ methodName = 'getStarred2' viewName = '%s.view' % methodName q = {} if musicFolderId: q['musicFolderId'] = musicFolderId req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def updatePlaylist(self, lid, name=None, comment=None, songIdsToAdd=[], songIndexesToRemove=[]): """ since 1.8.0 Updates a playlist. Only the owner of a playlist is allowed to update it. lid:str The playlist id name:str The human readable name of the playlist comment:str The playlist comment songIdsToAdd:list A list of song IDs to add to the playlist songIndexesToRemove:list Remove the songs at the 0 BASED INDEXED POSITIONS in the playlist, NOT the song ids. Note that this is always a list. Returns a normal status response dict """ methodName = 'updatePlaylist' viewName = '%s.view' % methodName q = self._getQueryDict({'playlistId': lid, 'name': name, 'comment': comment}) if not isinstance(songIdsToAdd, list) or isinstance(songIdsToAdd, tuple): songIdsToAdd = [songIdsToAdd] if not isinstance(songIndexesToRemove, list) or isinstance( songIndexesToRemove, tuple): songIndexesToRemove = [songIndexesToRemove] listMap = {'songIdToAdd': songIdsToAdd, 'songIndexToRemove': songIndexesToRemove} req = self._getRequestWithLists(viewName, listMap, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getAvatar(self, username): """ since 1.8.0 Returns the avatar for a user or None if the avatar does not exist username:str The user to retrieve the avatar for Returns the file-like object for reading or raises an exception on error """ methodName = 'getAvatar' viewName = '%s.view' % methodName q = {'username': username} req = self._getRequest(viewName, q) try: res = self._doBinReq(req) except urllib.error.HTTPError: # Avatar is not set/does not exist, return None return None if isinstance(res, dict): self._checkStatus(res) return res def star(self, sids=[], albumIds=[], artistIds=[]): """ since 1.8.0 Attaches a star to songs, albums or artists sids:list A list of song IDs to star albumIds:list A list of album IDs to star. Use this rather than "sids" if the client access the media collection according to ID3 tags rather than file structure artistIds:list The ID of an artist to star. Use this rather than sids if the client access the media collection according to ID3 tags rather than file structure Returns a normal status response dict """ methodName = 'star' viewName = '%s.view' % methodName if not isinstance(sids, list) or isinstance(sids, tuple): sids = [sids] if not isinstance(albumIds, list) or isinstance(albumIds, tuple): albumIds = [albumIds] if not isinstance(artistIds, list) or isinstance(artistIds, tuple): artistIds = [artistIds] listMap = {'id': sids, 'albumId': albumIds, 'artistId': artistIds} req = self._getRequestWithLists(viewName, listMap) res = self._doInfoReq(req) self._checkStatus(res) return res def unstar(self, sids=[], albumIds=[], artistIds=[]): """ since 1.8.0 Removes a star to songs, albums or artists. Basically, the same as star in reverse sids:list A list of song IDs to star albumIds:list A list of album IDs to star. Use this rather than "sids" if the client access the media collection according to ID3 tags rather than file structure artistIds:list The ID of an artist to star. Use this rather than sids if the client access the media collection according to ID3 tags rather than file structure Returns a normal status response dict """ methodName = 'unstar' viewName = '%s.view' % methodName if not isinstance(sids, list) or isinstance(sids, tuple): sids = [sids] if not isinstance(albumIds, list) or isinstance(albumIds, tuple): albumIds = [albumIds] if not isinstance(artistIds, list) or isinstance(artistIds, tuple): artistIds = [artistIds] listMap = {'id': sids, 'albumId': albumIds, 'artistId': artistIds} req = self._getRequestWithLists(viewName, listMap) res = self._doInfoReq(req) self._checkStatus(res) return res def getGenres(self): """ since 1.9.0 Returns all genres """ methodName = 'getGenres' viewName = '%s.view' % methodName req = self._getRequest(viewName) res = self._doInfoReq(req) self._checkStatus(res) return res def getSongsByGenre(self, genre, count=10, offset=0, musicFolderId=None): """ since 1.9.0 Returns songs in a given genre genre:str The genre, as returned by getGenres() 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 = 'getSongsByGenre' viewName = '%s.view' % methodName q = self._getQueryDict({'genre': genre, 'count': count, 'offset': offset, 'musicFolderId': musicFolderId, }) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def hls (self, mid, bitrate=None): """ since 1.8.0 Creates an HTTP live streaming playlist for streaming video or audio HLS is a streaming protocol implemented by Apple and works by breaking the overall stream into a sequence of small HTTP-based file downloads. It's supported by iOS and newer versions of Android. This method also supports adaptive bitrate streaming, see the bitRate parameter. mid:str The ID of the media to stream bitrate:str If specified, the server will attempt to limit the bitrate to this value, in kilobits per second. If this parameter is specified more than once, the server will create a variant playlist, suitable for adaptive bitrate streaming. The playlist will support streaming at all the specified bitrates. The server will automatically choose video dimensions that are suitable for the given bitrates. (since: 1.9.0) you may explicitly request a certain width (480) and height (360) like so: bitRate=1000@480x360 Returns the raw m3u8 file as a string """ methodName = 'hls' viewName = '%s.view' % methodName q = self._getQueryDict({'id': mid, 'bitrate': bitrate}) req = self._getRequest(viewName, q) try: res = self._doBinReq(req) except urllib.error.HTTPError: # Avatar is not set/does not exist, return None return None if isinstance(res, dict): self._checkStatus(res) return res.read() def refreshPodcasts(self): """ since: 1.9.0 Tells the server to check for new Podcast episodes. Note: The user must be authorized for Podcast administration """ methodName = 'refreshPodcasts' viewName = '%s.view' % methodName req = self._getRequest(viewName) res = self._doInfoReq(req) self._checkStatus(res) return res def createPodcastChannel(self, url): """ since: 1.9.0 Adds a new Podcast channel. Note: The user must be authorized for Podcast administration url:str The URL of the Podcast to add """ methodName = 'createPodcastChannel' viewName = '%s.view' % methodName q = {'url': url} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def deletePodcastChannel(self, pid): """ since: 1.9.0 Deletes a Podcast channel. Note: The user must be authorized for Podcast administration pid:str The ID of the Podcast channel to delete """ methodName = 'deletePodcastChannel' viewName = '%s.view' % methodName q = {'id': pid} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def deletePodcastEpisode(self, pid): """ since: 1.9.0 Deletes a Podcast episode. Note: The user must be authorized for Podcast administration pid:str The ID of the Podcast episode to delete """ methodName = 'deletePodcastEpisode' viewName = '%s.view' % methodName q = {'id': pid} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def downloadPodcastEpisode(self, pid): """ since: 1.9.0 Tells the server to start downloading a given Podcast episode. Note: The user must be authorized for Podcast administration pid:str The ID of the Podcast episode to download """ methodName = 'downloadPodcastEpisode' viewName = '%s.view' % methodName q = {'id': pid} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getInternetRadioStations(self): """ since: 1.9.0 Returns all internet radio stations """ methodName = 'getInternetRadioStations' viewName = '%s.view' % methodName req = self._getRequest(viewName) res = self._doInfoReq(req) self._checkStatus(res) return res def createInternetRadioStation(self, streamUrl, name, homepageUrl=None): """ since 1.16.0 Create an internet radio station streamUrl:str The stream URL for the station name:str The user-defined name for the station homepageUrl:str The homepage URL for the station """ methodName = 'createInternetRadioStation' viewName = '{}.view'.format(methodName) q = self._getQueryDict({ 'streamUrl': streamUrl, 'name': name, 'homepageUrl': homepageUrl}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def updateInternetRadioStation(self, iid, streamUrl, name, homepageUrl=None): """ since 1.16.0 Create an internet radio station iid:str The ID for the station streamUrl:str The stream URL for the station name:str The user-defined name for the station homepageUrl:str The homepage URL for the station """ methodName = 'updateInternetRadioStation' viewName = '{}.view'.format(methodName) q = self._getQueryDict({ 'id': iid, 'streamUrl': streamUrl, 'name': name, 'homepageUrl': homepageUrl, }) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def deleteInternetRadioStation(self, iid): """ since 1.16.0 Create an internet radio station iid:str The ID for the station """ methodName = 'deleteInternetRadioStation' viewName = '{}.view'.format(methodName) q = {'id': iid} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getBookmarks(self): """ since: 1.9.0 Returns all bookmarks for this user. A bookmark is a position within a media file """ methodName = 'getBookmarks' viewName = '%s.view' % methodName req = self._getRequest(viewName) res = self._doInfoReq(req) self._checkStatus(res) return res def createBookmark(self, mid, position, comment=None): """ since: 1.9.0 Creates or updates a bookmark (position within a media file). Bookmarks are personal and not visible to other users mid:str The ID of the media file to bookmark. If a bookmark already exists for this file, it will be overwritten position:int The position (in milliseconds) within the media file comment:str A user-defined comment """ methodName = 'createBookmark' viewName = '%s.view' % methodName q = self._getQueryDict({'id': mid, 'position': position, 'comment': comment}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def deleteBookmark(self, mid): """ since: 1.9.0 Deletes the bookmark for a given file mid:str The ID of the media file to delete the bookmark from. Other users' bookmarks are not affected """ methodName = 'deleteBookmark' viewName = '%s.view' % methodName q = {'id': mid} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getArtistInfo(self, aid, count=20, includeNotPresent=False): """ since: 1.11.0 Returns artist info with biography, image URLS and similar artists using data from last.fm aid:str The ID of the artist, album or song count:int The max number of similar artists to return includeNotPresent:bool Whether to return artists that are not present in the media library """ methodName = 'getArtistInfo' viewName = '%s.view' % methodName q = {'id': aid, 'count': count, 'includeNotPresent': includeNotPresent} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getArtistInfo2(self, aid, count=20, includeNotPresent=False): """ since: 1.11.0 Similar to getArtistInfo(), but organizes music according to ID3 tags aid:str The ID of the artist, album or song count:int The max number of similar artists to return includeNotPresent:bool Whether to return artists that are not present in the media library """ methodName = 'getArtistInfo2' viewName = '%s.view' % methodName q = {'id': aid, 'count': count, 'includeNotPresent': includeNotPresent} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getSimilarSongs(self, iid, count=50): """ since 1.11.0 Returns a random collection of songs from the given artist and similar artists, using data from last.fm. Typically used for artist radio features. iid:str The artist, album, or song ID count:int Max number of songs to return """ methodName = 'getSimilarSongs' viewName = '%s.view' % methodName q = {'id': iid, 'count': count} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getSimilarSongs2(self, iid, count=50): """ since 1.11.0 Similar to getSimilarSongs(), but organizes music according to ID3 tags iid:str The artist, album, or song ID count:int Max number of songs to return """ methodName = 'getSimilarSongs2' viewName = '%s.view' % methodName q = {'id': iid, 'count': count} req = self._getRequest(viewName, q) res = self._doInfoReq(req) 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 Same as selecting 'Settings' > 'Scan media folders now' with Subsonic web GUI Returns True if refresh successful, False otherwise """ methodName = 'scanNow' return self._unsupportedAPIFunction(methodName) def cleanupDatabase(self): """ This is not an officially supported method of the API Same as selecting 'Settings' > 'Clean-up Database' with Subsonic web GUI Returns True if cleanup initiated successfully, False otherwise Subsonic stores information about all media files ever encountered. By cleaning up the database, information about files that are no longer in your media collection is permanently removed. """ methodName = 'expunge' return self._unsupportedAPIFunction(methodName) def getVideoInfo(self, vid): """ since 1.14.0 Returns details for a video, including information about available audio tracks, subtitles (captions) and conversions. vid:int The video ID """ methodName = 'getVideoInfo' viewName = '%s.view' % methodName q = {'id': int(vid)} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getAlbumInfo(self, aid): """ since 1.14.0 Returns the album notes, image URLs, etc., using data from last.fm aid:int The album ID """ methodName = 'getAlbumInfo' viewName = '%s.view' % methodName q = {'id': int(aid)} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getAlbumInfo2(self, aid): """ since 1.14.0 Same as getAlbumInfo, but uses ID3 tags aid:int The album ID """ methodName = 'getAlbumInfo2' viewName = '%s.view' % methodName q = {'id': int(aid)} req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def getCaptions(self, vid, fmt=None): """ since 1.14.0 Returns captions (subtitles) for a video. Use getVideoInfo for a list of captions. vid:int The ID of the video fmt:str Preferred captions format ("srt" or "vtt") """ methodName = 'getCaptions' viewName = '%s.view' % methodName q = self._getQueryDict({'id': int(vid), 'format': fmt}) req = self._getRequest(viewName, q) res = self._doInfoReq(req) self._checkStatus(res) return res def _unsupportedAPIFunction(self, methodName): """ base function to call unsupported API methods Returns True if refresh successful, False otherwise :rtype : boolean """ baseMethod = 'musicFolderSettings' viewName = '%s.view' % baseMethod url = '%s:%d/%s/%s?%s' % (self._baseUrl, self._port, self._separateServerPath(), viewName, methodName) req = urllib.request.Request(url) res = self._opener.open(req) res_msg = res.msg.lower() return res_msg == 'ok' # # Private internal methods # def _getOpener(self, username, passwd): # 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 = urllib.request.build_opener( PysHTTPRedirectHandler, https_chain, ) return opener def _getQueryDict(self, d): """ Given a dictionary, it cleans out all the values set to None """ for k, v in list(d.items()): if v is None: del d[k] return d def _getBaseQdict(self): qdict = { 'f': 'json', 'v': self._apiVersion, 'c': self._appName, 'u': self._username, } if self._legacyAuth: qdict['p'] = 'enc:%s' % self._hexEnc(self._rawPass) else: salt = self._getSalt() token = md5((self._rawPass + salt).encode('utf-8')).hexdigest() qdict.update({ 's': salt, 't': token, }) return qdict def _getRequest(self, viewName, query={}): qdict = self._getBaseQdict() qdict.update(query) url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath, viewName) xbmc.log("Standard URL %s"%url,level=xbmc.LOGINFO) xbmc.log("Qdict %s"%str(qdict),level=xbmc.LOGINFO) req = urllib.request.Request(url, urlencode(qdict).encode('utf-8')) if(self._useGET or ('getCoverArt' in viewName) or ('stream' in viewName)): url += '?%s' % urlencode(qdict) xbmc.log("UseGET URL %s"%(url),xbmc.LOGINFO) req = urllib.request.Request(url) return req def _getRequestWithList(self, viewName, listName, alist, query={}): """ Like _getRequest, but allows appending a number of items with the same key (listName). This bypasses the limitation of urlencode() """ qdict = self._getBaseQdict() qdict.update(query) url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath, viewName) data = StringIO() data.write(urlencode(qdict)) for i in alist: data.write('&%s' % urlencode({listName: i})) req = urllib.request.Request(url, data.getvalue().encode('utf-8')) if self._useGET: url += '?%s' % data.getvalue() req = urllib2.Request(url) return req def _getRequestWithLists(self, viewName, listMap, query={}): """ Like _getRequestWithList(), but you must pass a dictionary that maps the listName to the list. This allows for multiple list parameters to be used, like in updatePlaylist() viewName:str The name of the view listMap:dict A mapping of listName to a list of entries query:dict The normal query dict """ qdict = self._getBaseQdict() qdict.update(query) url = '%s:%d/%s/%s' % (self._baseUrl, self._port, self._serverPath, viewName) data = StringIO() data.write(urlencode(qdict)) for k, l in listMap.items(): for i in l: data.write('&%s' % urlencode({k: i})) req = urllib.request.Request(url, data.getvalue().encode('utf-8')) if self._useGET: url += '?%s' % data.getvalue() req = urllib2.Request(url) return req def _doInfoReq(self, req): # Returns a parsed dictionary version of the result res = self._opener.open(req) dres = json.loads(res.read().decode('utf-8')) return dres['subsonic-response'] def _doBinReq(self, req): res = self._opener.open(req) info = res.info() if hasattr(info, 'getheader'): contType = info.getheader('Content-Type') else: contType = info.get('Content-Type') if contType: if contType.startswith('text/html') or \ contType.startswith('application/json'): dres = json.loads(res.read()) return dres['subsonic-response'] return res def _checkStatus(self, result): if result['status'] == 'ok': return True elif result['status'] == 'failed': exc = getExcByCode(result['error']['code']) raise exc(result['error']['message']) def _hexEnc(self, raw): """ Returns a "hex encoded" string per the Subsonic api docs raw:str The string to hex encode """ ret = '' for c in raw: ret += '%02X' % ord(c) return ret def _ts2milli(self, ts): """ For whatever reason, Subsonic uses timestamps in milliseconds since the unix epoch. I have no idea what need there is of this precision, but this will just multiply the timestamp times 1000 and return the int """ if ts is None: return None return int(ts * 1000) def _separateServerPath(self): """ separate REST portion of URL from base server path. """ return urllib.parse.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 list(data.items()): if k == 'lastModified': data[k] = int(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]