Initial commit
This commit is contained in:
commit
fd82969d5d
506
pysonic/daemon.py
Normal file
506
pysonic/daemon.py
Normal 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
13
setup.py
Normal 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']})
|
Loading…
Reference in New Issue
Block a user