This commit is contained in:
parent
7d727c832d
commit
bd2ba225ac
470
pysonic/api.py
470
pysonic/api.py
|
@ -1,157 +1,15 @@
|
|||
import re
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from time import time
|
||||
from random import shuffle
|
||||
from threading import Thread
|
||||
import cherrypy
|
||||
from collections import defaultdict
|
||||
from bs4 import BeautifulSoup
|
||||
from pysonic.library import LETTER_GROUPS
|
||||
from pysonic.types import MUSIC_TYPES
|
||||
from pysonic.apilib import formatresponse, ApiResponse
|
||||
import cherrypy
|
||||
|
||||
|
||||
CALLBACK_RE = re.compile(r'^[a-zA-Z0-9_]+$')
|
||||
logging = logging.getLogger("api")
|
||||
|
||||
|
||||
response_formats = defaultdict(lambda: "render_xml")
|
||||
response_formats["json"] = "render_json"
|
||||
response_formats["jsonp"] = "render_jsonp"
|
||||
|
||||
response_headers = defaultdict(lambda: "text/xml; charset=utf-8")
|
||||
response_headers["json"] = "application/json; charset=utf-8"
|
||||
response_headers["jsonp"] = "text/javascript; charset=utf-8"
|
||||
|
||||
|
||||
def formatresponse(func):
|
||||
"""
|
||||
Decorator for rendering ApiResponse responses
|
||||
"""
|
||||
def wrapper(*args, **kwargs):
|
||||
response = func(*args, **kwargs)
|
||||
response_format = kwargs.get("f", "xml")
|
||||
callback = kwargs.get("callback", None)
|
||||
cherrypy.response.headers['Content-Type'] = response_headers[response_format]
|
||||
renderer = getattr(response, response_formats[response_format])
|
||||
if response_format == "jsonp":
|
||||
if callback is None:
|
||||
return response.render_xml().encode('UTF-8') # copy original subsonic behavior
|
||||
else:
|
||||
return renderer(callback).encode('UTF-8')
|
||||
return renderer().encode('UTF-8')
|
||||
return wrapper
|
||||
|
||||
|
||||
class ApiResponse(object):
|
||||
def __init__(self, status="ok", version="1.15.0"):
|
||||
"""
|
||||
ApiResponses are python data structures that can be converted to other formats. The response has a status and a
|
||||
version. The response data structure is stored in self.data and follows these rules:
|
||||
- self.data is a dict
|
||||
- the dict's values become either child nodes or attributes, named by the key
|
||||
- lists become many oner one child
|
||||
- dict values are not allowed
|
||||
- all other types (str, int, NoneType) are attributes
|
||||
:param status:
|
||||
:param version:
|
||||
"""
|
||||
self.status = status
|
||||
self.version = version
|
||||
self.data = defaultdict(lambda: list())
|
||||
|
||||
def add_child(self, _type, _parent="", _real_parent=None, **kwargs):
|
||||
parent = _real_parent if _real_parent else self.get_child(_parent)
|
||||
m = defaultdict(lambda: list())
|
||||
m.update(dict(kwargs))
|
||||
parent[_type].append(m)
|
||||
return m
|
||||
|
||||
def get_child(self, _path):
|
||||
parent_path = _path.split(".")
|
||||
parent = self.data
|
||||
for item in parent_path:
|
||||
if not item:
|
||||
continue
|
||||
parent = parent.get(item)[0]
|
||||
return parent
|
||||
|
||||
def set_attrs(self, _path, **attrs):
|
||||
parent = self.get_child(_path)
|
||||
if type(parent) not in (dict, defaultdict):
|
||||
raise Exception("wot")
|
||||
parent.update(attrs)
|
||||
|
||||
def render_json(self):
|
||||
def _flatten_json(item):
|
||||
"""
|
||||
Convert defaultdicts to dicts and remove lists where node has 1 or no child
|
||||
"""
|
||||
listed_attrs = ["folder"]
|
||||
d = {}
|
||||
for k, v in item.items():
|
||||
if type(v) is list:
|
||||
if len(v) > 1:
|
||||
d[k] = []
|
||||
for subitem in v:
|
||||
d[k].append(_flatten_json(subitem))
|
||||
elif len(v) == 1:
|
||||
d[k] = _flatten_json(v[0])
|
||||
else:
|
||||
d[k] = {}
|
||||
else:
|
||||
d[k] = [v] if k in listed_attrs else v
|
||||
return d
|
||||
|
||||
data = _flatten_json(self.data)
|
||||
return json.dumps({"subsonic-response": dict(status=self.status, version=self.version, **data)}, indent=4)
|
||||
|
||||
def render_jsonp(self, callback):
|
||||
assert CALLBACK_RE.match(callback), "Invalid callback"
|
||||
return "{}({});".format(callback, self.render_json())
|
||||
|
||||
def render_xml(self):
|
||||
text_attrs = ['largeImageUrl', 'musicBrainzId', 'smallImageUrl', 'mediumImageUrl', 'lastFmUrl', 'biography',
|
||||
'folder']
|
||||
selftext_attrs = ['value']
|
||||
# These attributes will be placed in <hello>{{ value }}</hello> tags instead of hello="{{ value }}" on parent
|
||||
doc = BeautifulSoup('', features='lxml-xml')
|
||||
root = doc.new_tag("subsonic-response", xmlns="http://subsonic.org/restapi",
|
||||
status=self.status,
|
||||
version=self.version)
|
||||
doc.append(root)
|
||||
|
||||
def _render_xml(node, parent):
|
||||
"""
|
||||
For every key in the node dict, the parent gets a new child tag with name == key
|
||||
If the value is a dict, it becomes the new tag's attrs
|
||||
If the value is a list, the parent gets many new tags with each dict as attrs
|
||||
If the value is str int etc, parent gets attrs
|
||||
"""
|
||||
for key, value in node.items():
|
||||
if type(value) in (dict, defaultdict):
|
||||
tag = doc.new_tag(key)
|
||||
parent.append(tag)
|
||||
tag.attrs.update(value)
|
||||
elif type(value) is list:
|
||||
for item in value:
|
||||
tag = doc.new_tag(key)
|
||||
parent.append(tag)
|
||||
_render_xml(item, tag)
|
||||
else:
|
||||
if key in text_attrs:
|
||||
tag = doc.new_tag(key)
|
||||
parent.append(tag)
|
||||
tag.append(str(value))
|
||||
elif key in selftext_attrs:
|
||||
parent.append(str(value))
|
||||
else:
|
||||
parent.attrs[key] = value
|
||||
_render_xml(self.data, root)
|
||||
return doc.prettify()
|
||||
|
||||
|
||||
class PysonicApi(object):
|
||||
def __init__(self, db, library, options):
|
||||
self.db = db
|
||||
|
@ -190,49 +48,50 @@ class PysonicApi(object):
|
|||
def getIndexes_view(self, **kwargs):
|
||||
# Get listing of top-level dir
|
||||
response = ApiResponse()
|
||||
# TODO real lastmodified date
|
||||
# TODO deal with ignoredArticles
|
||||
response.add_child("indexes", lastModified="1502310831000", ignoredArticles="The El La Los Las Le Les")
|
||||
artists = self.library.get_artists(sortby="name", order="asc")
|
||||
for letter in LETTER_GROUPS:
|
||||
index = response.add_child("index", _parent="indexes", name=letter.upper())
|
||||
for artist in self.library.get_artists():
|
||||
for artist in artists:
|
||||
if artist["name"][0].lower() in letter:
|
||||
response.add_child("artist", _real_parent=index, id=artist["id"], name=artist["name"])
|
||||
response.add_child("artist", _real_parent=index, id=artist["dir"], name=artist["name"])
|
||||
return response
|
||||
|
||||
@cherrypy.expose
|
||||
def savePlayQueue_view(self, id, current, position, **kwargs):
|
||||
print("TODO save playlist with items {} current {} position {}".format(id, current, position))
|
||||
|
||||
@cherrypy.expose
|
||||
@formatresponse
|
||||
def getAlbumList_view(self, type, size=50, offset=0, **kwargs):
|
||||
albums = self.library.get_albums()
|
||||
qargs = {}
|
||||
if type == "random":
|
||||
shuffle(albums)
|
||||
qargs.update(sortby="random")
|
||||
elif type == "alphabeticalByName":
|
||||
albums.sort(key=lambda item: item.get("id3_album", item["album"] if item["album"] else "zzzzzUnsortable"))
|
||||
qargs.update(sortby="name", order="asc")
|
||||
elif type == "newest":
|
||||
qargs.update(sortby="added", order="desc")
|
||||
else:
|
||||
raise NotImplemented()
|
||||
albumset = albums[0 + int(offset):int(size) + int(offset)]
|
||||
|
||||
qargs.update(limit=(offset, size))
|
||||
|
||||
albums = self.library.get_albums(**qargs)
|
||||
|
||||
response = ApiResponse()
|
||||
|
||||
response.add_child("albumList")
|
||||
|
||||
for album in albumset:
|
||||
album_meta = album['metadata']
|
||||
album_kw = dict(id=album["id"],
|
||||
parent=album["parent"],
|
||||
isDir="true" if album['isdir'] else "false",
|
||||
title=album_meta.get("id3_title", album["name"]), #TODO these cant be blank or dsub gets mad
|
||||
album=album_meta.get("id3_album", album["album"]),
|
||||
artist=album_meta.get("id3_artist", album["artist"]),
|
||||
for album in albums:
|
||||
album_kw = dict(id=album["dir"],
|
||||
parent=album["artistdir"],
|
||||
isDir="true",
|
||||
title=album["name"],
|
||||
album=album["name"],
|
||||
artist=album["artistname"],
|
||||
coverArt=album["coverid"]
|
||||
#year=TODO
|
||||
# playCount="0"
|
||||
# created="2016-05-08T05:31:31.000Z"/>)
|
||||
)
|
||||
if 'cover' in album_meta:
|
||||
album_kw["coverArt"] = album_meta["cover"]
|
||||
if 'id3_year' in album_meta:
|
||||
album_kw["year"] = album_meta['id3_year']
|
||||
response.add_child("album", _parent="albumList", **album_kw)
|
||||
return response
|
||||
|
||||
|
@ -243,84 +102,67 @@ class PysonicApi(object):
|
|||
List an artist dir
|
||||
"""
|
||||
dir_id = int(id)
|
||||
|
||||
cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8'
|
||||
dirtype, dirinfo, entity = self.library.db.get_subsonic_musicdir(dirid=dir_id)
|
||||
|
||||
response = ApiResponse()
|
||||
response.add_child("directory")
|
||||
response.set_attrs(_path="directory", name=entity['name'], id=entity['id'],
|
||||
parent=dirinfo['parent'], playCount=420)
|
||||
|
||||
directory = self.library.get_dir(dir_id)
|
||||
dir_meta = directory["metadata"]
|
||||
children = self.library.get_dir_children(dir_id)
|
||||
response.set_attrs(_path="directory", name=directory['name'], id=directory['id'],
|
||||
parent=directory['parent'], playCount=10)
|
||||
|
||||
for item in children:
|
||||
for childtype, child in entity["children"]:
|
||||
# omit not dirs and media in browser
|
||||
if not item["isdir"] and item["type"] not in MUSIC_TYPES:
|
||||
continue
|
||||
item_meta = item['metadata']
|
||||
response.add_child("child", _parent="directory", **self.render_node(item, item_meta, directory, dir_meta))
|
||||
# if not item["isdir"] and item["type"] not in MUSIC_TYPES:
|
||||
# continue
|
||||
# item_meta = item['metadata']
|
||||
moreargs = {}
|
||||
if childtype == "album":
|
||||
moreargs.update(name=child["name"],
|
||||
isDir="true", # TODO song files in artist dir
|
||||
parent=entity["id"],
|
||||
id=child["dir"])
|
||||
if child["coverid"]:
|
||||
moreargs.update(coverArt=child["coverid"])
|
||||
# album=item["name"],
|
||||
# title=item["name"], # TODO dupe?
|
||||
# artist=artist["name"],
|
||||
# coverArt=item["coverid"],
|
||||
elif childtype == "song":
|
||||
moreargs.update(name=child["title"],
|
||||
artist=child["_artist"]["name"],
|
||||
contentType=child["format"],
|
||||
id=child["id"],
|
||||
duration=child["length"],
|
||||
isDir="false",
|
||||
parent=entity["dir"],
|
||||
# title=xxx
|
||||
)
|
||||
if entity["coverid"]:
|
||||
moreargs.update(coverArt=entity["coverid"])
|
||||
# duration="230" size="8409237" suffix="mp3" track="2" year="2005"/>
|
||||
response.add_child("child", _parent="directory",
|
||||
size="4096",
|
||||
type="music",
|
||||
**moreargs)
|
||||
|
||||
cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8'
|
||||
return response
|
||||
|
||||
def render_node(self, item, item_meta, directory, dir_meta):
|
||||
"""
|
||||
Given a node and it's parent directory, and meta, return a dict with the keys formatted how the subsonic clients
|
||||
expect them to be
|
||||
:param item:
|
||||
:param item_meta:
|
||||
:param directory:
|
||||
:param dir_meta:
|
||||
"""
|
||||
child = dict(id=item["id"],
|
||||
parent=item["id"],
|
||||
isDir="true" if item['isdir'] else "false",
|
||||
title=item_meta.get("id3_title", item["name"]),
|
||||
album=item_meta.get("id3_album", item["album"]),
|
||||
artist=item_meta.get("id3_artist", item["artist"]),
|
||||
# playCount="5",
|
||||
# created="2016-04-25T07:31:33.000Z"
|
||||
# genre="Other",
|
||||
# path="Cosmic Gate/Sign Of The Times/03 Flatline (featuring Kyler England).mp3"
|
||||
type="music")
|
||||
if 'kbitrate' in item_meta:
|
||||
child["bitrate"] = item_meta["kbitrate"]
|
||||
if item["size"] != -1:
|
||||
child["size"] = item["size"]
|
||||
if "media_length" in item_meta:
|
||||
child["duration"] = item_meta["media_length"]
|
||||
if "albumId" in directory:
|
||||
child["albumId"] = directory["id"]
|
||||
if "artistId" in directory:
|
||||
child["artistId"] = directory["parent"]
|
||||
if "." in item["name"]:
|
||||
child["suffix"] = item["name"].split(".")[-1]
|
||||
if item["type"]:
|
||||
child["contentType"] = item["type"]
|
||||
if 'cover' in item_meta:
|
||||
child["coverArt"] = item_meta["cover"]
|
||||
elif 'cover' in dir_meta:
|
||||
child["coverArt"] = dir_meta["cover"]
|
||||
if 'track' in item_meta:
|
||||
child["track"] = item_meta['track']
|
||||
if 'id3_year' in item_meta:
|
||||
child["year"] = item_meta['id3_year']
|
||||
return child
|
||||
|
||||
@cherrypy.expose
|
||||
def stream_view(self, id, maxBitRate="256", **kwargs):
|
||||
maxBitRate = int(maxBitRate)
|
||||
assert maxBitRate >= 32 and maxBitRate <= 320
|
||||
fpath = self.library.get_filepath(id)
|
||||
meta = self.library.get_file_metadata(id)
|
||||
to_bitrate = min(maxBitRate, self.options.max_bitrate, meta.get("media_kbitrate", 320))
|
||||
song = self.library.get_song(int(id))
|
||||
fpath = song["_fullpath"]
|
||||
media_bitrate = song.get("bitrate") / 1024 if song.get("bitrate") else 320
|
||||
to_bitrate = min(maxBitRate,
|
||||
self.options.max_bitrate,
|
||||
media_bitrate)
|
||||
cherrypy.response.headers['Content-Type'] = 'audio/mpeg'
|
||||
if "media_length" in meta:
|
||||
cherrypy.response.headers['X-Content-Duration'] = str(int(meta['media_length']))
|
||||
#if "media_length" in meta:
|
||||
# cherrypy.response.headers['X-Content-Duration'] = str(int(meta['media_length']))
|
||||
cherrypy.response.headers['X-Content-Kbitrate'] = str(to_bitrate)
|
||||
if (self.options.skip_transcode or meta.get("media_kbitrate", -1) == to_bitrate) \
|
||||
and meta["type"] == "audio/mpeg":
|
||||
if (self.options.skip_transcode or (song.get("bitrate") and media_bitrate == to_bitrate)) \
|
||||
and song["format"] == "audio/mpeg":
|
||||
def content():
|
||||
with open(fpath, "rb") as f:
|
||||
while True:
|
||||
|
@ -330,10 +172,9 @@ class PysonicApi(object):
|
|||
yield data
|
||||
return content()
|
||||
else:
|
||||
transcode_meta = "transcoded_{}_size".format(to_bitrate)
|
||||
if transcode_meta in meta:
|
||||
cherrypy.response.headers['Content-Length'] = str(int(meta[transcode_meta]))
|
||||
|
||||
# transcode_meta = "transcoded_{}_size".format(to_bitrate)
|
||||
# if transcode_meta in meta:
|
||||
# cherrypy.response.headers['Content-Length'] = str(int(meta[transcode_meta]))
|
||||
transcode_args = ["ffmpeg", "-i", fpath, "-map", "0:0", "-b:a",
|
||||
"{}k".format(to_bitrate),
|
||||
"-v", "0", "-f", "mp3", "-"]
|
||||
|
@ -343,13 +184,13 @@ class PysonicApi(object):
|
|||
|
||||
def content(proc):
|
||||
length = 0
|
||||
completed = False
|
||||
# completed = False
|
||||
start = time()
|
||||
try:
|
||||
while True:
|
||||
data = proc.stdout.read(16 * 1024)
|
||||
if not data:
|
||||
completed = True
|
||||
# completed = True
|
||||
break
|
||||
yield data
|
||||
length += len(data)
|
||||
|
@ -357,8 +198,8 @@ class PysonicApi(object):
|
|||
proc.poll()
|
||||
if proc.returncode is None or proc.returncode == 0:
|
||||
logging.warning("transcoded {} in {}s".format(id, int(time() - start)))
|
||||
if completed:
|
||||
self.library.report_transcode(id, to_bitrate, length)
|
||||
# if completed:
|
||||
# self.library.report_transcode(id, to_bitrate, length)
|
||||
else:
|
||||
logging.error("transcode of {} exited with code {} after {}s".format(id, proc.returncode,
|
||||
int(time() - start)))
|
||||
|
@ -378,7 +219,8 @@ class PysonicApi(object):
|
|||
|
||||
@cherrypy.expose
|
||||
def getCoverArt_view(self, id, **kwargs):
|
||||
fpath = self.library.get_filepath(id)
|
||||
cover = self.library.get_cover(id)
|
||||
fpath = cover["_fullpath"]
|
||||
type2ct = {
|
||||
'jpg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
|
@ -397,7 +239,6 @@ class PysonicApi(object):
|
|||
yield data
|
||||
logging.info("\nSent {} bytes for {}".format(total, fpath))
|
||||
return content()
|
||||
|
||||
getCoverArt_view._cp_config = {'response.stream': True}
|
||||
|
||||
@cherrypy.expose
|
||||
|
@ -471,15 +312,34 @@ class PysonicApi(object):
|
|||
"""
|
||||
response = ApiResponse()
|
||||
response.add_child("randomSongs")
|
||||
children = self.library.get_songs(size, shuffle=True)
|
||||
for item in children:
|
||||
# omit not dirs and media in browser
|
||||
if not item["isdir"] and item["type"] not in MUSIC_TYPES:
|
||||
continue
|
||||
item_meta = item['metadata']
|
||||
itemtype = "song" if item["type"] in MUSIC_TYPES else "album"
|
||||
response.add_child(itemtype, _parent="randomSongs",
|
||||
**self.render_node(item, item_meta, {}, self.db.getnode(item["parent"])["metadata"]))
|
||||
children = self.library.db.get_songs(limit=size, sortby="random")
|
||||
for song in children:
|
||||
moreargs = {}
|
||||
if song["format"]:
|
||||
moreargs.update(contentType=song["format"])
|
||||
if song["albumcoverid"]:
|
||||
moreargs.update(coverArt=song["albumcoverid"])
|
||||
if song["length"]:
|
||||
moreargs.update(duration=song["length"])
|
||||
if song["track"]:
|
||||
moreargs.update(track=song["track"])
|
||||
if song["year"]:
|
||||
moreargs.update(year=song["year"])
|
||||
|
||||
file_extension = song["file"].split(".")[-1]
|
||||
|
||||
response.add_child("song",
|
||||
_parent="randomSongs",
|
||||
title=song["title"],
|
||||
album=song["albumname"],
|
||||
artist=song["artistname"],
|
||||
id=song["id"],
|
||||
isDir="false",
|
||||
parent=song["albumid"],
|
||||
size=song["size"],
|
||||
suffix=file_extension,
|
||||
type="music",
|
||||
**moreargs)
|
||||
return response
|
||||
|
||||
@cherrypy.expose
|
||||
|
@ -487,9 +347,8 @@ class PysonicApi(object):
|
|||
def getGenres_view(self, **kwargs):
|
||||
response = ApiResponse()
|
||||
response.add_child("genres")
|
||||
response.add_child("genre", _parent="genres", value="Death Metal", songCount=420, albumCount=69)
|
||||
response.add_child("genre", _parent="genres", value="Metal", songCount=52, albumCount=3)
|
||||
response.add_child("genre", _parent="genres", value="Punk", songCount=34, albumCount=3)
|
||||
for row in self.library.db.get_genres():
|
||||
response.add_child("genre", _parent="genres", value=row["name"], songCount=420, albumCount=69)
|
||||
return response
|
||||
|
||||
@cherrypy.expose
|
||||
|
@ -500,7 +359,7 @@ class PysonicApi(object):
|
|||
:param submission: True if end of song reached. False on start of track.
|
||||
"""
|
||||
submission = True if submission == "true" else False
|
||||
# TODO save played track stats
|
||||
# TODO save played track stats and/or do last.fm bullshit
|
||||
return ApiResponse()
|
||||
|
||||
@cherrypy.expose
|
||||
|
@ -548,3 +407,108 @@ class PysonicApi(object):
|
|||
def setRating_view(self, id, rating):
|
||||
# rating is 1-5
|
||||
pass
|
||||
|
||||
@cherrypy.expose
|
||||
def savePlayQueue_view(self, id, current, position, **kwargs):
|
||||
print("TODO save playqueue with items {} current {} position {}".format(id, current, position))
|
||||
# TODO save playlist with items ['378', '386', '384', '380', '383'] current 383 position 4471
|
||||
# id entries are strings!
|
||||
|
||||
@cherrypy.expose
|
||||
@formatresponse
|
||||
def createPlaylist_view(self, name, songId, **kwargs):
|
||||
if type(songId) != list:
|
||||
songId = [songId]
|
||||
user = self.library.db.get_user(cherrypy.request.login)
|
||||
self.library.db.add_playlist(user["id"], name, songId)
|
||||
return ApiResponse()
|
||||
#TODO the response should be the new playlist, check the cap
|
||||
|
||||
@cherrypy.expose
|
||||
@formatresponse
|
||||
def getPlaylists_view(self, **kwargs):
|
||||
user = self.library.db.get_user(cherrypy.request.login)
|
||||
|
||||
response = ApiResponse()
|
||||
response.add_child("playlists")
|
||||
for playlist in self.library.db.get_playlists(user["id"]):
|
||||
response.add_child("playlist",
|
||||
_parent="playlists",
|
||||
id=playlist["id"],
|
||||
name=playlist["name"],
|
||||
owner=user["username"],
|
||||
public=playlist["public"],
|
||||
songCount=69,
|
||||
duration=420,
|
||||
# changed="2018-04-05T23:23:38.263Z"
|
||||
# created="2018-04-05T23:23:38.252Z"
|
||||
# coverArt="pl-1"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@cherrypy.expose
|
||||
@formatresponse
|
||||
def getPlaylist_view(self, id, **kwargs):
|
||||
user = self.library.db.get_user(cherrypy.request.login)
|
||||
plinfo, songs = self.library.get_playlist(int(id))
|
||||
|
||||
response = ApiResponse()
|
||||
response.add_child("playlist",
|
||||
id=plinfo["id"],
|
||||
name=plinfo["name"], # TODO this element should match getPlaylists_view
|
||||
owner=user["username"], # TODO translate id to name
|
||||
public=plinfo["public"],
|
||||
songCount=69,
|
||||
duration=420)
|
||||
for song in songs:
|
||||
response.add_child("entry",
|
||||
_parent="playlist",
|
||||
id=song["id"],
|
||||
parent=song["albumid"], # albumid seems wrong? should be dir parent?
|
||||
isDir="false",
|
||||
title=song["title"],
|
||||
album=song["albumname"],
|
||||
artist=song["artistname"],
|
||||
track=song["track"],
|
||||
year=song["year"],
|
||||
genre=song["genrename"],
|
||||
coverArt=song["albumcoverid"],
|
||||
size=song["size"],
|
||||
contentType=song["format"],
|
||||
# suffix="mp3"
|
||||
duration=song["length"],
|
||||
bitRate=song["bitrate"] / 1024,
|
||||
path=song["file"],
|
||||
playCount="1",
|
||||
# created="2015-06-09T15:26:01.000Z"
|
||||
albumId=song["albumid"],
|
||||
artistId=song["artistid"],
|
||||
type="music")
|
||||
return response
|
||||
|
||||
@cherrypy.expose
|
||||
@formatresponse
|
||||
def updatePlaylist_view(self, playlistId, songIndexToRemove=None, songIdToAdd=None, **kwargs):
|
||||
user = self.library.db.get_user(cherrypy.request.login)
|
||||
plinfo, songs = self.library.get_playlist(int(playlistId))
|
||||
|
||||
assert plinfo["ownerid"] == user["id"]
|
||||
|
||||
if songIndexToRemove:
|
||||
self.library.db.remove_index_from_playlist(playlistId, songIndexToRemove)
|
||||
elif songIdToAdd:
|
||||
self.library.db.add_to_playlist(playlistId, songIdToAdd)
|
||||
#TODO there are more modification methods
|
||||
|
||||
return ApiResponse()
|
||||
|
||||
@cherrypy.expose
|
||||
@formatresponse
|
||||
def deletePlaylist_view(self, id, **kwargs):
|
||||
user = self.library.db.get_user(cherrypy.request.login)
|
||||
plinfo, _ = self.library.get_playlist(int(id))
|
||||
assert plinfo["ownerid"] == user["id"]
|
||||
|
||||
self.library.delete_playlist(plinfo["id"])
|
||||
return ApiResponse()
|
||||
|
|
|
@ -0,0 +1,143 @@
|
|||
from collections import defaultdict
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import cherrypy
|
||||
import json
|
||||
|
||||
CALLBACK_RE = re.compile(r'^[a-zA-Z0-9_]+$')
|
||||
|
||||
response_formats = defaultdict(lambda: "render_xml")
|
||||
response_formats["json"] = "render_json"
|
||||
response_formats["jsonp"] = "render_jsonp"
|
||||
|
||||
response_headers = defaultdict(lambda: "text/xml; charset=utf-8")
|
||||
response_headers["json"] = "application/json; charset=utf-8"
|
||||
response_headers["jsonp"] = "text/javascript; charset=utf-8"
|
||||
|
||||
|
||||
def formatresponse(func):
|
||||
"""
|
||||
Decorator for rendering ApiResponse responses based on requested response type
|
||||
"""
|
||||
def wrapper(*args, **kwargs):
|
||||
response = func(*args, **kwargs)
|
||||
response_format = kwargs.get("f", "xml")
|
||||
callback = kwargs.get("callback", None)
|
||||
cherrypy.response.headers['Content-Type'] = response_headers[response_format]
|
||||
renderer = getattr(response, response_formats[response_format])
|
||||
if response_format == "jsonp":
|
||||
if callback is None:
|
||||
return response.render_xml().encode('UTF-8') # copy original subsonic behavior
|
||||
else:
|
||||
return renderer(callback).encode('UTF-8')
|
||||
return renderer().encode('UTF-8')
|
||||
return wrapper
|
||||
|
||||
|
||||
class ApiResponse(object):
|
||||
def __init__(self, status="ok", version="1.15.0"):
|
||||
"""
|
||||
ApiResponses are python data structures that can be converted to other formats. The response has a status and a
|
||||
version. The response data structure is stored in self.data and follows these rules:
|
||||
- self.data is a dict
|
||||
- the dict's values become either child nodes or attributes, named by the key
|
||||
- lists become many oner one child
|
||||
- dict values are not allowed
|
||||
- all other types (str, int, NoneType) are attributes
|
||||
:param status:
|
||||
:param version:
|
||||
"""
|
||||
self.status = status
|
||||
self.version = version
|
||||
self.data = defaultdict(lambda: list())
|
||||
|
||||
def add_child(self, _type, _parent="", _real_parent=None, **kwargs):
|
||||
kwargs = {k: v for k, v in kwargs.items() if v or type(v) is int} # filter out empty keys (0 is ok)
|
||||
parent = _real_parent if _real_parent else self.get_child(_parent)
|
||||
m = defaultdict(lambda: list())
|
||||
m.update(dict(kwargs))
|
||||
parent[_type].append(m)
|
||||
return m
|
||||
|
||||
def get_child(self, _path):
|
||||
parent_path = _path.split(".")
|
||||
parent = self.data
|
||||
for item in parent_path:
|
||||
if not item:
|
||||
continue
|
||||
parent = parent.get(item)[0]
|
||||
return parent
|
||||
|
||||
def set_attrs(self, _path, **attrs):
|
||||
parent = self.get_child(_path)
|
||||
if type(parent) not in (dict, defaultdict):
|
||||
raise Exception("wot")
|
||||
parent.update(attrs)
|
||||
|
||||
def render_json(self):
|
||||
def _flatten_json(item):
|
||||
"""
|
||||
Convert defaultdicts to dicts and remove lists where node has 1 or no child
|
||||
"""
|
||||
listed_attrs = ["folder"]
|
||||
d = {}
|
||||
for k, v in item.items():
|
||||
if type(v) is list:
|
||||
if len(v) > 1:
|
||||
d[k] = []
|
||||
for subitem in v:
|
||||
d[k].append(_flatten_json(subitem))
|
||||
elif len(v) == 1:
|
||||
d[k] = _flatten_json(v[0])
|
||||
else:
|
||||
d[k] = {}
|
||||
else:
|
||||
d[k] = [v] if k in listed_attrs else v
|
||||
return d
|
||||
|
||||
data = _flatten_json(self.data)
|
||||
return json.dumps({"subsonic-response": dict(status=self.status, version=self.version, **data)}, indent=4)
|
||||
|
||||
def render_jsonp(self, callback):
|
||||
assert CALLBACK_RE.match(callback), "Invalid callback"
|
||||
return "{}({});".format(callback, self.render_json())
|
||||
|
||||
def render_xml(self):
|
||||
text_attrs = ['largeImageUrl', 'musicBrainzId', 'smallImageUrl', 'mediumImageUrl', 'lastFmUrl', 'biography',
|
||||
'folder']
|
||||
selftext_attrs = ['value']
|
||||
# These attributes will be placed in <hello>{{ value }}</hello> tags instead of hello="{{ value }}" on parent
|
||||
doc = BeautifulSoup('', features='lxml-xml')
|
||||
root = doc.new_tag("subsonic-response", xmlns="http://subsonic.org/restapi",
|
||||
status=self.status,
|
||||
version=self.version)
|
||||
doc.append(root)
|
||||
|
||||
def _render_xml(node, parent):
|
||||
"""
|
||||
For every key in the node dict, the parent gets a new child tag with name == key
|
||||
If the value is a dict, it becomes the new tag's attrs
|
||||
If the value is a list, the parent gets many new tags with each dict as attrs
|
||||
If the value is str int etc, parent gets attrs
|
||||
"""
|
||||
for key, value in node.items():
|
||||
if type(value) in (dict, defaultdict):
|
||||
tag = doc.new_tag(key)
|
||||
parent.append(tag)
|
||||
tag.attrs.update(value)
|
||||
elif type(value) is list:
|
||||
for item in value:
|
||||
tag = doc.new_tag(key)
|
||||
parent.append(tag)
|
||||
_render_xml(item, tag)
|
||||
else:
|
||||
if key in text_attrs:
|
||||
tag = doc.new_tag(key)
|
||||
parent.append(tag)
|
||||
tag.append(str(value))
|
||||
elif key in selftext_attrs:
|
||||
parent.append(str(value))
|
||||
else:
|
||||
parent.attrs[key] = value
|
||||
_render_xml(self.data, root)
|
||||
return doc.prettify()
|
|
@ -1,10 +1,10 @@
|
|||
import os
|
||||
import logging
|
||||
import cherrypy
|
||||
from sqlite3 import IntegrityError
|
||||
from sqlite3 import DatabaseError
|
||||
from pysonic.api import PysonicApi
|
||||
from pysonic.library import PysonicLibrary, DuplicateRootException
|
||||
from pysonic.database import PysonicDatabase
|
||||
from pysonic.library import PysonicLibrary
|
||||
from pysonic.database import PysonicDatabase, DuplicateRootException
|
||||
|
||||
|
||||
def main():
|
||||
|
@ -31,14 +31,15 @@ def main():
|
|||
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO if args.debug else logging.WARNING)
|
||||
logging.basicConfig(level=logging.INFO if args.debug else logging.WARNING,
|
||||
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
|
||||
|
||||
db = PysonicDatabase(path=args.database_path)
|
||||
library = PysonicLibrary(db)
|
||||
for dirname in args.dirs:
|
||||
assert os.path.exists(dirname) and dirname.startswith("/"), "--dirs must be absolute paths and exist!"
|
||||
try:
|
||||
library.add_dir(dirname)
|
||||
library.add_root_dir(dirname)
|
||||
except DuplicateRootException:
|
||||
pass
|
||||
library.update()
|
||||
|
@ -46,21 +47,25 @@ def main():
|
|||
for username, password in args.user:
|
||||
try:
|
||||
db.add_user(username, password)
|
||||
except IntegrityError:
|
||||
except DatabaseError:
|
||||
db.update_user(username, password)
|
||||
|
||||
logging.warning("Libraries: {}".format([i["name"] for i in library.get_libraries()]))
|
||||
logging.warning("Artists: {}".format([i["name"] for i in library.get_artists()]))
|
||||
logging.warning("Albums: {}".format(len(library.get_albums())))
|
||||
# logging.warning("Libraries: {}".format([i["name"] for i in library.get_libraries()]))
|
||||
# logging.warning("Artists: {}".format([i["name"] for i in library.get_artists()]))
|
||||
# logging.warning("Albums: {}".format(len(library.get_albums())))
|
||||
|
||||
api = PysonicApi(db, library, args)
|
||||
api_config = {}
|
||||
if args.disable_auth:
|
||||
logging.warning("starting up with auth disabled")
|
||||
else:
|
||||
def validate_password(realm, username, password):
|
||||
print("I JUST VALIDATED {}:{} ({})".format(username, password, realm))
|
||||
return True
|
||||
|
||||
api_config.update({'tools.auth_basic.on': True,
|
||||
'tools.auth_basic.realm': 'pysonic',
|
||||
'tools.auth_basic.checkpassword': db.validate_password})
|
||||
'tools.auth_basic.checkpassword': validate_password})
|
||||
if args.enable_cors:
|
||||
def cors():
|
||||
cherrypy.response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
|
@ -99,5 +104,6 @@ def main():
|
|||
logging.info("API has shut down")
|
||||
cherrypy.engine.exit()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
import logging
|
||||
from hashlib import sha512
|
||||
from time import time
|
||||
from contextlib import closing
|
||||
|
||||
from collections import Iterable
|
||||
|
||||
logging = logging.getLogger("database")
|
||||
keys_in_table = ["title", "album", "artist", "type", "size"]
|
||||
|
@ -21,12 +20,33 @@ class NotFoundError(Exception):
|
|||
pass
|
||||
|
||||
|
||||
class DuplicateRootException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def hash_password(unicode_string):
|
||||
return sha512(unicode_string.encode('UTF-8')).hexdigest()
|
||||
|
||||
|
||||
def readcursor(func):
|
||||
"""
|
||||
Provides a cursor to the wrapped method as the first arg.
|
||||
"""
|
||||
def wrapped(*args, **kwargs):
|
||||
self = args[0]
|
||||
if len(args) >= 2 and isinstance(args[1], sqlite3.Cursor):
|
||||
return func(*args, **kwargs)
|
||||
else:
|
||||
with closing(self.db.cursor()) as cursor:
|
||||
return func(*[self, cursor], *args[1:], **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
class PysonicDatabase(object):
|
||||
def __init__(self, path):
|
||||
self.sqlite_opts = dict(check_same_thread=False, cached_statements=0, isolation_level=None)
|
||||
self.sqlite_opts = dict(check_same_thread=False)
|
||||
self.path = path
|
||||
self.db = None
|
||||
|
||||
self.open()
|
||||
self.migrate()
|
||||
|
||||
|
@ -36,212 +56,423 @@ class PysonicDatabase(object):
|
|||
|
||||
def migrate(self):
|
||||
# Create db
|
||||
queries = ["""CREATE TABLE 'meta' (
|
||||
queries = ["""CREATE TABLE 'libraries' (
|
||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
'name' TEXT,
|
||||
'path' TEXT UNIQUE);""",
|
||||
"""CREATE TABLE 'dirs' (
|
||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
'library' INTEGER,
|
||||
'parent' INTEGER,
|
||||
'name' TEXT,
|
||||
UNIQUE(parent, name)
|
||||
)""",
|
||||
"""CREATE TABLE 'genres' (
|
||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
'name' TEXT UNIQUE)""",
|
||||
"""CREATE TABLE 'artists' (
|
||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
'libraryid' INTEGER,
|
||||
'dir' INTEGER UNIQUE,
|
||||
'name' TEXT)""",
|
||||
"""CREATE TABLE 'albums' (
|
||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
'artistid' INTEGER,
|
||||
'coverid' INTEGER,
|
||||
'dir' INTEGER,
|
||||
'name' TEXT,
|
||||
'added' INTEGER NOT NULL DEFAULT -1,
|
||||
UNIQUE (artistid, dir));""",
|
||||
"""CREATE TABLE 'songs' (
|
||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
'library' INTEGER,
|
||||
'albumid' BOOLEAN,
|
||||
'genre' INTEGER DEFAULT NULL,
|
||||
'file' TEXT UNIQUE, -- path from the library root
|
||||
'size' INTEGER NOT NULL DEFAULT -1,
|
||||
'title' TEXT NOT NULL,
|
||||
'lastscan' INTEGER NOT NULL DEFAULT -1,
|
||||
'format' TEXT,
|
||||
'length' INTEGER,
|
||||
'bitrate' INTEGER,
|
||||
'track' INTEGER,
|
||||
'year' INTEGER
|
||||
)""",
|
||||
"""CREATE TABLE 'covers' (
|
||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
'library' INTEGER,
|
||||
'type' TEXT,
|
||||
'size' TEXT,
|
||||
'path' TEXT UNIQUE);""",
|
||||
"""CREATE TABLE 'users' (
|
||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
'username' TEXT UNIQUE NOT NULL,
|
||||
'password' TEXT NOT NULL,
|
||||
'admin' BOOLEAN DEFAULT 0,
|
||||
'email' TEXT)""",
|
||||
"""CREATE TABLE 'stars' (
|
||||
'userid' INTEGER,
|
||||
'songid' INTEGER,
|
||||
primary key ('userid', 'songid'))""",
|
||||
"""CREATE TABLE 'playlists' (
|
||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
'ownerid' INTEGER,
|
||||
'name' TEXT,
|
||||
'public' BOOLEAN,
|
||||
'created' INTEGER,
|
||||
'changed' INTEGER,
|
||||
'cover' INTEGER,
|
||||
UNIQUE ('ownerid', 'name'))""",
|
||||
"""CREATE TABLE 'playlist_entries' (
|
||||
'playlistid' INTEGER,
|
||||
'songid' INTEGER,
|
||||
'order' FLOAT)""",
|
||||
"""CREATE TABLE 'meta' (
|
||||
'key' TEXT PRIMARY KEY NOT NULL,
|
||||
'value' TEXT);""",
|
||||
"""INSERT INTO meta VALUES ('db_version', '3');""",
|
||||
"""CREATE TABLE 'nodes' (
|
||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
'parent' INTEGER NOT NULL,
|
||||
'isdir' BOOLEAN NOT NULL,
|
||||
'size' INTEGER NOT NULL DEFAULT -1,
|
||||
'name' TEXT NOT NULL,
|
||||
'type' TEXT,
|
||||
'title' TEXT,
|
||||
'album' TEXT,
|
||||
'artist' TEXT,
|
||||
'metadata' TEXT
|
||||
)""",
|
||||
"""CREATE TABLE 'users' (
|
||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
'username' TEXT UNIQUE NOT NULL,
|
||||
'password' TEXT NOT NULL,
|
||||
'admin' BOOLEAN DEFAULT 0,
|
||||
'email' TEXT)""",
|
||||
"""CREATE TABLE 'stars' (
|
||||
'userid' INTEGER,
|
||||
'nodeid' INTEGER,
|
||||
primary key ('userid', 'nodeid'))"""]
|
||||
"""INSERT INTO meta VALUES ('db_version', '1');"""]
|
||||
|
||||
with closing(self.db.cursor()) as cursor:
|
||||
cursor.execute("SELECT * FROM sqlite_master WHERE type='table' AND name='meta';")
|
||||
cursor.execute("SELECT * FROM sqlite_master WHERE type='table' AND name='meta'")
|
||||
|
||||
# Initialize DB
|
||||
if len(cursor.fetchall()) == 0:
|
||||
logging.warning("Initializing database")
|
||||
for query in queries:
|
||||
cursor.execute(query)
|
||||
cursor.execute("COMMIT")
|
||||
else:
|
||||
# Migrate if old db exists
|
||||
version = int(cursor.execute("SELECT * FROM meta WHERE key='db_version';").fetchone()['value'])
|
||||
if version < 1:
|
||||
logging.warning("migrating database to v1 from %s", version)
|
||||
users_table = """CREATE TABLE 'users' (
|
||||
'id' INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
'username' TEXT UNIQUE NOT NULL,
|
||||
'password' TEXT NOT NULL,
|
||||
'admin' BOOLEAN DEFAULT 0,
|
||||
'email' TEXT)"""
|
||||
cursor.execute(users_table)
|
||||
version = 1
|
||||
if version < 2:
|
||||
logging.warning("migrating database to v2 from %s", version)
|
||||
stars_table = """CREATE TABLE 'stars' (
|
||||
'userid' INTEGER,
|
||||
'nodeid' INTEGER,
|
||||
primary key ('userid', 'nodeid'))"""
|
||||
cursor.execute(stars_table)
|
||||
version = 2
|
||||
if version < 3:
|
||||
logging.warning("migrating database to v3 from %s", version)
|
||||
size_col = """ALTER TABLE nodes ADD 'size' INTEGER NOT NULL DEFAULT -1;"""
|
||||
cursor.execute(size_col)
|
||||
version = 3
|
||||
|
||||
cursor.execute("""UPDATE meta SET value=? WHERE key="db_version";""", (str(version), ))
|
||||
logging.warning("db schema is version {}".format(version))
|
||||
|
||||
# Virtual file tree
|
||||
def getnode(self, node_id):
|
||||
return self.getnodes(node_id=node_id)[0]
|
||||
|
||||
def _populate_meta(self, node):
|
||||
node['metadata'] = self.decode_metadata(node['metadata'])
|
||||
return node
|
||||
|
||||
def getnodes(self, *parent_ids, node_id=None, types=None, limit=None, order=None):
|
||||
"""
|
||||
Find nodes that match the passed paramters.
|
||||
:param parent_ids: one or more parents to find children of
|
||||
:type parent_ids: int
|
||||
:param node_id: single node id to return
|
||||
:type node_id: int
|
||||
:param types: filter by type column
|
||||
:type types: list
|
||||
:param limit: number of records to limit to
|
||||
:param order: one of ("rand") to select ordering mode
|
||||
"""
|
||||
query = "SELECT * FROM nodes WHERE "
|
||||
qargs = []
|
||||
|
||||
def add_filter(name, values):
|
||||
nonlocal query
|
||||
nonlocal qargs
|
||||
query += "{} in (".format(name)
|
||||
for value in (values if type(values) in [list, tuple] else [values]):
|
||||
query += "?, "
|
||||
qargs += [value]
|
||||
query = query.rstrip(", ")
|
||||
query += ") AND"
|
||||
|
||||
if node_id:
|
||||
add_filter("id", node_id)
|
||||
if parent_ids:
|
||||
add_filter("parent", parent_ids)
|
||||
if types:
|
||||
add_filter("type", types)
|
||||
|
||||
query = query.rstrip(" AND").rstrip("WHERE ")
|
||||
|
||||
if order:
|
||||
query += "ORDER BY "
|
||||
if order == "rand":
|
||||
query += "RANDOM()"
|
||||
|
||||
if limit: # TODO 2-item tuple limit
|
||||
query += " limit {}".format(limit)
|
||||
|
||||
with closing(self.db.cursor()) as cursor:
|
||||
return list(map(self._populate_meta, cursor.execute(query, qargs).fetchall()))
|
||||
|
||||
def addnode(self, parent_id, fspath, name, size=-1):
|
||||
fullpath = os.path.join(fspath, name)
|
||||
is_dir = os.path.isdir(fullpath)
|
||||
return self._addnode(parent_id, name, is_dir, size=size)
|
||||
|
||||
def _addnode(self, parent_id, name, is_dir=True, size=-1):
|
||||
with closing(self.db.cursor()) as cursor:
|
||||
cursor.execute("INSERT INTO nodes (parent, isdir, name, size) VALUES (?, ?, ?, ?);",
|
||||
(parent_id, 1 if is_dir else 0, name, size))
|
||||
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 {}
|
||||
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):
|
||||
node = self.getnode(node_id)
|
||||
meta = node["metadata"]
|
||||
meta.update({item: node[item] for item in keys_in_table})
|
||||
return meta
|
||||
|
||||
def decode_metadata(self, metadata):
|
||||
if metadata:
|
||||
return json.loads(metadata)
|
||||
return {}
|
||||
|
||||
def hashit(self, unicode_string):
|
||||
return sha512(unicode_string.encode('UTF-8')).hexdigest()
|
||||
|
||||
def validate_password(self, realm, username, password):
|
||||
with closing(self.db.cursor()) as cursor:
|
||||
users = cursor.execute("SELECT * FROM users WHERE username=? AND password=?;",
|
||||
(username, self.hashit(password))).fetchall()
|
||||
return bool(users)
|
||||
|
||||
def add_user(self, username, password, is_admin=False):
|
||||
with closing(self.db.cursor()) as cursor:
|
||||
cursor.execute("INSERT INTO users (username, password, admin) VALUES (?, ?, ?)",
|
||||
(username, self.hashit(password), is_admin))
|
||||
|
||||
def update_user(self, username, password, is_admin=False):
|
||||
with closing(self.db.cursor()) as cursor:
|
||||
cursor.execute("UPDATE users SET password=?, admin=? WHERE username=?;",
|
||||
(self.hashit(password), is_admin, username))
|
||||
|
||||
def get_user(self, user):
|
||||
with closing(self.db.cursor()) as cursor:
|
||||
try:
|
||||
column = "id" if type(user) is int else "username"
|
||||
return cursor.execute("SELECT * FROM users WHERE {}=?;".format(column), (user, )).fetchall()[0]
|
||||
except IndexError:
|
||||
raise NotFoundError("User doesn't exist")
|
||||
|
||||
def set_starred(self, user_id, node_id, starred=True):
|
||||
with closing(self.db.cursor()) as cursor:
|
||||
if starred:
|
||||
query = "INSERT INTO stars (userid, nodeid) VALUES (?, ?);"
|
||||
else:
|
||||
query = "DELETE FROM stars WHERE userid=? and nodeid=?;"
|
||||
try:
|
||||
cursor.execute(query, (user_id, node_id))
|
||||
except sqlite3.IntegrityError:
|
||||
# cursor.execute("""UPDATE meta SET value=? WHERE key="db_version";""", (str(version), ))
|
||||
# logging.warning("db schema is version {}".format(version))
|
||||
pass
|
||||
|
||||
def get_starred_items(self, for_user_id=None):
|
||||
with closing(self.db.cursor()) as cursor:
|
||||
q = """SELECT n.* FROM nodes as n INNER JOIN stars as s ON s.nodeid = n.id"""
|
||||
qargs = []
|
||||
if for_user_id:
|
||||
q += """ AND userid=?"""
|
||||
qargs += [int(for_user_id)]
|
||||
return list(map(self._populate_meta,
|
||||
cursor.execute(q, qargs).fetchall()))
|
||||
# Music related
|
||||
@readcursor
|
||||
def add_root(self, cursor, path, name="Library"):
|
||||
"""
|
||||
Add a new library root. Returns the root ID or raises on collision
|
||||
:param path: normalized absolute path to add to the library
|
||||
:type path: str:
|
||||
:return: int
|
||||
:raises: sqlite3.IntegrityError
|
||||
"""
|
||||
assert path.startswith("/")
|
||||
try:
|
||||
cursor.execute("INSERT INTO libraries ('name', 'path') VALUES (?, ?)", (name, path, ))
|
||||
cursor.execute("COMMIT")
|
||||
return cursor.lastrowid
|
||||
except sqlite3.IntegrityError:
|
||||
raise DuplicateRootException("Root '{}' already exists".format(path))
|
||||
|
||||
@readcursor
|
||||
def get_libraries(self, cursor, id=None):
|
||||
libs = []
|
||||
q = "SELECT * FROM libraries"
|
||||
params = []
|
||||
conditions = []
|
||||
if id:
|
||||
conditions.append("id = ?")
|
||||
params.append(id)
|
||||
if conditions:
|
||||
q += " WHERE " + " AND ".join(conditions)
|
||||
cursor.execute(q, params)
|
||||
for row in cursor:
|
||||
libs.append(row)
|
||||
return libs
|
||||
|
||||
@readcursor
|
||||
def get_artists(self, cursor, id=None, dirid=None, sortby=None, order=None):
|
||||
assert order in ["asc", "desc", None]
|
||||
artists = []
|
||||
q = "SELECT * FROM artists"
|
||||
params = []
|
||||
conditions = []
|
||||
if id:
|
||||
conditions.append("id = ?")
|
||||
params.append(id)
|
||||
if dirid:
|
||||
conditions.append("dir = ?")
|
||||
params.append(dirid)
|
||||
if conditions:
|
||||
q += " WHERE " + " AND ".join(conditions)
|
||||
if sortby:
|
||||
q += " ORDER BY {} {}".format(sortby, order.upper() if order else "ASC")
|
||||
cursor.execute(q, params)
|
||||
for row in cursor:
|
||||
artists.append(row)
|
||||
return artists
|
||||
|
||||
@readcursor
|
||||
def get_albums(self, cursor, id=None, artist=None, sortby=None, order=None, limit=None):
|
||||
"""
|
||||
:param limit: int or tuple of int, int. translates directly to sql logic.
|
||||
"""
|
||||
if order:
|
||||
order = {"asc": "ASC", "desc": "DESC"}[order]
|
||||
|
||||
if sortby and sortby == "random":
|
||||
sortby = "RANDOM()"
|
||||
|
||||
albums = []
|
||||
|
||||
q = """
|
||||
SELECT
|
||||
alb.*,
|
||||
art.name as artistname,
|
||||
dirs.parent as artistdir
|
||||
FROM albums as alb
|
||||
INNER JOIN artists as art
|
||||
on alb.artistid = art.id
|
||||
INNER JOIN dirs
|
||||
on dirs.id = alb.dir
|
||||
"""
|
||||
params = []
|
||||
|
||||
conditions = []
|
||||
if id:
|
||||
conditions.append("id = ?")
|
||||
params.append(id)
|
||||
if artist:
|
||||
conditions.append("artistid = ?")
|
||||
params.append(artist)
|
||||
if conditions:
|
||||
q += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
if sortby:
|
||||
q += " ORDER BY {}".format(sortby)
|
||||
if order:
|
||||
q += " {}".format(order)
|
||||
|
||||
if limit:
|
||||
q += " LIMIT {}".format(limit) if isinstance(limit, int) \
|
||||
else " LIMIT {}, {}".format(*limit)
|
||||
|
||||
cursor.execute(q, params)
|
||||
for row in cursor:
|
||||
albums.append(row)
|
||||
return albums
|
||||
|
||||
@readcursor
|
||||
def get_songs(self, cursor, id=None, genre=None, sortby=None, order=None, limit=None):
|
||||
# TODO make this query massively uglier by joining albums and artists so that artistid etc can be a filter
|
||||
# or maybe lookup those IDs in the library layer?
|
||||
if order:
|
||||
order = {"asc": "ASC", "desc": "DESC"}[order]
|
||||
|
||||
if sortby and sortby == "random":
|
||||
sortby = "RANDOM()"
|
||||
|
||||
songs = []
|
||||
|
||||
q = """
|
||||
SELECT
|
||||
s.*,
|
||||
alb.name as albumname,
|
||||
alb.coverid as albumcoverid,
|
||||
art.name as artistname,
|
||||
g.name as genrename
|
||||
FROM songs as s
|
||||
INNER JOIN albums as alb
|
||||
on s.albumid == alb.id
|
||||
INNER JOIN artists as art
|
||||
on alb.artistid = art.id
|
||||
LEFT JOIN genres as g
|
||||
on s.genre == g.id
|
||||
"""
|
||||
|
||||
params = []
|
||||
|
||||
conditions = []
|
||||
if id and isinstance(id, int):
|
||||
conditions.append("s.id = ?")
|
||||
params.append(id)
|
||||
elif id and isinstance(id, Iterable):
|
||||
conditions.append("s.id IN ({})".format(",".join("?" * len(id))))
|
||||
params += id
|
||||
if genre:
|
||||
conditions.append("g.name = ?")
|
||||
params.append(genre)
|
||||
if conditions:
|
||||
q += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
if sortby:
|
||||
q += " ORDER BY {}".format(sortby)
|
||||
if order:
|
||||
q += " {}".format(order)
|
||||
|
||||
if limit:
|
||||
q += " LIMIT {}".format(limit) # TODO support limit pagination
|
||||
|
||||
cursor.execute(q, params)
|
||||
for row in cursor:
|
||||
songs.append(row)
|
||||
return songs
|
||||
|
||||
@readcursor
|
||||
def get_genres(self, cursor, genre_id=None):
|
||||
genres = []
|
||||
q = "SELECT * FROM genres"
|
||||
params = []
|
||||
conditions = []
|
||||
if genre_id:
|
||||
conditions.append("id = ?")
|
||||
params.append(genre_id)
|
||||
if conditions:
|
||||
q += " WHERE " + " AND ".join(conditions)
|
||||
cursor.execute(q, params)
|
||||
for row in cursor:
|
||||
genres.append(row)
|
||||
return genres
|
||||
|
||||
@readcursor
|
||||
def get_cover(self, cursor, coverid):
|
||||
cover = None
|
||||
for cover in cursor.execute("SELECT * FROM covers WHERE id = ?", (coverid, )):
|
||||
return cover
|
||||
|
||||
@readcursor
|
||||
def get_subsonic_musicdir(self, cursor, dirid):
|
||||
"""
|
||||
The world is a harsh place.
|
||||
Again, this bullshit exists only to serve subsonic clients. Given a directory ID it returns a dict containing:
|
||||
- the directory itself
|
||||
- its parent
|
||||
- its child dirs
|
||||
- its child media
|
||||
|
||||
that's a lie, it's a tuple and it's full of BS. read the code
|
||||
"""
|
||||
# find directory
|
||||
dirinfo = None
|
||||
for dirinfo in cursor.execute("SELECT * FROM dirs WHERE id = ?", (dirid, )):
|
||||
pass
|
||||
assert dirinfo
|
||||
|
||||
ret = None
|
||||
|
||||
# see if it matches the artists or albums table
|
||||
artist = None
|
||||
for artist in cursor.execute("SELECT * FROM artists WHERE dir = ?", (dirid, )):
|
||||
pass
|
||||
|
||||
# if artist:
|
||||
# get child albums
|
||||
if artist:
|
||||
ret = ("artist", dirinfo, artist)
|
||||
children = []
|
||||
for album in cursor.execute("SELECT * FROM albums WHERE artistid = ?", (artist["id"], )):
|
||||
children.append(("album", album))
|
||||
ret[2]['children'] = children
|
||||
return ret
|
||||
|
||||
# else if album:
|
||||
# get child tracks
|
||||
album = None
|
||||
for album in cursor.execute("SELECT * FROM albums WHERE dir = ?", (dirid, )):
|
||||
pass
|
||||
if album:
|
||||
ret = ("album", dirinfo, album)
|
||||
|
||||
artist_info = cursor.execute("SELECT * FROM artists WHERE id = ?", (album["artistid"], )).fetchall()[0]
|
||||
|
||||
children = []
|
||||
for song in cursor.execute("SELECT * FROM songs WHERE albumid = ?", (album["id"], )):
|
||||
song["_artist"] = artist_info
|
||||
children.append(("song", song))
|
||||
ret[2]['children'] = children
|
||||
return ret
|
||||
|
||||
# Playlist related
|
||||
@readcursor
|
||||
def add_playlist(self, cursor, ownerid, name, song_ids, public=False):
|
||||
"""
|
||||
Create a playlist
|
||||
"""
|
||||
now = time()
|
||||
cursor.execute("INSERT INTO playlists (ownerid, name, public, created, changed) VALUES (?, ?, ?, ?, ?)",
|
||||
(ownerid, name, public, now, now))
|
||||
plid = cursor.lastrowid
|
||||
for song_id in song_ids:
|
||||
self.add_to_playlist(cursor, plid, song_id)
|
||||
cursor.execute("COMMIT")
|
||||
|
||||
@readcursor
|
||||
def add_to_playlist(self, cursor, playlist_id, song_id):
|
||||
# TODO deal with order column
|
||||
cursor.execute("INSERT INTO playlist_entries (playlistid, songid) VALUES (?, ?)", (playlist_id, song_id))
|
||||
|
||||
@readcursor
|
||||
def get_playlist(self, cursor, playlist_id):
|
||||
return cursor.execute("SELECT * FROM playlists WHERE id=?", (playlist_id, )).fetchone()
|
||||
|
||||
@readcursor
|
||||
def get_playlist_songs(self, cursor, playlist_id):
|
||||
songs = []
|
||||
q = """
|
||||
SELECT
|
||||
s.*,
|
||||
alb.name as albumname,
|
||||
alb.coverid as albumcoverid,
|
||||
art.name as artistname,
|
||||
art.name as artistid,
|
||||
g.name as genrename
|
||||
FROM playlist_entries as pe
|
||||
INNER JOIN songs as s
|
||||
on pe.songid == s.id
|
||||
INNER JOIN albums as alb
|
||||
on s.albumid == alb.id
|
||||
INNER JOIN artists as art
|
||||
on alb.artistid = art.id
|
||||
LEFT JOIN genres as g
|
||||
on s.genre == g.id
|
||||
WHERE pe.playlistid = ?
|
||||
ORDER BY pe.'order' ASC;
|
||||
"""
|
||||
for row in cursor.execute(q, (playlist_id, )):
|
||||
songs.append(row)
|
||||
return songs
|
||||
|
||||
@readcursor
|
||||
def get_playlists(self, cursor, user_id):
|
||||
playlists = []
|
||||
for row in cursor.execute("SELECT * FROM playlists WHERE ownerid=? or public=1", (user_id, )):
|
||||
playlists.append(row)
|
||||
return playlists
|
||||
|
||||
@readcursor
|
||||
def remove_index_from_playlist(self, cursor, playlist_id, index):
|
||||
cursor.execute("DELETE FROM playlist_entries WHERE playlistid=? LIMIT ?, 1", (playlist_id, index, ))
|
||||
cursor.execute("COMMIT")
|
||||
|
||||
@readcursor
|
||||
def empty_playlist(self, cursor, playlist_id):
|
||||
#TODO combine with # TODO combine with
|
||||
cursor.execute("DELETE FROM playlist_entries WHERE playlistid=?", (playlist_id, ))
|
||||
cursor.execute("COMMIT")
|
||||
|
||||
@readcursor
|
||||
def delete_playlist(self, cursor, playlist_id):
|
||||
cursor.execute("DELETE FROM playlists WHERE id=?", (playlist_id, ))
|
||||
cursor.execute("COMMIT")
|
||||
|
||||
# User related
|
||||
@readcursor
|
||||
def add_user(self, cursor, username, password, is_admin=False):
|
||||
cursor.execute("INSERT INTO users (username, password, admin) VALUES (?, ?, ?)",
|
||||
(username, hash_password(password), is_admin))
|
||||
cursor.execute("COMMIT")
|
||||
|
||||
@readcursor
|
||||
def update_user(self, cursor, username, password, is_admin=False):
|
||||
cursor.execute("UPDATE users SET password=?, admin=? WHERE username=?;",
|
||||
(hash_password(password), is_admin, username))
|
||||
cursor.execute("COMMIT")
|
||||
|
||||
@readcursor
|
||||
def get_user(self, cursor, user):
|
||||
try:
|
||||
column = "id" if type(user) is int else "username"
|
||||
return cursor.execute("SELECT * FROM users WHERE {}=?;".format(column), (user, )).fetchall()[0]
|
||||
except IndexError:
|
||||
raise NotFoundError("User doesn't exist")
|
||||
|
|
|
@ -28,64 +28,46 @@ class NoDataException(Exception):
|
|||
pass
|
||||
|
||||
|
||||
class DuplicateRootException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PysonicLibrary(object):
|
||||
def __init__(self, database):
|
||||
self.db = database
|
||||
|
||||
self.get_libraries = self.db.get_libraries
|
||||
self.get_artists = self.db.get_artists
|
||||
self.get_albums = self.db.get_albums
|
||||
# self.get_song = self.db.get_song
|
||||
# self.get_cover = self.db.get_cover
|
||||
|
||||
self.scanner = PysonicFilesystemScanner(self)
|
||||
logging.info("library ready")
|
||||
|
||||
def update(self):
|
||||
"""
|
||||
Start the library media scanner ands
|
||||
"""
|
||||
self.scanner.init_scan()
|
||||
|
||||
def add_dir(self, dir_path):
|
||||
dir_path = os.path.abspath(os.path.normpath(dir_path))
|
||||
libraries = [i['metadata']['fspath'] for i in self.db.getnodes(-1)]
|
||||
if dir_path in libraries:
|
||||
raise DuplicateRootException("Dir already in library")
|
||||
else:
|
||||
new_root = self.db._addnode(-1, 'New Library', is_dir=True)
|
||||
self.db.update_metadata(new_root['id'], fspath=dir_path)
|
||||
|
||||
#@memoize
|
||||
def get_libraries(self):
|
||||
def add_root_dir(self, path):
|
||||
"""
|
||||
Libraries are top-level nodes
|
||||
The music library consists of a number of root dirs. This adds a new root
|
||||
"""
|
||||
return self.db.getnodes(-1)
|
||||
path = os.path.abspath(os.path.normpath(path))
|
||||
self.db.add_root(path)
|
||||
|
||||
#@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_artists(self, *args, **kwargs):
|
||||
# artists = self.db.get_artists(*args, **kwargs)
|
||||
# for item in artists:
|
||||
# item["parent"] = item["libraryid"]
|
||||
# return artists
|
||||
|
||||
def get_dir(self, dirid):
|
||||
return self.db.getnode(dirid)
|
||||
|
||||
def get_dir_children(self, dirid):
|
||||
return self.db.getnodes(dirid)
|
||||
|
||||
#@memoize
|
||||
def get_albums(self):
|
||||
return self.db.getnodes(*[item["id"] for item in self.get_artists()])
|
||||
|
||||
#@memoize
|
||||
def get_filepath(self, nodeid):
|
||||
parents = [self.db.getnode(nodeid)]
|
||||
while parents[-1]['parent'] != -1:
|
||||
parents.append(self.db.getnode(parents[-1]['parent']))
|
||||
root = parents.pop()
|
||||
parents.reverse()
|
||||
return os.path.join(root['metadata']['fspath'], *[i['name'] for i in parents])
|
||||
|
||||
def get_file_metadata(self, nodeid):
|
||||
return self.db.get_metadata(nodeid)
|
||||
# def get_albums(self, *args, **kwargs):
|
||||
# albums = self.db.get_albums(*args, **kwargs)
|
||||
# for item in albums:
|
||||
# item["parent"] = item["artistid"]
|
||||
# return albums
|
||||
|
||||
def get_artist_info(self, item_id):
|
||||
# artist = self.db.getnode(item_id)
|
||||
#TODO
|
||||
return {"biography": "placeholder biography",
|
||||
"musicBrainzId": "playerholder",
|
||||
"lastFmUrl": "https://www.last.fm/music/Placeholder",
|
||||
|
@ -94,28 +76,23 @@ class PysonicLibrary(object):
|
|||
"largeImageUrl": "",
|
||||
"similarArtists": []}
|
||||
|
||||
def set_starred(self, username, node_id, starred):
|
||||
self.db.set_starred(self.db.get_user(username)["id"], node_id, starred)
|
||||
def get_cover(self, cover_id):
|
||||
cover = self.db.get_cover(cover_id)
|
||||
library = self.db.get_libraries(cover["library"])[0]
|
||||
cover['_fullpath'] = os.path.join(library["path"], cover["path"])
|
||||
return cover
|
||||
|
||||
def get_stars(self, user, user_id):
|
||||
self.db.get_stars()
|
||||
def get_song(self, song_id):
|
||||
song = self.db.get_songs(id=song_id)[0]
|
||||
library = self.db.get_libraries(song["library"])[0]
|
||||
song['_fullpath'] = os.path.join(library["path"], song["file"])
|
||||
return song
|
||||
|
||||
def get_user(self, user):
|
||||
return self.db.get_user(user)
|
||||
def get_playlist(self, playlist_id):
|
||||
playlist_info = self.db.get_playlist(playlist_id)
|
||||
songs = self.db.get_playlist_songs(playlist_id)
|
||||
return (playlist_info, songs)
|
||||
|
||||
def get_starred(self, username):
|
||||
return self.db.get_starred_items(self.db.get_user(username)["id"])
|
||||
|
||||
def get_songs(self, limit=50, shuffle=True):
|
||||
return self.db.getnodes(types=MUSIC_TYPES, limit=limit, order="rand")
|
||||
|
||||
def get_song(self, id=None):
|
||||
if id:
|
||||
return self.db.getnode(id)
|
||||
else:
|
||||
return self.db.getnodes(types=MUSIC_TYPES, limit=1, order="rand")
|
||||
|
||||
def report_transcode(self, item_id, bitrate, num_bytes):
|
||||
assert type(bitrate) is int and bitrate > 0 and bitrate <= 320
|
||||
logging.info("Got transcode report of {} for item {} @ {}".format(num_bytes, item_id, bitrate))
|
||||
self.db.update_metadata(item_id, {"transcoded_{}_size".format(bitrate):int(num_bytes)})
|
||||
def delete_playlist(self, playlist_id):
|
||||
self.db.empty_playlist(playlist_id)
|
||||
self.db.delete_playlist(playlist_id)
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import os
|
||||
import re
|
||||
import logging
|
||||
from contextlib import closing
|
||||
import mimetypes
|
||||
from time import time
|
||||
from threading import Thread
|
||||
from pysonic.types import KNOWN_MIMES, MUSIC_TYPES, MPX_TYPES, FLAC_TYPES, WAV_TYPES
|
||||
from pysonic.types import KNOWN_MIMES, MUSIC_TYPES, MPX_TYPES, FLAC_TYPES, WAV_TYPES, MUSIC_EXTENSIONS, IMAGE_EXTENSIONS, IMAGE_TYPES
|
||||
from mutagen.id3 import ID3
|
||||
from mutagen import MutagenError
|
||||
from mutagen.id3._util import ID3NoHeaderError
|
||||
|
@ -25,151 +26,321 @@ class PysonicFilesystemScanner(object):
|
|||
self.scanner.start()
|
||||
|
||||
def rescan(self):
|
||||
# Perform directory scan
|
||||
logging.warning("Beginning library rescan")
|
||||
"""
|
||||
Perform a full scan of the media library's files
|
||||
"""
|
||||
start = time()
|
||||
for parent in self.library.get_libraries():
|
||||
meta = parent["metadata"]
|
||||
logging.info("Scanning {}".format(meta["fspath"]))
|
||||
logging.warning("Beginning library rescan")
|
||||
for parent in self.library.db.get_libraries():
|
||||
logging.info("Scanning {}".format(parent["path"]))
|
||||
self.scan_root(parent["id"], parent["path"])
|
||||
logging.warning("Rescan complete in %ss", round(time() - start, 3))
|
||||
|
||||
def recurse_dir(path, parent):
|
||||
logging.info("Scanning {}".format(path))
|
||||
# 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
|
||||
def scan_root(self, pid, root):
|
||||
"""
|
||||
Scan a single root the library
|
||||
:param pid: parent ID
|
||||
:param root: absolute path to scan
|
||||
"""
|
||||
logging.warning("Beginning file scan for library %s", pid)
|
||||
root_depth = len(self.split_path(root))
|
||||
for path, dirs, files in os.walk(root):
|
||||
child = self.split_path(path)[root_depth:]
|
||||
# dirid = self.create_or_get_dbdir_tree(pid, child) # dumb table for Subsonic
|
||||
self.scan_dir(pid, root, child, dirs, files)
|
||||
|
||||
# If any size have changed, mark the file to be rescanned
|
||||
for entry in db_entires:
|
||||
finfo = os.stat(os.path.join(path, entry["name"]))
|
||||
if finfo.st_size != entry["size"]:
|
||||
logging.info("{} has changed in size, marking for meta rescan".format(entry["id"]))
|
||||
self.library.db.update_metadata(entry['id'], id3_done=False, size=finfo.st_size)
|
||||
logging.warning("Beginning metadata scan for library %s", pid)
|
||||
self.scan_metadata(pid, root, freshonly=True)
|
||||
|
||||
# Create any nodes not found in the db
|
||||
for create in to_create:
|
||||
new_finfo = os.stat(os.path.join(path, create))
|
||||
new_node = self.library.db.addnode(parent["id"], path, create, size=new_finfo.st_size)
|
||||
logging.info("Added {}".format(os.path.join(path, create)))
|
||||
db_entires.append(new_node)
|
||||
logging.warning("Finished scan for library %s", pid)
|
||||
|
||||
# Delete any db nodes not found on disk
|
||||
for delete in to_delete:
|
||||
logging.info("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"])
|
||||
logging.info("Pruned {}, deleting total of {}".format(node, deleted))
|
||||
def create_or_get_dbdir_tree(self, cursor, pid, path):
|
||||
"""
|
||||
Return the ID of the directory specified by `path`. The path will be created as necessary. This bullshit exists
|
||||
only to serve Subsonic, and can easily be lopped off.
|
||||
:param pid: root parent the path resides in
|
||||
:param path: single-file tree as a list of dir names under the root parent
|
||||
:type path list
|
||||
"""
|
||||
assert path
|
||||
# with closing(self.library.db.db.cursor()) as cursor:
|
||||
parent_id = 0 # 0 indicates a top level item in the library
|
||||
for name in path:
|
||||
parent_id = self.create_or_get_dbdir(cursor, pid, parent_id, name)
|
||||
return parent_id
|
||||
|
||||
for entry in db_entires:
|
||||
if entry["name"] in to_delete:
|
||||
def create_or_get_dbdir(self, cursor, pid, parent_id, name):
|
||||
for row in cursor.execute("SELECT * FROM dirs WHERE library=? and parent=? and name=?",
|
||||
(pid, parent_id, name, )):
|
||||
return row['id']
|
||||
cursor.execute("INSERT INTO dirs (library, parent, name) VALUES (?, ?, ?)", (pid, parent_id, name))
|
||||
return cursor.lastrowid
|
||||
|
||||
def scan_dir(self, pid, root, path, dirs, files):
|
||||
"""
|
||||
Scan a single directory in the library. Actually, this ignores all dirs that don't contain files. Dirs are
|
||||
interpreted as follows:
|
||||
- The library root is ignored
|
||||
- Empty dirs are ignored
|
||||
- Dirs containing files are assumed to be an album
|
||||
- Top level dirs in the library are assumed to be artists
|
||||
- Any dirs not following the above rules are transparently ignored
|
||||
- Files placed in an artist dir is an unhandled edge case TODO
|
||||
- Any files with an image extension in an album dir will be assumed to be the cover regardless of naming
|
||||
- TODO ignore dotfiles/dirs
|
||||
TODO remove all file scanning / statting etc from paths where a db transaction is active (gather data then open)
|
||||
:param pid: parent id
|
||||
:param root: library root path
|
||||
:param path: scan location path, as a list of subdirs within the root
|
||||
:param dirs: dirs in the current path
|
||||
:param files: files in the current path
|
||||
"""
|
||||
# If this is the library root or an empty dir just bail
|
||||
if not path or not files:
|
||||
return
|
||||
# If it is the library root just bail
|
||||
if len(path) == 0:
|
||||
return
|
||||
|
||||
logging.info("In library %s scanning %s", pid, os.path.join(*path))
|
||||
|
||||
# Guess an album from the dir, if possible
|
||||
album = None
|
||||
if len(path) > 1:
|
||||
album = path[-1]
|
||||
|
||||
with closing(self.library.db.db.cursor()) as cursor:
|
||||
artist_id, artist_dirid = self.create_or_get_artist(cursor, pid, path[0])
|
||||
|
||||
album_id = None
|
||||
album_dirid = None
|
||||
if album:
|
||||
album_id, album_dirid = self.create_or_get_album(cursor, pid, path, artist_id)
|
||||
|
||||
libpath = os.path.join(*path)
|
||||
|
||||
new_files = False
|
||||
for fname in files:
|
||||
if not any([fname.endswith(".{}".format(i)) for i in MUSIC_EXTENSIONS]):
|
||||
continue
|
||||
new_files = self.add_music_if_new(cursor, pid, root, album_id, libpath, fname) or new_files
|
||||
|
||||
# Create cover entry TODO we can probably skip this if there were no new audio files?
|
||||
if album_id:
|
||||
for file in files:
|
||||
if not any([file.endswith(".{}".format(i)) for i in IMAGE_EXTENSIONS]):
|
||||
continue
|
||||
if int(entry['isdir']): # 1 means dir
|
||||
recurse_dir(os.path.join(path, entry["name"]), entry)
|
||||
fpath = os.path.join(libpath, file)
|
||||
cursor.execute("SELECT id FROM covers WHERE path=?", (fpath, ))
|
||||
if not cursor.fetchall():
|
||||
# We leave most fields blank now and return later
|
||||
cursor.execute("INSERT INTO covers (library, path) VALUES (?, ?);", (pid, fpath, ))
|
||||
cursor.execute("UPDATE albums SET coverid=? WHERE id=?", (cursor.lastrowid, album_id))
|
||||
break
|
||||
|
||||
# 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 = album_dir["metadata"]
|
||||
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)
|
||||
logging.info("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)
|
||||
logging.info("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)
|
||||
logging.info("Adding simple metadata for {} #{}".format(artist, artist_dir["id"]))
|
||||
if title in ["cover.jpg", "cover.png"] and 'cover' not in album_meta:
|
||||
# // add cover art
|
||||
self.library.db.update_metadata(album_dir["id"], cover=track_file["id"])
|
||||
logging.info("added cover for {}".format(album_dir['id']))
|
||||
if new_files: # Commit after each dir IF audio files were found. no audio == dump the artist
|
||||
cursor.execute("COMMIT")
|
||||
|
||||
if track_file["type"] is None:
|
||||
fpath = self.library.get_filepath(track_file['id'])
|
||||
ftype, extra = mimetypes.guess_type(fpath)
|
||||
def add_music_if_new(self, cursor, pid, root_dir, album_id, fdir, fname):
|
||||
fpath = os.path.join(fdir, fname)
|
||||
cursor.execute("SELECT id FROM songs WHERE file=?", (fpath, ))
|
||||
if not cursor.fetchall():
|
||||
# We leave most fields blank now and return later
|
||||
# TODO probably not here but track file sizes and mark them for rescan on change
|
||||
cursor.execute("INSERT INTO songs (library, albumid, file, size, title) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(pid,
|
||||
album_id,
|
||||
fpath,
|
||||
os.stat(os.path.join(root_dir, fpath)).st_size,
|
||||
fname, ))
|
||||
return True
|
||||
return False
|
||||
|
||||
if ftype in KNOWN_MIMES:
|
||||
self.library.db.update_metadata(track_file["id"], type=ftype)
|
||||
logging.info("added type {} for {}".format(ftype, track_file['id']))
|
||||
else:
|
||||
logging.warning("Ignoring unreadable file at {}, unknown ftype ({}, {})"
|
||||
.format(fpath, ftype, extra))
|
||||
#
|
||||
#
|
||||
#
|
||||
# Add advanced id3 / media info 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 = album_dir["metadata"]
|
||||
for track_file in self.library.db.getnodes(album_dir["id"]):
|
||||
track_meta = track_file['metadata']
|
||||
title = track_file["name"]
|
||||
fpath = self.library.get_filepath(track_file["id"])
|
||||
if track_meta.get('id3_done', False) or track_file.get("type", None) not in MUSIC_TYPES:
|
||||
continue
|
||||
tags = {'id3_done': True}
|
||||
try:
|
||||
audio = None
|
||||
if track_file.get("type", None) in MPX_TYPES:
|
||||
audio = MP3(fpath)
|
||||
if audio.info.sketchy:
|
||||
logging.warning("media reported as sketchy: %s", fpath)
|
||||
elif track_file.get("type", None) in FLAC_TYPES:
|
||||
audio = FLAC(fpath)
|
||||
else:
|
||||
audio = ID3(fpath)
|
||||
# print(audio.pprint())
|
||||
try:
|
||||
tags["media_length"] = int(audio.info.length)
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
try:
|
||||
bitrate = int(audio.info.bitrate)
|
||||
tags["media_bitrate"] = bitrate
|
||||
tags["media_kbitrate"] = int(bitrate / 1024)
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
try:
|
||||
tags["track"] = int(RE_NUMBERS.findall(''.join(audio['TRCK'].text))[0])
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
try:
|
||||
tags["id3_artist"] = ''.join(audio['TPE1'].text)
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
tags["id3_album"] = ''.join(audio['TALB'].text)
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
tags["id3_title"] = ''.join(audio['TIT2'].text)
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
tags["id3_year"] = audio['TDRC'].text[0].year
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
logging.info("got all media info from %s", fpath)
|
||||
except ID3NoHeaderError:
|
||||
pass
|
||||
except MutagenError as m:
|
||||
logging.error("failed to read audio information: %s", m)
|
||||
continue
|
||||
self.library.db.update_metadata(track_file["id"], **tags)
|
||||
def create_or_get_artist(self, cursor, pid, dirname):
|
||||
"""
|
||||
Retrieve, creating if necessary, directory information about an artist. Return tuple contains the artist's ID
|
||||
and the dir id associated with the artist.
|
||||
:param cursor: sqlite cursor to use
|
||||
:param pid: root parent id we're working int
|
||||
:param dirname: name of the artist dir
|
||||
:return tuple:
|
||||
"""
|
||||
artist_dirid = self.create_or_get_dbdir_tree(cursor, pid, [dirname])
|
||||
cursor.execute("SELECT * FROM artists WHERE dir = ?", (artist_dirid, ))
|
||||
row = cursor.fetchone()
|
||||
artist_id = None
|
||||
if row:
|
||||
artist_id = row['id']
|
||||
else:
|
||||
cursor.execute("INSERT INTO artists (libraryid, dir, name) VALUES (?, ?, ?)",
|
||||
(pid, artist_dirid, dirname))
|
||||
artist_id = cursor.lastrowid
|
||||
return artist_id, artist_dirid
|
||||
|
||||
logging.warning("Library scan complete in {}s".format(round(time() - start, 2)))
|
||||
def create_or_get_album(self, cursor, pid, dirnames, artist_id):
|
||||
"""
|
||||
Retrieve, creating if necessary, directory information about an album. Return tuple contains the albums's ID
|
||||
and the dir id associated with the album.
|
||||
:param cursor: sqlite cursor to use
|
||||
:param pid: root parent id we're working int
|
||||
:param dirnames: list of directories from the root to the album dir
|
||||
:param artist_id: id of the artist the album belongs to
|
||||
:return tuple:
|
||||
"""
|
||||
album_dirid = self.create_or_get_dbdir_tree(cursor, pid, dirnames)
|
||||
cursor.execute("SELECT * FROM albums WHERE artistid = ? AND dir = ?", (artist_id, album_dirid, ))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
album_id = row['id']
|
||||
else:
|
||||
cursor.execute("INSERT INTO albums (artistid, dir, name, added) VALUES (?, ?, ?, ?)",
|
||||
(artist_id, album_dirid, dirnames[-1], int(time())))
|
||||
album_id = cursor.lastrowid
|
||||
|
||||
return album_id, album_dirid
|
||||
|
||||
def split_path(self, path):
|
||||
"""
|
||||
Given a path like /foo/bar, return ['foo', 'bar']
|
||||
"""
|
||||
parts = []
|
||||
head = path
|
||||
while True:
|
||||
head, tail = os.path.split(head)
|
||||
if tail:
|
||||
parts.append(tail)
|
||||
else:
|
||||
break
|
||||
parts.reverse()
|
||||
return parts
|
||||
|
||||
def scan_metadata(self, pid, root, freshonly=False):
|
||||
"""
|
||||
Iterate through files in the library and update metadata
|
||||
:param freshonly: only update metadata on files that have never been scanned before
|
||||
"""
|
||||
q = "SELECT * FROM songs "
|
||||
if freshonly:
|
||||
q += "WHERE lastscan = -1 "
|
||||
q += "ORDER BY albumid"
|
||||
|
||||
#TODO scraping ID3 etc from the media files can be parallelized
|
||||
with closing(self.library.db.db.cursor()) as reader, \
|
||||
closing(self.library.db.db.cursor()) as writer:
|
||||
processed = 0 # commit batching counter
|
||||
for row in reader.execute(q):
|
||||
# Find meta, bail if the file was unreadable
|
||||
# TODO file metadata scanning could be done in parallel
|
||||
meta = self.scan_file_metadata(os.path.join(root, row['file']))
|
||||
if not meta:
|
||||
continue
|
||||
# Meta may have additional keys that arent in the songs table, omit them
|
||||
song_attrs = ["title", "lastscan", "format", "length", "bitrate", "track", "year"]
|
||||
song_meta = {k: v for k, v in meta.items() if k in song_attrs}
|
||||
|
||||
# Update the song row
|
||||
q = "UPDATE songs SET "
|
||||
params = []
|
||||
for key, value in song_meta.items():
|
||||
q += "{}=?, ".format(key)
|
||||
params.append(value)
|
||||
q += "lastscan=? WHERE id=?"
|
||||
params += [int(time()), row["id"]]
|
||||
writer.execute(q, params)
|
||||
|
||||
# If the metadata has an artist or album name, update the relevant items
|
||||
# TODO ignore metadata if theyre blank
|
||||
if "album" in meta:
|
||||
writer.execute("UPDATE albums SET name=? WHERE id=?", (meta["album"], row["albumid"]))
|
||||
if "artist" in meta:
|
||||
album = writer.execute("SELECT artistid FROM albums WHERE id=?", (row['albumid'], )).fetchone()
|
||||
if album:
|
||||
writer.execute("UPDATE artists SET name=? WHERE id=?", (meta["artist"], album["artistid"]))
|
||||
if "genre" in meta:
|
||||
genre_name = meta["genre"].strip()
|
||||
if genre_name:
|
||||
genre_id = self.get_genre_id(writer, meta["genre"])
|
||||
writer.execute("UPDATE songs SET genre=? WHERE id=?", (genre_id, row['id']))
|
||||
|
||||
# Commit every 50 items
|
||||
processed += 1
|
||||
if processed > 50:
|
||||
writer.execute("COMMIT")
|
||||
processed = 0
|
||||
|
||||
if processed != 0:
|
||||
writer.execute("COMMIT")
|
||||
|
||||
def get_genre_id(self, cursor, genre_name):
|
||||
genre_name = genre_name.title().strip() # normalize
|
||||
for row in cursor.execute("SELECT * FROM genres WHERE name=?", (genre_name, )):
|
||||
return row['id']
|
||||
cursor.execute("INSERT INTO genres (name) VALUES (?)", (genre_name, ))
|
||||
return cursor.lastrowid
|
||||
|
||||
def scan_file_metadata(self, fpath):
|
||||
"""
|
||||
Scan the file for metadata.
|
||||
:param fpath: path to the file to scan
|
||||
"""
|
||||
ftype, extra = mimetypes.guess_type(fpath)
|
||||
|
||||
if ftype in MUSIC_TYPES:
|
||||
return self.scan_mutagen_metadata(fpath, ftype)
|
||||
|
||||
def scan_mutagen_metadata(self, fpath, ftype):
|
||||
meta = {"format": ftype}
|
||||
try:
|
||||
# Open file with mutagen
|
||||
if ftype in MPX_TYPES:
|
||||
audio = MP3(fpath)
|
||||
if audio.info.sketchy:
|
||||
logging.warning("media reported as sketchy: %s", fpath)
|
||||
elif ftype in FLAC_TYPES:
|
||||
audio = FLAC(fpath)
|
||||
else:
|
||||
audio = ID3(fpath)
|
||||
except ID3NoHeaderError:
|
||||
return
|
||||
except MutagenError as m:
|
||||
logging.error("failed to read audio information: %s", m)
|
||||
return
|
||||
|
||||
try:
|
||||
meta["length"] = int(audio.info.length)
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
try:
|
||||
bitrate = int(audio.info.bitrate)
|
||||
meta["bitrate"] = bitrate
|
||||
# meta["kbitrate"] = int(bitrate / 1024)
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
try:
|
||||
meta["track"] = int(RE_NUMBERS.findall(''.join(audio['TRCK'].text))[0])
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
try:
|
||||
meta["artist"] = ''.join(audio['TPE1'].text)
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
meta["album"] = ''.join(audio['TALB'].text)
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
meta["title"] = ''.join(audio['TIT2'].text)
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
meta["year"] = audio['TDRC'].text[0].year
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
try:
|
||||
meta["genre"] = audio['TCON'].text[0]
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
logging.info("got all media info from %s", fpath)
|
||||
|
||||
return meta
|
||||
|
|
|
@ -1,7 +1,16 @@
|
|||
|
||||
KNOWN_MIMES = ["audio/mpeg", "audio/flac", "audio/x-wav", "image/jpeg", "image/png"]
|
||||
|
||||
MUSIC_TYPES = ["audio/mpeg", "audio/flac", "audio/x-wav"]
|
||||
|
||||
MPX_TYPES = ["audio/mpeg"]
|
||||
|
||||
FLAC_TYPES = ["audio/flac"]
|
||||
|
||||
WAV_TYPES = ["audio/x-wav"]
|
||||
|
||||
IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif"]
|
||||
|
||||
IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif"]
|
||||
|
||||
MUSIC_EXTENSIONS = ["mp3", "flac", "wav"]
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
beautifulsoup4==4.6.0
|
||||
cheroot==5.8.3
|
||||
CherryPy==11.0.0
|
||||
lxml==3.8.0
|
||||
mutagen==1.38
|
||||
portend==2.1.2
|
||||
pytz==2017.2
|
||||
six==1.10.0
|
||||
tempora==1.8
|
||||
bs4==0.0.1
|
||||
cheroot==6.0.0
|
||||
CherryPy==14.0.1
|
||||
lxml==4.2.1
|
||||
more-itertools==4.1.0
|
||||
mutagen==1.40.0
|
||||
portend==2.2
|
||||
pysonic==0.0.1
|
||||
pytz==2018.3
|
||||
six==1.11.0
|
||||
tempora==1.11
|
||||
|
|
Loading…
Reference in New Issue