diff --git a/addon.py b/addon.py new file mode 100644 index 0000000..eefaec6 --- /dev/null +++ b/addon.py @@ -0,0 +1,414 @@ +import os +import sys +import urllib +import urlparse + +import xbmc +import xbmcgui +import xbmcaddon +import xbmcplugin + +# Make sure library folder is on the path +addon = xbmcaddon.Addon("plugin.audio.subsonic") +sys.path.append(xbmc.translatePath(os.path.join( + addon.getAddonInfo("path"), "lib"))) + +import libsonic_extra + + +class Plugin(object): + """ + Plugin container + """ + + def __init__(self, addon_url, addon_handle, addon_args): + self.addon_url = addon_url + self.addon_handle = addon_handle + self.addon_args = addon_args + + # Retrieve plugin settings + self.url = addon.getSetting("subsonic_url") + self.username = addon.getSetting("username") + self.password = addon.getSetting("password") + + self.random_count = addon.getSetting("random_count") + self.bitrate = addon.getSetting("bitrate") + self.trans_format = addon.getSetting("trans_format") + + # Create connection + self.connection = libsonic_extra.Connection( + self.url, self.username, self.password) + + def build_url(self, query): + """ + """ + + parts = list(urlparse.urlparse(self.addon_url)) + parts[4] = urllib.urlencode(query) + + return urlparse.urlunparse(parts) + + def walk_genres(self): + """ + Request Subsonic's genres list and iterate each item. + """ + + response = self.connection.getGenres() + + for genre in response["genres"]["genre"]: + yield genre + + def walk_artists(self): + """ + Request SubSonic's index and iterate each item. + """ + + response = self.connection.getArtists() + + for index in response["artists"]["index"]: + for artist in index["artist"]: + yield artist + + def walk_album_list2_genre(self, genre): + """ + """ + + offset = 0 + + while True: + response = self.connection.getAlbumList2( + ltype="byGenre", genre=genre, size=500, offset=offset) + + if not response["albumList2"]["album"]: + break + + for album in response["albumList2"]["album"]: + yield album + + offset += 500 + + def walk_album(self, album_id): + """ + Request Album and iterate each song. + """ + + response = self.connection.getAlbum(album_id) + + for song in response["album"]["song"]: + yield song + + def walk_playlists(self): + """ + Request SubSonic's playlists and iterate over each item. + """ + + response = self.connection.getPlaylists() + + for child in response["playlists"]["playlist"]: + yield child + + def walk_playlist(self, playlist_id): + """ + Request SubSonic's playlist items and iterate over each item. + """ + + response = self.connection.getPlaylist(playlist_id) + + for order, child in enumerate(response["playlist"]["entry"], start=1): + child["order"] = order + yield child + + def walk_directory(self, directory_id): + """ + Request a SubSonic music directory and iterate over each item. + """ + + response = self.connection.getMusicDirectory(directory_id) + + for child in response["directory"]["child"]: + if child.get("isDir"): + for child in self.walk_directory(child["id"]): + yield child + else: + yield child + + def walk_artist(self, artist_id): + """ + Request a SubSonic artist and iterate over each album. + """ + + response = self.connection.getArtist(artist_id) + + for child in response["artist"]["album"]: + yield child + + def walk_random_songs(self, size, genre=None, from_year=None, + to_year=None): + """ + """ + + response = self.connection.getRandomSongs( + size=size, genre=genre, fromYear=from_year, toYear=to_year) + + for song in response["randomSongs"]["song"]: + song["id"] = int(song["id"]) + + yield song + + def route(self): + mode = self.addon_args.get("mode", ["main_page"])[0] + getattr(self, mode)() + + def add_track(self, track, show_artist=False): + """ + """ + + cover_art_url = self.connection.getCoverArtUrl(track["id"]) + url = self.connection.streamUrl( + sid=track["id"], maxBitRate=self.bitrate, + tformat=self.trans_format) + + if show_artist: + li = xbmcgui.ListItem(track["artist"] + " - " + track["title"]) + else: + li = xbmcgui.ListItem(track["title"]) + + li.setIconImage(cover_art_url) + li.setThumbnailImage(cover_art_url) + li.setProperty("fanart_image", cover_art_url) + li.setProperty("IsPlayable", "true") + li.setInfo(type="Music", infoLabels={ + "Artist": track["artist"], + "Title": track["title"], + "Year": track.get("year"), + "Duration": track.get("duration"), + "Genre": track.get("genre")}) + + xbmcplugin.addDirectoryItem( + handle=self.addon_handle, url=url, listitem=li) + + def add_album(self, album, show_artist=False): + cover_art_url = self.connection.getCoverArtUrl(album["id"]) + url = self.build_url({ + "mode": "track_list", + "album_id": album["id"]}) + + if show_artist: + li = xbmcgui.ListItem(album["artist"] + " - " + album["name"]) + else: + li = xbmcgui.ListItem(album["name"]) + + li.setIconImage(cover_art_url) + li.setThumbnailImage(cover_art_url) + li.setProperty("fanart_image", cover_art_url) + + xbmcplugin.addDirectoryItem( + handle=self.addon_handle, url=url, listitem=li, isFolder=True) + + def main_page(self): + """ + Display main menu. + """ + + menu = [ + {"mode": "playlists_list", "foldername": "Playlists"}, + {"mode": "artist_list", "foldername": "Artists"}, + {"mode": "genre_list", "foldername": "Genres"}, + {"mode": "random_list", "foldername": "Random songs"}] + + for entry in menu: + url = self.build_url(entry) + + li = xbmcgui.ListItem(entry["foldername"]) + xbmcplugin.addDirectoryItem( + handle=self.addon_handle, url=url, listitem=li, isFolder=True) + + xbmcplugin.endOfDirectory(self.addon_handle) + + def playlists_list(self): + """ + Display playlists. + """ + + for playlist in self.walk_playlists(): + cover_art_url = self.connection.getCoverArtUrl( + playlist["coverArt"]) + url = self.build_url({ + "mode": "playlist_list", "playlist_id": playlist["id"]}) + + li = xbmcgui.ListItem(playlist["name"], iconImage=cover_art_url) + xbmcplugin.addDirectoryItem( + handle=self.addon_handle, url=url, listitem=li, isFolder=True) + + xbmcplugin.endOfDirectory(self.addon_handle) + + def playlist_list(self): + """ + Display playlist tracks. + """ + + playlist_id = self.addon_args["playlist_id"][0] + + for track in self.walk_playlist(playlist_id): + self.add_track(track, show_artist=True) + + xbmcplugin.setContent(self.addon_handle, "songs") + xbmcplugin.endOfDirectory(self.addon_handle) + + def genre_list(self): + """ + Display list of genres menu. + """ + + for genre in self.walk_genres(): + url = self.build_url({ + "mode": "albums_by_genre_list", + "foldername": genre["value"].encode("utf-8")}) + + li = xbmcgui.ListItem(genre["value"]) + xbmcplugin.addDirectoryItem( + handle=self.addon_handle, url=url, listitem=li, isFolder=True) + + xbmcplugin.endOfDirectory(self.addon_handle) + + def albums_by_genre_list(self): + """ + Display album list by genre menu. + """ + + genre = self.addon_args["foldername"][0].decode("utf-8") + + for album in self.walk_album_list2_genre(genre): + self.add_album(album, show_artist=True) + + xbmcplugin.setContent(self.addon_handle, "albums") + xbmcplugin.endOfDirectory(self.addon_handle) + + def artist_list(self): + """ + Display artist list + """ + + for artist in self.walk_artists(): + cover_art_url = self.connection.getCoverArtUrl(artist["id"]) + url = self.build_url({ + "mode": "album_list", + "artist_id": artist["id"]}) + + li = xbmcgui.ListItem(artist["name"]) + li.setIconImage(cover_art_url) + li.setThumbnailImage(cover_art_url) + li.setProperty("fanart_image", cover_art_url) + xbmcplugin.addDirectoryItem( + handle=self.addon_handle, url=url, listitem=li, isFolder=True) + + xbmcplugin.setContent(self.addon_handle, "artists") + xbmcplugin.endOfDirectory(self.addon_handle) + + def album_list(self): + """ + Display list of albums for certain artist. + """ + + artist_id = self.addon_args["artist_id"][0] + + for album in self.walk_artist(artist_id): + self.add_album(album) + + xbmcplugin.setContent(self.addon_handle, "albums") + xbmcplugin.endOfDirectory(self.addon_handle) + + def track_list(self): + """ + Display track list. + """ + + album_id = self.addon_args["album_id"][0] + + for track in self.walk_album(album_id): + self.add_track(track) + + xbmcplugin.setContent(self.addon_handle, "songs") + xbmcplugin.endOfDirectory(self.addon_handle) + + def random_list(self): + """ + Display random options. + """ + + menu = [ + {"mode": "random_by_genre_list", "foldername": "By genre"}, + {"mode": "random_by_year_list", "foldername": "By year"}] + + for entry in menu: + url = self.build_url(entry) + + li = xbmcgui.ListItem(entry["foldername"]) + xbmcplugin.addDirectoryItem( + handle=self.addon_handle, url=url, listitem=li, isFolder=True) + + xbmcplugin.endOfDirectory(self.addon_handle) + + def random_by_genre_list(self): + """ + Display random genre list. + """ + + for genre in self.walk_genres(): + url = self.build_url({ + "mode": "random_by_genre_track_list", + "foldername": genre["value"].encode("utf-8")}) + + li = xbmcgui.ListItem(genre["value"]) + xbmcplugin.addDirectoryItem( + handle=self.addon_handle, url=url, listitem=li, isFolder=True) + + xbmcplugin.endOfDirectory(self.addon_handle) + + def random_by_genre_track_list(self): + """ + Display random tracks by genre + """ + + genre = self.addon_args["foldername"][0].decode("utf-8") + + for track in self.walk_random_songs( + size=self.random_count, genre=genre): + self.add_track(track, show_artist=True) + + xbmcplugin.setContent(self.addon_handle, "songs") + xbmcplugin.endOfDirectory(self.addon_handle) + + def random_by_year_list(self): + """ + Display random tracks by year. + """ + + from_year = xbmcgui.Dialog().input( + "From year", type=xbmcgui.INPUT_NUMERIC) + to_year = xbmcgui.Dialog().input( + "To year", type=xbmcgui.INPUT_NUMERIC) + + for track in self.walk_random_songs( + size=self.random_count, from_year=from_year, to_year=to_year): + self.add_track(track, show_artist=True) + + xbmcplugin.setContent(self.addon_handle, "songs") + xbmcplugin.endOfDirectory(self.addon_handle) + + +def main(): + """ + Entry point for this plugin. + """ + + addon_url = sys.argv[0] + addon_handle = int(sys.argv[1]) + addon_args = urlparse.parse_qs(sys.argv[2][1:]) + + # Route request to action + Plugin(addon_url, addon_handle, addon_args).route() + +# Start plugin from Kodi +if __name__ == "__main__": + main() diff --git a/addon.xml b/addon.xml new file mode 100644 index 0000000..51d1f79 --- /dev/null +++ b/addon.xml @@ -0,0 +1,21 @@ + + + + + + + audio + + + Subsonic music addon for Kodi. + Subsonic music addon for Kodi. + + + all + MIT + + + + + + diff --git a/fanart.jpg b/fanart.jpg new file mode 100644 index 0000000..1de02c3 Binary files /dev/null and b/fanart.jpg differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..8b5a6ea Binary files /dev/null and b/icon.png differ diff --git a/lib/libsonic/__init__.py b/lib/libsonic/__init__.py new file mode 100644 index 0000000..503f141 --- /dev/null +++ b/lib/libsonic/__init__.py @@ -0,0 +1,32 @@ +""" +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 + +For information on method calls, see 'pydoc libsonic.connection' + +---------- +Basic example: +---------- + +import libsonic + +conn = libsonic.Connection('http://localhost' , 'admin' , 'password') +print conn.ping() + +""" + +from connection import * + +__version__ = '0.3.3' diff --git a/lib/libsonic/connection.py b/lib/libsonic/connection.py new file mode 100644 index 0000000..0383c7c --- /dev/null +++ b/lib/libsonic/connection.py @@ -0,0 +1,2441 @@ +""" +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 base64 import b64encode +from urllib import urlencode +from .errors import * +from pprint import pprint +from cStringIO import StringIO +import json , urllib2, httplib, logging, socket, ssl + +API_VERSION = '1.11.0' + +logger = logging.getLogger(__name__) + +class HTTPSConnectionChain(httplib.HTTPSConnection): + _preferred_ssl_protos = ( + ('TLSv1' , ssl.PROTOCOL_TLSv1) , + ('SSLv3' , ssl.PROTOCOL_SSLv3) , + ('SSLv23' , ssl.PROTOCOL_SSLv23) , + ) + _ssl_working_proto = None + + def connect(self): + sock = socket.create_connection((self.host, self.port), self.timeout) + if self._tunnel_host: + self.sock = sock + self._tunnel() + if self._ssl_working_proto is not None: + # If we have a working proto, let's use that straight away + logger.debug("Using known working proto: '%s'", + self._ssl_working_proto) + self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file, + ssl_version=self._ssl_working_proto) + return + # Try connecting via the different SSL protos in preference order + for proto_name , proto in self._preferred_ssl_protos: + try: + self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file, + ssl_version=proto) + except: + pass + else: + # Cache the working ssl version + HTTPSConnectionChain._ssl_working_proto = proto + break + + +class HTTPSHandlerChain(urllib2.HTTPSHandler): + def https_open(self , req): + return self.do_open(HTTPSConnectionChain, req) + +# install opener +urllib2.install_opener(urllib2.build_opener(HTTPSHandlerChain())) + +class PysHTTPRedirectHandler(urllib2.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 req.headers.items() + if k.lower() not in ("content-length", "content-type") + ) + data = None + if req.has_data(): + data = req.get_data() + return urllib2.Request(newurl, + data=data, + headers=newheaders, + origin_req_host=req.get_origin_req_host(), + unverifiable=True) + else: + raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp) + +class Connection(object): + def __init__(self , baseUrl , username , password , port=4040 , + serverPath='/rest' , appName='py-sonic' , apiVersion=API_VERSION): + """ + 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 + password:str The password to use for the connection + 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. + """ + self._baseUrl = baseUrl + self._username = username + self._rawPass = password + self._port = int(port) + self._apiVersion = apiVersion + self._appName = appName + self._serverPath = serverPath.strip('/') + 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) + + # 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': + raise getExcByCode(res['error']['code']) + 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 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 time + + 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) + 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): + """ + 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] + + 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 = {'query': query , 'artistCount': artistCount , + 'artistOffset': artistOffset , 'albumCount': albumCount , + 'albumOffset': albumOffset , 'songCount': songCount , + 'songOffset': songOffset} + + 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): + """ + 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] + + 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 = {'query': query , 'artistCount': artistCount , + 'artistOffset': artistOffset , 'albumCount': albumCount , + 'albumOffset': albumOffset , 'songCount': songCount , + 'songOffset': songOffset} + + 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): + """ + 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 + + 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}) + + req = self._getRequest(viewName , q) + res = self._doBinReq(req) + if isinstance(res , dict): + self._checkStatus(res) + return res + + 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 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): + """ + 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 + + + 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 = {'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} + + 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): + """ + since 1.10.1 + + Modifies an existing Subsonic user. + + username:str The username of the user to update. + + 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}) + 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): + """ + since 1.8.0 + + 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 + + req = self._getRequest(viewName) + res = self._doInfoReq(req) + self._checkStatus(res) + return res + + def getStarred2(self): + """ + since 1.8.0 + + 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 + + req = self._getRequest(viewName) + 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 urllib2.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): + """ + 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 + """ + methodName = 'getGenres' + viewName = '%s.view' % methodName + + q = {'genre': genre , + 'count': count , + 'offset': offset , + } + + 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 urllib2.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 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 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 _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 = urllib2.Request(url) + res = self._opener.open(req) + res_msg = res.msg.lower() + return res_msg == 'ok' + + # Private internal methods + def _getOpener(self , username , passwd): + creds = b64encode('%s:%s' % (username , passwd)) + opener = urllib2.build_opener(PysHTTPRedirectHandler , + HTTPSHandlerChain) + opener.addheaders = [('Authorization' , 'Basic %s' % creds)] + return opener + + def _getQueryDict(self , d): + """ + Given a dictionary, it cleans out all the values set to None + """ + for k , v in d.items(): + if v is None: + del d[k] + return d + + def _getRequest(self , viewName , query={}): + qstring = {'f': 'json' , 'v': self._apiVersion , 'c': self._appName} + qstring.update(query) + url = '%s:%d/%s/%s' % (self._baseUrl , self._port , self._serverPath , + viewName) + req = urllib2.Request(url , urlencode(qstring)) + 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() + """ + qstring = {'f': 'json' , 'v': self._apiVersion , 'c': self._appName} + qstring.update(query) + url = '%s:%d/%s/%s' % (self._baseUrl , self._port , self._serverPath , + viewName) + data = StringIO() + data.write(urlencode(qstring)) + for i in alist: + data.write('&%s' % urlencode({listName: i})) + req = urllib2.Request(url , data.getvalue()) + 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 + """ + qstring = {'f': 'json' , 'v': self._apiVersion , 'c': self._appName} + qstring.update(query) + url = '%s:%d/%s/%s' % (self._baseUrl , self._port , self._serverPath , + viewName) + data = StringIO() + data.write(urlencode(qstring)) + for k , l in listMap.iteritems(): + for i in l: + data.write('&%s' % urlencode({k: i})) + req = urllib2.Request(url , data.getvalue()) + return req + + def _doInfoReq(self , req): + # Returns a parsed dictionary version of the result + res = self._opener.open(req) + dres = json.loads(res.read()) + return dres['subsonic-response'] + + def _doBinReq(self , req): + res = self._opener.open(req) + contType = res.info().getheader('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 urllib2.splithost(self._serverPath)[1].split('/')[0] + diff --git a/lib/libsonic/errors.py b/lib/libsonic/errors.py new file mode 100644 index 0000000..a000c57 --- /dev/null +++ b/lib/libsonic/errors.py @@ -0,0 +1,59 @@ +""" +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 +""" + +class SonicError(Exception): + pass + +class ParameterError(SonicError): + pass + +class VersionError(SonicError): + pass + +class CredentialError(SonicError): + pass + +class AuthError(SonicError): + pass + +class LicenseError(SonicError): + pass + +class DataNotFoundError(SonicError): + pass + +class ArgumentError(SonicError): + pass + +# This maps the error code numbers from the Subsonic server to their +# appropriate Exceptions +ERR_CODE_MAP = { + 0: SonicError , + 10: ParameterError , + 20: VersionError , + 30: VersionError , + 40: CredentialError , + 50: AuthError , + 60: LicenseError , + 70: DataNotFoundError , +} + +def getExcByCode(code): + code = int(code) + if code in ERR_CODE_MAP: + return ERR_CODE_MAP[code] + return SonicError diff --git a/lib/libsonic_extra/__init__.py b/lib/libsonic_extra/__init__.py new file mode 100644 index 0000000..3b0b8fe --- /dev/null +++ b/lib/libsonic_extra/__init__.py @@ -0,0 +1,231 @@ +import urllib +import urlparse +import libsonic + + +def force_dict(value): + """ + Coerce the input value to a dict. + """ + + if type(value) == dict: + return value + else: + return {} + + +def force_list(value): + """ + Coerce the input value to a list. + + If `value` is `None`, return an empty list. If it is a single value, create + a new list with that element on index 0. + + :param value: Input value to coerce. + :return: Value as list. + :rtype: list + """ + + if value is None: + return [] + elif type(value) == list: + return value + else: + return [value] + + +class Connection(libsonic.Connection): + """ + Extend `libsonic.Connection` with new features and fix a few issues. + + - Add library name property. + - Parse URL for host and port for constructor. + - Make sure API results are of of uniform type. + + :param str name: Name of connection. + :param str url: Full URL (including protocol) of SubSonic server. + :param str username: Username of server. + :param str password: Password of server. + """ + + def __init__(self, url, username, password): + self._intercept_url = False + + # Parse SubSonic URL + parts = urlparse.urlparse(url) + scheme = parts.scheme or "http" + + # Make sure there is hostname + if not parts.hostname: + raise ValueError("Expected hostname for URL: %s" % url) + + # Validate scheme + if scheme not in ("http", "https"): + raise ValueError("Unexpected scheme '%s' for URL: %s" % ( + scheme, url)) + + # Pick a default port + host = "%s://%s" % (scheme, parts.hostname) + port = parts.port or {"http": 80, "https": 443}[scheme] + + # Invoke original constructor + super(Connection, self).__init__(host, username, password, port=port) + + def getArtists(self, *args, **kwargs): + """ + """ + + def _artists_iterator(artists): + for artist in force_list(artists): + artist["id"] = int(artist["id"]) + yield artist + + def _index_iterator(index): + for index in force_list(index): + index["artist"] = list(_artists_iterator(index.get("artist"))) + yield index + + response = super(Connection, self).getArtists(*args, **kwargs) + response["artists"] = response.get("artists", {}) + response["artists"]["index"] = list( + _index_iterator(response["artists"].get("index"))) + + return response + + def getPlaylists(self, *args, **kwargs): + """ + """ + + def _playlists_iterator(playlists): + for playlist in force_list(playlists): + playlist["id"] = int(playlist["id"]) + yield playlist + + response = super(Connection, self).getPlaylists(*args, **kwargs) + response["playlists"]["playlist"] = list( + _playlists_iterator(response["playlists"].get("playlist"))) + + return response + + def getPlaylist(self, *args, **kwargs): + """ + """ + + def _entries_iterator(entries): + for entry in force_list(entries): + entry["id"] = int(entry["id"]) + yield entry + + response = super(Connection, self).getPlaylist(*args, **kwargs) + response["playlist"]["entry"] = list( + _entries_iterator(response["playlist"].get("entry"))) + + return response + + def getArtist(self, *args, **kwargs): + """ + """ + + def _albums_iterator(albums): + for album in force_list(albums): + album["id"] = int(album["id"]) + yield album + + response = super(Connection, self).getArtist(*args, **kwargs) + response["artist"]["album"] = list( + _albums_iterator(response["artist"].get("album"))) + + return response + + def getAlbum(self, *args, **kwargs): + "" + "" + + def _songs_iterator(songs): + for song in force_list(songs): + song["id"] = int(song["id"]) + yield song + + response = super(Connection, self).getAlbum(*args, **kwargs) + response["album"]["song"] = list( + _songs_iterator(response["album"].get("song"))) + + return response + + def getAlbumList2(self, *args, **kwargs): + "" + "" + + def _album_iterator(albums): + for album in force_list(albums): + album["id"] = int(album["id"]) + yield album + + response = super(Connection, self).getAlbumList2(*args, **kwargs) + response["albumList2"]["album"] = list( + _album_iterator(response["albumList2"].get("album"))) + + return response + + def getMusicDirectory(self, *args, **kwargs): + """ + """ + + def _children_iterator(children): + for child in force_list(children): + child["id"] = int(child["id"]) + + if "parent" in child: + child["parent"] = int(child["parent"]) + if "coverArt" in child: + child["coverArt"] = int(child["coverArt"]) + if "artistId" in child: + child["artistId"] = int(child["artistId"]) + if "albumId" in child: + child["albumId"] = int(child["albumId"]) + + yield child + + response = super(Connection, self).getMusicDirectory(*args, **kwargs) + response["directory"]["child"] = list( + _children_iterator(response["directory"].get("child"))) + + return response + + def getCoverArtUrl(self, *args, **kwargs): + """ + Return an URL to the cover art. + """ + + self._intercept_url = True + url = self.getCoverArt(*args, **kwargs) + self._intercept_url = False + + return url + + def streamUrl(self, *args, **kwargs): + """ + Return an URL to the file to stream. + """ + + self._intercept_url = True + url = self.stream(*args, **kwargs) + self._intercept_url = False + + return url + + def _doBinReq(self, *args, **kwargs): + """ + Intercept request URL. + """ + + if self._intercept_url: + parts = list(urlparse.urlparse( + args[0].get_full_url() + "?" + args[0].data)) + parts[4] = dict(urlparse.parse_qsl(parts[4])) + parts[4].update({"u": self.username, "p": self.password}) + parts[4] = urllib.urlencode(parts[4]) + + return urlparse.urlunparse(parts) + else: + return super(Connection, self)._doBinReq(*args, **kwargs) diff --git a/resources/settings.xml b/resources/settings.xml new file mode 100644 index 0000000..d6b3981 --- /dev/null +++ b/resources/settings.xml @@ -0,0 +1,9 @@ + + + + + + + + +