Refactoring
This commit is contained in:
parent
fd82969d5d
commit
1b151d3905
|
@ -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()
|
|
@ -1,500 +1,51 @@
|
||||||
import logging
|
import logging
|
||||||
import cherrypy
|
import cherrypy
|
||||||
from bs4 import BeautifulSoup
|
from pysonic.api import PysonicApi
|
||||||
import sqlite3
|
from pysonic.library import PysonicLibrary
|
||||||
import os
|
from pysonic.database import PysonicDatabase
|
||||||
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():
|
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({
|
cherrypy.config.update({
|
||||||
'sessionFilter.on': True,
|
'sessionFilter.on': True,
|
||||||
'tools.sessions.on': True,
|
'tools.sessions.on': True,
|
||||||
'tools.sessions.locking': 'explicit',
|
'tools.sessions.locking': 'explicit',
|
||||||
'tools.sessions.timeout': 525600,
|
'tools.sessions.timeout': 525600,
|
||||||
'request.show_tracebacks': True,
|
'request.show_tracebacks': True,
|
||||||
'server.socket_port': 3000,
|
'server.socket_port': args.port,
|
||||||
'server.thread_pool': 25,
|
'server.thread_pool': 25,
|
||||||
'server.socket_host': '0.0.0.0',
|
'server.socket_host': '0.0.0.0',
|
||||||
'server.show_tracebacks': True,
|
'server.show_tracebacks': True,
|
||||||
'server.socket_timeout': 5,
|
'server.socket_timeout': 5,
|
||||||
'log.screen': False,
|
'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:
|
try:
|
||||||
cherrypy.engine.start()
|
cherrypy.engine.start()
|
||||||
cherrypy.engine.block()
|
cherrypy.engine.block()
|
||||||
|
|
|
@ -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 {}
|
|
@ -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": []}
|
|
@ -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.")
|
Loading…
Reference in New Issue