diff --git a/pysonic/api.py b/pysonic/api.py index aa23261..d6692ff 100644 --- a/pysonic/api.py +++ b/pysonic/api.py @@ -1,3 +1,4 @@ +import re import json import logging import subprocess @@ -11,55 +12,17 @@ from pysonic.library import LETTER_GROUPS from pysonic.types import MUSIC_TYPES +CALLBACK_RE = re.compile(r'^[a-zA-Z0-9_]+$') logging = logging.getLogger("api") -class ApiResponse(object): - def __init__(self, status="ok", version="1.15.0", top=None, **kwargs): - self.status = status - self.version = version - self.data = {} - self.top = top - if self.top: - self.data[self.top] = kwargs ## kwargs unused TODO - - def add_child(self, _type, **kwargs): - if not self.top: - raise Exception("You can't do this?") - if _type not in self.data[self.top]: - self.data[self.top][_type] = [] - self.data[self.top][_type].append(kwargs) - - def render_json(self): - return json.dumps({"subsonic-response": dict(status=self.status, version="1.15.0", **self.data)}, indent=4) - - def render_xml(self): - doc = BeautifulSoup('', features='lxml-xml') - root = doc.new_tag("subsonic-response", xmlns="http://subsonic.org/restapi", - status=self.status, - version=self.version) - doc.append(root) - - if self.top: - top = doc.new_tag(self.top) - root.append(top) - # TODO top_attrs ? - for top_child_type, top_child_instances in self.data[self.top].items(): - for top_child_attrs in top_child_instances: - child = doc.new_tag(top_child_type) - child.attrs.update(top_child_attrs) - top.append(child) - - albumlist = doc.new_tag("albumList") - doc.append(albumlist) - return doc.prettify() - - response_formats = defaultdict(lambda: "render_xml") response_formats["json"] = "render_json" +response_formats["jsonp"] = "render_jsonp" response_headers = defaultdict(lambda: "text/xml; charset=utf-8") -response_headers["json"] = "text/json" #TODO is this right? +response_headers["json"] = "application/json; charset=utf-8" +response_headers["jsonp"] = "text/javascript; charset=utf-8" def formatresponse(func): @@ -68,84 +31,169 @@ def formatresponse(func): """ def wrapper(*args, **kwargs): response = func(*args, **kwargs) - cherrypy.response.headers['Content-Type'] = response_headers[kwargs.get("f", "xml")] - return getattr(response, response_formats[kwargs.get("f", "xml")])() + response_format = kwargs.get("f", "xml") + callback = kwargs.get("callback", None) + cherrypy.response.headers['Content-Type'] = response_headers[response_format] + renderer = getattr(response, response_formats[response_format]) + if response_format == "jsonp": + if callback is None: + return response.render_xml().encode('UTF-8') # copy original subsonic behavior + else: + return renderer(callback).encode('UTF-8') + return renderer().encode('UTF-8') return wrapper +class ApiResponse(object): + def __init__(self, status="ok", version="1.15.0"): + """ + ApiResponses are python data structures that can be converted to other formats. The response has a status and a + version. The response data structure is stored in self.data and follows these rules: + - self.data is a dict + - the dict's values become either child nodes or attributes, named by the key + - lists become many oner one child + - dict values are not allowed + - all other types (str, int, NoneType) are attributes + :param status: + :param version: + """ + self.status = status + self.version = version + self.data = defaultdict(lambda: list()) + + def add_child(self, _type, _parent="", _real_parent=None, **kwargs): + parent = _real_parent if _real_parent else self.get_child(_parent) + m = defaultdict(lambda: list()) + m.update(dict(kwargs)) + parent[_type].append(m) + return m + + def get_child(self, _path): + parent_path = _path.split(".") + parent = self.data + for item in parent_path: + if not item: + continue + parent = parent.get(item)[0] + return parent + + def set_attrs(self, _path, **attrs): + parent = self.get_child(_path) + if type(parent) not in (dict, defaultdict): + raise Exception("wot") + parent.update(attrs) + + def render_json(self): + def _flatten_json(item): + """ + Convert defaultdicts to dicts and remove lists where node has 1 or no child + """ + listed_attrs = ["folder"] + d = {} + for k, v in item.items(): + if type(v) is list: + if len(v) > 1: + d[k] = [] + for subitem in v: + d[k].append(_flatten_json(subitem)) + elif len(v) == 1: + d[k] = _flatten_json(v[0]) + else: + d[k] = {} + else: + d[k] = [v] if k in listed_attrs else v + return d + + data = _flatten_json(self.data) + return json.dumps({"subsonic-response": dict(status=self.status, version=self.version, **data)}, indent=4) + + def render_jsonp(self, callback): + assert CALLBACK_RE.match(callback), "Invalid callback" + return "{}({});".format(callback, self.render_json()) + + def render_xml(self): + text_attrs = ['largeImageUrl', 'musicBrainzId', 'smallImageUrl', 'mediumImageUrl', 'lastFmUrl', 'biography', + 'folder'] + # These attributes will be placed in {{ value }} tags instead of hello="{{ value }}" on parent + doc = BeautifulSoup('', features='lxml-xml') + root = doc.new_tag("subsonic-response", xmlns="http://subsonic.org/restapi", + status=self.status, + version=self.version) + doc.append(root) + + def _render_xml(node, parent): + """ + For every key in the node dict, the parent gets a new child tag with name == key + If the value is a dict, it becomes the new tag's attrs + If the value is a list, the parent gets many new tags with each dict as attrs + If the value is str int etc, parent gets attrs + """ + for key, value in node.items(): + if type(value) in (dict, defaultdict): + tag = doc.new_tag(key) + parent.append(tag) + tag.attrs.update(value) + elif type(value) is list: + for item in value: + tag = doc.new_tag(key) + parent.append(tag) + _render_xml(item, tag) + else: + if key in text_attrs: + tag = doc.new_tag(key) + parent.append(tag) + tag.append(str(value)) + else: + parent.attrs[key] = value + _render_xml(self.data, root) + return doc.prettify() + + class PysonicApi(object): def __init__(self, db, library, options): self.db = db self.library = library self.options = options - def response(self, status="ok"): - doc = BeautifulSoup('', features='lxml-xml') - root = doc.new_tag("subsonic-response", xmlns="http://subsonic.org/restapi", status=status, version="1.15.0") - doc.append(root) - return doc, root - @cherrypy.expose + @formatresponse def ping_view(self, **kwargs): # Called when the app hits the "test connection" server option - cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' - doc, root = self.response() - yield doc.prettify() + return ApiResponse() @cherrypy.expose + @formatresponse def getLicense_view(self, **kwargs): # Called after ping.view - cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' - doc, root = self.response() - root.append(doc.new_tag("license", - valid="true", - email="admin@localhost", - licenseExpires="2100-01-01T00:00:00.000Z", - trialExpires="2100-01-01T01:01:00.000Z")) - yield doc.prettify() + response = ApiResponse() + response.add_child("license", + valid="true", + email="admin@localhost", + licenseExpires="2100-01-01T00:00:00.000Z", + trialExpires="2100-01-01T01:01:00.000Z") + return response @cherrypy.expose + @formatresponse def getMusicFolders_view(self, **kwargs): - # Get list of configured dirs - # {'c': 'DSub', 's': 'bfk9mir8is02u3m5as8ucsehn0', 'v': '1.2.0', - # 't': 'e2b09fb9233d1bfac9abe3dc73017f1e', 'u': 'dave'} - # Access-Control-Allow-Origin:* - # Content-Encoding:gzip - # Content-Type:text/xml; charset=utf-8 - # Server:Jetty(6.1.x) - # Transfer-Encoding:chunked - cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' - - doc, root = self.response() - folder_list = doc.new_tag("musicFolders") - root.append(folder_list) - + response = ApiResponse() + response.add_child("musicFolders") for folder in self.library.get_libraries(): - entry = doc.new_tag("musicFolder", id=folder["id"]) - entry.attrs["name"] = folder["name"] - folder_list.append(entry) - yield doc.prettify() + response.add_child("musicFolder", _parent="musicFolders", id=folder["id"], name=folder["name"]) + return response @cherrypy.expose + @formatresponse def getIndexes_view(self, **kwargs): # Get listing of top-level dir - # /rest/getIndexes.view?u=dave&s=bfk9mir8is02u3m5as8ucsehn0 - # &t=e2b09fb9233d1bfac9abe3dc73017f1e&v=1.2.0&c=DSub HTTP/1.1 - cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' - doc, root = self.response() - indexes = doc.new_tag("indexes", lastModified="1502310831000", ignoredArticles="The El La Los Las Le Les") - doc.append(indexes) - + response = ApiResponse() + response.add_child("indexes", lastModified="1502310831000", ignoredArticles="The El La Los Las Le Les") for letter in LETTER_GROUPS: - index = doc.new_tag("index") - index.attrs["name"] = letter.upper() - indexes.append(index) + index = response.add_child("index", _parent="indexes", name=letter.upper()) for artist in self.library.get_artists(): if artist["name"][0].lower() in letter: - artist_tag = doc.new_tag("artist") - artist_tag.attrs.update({"id": artist["id"], "name": artist["name"]}) - index.append(artist_tag) - yield doc.prettify() + response.add_child("artist", _real_parent=index, id=artist["id"], name=artist["name"]) + return response @cherrypy.expose def savePlayQueue_view(self, id, current, position, **kwargs): @@ -174,7 +222,9 @@ class PysonicApi(object): raise NotImplemented() albumset = albums[0 + int(offset):int(size) + int(offset)] - response = ApiResponse(top="albumList") + response = ApiResponse() + + response.add_child("albumList") for album in albumset: album_meta = album['metadata'] @@ -184,8 +234,6 @@ class PysonicApi(object): title=album_meta.get("id3_title", album["name"]), #TODO these cant be blank or dsub gets mad album=album_meta.get("id3_album", album["album"]), artist=album_meta.get("id3_artist", album["artist"]), - # X year="2014" - # X coverArt="3228" # playCount="0" # created="2016-05-08T05:31:31.000Z"/>) ) @@ -193,10 +241,11 @@ class PysonicApi(object): album_kw["coverArt"] = album_meta["cover"] if 'id3_year' in album_meta: album_kw["year"] = album_meta['id3_year'] - response.add_child("album", **album_kw) + response.add_child("album", _parent="albumList", **album_kw) return response @cherrypy.expose + @formatresponse def getMusicDirectory_view(self, id, **kwargs): """ List an artist dir @@ -204,66 +253,67 @@ class PysonicApi(object): dir_id = int(id) cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' - doc, root = self.response() - dirtag = doc.new_tag("directory") + response = ApiResponse() + response.add_child("directory") directory = self.library.get_dir(dir_id) dir_meta = directory["metadata"] children = self.library.get_dir_children(dir_id) - dirtag.attrs.update(name=directory['name'], id=directory['id'], - parent=directory['parent'], playCount=10) - root.append(dirtag) + response.set_attrs(_path="directory", name=directory['name'], id=directory['id'], + parent=directory['parent'], playCount=10) for item in children: # omit not dirs and media in browser if not item["isdir"] and item["type"] not in MUSIC_TYPES: continue item_meta = item['metadata'] - dirtag.append(self.render_node(doc, item, item_meta, directory, dir_meta)) - yield doc.prettify() + response.add_child("child", _parent="directory", **self.render_node2(item, item_meta, directory, dir_meta)) - def render_node(self, doc, item, item_meta, directory, dir_meta, tagname="child"): - child = doc.new_tag(tagname, - id=item["id"], - parent=item["id"], - isDir="true" if item['isdir'] else "false", - title=item_meta.get("id3_title", item["name"]), - album=item_meta.get("id3_album", item["album"]), - artist=item_meta.get("id3_artist", item["artist"]), - # playCount="5", - # created="2016-04-25T07:31:33.000Z" - # X track="3", - # X year="2012", - # X coverArt="12835", - # X contentType="audio/mpeg" - # X suffix="mp3" - # genre="Other", - # size="15838864" - # duration="395" - # bitRate="320" - # path="Cosmic Gate/Sign Of The Times/03 Flatline (featuring Kyler England).mp3" - type="music") + return response + + def render_node2(self, item, item_meta, directory, dir_meta): + """ + Given a node and it's parent directory, and meta, return a dict with the keys formatted how the subsonic clients + expect them to be + :param item: + :param item_meta: + :param directory: + :param dir_meta: + """ + child = dict(id=item["id"], + parent=item["id"], + isDir="true" if item['isdir'] else "false", + title=item_meta.get("id3_title", item["name"]), + album=item_meta.get("id3_album", item["album"]), + artist=item_meta.get("id3_artist", item["artist"]), + # playCount="5", + # created="2016-04-25T07:31:33.000Z" + # genre="Other", + # path="Cosmic Gate/Sign Of The Times/03 Flatline (featuring Kyler England).mp3" + type="music") + if 'kbitrate' in item_meta: + child["bitrate"] = item_meta["kbitrate"] if item["size"] != -1: - child.attrs["size"] = item["size"] + child["size"] = item["size"] if "media_length" in item_meta: - child.attrs["duration"] = item_meta["media_length"] + child["duration"] = item_meta["media_length"] if "albumId" in directory: - child.attrs["albumId"] = directory["id"] + child["albumId"] = directory["id"] if "artistId" in directory: - child.attrs["artistId"] = directory["parent"] + child["artistId"] = directory["parent"] if "." in item["name"]: - child.attrs["suffix"] = item["name"].split(".")[-1] + child["suffix"] = item["name"].split(".")[-1] if item["type"]: - child.attrs["contentType"] = item["type"] + child["contentType"] = item["type"] if 'cover' in item_meta: - child.attrs["coverArt"] = item_meta["cover"] + child["coverArt"] = item_meta["cover"] elif 'cover' in dir_meta: - child.attrs["coverArt"] = dir_meta["cover"] + child["coverArt"] = dir_meta["cover"] if 'track' in item_meta: - child.attrs["track"] = item_meta['track'] + child["track"] = item_meta['track'] if 'id3_year' in item_meta: - child.attrs["year"] = item_meta['id3_year'] + child["year"] = item_meta['id3_year'] return child @cherrypy.expose @@ -341,7 +391,8 @@ class PysonicApi(object): fpath = self.library.get_filepath(id) type2ct = { 'jpg': 'image/jpeg', - 'png': 'image/png' + 'png': 'image/png', + 'gif': 'image/gif' } cherrypy.response.headers['Content-Type'] = type2ct[fpath[-3:]] @@ -359,7 +410,52 @@ class PysonicApi(object): getCoverArt_view._cp_config = {'response.stream': True} + def response(self, status="ok"): + doc = BeautifulSoup('', features='lxml-xml') + root = doc.new_tag("subsonic-response", xmlns="http://subsonic.org/restapi", status=status, version="1.15.0") + doc.append(root) + return doc, root + + def render_node(self, doc, item, item_meta, directory, dir_meta, tagname="child"): + child = doc.new_tag(tagname, + id=item["id"], + parent=item["id"], + isDir="true" if item['isdir'] else "false", + title=item_meta.get("id3_title", item["name"]), + album=item_meta.get("id3_album", item["album"]), + artist=item_meta.get("id3_artist", item["artist"]), + # playCount="5", + # created="2016-04-25T07:31:33.000Z" + # genre="Other", + # path="Cosmic Gate/Sign Of The Times/03 Flatline (featuring Kyler England).mp3" + type="music") + if 'kbitrate' in item_meta: + child.attrs["bitrate"] = item_meta["kbitrate"] + if item["size"] != -1: + child.attrs["size"] = item["size"] + if "media_length" in item_meta: + child.attrs["duration"] = item_meta["media_length"] + if "albumId" in directory: + child.attrs["albumId"] = directory["id"] + if "artistId" in directory: + child.attrs["artistId"] = directory["parent"] + if "." in item["name"]: + child.attrs["suffix"] = item["name"].split(".")[-1] + if item["type"]: + child.attrs["contentType"] = item["type"] + if 'cover' in item_meta: + child.attrs["coverArt"] = item_meta["cover"] + elif 'cover' in dir_meta: + child.attrs["coverArt"] = dir_meta["cover"] + if 'track' in item_meta: + child.attrs["track"] = item_meta['track'] + if 'id3_year' in item_meta: + child.attrs["year"] = item_meta['id3_year'] + return child + + @cherrypy.expose + @formatresponse def getArtistInfo_view(self, id, includeNotPresent="true", **kwargs): # /rest/getArtistInfo.view? # u=dave @@ -370,83 +466,70 @@ class PysonicApi(object): # id=7 # includeNotPresent=true info = self.library.get_artist_info(id) - cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' - doc, root = self.response() - dirtag = doc.new_tag("artistInfo") - root.append(dirtag) + response = ApiResponse() + response.add_child("artistInfo") + response.set_attrs("artistInfo", **info) - for key, value in info.items(): - if key == "similarArtists": - continue - tag = doc.new_tag(key) - tag.append(str(value)) - dirtag.append(tag) - yield doc.prettify() + return response @cherrypy.expose + @formatresponse def getUser_view(self, u, username, **kwargs): - cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' - doc, root = self.response() - user = {} if self.options.disable_auth else self.library.db.get_user(cherrypy.request.login) - tag = doc.new_tag("user", - username=user["username"], - email=user["email"], - scrobblingEnabled="false", - adminRole="true" if user["admin"] else "false", - settingsRole="false", - downloadRole="true", - uploadRole="false", - playlistRole="true", - coverArtRole="false", - commentRole="false", - podcastRole="false", - streamRole="true", - jukeboxRole="false", - shareRole="true", - videoConversionRole="false", - avatarLastChanged="2017-08-07T20:16:24.596Z") - root.append(tag) - folder = doc.new_tag("folder") - folder.append("0") - tag.append(folder) - yield doc.prettify() + response = ApiResponse() + response.add_child("user", + username=user["username"], + email=user["email"], + scrobblingEnabled="false", + adminRole="true" if user["admin"] else "false", + settingsRole="false", + downloadRole="true", + uploadRole="false", + playlistRole="true", + coverArtRole="false", + commentRole="false", + podcastRole="false", + streamRole="true", + jukeboxRole="false", + shareRole="true", + videoConversionRole="false", + avatarLastChanged="2017-08-07T20:16:24.596Z", + folder=0) + return response @cherrypy.expose + @formatresponse def star_view(self, id, **kwargs): self.library.set_starred(cherrypy.request.login, int(id), starred=True) - yield self.response()[0].prettify() + return ApiResponse() @cherrypy.expose + @formatresponse def unstar_view(self, id, **kwargs): self.library.set_starred(cherrypy.request.login, int(id), starred=False) - yield self.response()[0].prettify() + return ApiResponse() @cherrypy.expose + @formatresponse def getStarred_view(self, **kwargs): - cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' - doc, root = self.response() - tag = doc.new_tag("starred") - root.append(tag) - children = self.library.get_starred(cherrypy.request.login) + response = ApiResponse() + response.add_child("starred") for item in children: # omit not dirs and media in browser if not item["isdir"] and item["type"] not in MUSIC_TYPES: continue item_meta = item['metadata'] itemtype = "song" if item["type"] in MUSIC_TYPES else "album" - tag.append(self.render_node(doc, item, item_meta, {}, {}, tagname=itemtype)) - yield doc.prettify() + response.add_child(itemtype, _parent="starred", **self.render_node2(item, item_meta, {}, {})) + return response @cherrypy.expose + @formatresponse def getRandomSongs_view(self, size=50, genre=None, fromYear=0, toYear=0, **kwargs): - cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' - doc, root = self.response() - tag = doc.new_tag("randomSongs") - root.append(tag) - + response = ApiResponse() + response.add_child("randomSongs") children = self.library.get_songs(size, shuffle=True) for item in children: # omit not dirs and media in browser @@ -454,5 +537,6 @@ class PysonicApi(object): continue item_meta = item['metadata'] itemtype = "song" if item["type"] in MUSIC_TYPES else "album" - tag.append(self.render_node(doc, item, item_meta, {}, self.db.getnode(item["parent"])["metadata"], tagname=itemtype)) - yield doc.prettify() + response.add_child(itemtype, _parent="randomSongs", + **self.render_node2(item, item_meta, {}, self.db.getnode(item["parent"])["metadata"])) + return response diff --git a/pysonic/daemon.py b/pysonic/daemon.py index 567d5f3..5595138 100644 --- a/pysonic/daemon.py +++ b/pysonic/daemon.py @@ -1,6 +1,7 @@ import os import logging import cherrypy +from sqlite3 import IntegrityError from pysonic.api import PysonicApi from pysonic.library import PysonicLibrary, DuplicateRootException from pysonic.database import PysonicDatabase @@ -43,7 +44,10 @@ def main(): library.update() for username, password in args.user: - db.add_user(username, password) + try: + db.add_user(username, password) + except IntegrityError: + db.update_user(username, password) logging.warning("Libraries: {}".format([i["name"] for i in library.get_libraries()])) logging.warning("Artists: {}".format([i["name"] for i in library.get_artists()])) diff --git a/pysonic/database.py b/pysonic/database.py index 97e01b0..96f4d46 100644 --- a/pysonic/database.py +++ b/pysonic/database.py @@ -209,8 +209,13 @@ class PysonicDatabase(object): def add_user(self, username, password, is_admin=False): with closing(self.db.cursor()) as cursor: - cursor.execute("REPLACE INTO users (username, password, admin) VALUES (?, ?, ?)", - (username, self.hashit(password), is_admin)).fetchall() + cursor.execute("INSERT INTO users (username, password, admin) VALUES (?, ?, ?)", + (username, self.hashit(password), is_admin)) + + def update_user(self, username, password, is_admin=False): + with closing(self.db.cursor()) as cursor: + cursor.execute("UPDATE users SET password=?, admin=? WHERE username=?;", + (self.hashit(password), is_admin, username)) def get_user(self, user): with closing(self.db.cursor()) as cursor: diff --git a/pysonic/types.py b/pysonic/types.py index 28f99fe..3f92856 100644 --- a/pysonic/types.py +++ b/pysonic/types.py @@ -4,4 +4,4 @@ MUSIC_TYPES = ["audio/mpeg", "audio/flac", "audio/x-wav"] MPX_TYPES = ["audio/mpeg"] FLAC_TYPES = ["audio/flac"] WAV_TYPES = ["audio/x-wav"] -IMAGE_TYPES = ["image/jpeg", "image/png"] +IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif"]