Initial commit

This commit is contained in:
dave 2017-08-13 18:42:16 -07:00
commit fd82969d5d
2 changed files with 519 additions and 0 deletions

506
pysonic/daemon.py Normal file
View File

@ -0,0 +1,506 @@
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()
def main():
logging.basicConfig(level=logging.INFO)
cherrypy.tree.mount(PysonicApi(), '/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.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
})
try:
cherrypy.engine.start()
cherrypy.engine.block()
finally:
print("API has shut down")
cherrypy.engine.exit()
if __name__ == '__main__':
main()

13
setup.py Normal file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env python3
from setuptools import setup
from pysonic import __version__
setup(name='pysonic',
version=__version__,
description='pysonic audio server',
url='http://gitlab.davepedu.com/dave/pysonic',
author='dpedu',
author_email='dave@davepedu.com',
packages=['pysonic'],
entry_points={'console_scripts': ['pysonicd=pysonic.daemon:main']})