diff --git a/pysonic/api.py b/pysonic/api.py new file mode 100644 index 0000000..bf1e8e8 --- /dev/null +++ b/pysonic/api.py @@ -0,0 +1,231 @@ +import sys +import cherrypy +from bs4 import BeautifulSoup +from pysonic.library import LETTER_GROUPS + + +class PysonicApi(object): + def __init__(self, db, library): + self.db = db + self.library = library + + print("Libraries:", [i["name"] for i in self.library.get_libraries()]) + print("Artists:", [i["name"] for i in self.library.get_artists()]) + + 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 + 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() + + @cherrypy.expose + 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="2018-06-22T10:31:49.921Z", + trialExpires="2016-06-29T03:03:58.200Z")) + yield doc.prettify() + + @cherrypy.expose + 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) + + 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() + + @cherrypy.expose + 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) + + for letter in LETTER_GROUPS: + index = doc.new_tag("index") + index.attrs["name"] = letter.upper() + indexes.append(index) + for artist in self.library.get_artists(): + if artist["name"][0].lower() == letter: + artist_tag = doc.new_tag("artist") + artist_tag.attrs.update({"id": artist["id"], "name": artist["name"]}) + index.append(artist_tag) + yield doc.prettify() + + @cherrypy.expose + def getMusicDirectory_view(self, id, **kwargs): + """ + List an artist dir + """ + dir_id = int(id) + + cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' + doc, root = self.response() + + dirtag = doc.new_tag("directory") + + directory = self.library.get_dir(dir_id) + dir_meta = self.db.decode_metadata(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) + + for item in children: + child = doc.new_tag("child", + id=item["id"], + parent=directory["id"], + isDir="true" if item['isdir'] else "false", + title=item["name"], + album=item["name"], + artist=directory["name"], + # playCount="5", + # created="2016-04-25T07:31:33.000Z" + # track="3", + # year="2012", + # genre="Other", + # coverArt="12835", + # contentType="audio/mpeg" + # suffix="mp3" + # size="15838864" + # duration="395" + # bitRate="320" + # path="Cosmic Gate/Sign Of The Times/03 Flatline (featuring Kyler England).mp3" + # albumId="933" + # artistId="353" + # type="music"/> + ) + item_meta = self.db.decode_metadata(item['metadata']) + if 'cover' in item_meta: + child.attrs["coverArt"] = item_meta["cover"] + elif 'cover' in dir_meta: + child.attrs["coverArt"] = dir_meta["cover"] + dirtag.append(child) + yield doc.prettify() + + @cherrypy.expose + def stream_view(self, id, **kwargs): + # /rest/stream.view?u=dave&s=rid5h452ag6nmb153r8sjtctk8 + # &t=dad1e6f7331160ea7f04120c7fbab1c8&v=1.2.0&c=DSub&id=167&maxBitRate=256 + fpath = self.library.get_filepath(id) + cherrypy.response.headers['Content-Type'] = 'audio/mpeg' + + 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 + sys.stdout.write('.') + sys.stdout.flush() + print("\nSent {} bytes for {}".format(total, fpath)) + return content() + stream_view._cp_config = {'response.stream': True} + + @cherrypy.expose + def getCoverArt_view(self, id, **kwargs): + # /rest/getCoverArt.view?u=dave&s=bfk9mir8is02u3m5as8ucsehn0 + # &t=e2b09fb9233d1bfac9abe3dc73017f1e&v=1.2.0&c=DSub&id=12833 + fpath = self.library.get_filepath(id) + cherrypy.response.headers['Content-Type'] = 'image/jpeg' + + 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 + sys.stdout.write('.') + sys.stdout.flush() + print("\nSent {} bytes for {}".format(total, fpath)) + return content() + + getCoverArt_view._cp_config = {'response.stream': True} + + @cherrypy.expose + def getArtistInfo_view(self, id, includeNotPresent="true", **kwargs): + # /rest/getArtistInfo.view? + # u=dave + # s=gqua9i6c414aomjok8f6b0kdp1 + # t=ed1d31850bbd27690687305d9ccbdabf + # v=1.2.0 + # c=DSub + # 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) + + for key, value in info.items(): + if key == "similarArtists": + continue + tag = doc.new_tag(key) + tag.append(str(value)) + # print(dir(tag)) + # print(value) + dirtag.append(tag) + yield doc.prettify() + + @cherrypy.expose + def getUser_view(self, u, username, **kwargs): + cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' + doc, root = self.response() + user = doc.new_tag("user", + username="admin", + email="admin@localhost", + scrobblingEnabled="false", + adminRole="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(user) + folder = doc.new_tag("folder") + folder.append("0") + user.append(folder) + yield doc.prettify() diff --git a/pysonic/daemon.py b/pysonic/daemon.py index ebd609e..527ba61 100644 --- a/pysonic/daemon.py +++ b/pysonic/daemon.py @@ -1,500 +1,51 @@ import logging import cherrypy -from bs4 import BeautifulSoup -import sqlite3 -import os -from contextlib import closing -import json -from threading import Thread -from itertools import chain -import sys - -# import pdb -# from pprint import pprint - -LETTER_GROUPS = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", - "u", "v", "w", "x-z", "#"] - - -def dict_factory(cursor, row): - d = {} - for idx, col in enumerate(cursor.description): - d[col[0]] = row[idx] - return d - - -class PysonicDatabase(object): - def __init__(self): - self.sqlite_opts = dict(check_same_thread=False, cached_statements=0, isolation_level=None) - self.db = None - - self.open() - self.migrate() - - self.scanner = Thread(target=self.rescan, daemon=True) - self.scanner.start() - - def open(self): - self.db = sqlite3.connect("db.sqlite", **self.sqlite_opts) - self.db.row_factory = dict_factory - - def migrate(self): - # Create db - queries = ["""CREATE TABLE 'meta' ( - 'key' TEXT PRIMARY KEY NOT NULL, - 'value' TEXT);""", - """INSERT INTO meta VALUES ('db_version', '0');""", - """CREATE TABLE 'nodes' ( - 'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - 'parent' INTEGER NOT NULL, - 'isdir' BOOLEAN NOT NULL, - 'name' TEXT NOT NULL, - 'title' TEXT, - 'album' TEXT, - 'artist' TEXT, - 'metadata' TEXT - )""", - """INSERT INTO nodes (parent, isdir, name, metadata) - VALUES (-1, 1, 'Main Library', '{"fspath": "/home/dave/Code/pysonic/music/"}');"""] - - with closing(self.db.cursor()) as cursor: - cursor.execute("SELECT * FROM sqlite_master WHERE type='table' AND name='meta';") - - # Initialize DB - if len(cursor.fetchall()) == 0: - print("Initializing database") - for query in queries: - cursor.execute(query) - else: - # Migrate if old db exists - version = int(cursor.execute("SELECT * FROM meta WHERE key='db_version';").fetchone()['value']) - print("db schema is version {}".format(version)) - - # Virtual file tree - def getnode(self, node_id): - with closing(self.db.cursor()) as cursor: - return cursor.execute("SELECT * FROM nodes WHERE id=?;", (node_id, )).fetchone() - - def getnodes(self, *parent_ids): - with closing(self.db.cursor()) as cursor: - return list(chain(*[cursor.execute("SELECT * FROM nodes WHERE parent=?;", (parent_id, )).fetchall() - for parent_id in parent_ids])) - - def addnode(self, parent, fspath, name): - fullpath = os.path.join(fspath, name) - print("Adding ", fullpath) - is_dir = os.path.isdir(fullpath) - with closing(self.db.cursor()) as cursor: - cursor.execute("INSERT INTO nodes (parent, isdir, name) VALUES (?, ?, ?);", - (parent["id"], 1 if is_dir else 0, name)) - return self.getnode(cursor.lastrowid) - - def delnode(self, node_id): - deleted = 1 - for child in self.getnodes(node_id): - deleted += self.delnode(child["id"]) - with closing(self.db.cursor()) as cursor: - cursor.execute("DELETE FROM nodes WHERE id=?;", (node_id, )) - return deleted - - def update_metadata(self, node_id, mergedict=None, **kwargs): - mergedict = mergedict if mergedict else {} - keys_in_table = ["title", "album", "artist"] - mergedict.update(kwargs) - with closing(self.db.cursor()) as cursor: - for table_key in keys_in_table: - if table_key in mergedict: - cursor.execute("UPDATE nodes SET {}=? WHERE id=?;".format(table_key), - (mergedict[table_key], node_id)) - other_meta = {k: v for k, v in mergedict.items() if k not in keys_in_table} - if other_meta: - metadata = self.get_metadata(node_id) - metadata.update(other_meta) - cursor.execute("UPDATE nodes SET metadata=? WHERE id=?;", (json.dumps(metadata), node_id, )) - - def get_metadata(self, node_id): - return self.decode_metadata(self.getnode(node_id)["metadata"]) - - def decode_metadata(self, metadata): - if metadata: - return json.loads(metadata) - return {} - - def rescan(self): - # Perform directory scan - with closing(self.db.cursor()) as cursor: - - # Find top level dirs, parent=-1 - for parent in cursor.execute("SELECT id, name, metadata FROM nodes WHERE parent=-1;").fetchall(): - meta = json.loads(parent["metadata"]) - # print("Scanning {}".format(meta["fspath"])) - - def recurse_dir(path, parent): - # print("Scanning {} with parent {}".format(path, parent)) - # create or update the database of nodes by comparing sets of names - fs_entries = set(os.listdir(path)) - db_entires = self.getnodes(parent["id"]) - db_entires_names = set([i['name'] for i in db_entires]) - to_delete = db_entires_names - fs_entries - to_create = fs_entries - db_entires_names - - # Create any nodes not found in the db - for create in to_create: - new_node = self.addnode(parent, path, create) - db_entires.append(new_node) - - # Delete any db nodes not found on disk - for delete in to_delete: - print("Prune ", delete, "in parent", path) - node = [i for i in db_entires if i["name"] == delete] - if node: - deleted = self.delnode(node[0]["id"]) - print("Pruned {}, deleting total of {}".format(node, deleted)) - - for entry in db_entires: - if entry["name"] in to_delete: - continue - - if int(entry['isdir']): # 1 means dir - recurse_dir(os.path.join(path, entry["name"]), entry) - # Populate all files for this top-level root - recurse_dir(meta["fspath"], parent) - # - # - # - # Add simple metadata - for artist_dir in self.getnodes(parent["id"]): - artist = artist_dir["name"] - for album_dir in self.getnodes(artist_dir["id"]): - album = album_dir["name"] - album_meta = self.get_metadata(album_dir["id"]) - for track_file in self.getnodes(album_dir["id"]): - title = track_file["name"] - if not track_file["title"]: - self.update_metadata(track_file["id"], artist=artist, album=album, title=title) - print("Adding simple metadata for {}/{}/{} #{}".format(artist, album, - title, track_file["id"])) - if not album_dir["album"]: - self.update_metadata(album_dir["id"], artist=artist, album=album) - print("Adding simple metadata for {}/{} #{}".format(artist, album, album_dir["id"])) - if not artist_dir["artist"]: - self.update_metadata(artist_dir["id"], artist=artist) - print("Adding simple metadata for {} #{}".format(artist, artist_dir["id"])) - if title == "cover.jpg" and 'cover' not in album_meta: - # // add cover art - self.update_metadata(album_dir["id"], cover=track_file["id"]) - print("added cover for {}".format(album_dir['id'])) - print("Metadata scan complete.") - - -def memoize(function): - memo = {} - - def wrapper(*args): - if args in memo: - return memo[args] - else: - rv = function(*args) - memo[args] = rv - return rv - return wrapper - - -class NoDataException(Exception): - pass - - -class PysonicLibrary(object): - def __init__(self, database): - self.db = database - print("library ready") - - @memoize - def get_libraries(self): - """ - Libraries are top-level nodes - """ - return self.db.getnodes(-1) - - @memoize - def get_artists(self): - # Assume artists are second level dirs - return self.db.getnodes(*[item["id"] for item in self.get_libraries()]) - - def get_dir(self, dirid): - return self.db.getnode(dirid) - - def get_dir_children(self, dirid): - return self.db.getnodes(dirid) - - @memoize - def get_filepath(self, fileid): - parents = [self.db.getnode(fileid)] - while parents[-1]['parent'] != -1: - parents.append(self.db.getnode(parents[-1]['parent'])) - root = parents.pop() - parents.reverse() - return os.path.join(json.loads(root['metadata'])['fspath'], *[i['name'] for i in parents]) - - def get_artist_info(self, item_id): - # artist = self.db.getnode(item_id) - return {"biography": "placeholder biography", - "musicBrainzId": "playerholder", - "lastFmUrl": "https://www.last.fm/music/Placeholder", - "smallImageUrl": "", - "mediumImageUrl": "", - "largeImageUrl": "", - "similarArtists": []} - - -class PysonicApi(object): - def __init__(self): - self.db = PysonicDatabase() - self.library = PysonicLibrary(self.db) - - print("Libraries:", [i["name"] for i in self.library.get_libraries()]) - print("Artists:", [i["name"] for i in self.library.get_artists()]) - - 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 - 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() - - @cherrypy.expose - 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="2018-06-22T10:31:49.921Z", - trialExpires="2016-06-29T03:03:58.200Z")) - yield doc.prettify() - - @cherrypy.expose - 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) - - 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() - - @cherrypy.expose - 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) - - for letter in LETTER_GROUPS: - index = doc.new_tag("index") - index.attrs["name"] = letter.upper() - indexes.append(index) - for artist in self.library.get_artists(): - if artist["name"][0].lower() == letter: - artist_tag = doc.new_tag("artist") - artist_tag.attrs.update({"id": artist["id"], "name": artist["name"]}) - index.append(artist_tag) - yield doc.prettify() - - @cherrypy.expose - def getMusicDirectory_view(self, id, **kwargs): - """ - List an artist dir - """ - dir_id = int(id) - - cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' - doc, root = self.response() - - dirtag = doc.new_tag("directory") - - directory = self.library.get_dir(dir_id) - dir_meta = self.db.decode_metadata(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) - - for item in children: - child = doc.new_tag("child", - id=item["id"], - parent=directory["id"], - isDir="true" if item['isdir'] else "false", - title=item["name"], - album=item["name"], - artist=directory["name"], - # playCount="5", - # created="2016-04-25T07:31:33.000Z" - # track="3", - # year="2012", - # genre="Other", - # coverArt="12835", - # contentType="audio/mpeg" - # suffix="mp3" - # size="15838864" - # duration="395" - # bitRate="320" - # path="Cosmic Gate/Sign Of The Times/03 Flatline (featuring Kyler England).mp3" - # albumId="933" - # artistId="353" - # type="music"/> - ) - item_meta = self.db.decode_metadata(item['metadata']) - if 'cover' in item_meta: - child.attrs["coverArt"] = item_meta["cover"] - elif 'cover' in dir_meta: - child.attrs["coverArt"] = dir_meta["cover"] - dirtag.append(child) - yield doc.prettify() - - @cherrypy.expose - def stream_view(self, id, **kwargs): - # /rest/stream.view?u=dave&s=rid5h452ag6nmb153r8sjtctk8 - # &t=dad1e6f7331160ea7f04120c7fbab1c8&v=1.2.0&c=DSub&id=167&maxBitRate=256 - fpath = self.library.get_filepath(id) - cherrypy.response.headers['Content-Type'] = 'audio/mpeg' - - 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 - sys.stdout.write('.') - sys.stdout.flush() - print("\nSent {} bytes for {}".format(total, fpath)) - return content() - stream_view._cp_config = {'response.stream': True} - - @cherrypy.expose - def getCoverArt_view(self, id, **kwargs): - # /rest/getCoverArt.view?u=dave&s=bfk9mir8is02u3m5as8ucsehn0 - # &t=e2b09fb9233d1bfac9abe3dc73017f1e&v=1.2.0&c=DSub&id=12833 - fpath = self.library.get_filepath(id) - cherrypy.response.headers['Content-Type'] = 'image/jpeg' - - 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 - sys.stdout.write('.') - sys.stdout.flush() - print("\nSent {} bytes for {}".format(total, fpath)) - return content() - - getCoverArt_view._cp_config = {'response.stream': True} - - @cherrypy.expose - def getArtistInfo_view(self, id, includeNotPresent="true", **kwargs): - #/rest/getArtistInfo.view? - # u=dave - # s=gqua9i6c414aomjok8f6b0kdp1 - # t=ed1d31850bbd27690687305d9ccbdabf - # v=1.2.0 - # c=DSub - # 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) - - for key, value in info.items(): - if key == "similarArtists": - continue - tag = doc.new_tag(key) - tag.append(str(value)) - # print(dir(tag)) - # print(value) - dirtag.append(tag) - yield doc.prettify() - - @cherrypy.expose - def getUser_view(self, u, username, **kwargs): - cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' - doc, root = self.response() - user = doc.new_tag("user", - username="admin", - email="admin@localhost", - scrobblingEnabled="false", - adminRole="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(user) - folder = doc.new_tag("folder") - folder.append("0") - user.append(folder) - yield doc.prettify() +from pysonic.api import PysonicApi +from pysonic.library import PysonicLibrary +from pysonic.database import PysonicDatabase def main(): + import argparse + import signal - logging.basicConfig(level=logging.INFO) + parser = argparse.ArgumentParser(description="Pysonic music streaming server") - cherrypy.tree.mount(PysonicApi(), '/rest/', {'/': {}}) + parser.add_argument('-p', '--port', default=8080, type=int, help="tcp port to listen on") + parser.add_argument('-d', '--dirs', required=True, nargs='+', help="new music dirs to share") + parser.add_argument('-s', '--database-path', default="./db.sqlite", help="path to persistent sqlite database") + parser.add_argument('--debug', action="store_true", help="enable development options") + args = parser.parse_args() + logging.basicConfig(level=logging.INFO if args.debug else logging.WARNING) + + db = PysonicDatabase(path=args.database_path) + library = PysonicLibrary(db) + library.update() + + cherrypy.tree.mount(PysonicApi(db, library), '/rest/', {'/': {}}) cherrypy.config.update({ 'sessionFilter.on': True, 'tools.sessions.on': True, 'tools.sessions.locking': 'explicit', 'tools.sessions.timeout': 525600, 'request.show_tracebacks': True, - 'server.socket_port': 3000, + 'server.socket_port': args.port, 'server.thread_pool': 25, 'server.socket_host': '0.0.0.0', 'server.show_tracebacks': True, 'server.socket_timeout': 5, 'log.screen': False, - 'engine.autoreload.on': True + 'engine.autoreload.on': args.debug }) + def signal_handler(signum, stack): + print('Got sig {}, exiting...'.format(signum)) + cherrypy.engine.exit() + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + try: cherrypy.engine.start() cherrypy.engine.block() diff --git a/pysonic/database.py b/pysonic/database.py new file mode 100644 index 0000000..49c7f1a --- /dev/null +++ b/pysonic/database.py @@ -0,0 +1,108 @@ +import os +import json +import sqlite3 +from itertools import chain +from contextlib import closing + + +def dict_factory(cursor, row): + d = {} + for idx, col in enumerate(cursor.description): + d[col[0]] = row[idx] + return d + + +class PysonicDatabase(object): + def __init__(self, path): + self.sqlite_opts = dict(check_same_thread=False, cached_statements=0, isolation_level=None) + self.path = path + self.db = None + + self.open() + self.migrate() + + def open(self): + self.db = sqlite3.connect(self.path, **self.sqlite_opts) + self.db.row_factory = dict_factory + + def migrate(self): + # Create db + queries = ["""CREATE TABLE 'meta' ( + 'key' TEXT PRIMARY KEY NOT NULL, + 'value' TEXT);""", + """INSERT INTO meta VALUES ('db_version', '0');""", + """CREATE TABLE 'nodes' ( + 'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + 'parent' INTEGER NOT NULL, + 'isdir' BOOLEAN NOT NULL, + 'name' TEXT NOT NULL, + 'title' TEXT, + 'album' TEXT, + 'artist' TEXT, + 'metadata' TEXT + )""", + """INSERT INTO nodes (parent, isdir, name, metadata) + VALUES (-1, 1, 'Main Library', '{"fspath": "/home/dave/Code/pysonic/music/"}');"""] + + with closing(self.db.cursor()) as cursor: + cursor.execute("SELECT * FROM sqlite_master WHERE type='table' AND name='meta';") + + # Initialize DB + if len(cursor.fetchall()) == 0: + print("Initializing database") + for query in queries: + cursor.execute(query) + else: + # Migrate if old db exists + version = int(cursor.execute("SELECT * FROM meta WHERE key='db_version';").fetchone()['value']) + print("db schema is version {}".format(version)) + + # Virtual file tree + def getnode(self, node_id): + with closing(self.db.cursor()) as cursor: + return cursor.execute("SELECT * FROM nodes WHERE id=?;", (node_id, )).fetchone() + + def getnodes(self, *parent_ids): + with closing(self.db.cursor()) as cursor: + return list(chain(*[cursor.execute("SELECT * FROM nodes WHERE parent=?;", (parent_id, )).fetchall() + for parent_id in parent_ids])) + + def addnode(self, parent, fspath, name): + fullpath = os.path.join(fspath, name) + print("Adding ", fullpath) + is_dir = os.path.isdir(fullpath) + with closing(self.db.cursor()) as cursor: + cursor.execute("INSERT INTO nodes (parent, isdir, name) VALUES (?, ?, ?);", + (parent["id"], 1 if is_dir else 0, name)) + return self.getnode(cursor.lastrowid) + + def delnode(self, node_id): + deleted = 1 + for child in self.getnodes(node_id): + deleted += self.delnode(child["id"]) + with closing(self.db.cursor()) as cursor: + cursor.execute("DELETE FROM nodes WHERE id=?;", (node_id, )) + return deleted + + def update_metadata(self, node_id, mergedict=None, **kwargs): + mergedict = mergedict if mergedict else {} + keys_in_table = ["title", "album", "artist"] + mergedict.update(kwargs) + with closing(self.db.cursor()) as cursor: + for table_key in keys_in_table: + if table_key in mergedict: + cursor.execute("UPDATE nodes SET {}=? WHERE id=?;".format(table_key), + (mergedict[table_key], node_id)) + other_meta = {k: v for k, v in mergedict.items() if k not in keys_in_table} + if other_meta: + metadata = self.get_metadata(node_id) + metadata.update(other_meta) + cursor.execute("UPDATE nodes SET metadata=? WHERE id=?;", (json.dumps(metadata), node_id, )) + + def get_metadata(self, node_id): + return self.decode_metadata(self.getnode(node_id)["metadata"]) + + def decode_metadata(self, metadata): + if metadata: + return json.loads(metadata) + return {} diff --git a/pysonic/library.py b/pysonic/library.py new file mode 100644 index 0000000..95ad967 --- /dev/null +++ b/pysonic/library.py @@ -0,0 +1,71 @@ +import os +import json +from pysonic.scanner import PysonicFilesystemScanner + + +LETTER_GROUPS = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", + "u", "v", "w", "x-z", "#"] + + +def memoize(function): + memo = {} + + def wrapper(*args): + if args in memo: + return memo[args] + else: + rv = function(*args) + memo[args] = rv + return rv + return wrapper + + +class NoDataException(Exception): + pass + + +class PysonicLibrary(object): + def __init__(self, database): + self.db = database + self.scanner = PysonicFilesystemScanner(self) + print("library ready") + + def update(self): + self.scanner.init_scan() + + @memoize + def get_libraries(self): + """ + Libraries are top-level nodes + """ + return self.db.getnodes(-1) + + @memoize + def get_artists(self): + # Assume artists are second level dirs + return self.db.getnodes(*[item["id"] for item in self.get_libraries()]) + + def get_dir(self, dirid): + return self.db.getnode(dirid) + + def get_dir_children(self, dirid): + return self.db.getnodes(dirid) + + @memoize + def get_filepath(self, fileid): + parents = [self.db.getnode(fileid)] + while parents[-1]['parent'] != -1: + parents.append(self.db.getnode(parents[-1]['parent'])) + root = parents.pop() + parents.reverse() + return os.path.join(json.loads(root['metadata'])['fspath'], *[i['name'] for i in parents]) + + def get_artist_info(self, item_id): + # artist = self.db.getnode(item_id) + return {"biography": "placeholder biography", + "musicBrainzId": "playerholder", + "lastFmUrl": "https://www.last.fm/music/Placeholder", + "smallImageUrl": "", + "mediumImageUrl": "", + "largeImageUrl": "", + "similarArtists": []} diff --git a/pysonic/scanner.py b/pysonic/scanner.py new file mode 100644 index 0000000..f99f553 --- /dev/null +++ b/pysonic/scanner.py @@ -0,0 +1,75 @@ +import os +import json +from threading import Thread + + +class PysonicFilesystemScanner(object): + def __init__(self, library): + self.library = library + + def init_scan(self): + self.scanner = Thread(target=self.rescan, daemon=True) + self.scanner.start() + + def rescan(self): + # Perform directory scan + for parent in self.library.get_libraries(): + meta = json.loads(parent["metadata"]) + # print("Scanning {}".format(meta["fspath"])) + + def recurse_dir(path, parent): + # print("Scanning {} with parent {}".format(path, parent)) + # create or update the database of nodes by comparing sets of names + fs_entries = set(os.listdir(path)) + db_entires = self.library.db.getnodes(parent["id"]) + db_entires_names = set([i['name'] for i in db_entires]) + to_delete = db_entires_names - fs_entries + to_create = fs_entries - db_entires_names + + # Create any nodes not found in the db + for create in to_create: + new_node = self.library.db.addnode(parent, path, create) + db_entires.append(new_node) + + # Delete any db nodes not found on disk + for delete in to_delete: + print("Prune ", delete, "in parent", path) + node = [i for i in db_entires if i["name"] == delete] + if node: + deleted = self.library.db.delnode(node[0]["id"]) + print("Pruned {}, deleting total of {}".format(node, deleted)) + + for entry in db_entires: + if entry["name"] in to_delete: + continue + + if int(entry['isdir']): # 1 means dir + recurse_dir(os.path.join(path, entry["name"]), entry) + # Populate all files for this top-level root + recurse_dir(meta["fspath"], parent) + # + # + # + # Add simple metadata + for artist_dir in self.library.db.getnodes(parent["id"]): + artist = artist_dir["name"] + for album_dir in self.library.db.getnodes(artist_dir["id"]): + album = album_dir["name"] + album_meta = self.library.db.get_metadata(album_dir["id"]) + for track_file in self.library.db.getnodes(album_dir["id"]): + title = track_file["name"] + if not track_file["title"]: + self.library.db.update_metadata(track_file["id"], artist=artist, album=album, title=title) + print("Adding simple metadata for {}/{}/{} #{}".format(artist, album, + title, track_file["id"])) + if not album_dir["album"]: + self.library.db.update_metadata(album_dir["id"], artist=artist, album=album) + print("Adding simple metadata for {}/{} #{}".format(artist, album, album_dir["id"])) + if not artist_dir["artist"]: + self.library.db.update_metadata(artist_dir["id"], artist=artist) + print("Adding simple metadata for {} #{}".format(artist, artist_dir["id"])) + if title == "cover.jpg" and 'cover' not in album_meta: + # // add cover art + self.library.db.update_metadata(album_dir["id"], cover=track_file["id"]) + print("added cover for {}".format(album_dir['id'])) + print("Metadata scan complete.")