import re import json import logging import subprocess from time import time from random import shuffle from threading import Thread import cherrypy from collections import defaultdict from bs4 import BeautifulSoup 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") 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"] = "application/json; charset=utf-8" response_headers["jsonp"] = "text/javascript; charset=utf-8" def formatresponse(func): """ Decorator for rendering ApiResponse responses """ def wrapper(*args, **kwargs): response = func(*args, **kwargs) 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'] selftext_attrs = ['value'] # 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)) elif key in selftext_attrs: parent.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 @cherrypy.expose @formatresponse def ping_view(self, **kwargs): # Called when the app hits the "test connection" server option return ApiResponse() @cherrypy.expose @formatresponse def getLicense_view(self, **kwargs): # Called after ping.view 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): response = ApiResponse() response.add_child("musicFolders") for folder in self.library.get_libraries(): 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 response = ApiResponse() # TODO real lastmodified date # TODO deal with ignoredArticles response.add_child("indexes", lastModified="1502310831000", ignoredArticles="The El La Los Las Le Les") artists = self.library.get_artists(sortby="name", order="asc") for letter in LETTER_GROUPS: index = response.add_child("index", _parent="indexes", name=letter.upper()) for artist in artists: if artist["name"][0].lower() in letter: response.add_child("artist", _real_parent=index, id=artist["dir"], name=artist["name"]) return response @cherrypy.expose def savePlayQueue_view(self, id, current, position, **kwargs): print("TODO save playlist with items {} current {} position {}".format(id, current, position)) @cherrypy.expose @formatresponse def getAlbumList_view(self, type, size=50, offset=0, **kwargs): albums = self.library.get_albums() if type == "random": shuffle(albums) elif type == "alphabeticalByName": albums.sort(key=lambda item: item.get("id3_album", item["album"] if item["album"] else "zzzzzUnsortable")) else: raise NotImplemented() albumset = albums[0 + int(offset):int(size) + int(offset)] response = ApiResponse() response.add_child("albumList") for album in albumset: album_meta = album['metadata'] album_kw = dict(id=album["id"], parent=album["parent"], isDir="true" if album['isdir'] else "false", 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"]), # playCount="0" # created="2016-05-08T05:31:31.000Z"/>) ) if 'cover' in album_meta: album_kw["coverArt"] = album_meta["cover"] if 'id3_year' in album_meta: album_kw["year"] = album_meta['id3_year'] response.add_child("album", _parent="albumList", **album_kw) return response @cherrypy.expose @formatresponse def getMusicDirectory_view(self, id, **kwargs): """ List an artist dir """ dir_id = int(id) cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' response = ApiResponse() response.add_child("directory") dirtype, dirinfo, entity = self.library.db.get_musicdir(dirid=dir_id) from pprint import pprint pprint(dirinfo) pprint(entity) response.set_attrs(_path="directory", name=entity['name'], id=entity['id'], parent=dirinfo['parent'], playCount=420) for childtype, child in entity["children"]: # omit not dirs and media in browser # if not item["isdir"] and item["type"] not in MUSIC_TYPES: # continue # item_meta = item['metadata'] moreargs = {} if childtype == "album": moreargs.update(name=child["name"], isDir="true", # TODO song files in artist dir parent=entity["id"], coverArt=child["coverid"], id=child["dir"]) # album=item["name"], # title=item["name"], # TODO dupe? # artist=artist["name"], # coverArt=item["coverid"], elif childtype == "song": moreargs.update(name=child["title"], artist=child["_artist"]["name"], contentType=child["format"], coverArt=entity["coverid"], id=child["id"], # this is probably fucked ? duration=child["length"], isDir="false", parent=entity["dir"], # title=xxx ) # duration="230" size="8409237" suffix="mp3" track="2" year="2005"/> response.add_child("child", _parent="directory", size="4096", type="music", **moreargs) return response def render_node(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: """ raise Exception("stop using this") child = dict(id=item["id"], parent=item["id"], isDir="true" if "file" not in item 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["size"] = item["size"] if "media_length" in item_meta: child["duration"] = item_meta["media_length"] if "albumId" in directory: child["albumId"] = directory["id"] if "artistId" in directory: child["artistId"] = directory["parent"] if "." in item["name"]: child["suffix"] = item["name"].split(".")[-1] if item["type"]: child["contentType"] = item["type"] if 'cover' in item_meta: child["coverArt"] = item_meta["cover"] elif 'cover' in dir_meta: child["coverArt"] = dir_meta["cover"] if 'track' in item_meta: child["track"] = item_meta['track'] if 'id3_year' in item_meta: child["year"] = item_meta['id3_year'] return child @cherrypy.expose def stream_view(self, id, maxBitRate="256", **kwargs): maxBitRate = int(maxBitRate) assert maxBitRate >= 32 and maxBitRate <= 320 song = self.library.get_song(id) fpath = "library/" + song["file"] # import pdb # from pprint import pprint # pdb.set_trace() # meta = self.library.get_file_metadata(id) to_bitrate = min(maxBitRate, self.options.max_bitrate, song.get("bitrate", 320 * 1024) / 1024) cherrypy.response.headers['Content-Type'] = 'audio/mpeg' #if "media_length" in meta: # cherrypy.response.headers['X-Content-Duration'] = str(int(meta['media_length'])) cherrypy.response.headers['X-Content-Kbitrate'] = str(to_bitrate) if (self.options.skip_transcode or song.get("bitrate", -1024) / 1024 == to_bitrate) \ and format["type"] == "audio/mpeg": def content(): with open(fpath, "rb") as f: while True: data = f.read(16 * 1024) if not data: break yield data return content() else: # transcode_meta = "transcoded_{}_size".format(to_bitrate) # if transcode_meta in meta: # cherrypy.response.headers['Content-Length'] = str(int(meta[transcode_meta])) print(fpath) transcode_args = ["ffmpeg", "-i", fpath, "-map", "0:0", "-b:a", "{}k".format(to_bitrate), "-v", "0", "-f", "mp3", "-"] logging.info(' '.join(transcode_args)) proc = subprocess.Popen(transcode_args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) def content(proc): length = 0 # completed = False start = time() try: while True: data = proc.stdout.read(16 * 1024) if not data: # completed = True break yield data length += len(data) finally: proc.poll() if proc.returncode is None or proc.returncode == 0: logging.warning("transcoded {} in {}s".format(id, int(time() - start))) # if completed: # self.library.report_transcode(id, to_bitrate, length) else: logging.error("transcode of {} exited with code {} after {}s".format(id, proc.returncode, int(time() - start))) def stopit(proc): try: proc.wait(timeout=90) except subprocess.TimeoutExpired: logging.warning("killing timed-out transcoder") proc.kill() proc.wait() Thread(target=stopit, args=(proc, )).start() return content(proc) stream_view._cp_config = {'response.stream': True} @cherrypy.expose def getCoverArt_view(self, id, **kwargs): cover = self.library.get_cover(id) fpath = "library/" + cover["path"] type2ct = { 'jpg': 'image/jpeg', 'png': 'image/png', 'gif': 'image/gif' } cherrypy.response.headers['Content-Type'] = type2ct[fpath[-3:]] def content(): total = 0 with open(fpath, "rb") as f: while True: data = f.read(8192) if not data: break total += len(data) yield data logging.info("\nSent {} bytes for {}".format(total, fpath)) return content() getCoverArt_view._cp_config = {'response.stream': True} @cherrypy.expose @formatresponse def getArtistInfo_view(self, id, includeNotPresent="true", **kwargs): info = self.library.get_artist_info(id) response = ApiResponse() response.add_child("artistInfo") response.set_attrs("artistInfo", **info) return response @cherrypy.expose @formatresponse def getUser_view(self, username, **kwargs): user = {} if self.options.disable_auth else self.library.db.get_user(cherrypy.request.login) 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) return ApiResponse() @cherrypy.expose @formatresponse def unstar_view(self, id, **kwargs): self.library.set_starred(cherrypy.request.login, int(id), starred=False) return ApiResponse() @cherrypy.expose @formatresponse def getStarred_view(self, **kwargs): 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" response.add_child(itemtype, _parent="starred", **self.render_node(item, item_meta, {}, {})) return response @cherrypy.expose @formatresponse def getRandomSongs_view(self, size=50, genre=None, fromYear=0, toYear=0, **kwargs): """ Get a playlist of random songs :param genre: genre name to find songs under :type genre: str """ 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 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" response.add_child(itemtype, _parent="randomSongs", **self.render_node(item, item_meta, {}, self.db.getnode(item["parent"])["metadata"])) return response @cherrypy.expose @formatresponse def getGenres_view(self, **kwargs): response = ApiResponse() response.add_child("genres") response.add_child("genre", _parent="genres", value="Death Metal", songCount=420, albumCount=69) response.add_child("genre", _parent="genres", value="Metal", songCount=52, albumCount=3) response.add_child("genre", _parent="genres", value="Punk", songCount=34, albumCount=3) return response @cherrypy.expose @formatresponse def scrobble_view(self, id, submission, **kwargs): """ :param id: song id being played :param submission: True if end of song reached. False on start of track. """ submission = True if submission == "true" else False # TODO save played track stats return ApiResponse() @cherrypy.expose @formatresponse def search2_view(self, query, artistCount, albumCount, songCount, **kwargs): response = ApiResponse() response.add_child("searchResult2") artistCount = int(artistCount) albumCount = int(albumCount) songCount = int(songCount) query = query.replace("*", "") # TODO handle this artists = 0 for item in self.library.get_artists(): if query in item["name"].lower(): response.add_child("artist", _parent="searchResult2", id=item["id"], name=item["name"]) artists += 1 if artists >= artistCount: break # TODO make this more efficient albums = 0 for item in self.library.get_artists(): if query in item["name"].lower(): response.add_child("album", _parent="searchResult2", **self.render_node(item, item["metadata"], {}, {})) albums += 1 if albums >= albumCount: break # TODO make this more efficient songs = 0 for item in self.library.get_songs(limit=9999999, shuffle=False): if query in item["name"].lower(): response.add_child("song", _parent="searchResult2", **self.render_node(item, item["metadata"], {}, {})) songs += 1 if songs > songCount: break return response @cherrypy.expose @formatresponse def setRating_view(self, id, rating): # rating is 1-5 pass