diff --git a/pysonic/api.py b/pysonic/api.py
index 050240c..936f9e1 100644
--- a/pysonic/api.py
+++ b/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 {{ value }} 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()
diff --git a/pysonic/apilib.py b/pysonic/apilib.py
new file mode 100644
index 0000000..26f6674
--- /dev/null
+++ b/pysonic/apilib.py
@@ -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 {{ value }} 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()
diff --git a/pysonic/daemon.py b/pysonic/daemon.py
index 5595138..c45b7f1 100644
--- a/pysonic/daemon.py
+++ b/pysonic/daemon.py
@@ -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()
diff --git a/pysonic/database.py b/pysonic/database.py
index 96f4d46..4c57ef7 100644
--- a/pysonic/database.py
+++ b/pysonic/database.py
@@ -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")
diff --git a/pysonic/library.py b/pysonic/library.py
index 614e319..92c8d28 100644
--- a/pysonic/library.py
+++ b/pysonic/library.py
@@ -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)
diff --git a/pysonic/scanner.py b/pysonic/scanner.py
index 151df54..add1b0c 100644
--- a/pysonic/scanner.py
+++ b/pysonic/scanner.py
@@ -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
diff --git a/pysonic/types.py b/pysonic/types.py
index 3f92856..e6a11a9 100644
--- a/pysonic/types.py
+++ b/pysonic/types.py
@@ -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"]
diff --git a/requirements.txt b/requirements.txt
index 5777cbf..2d29bb7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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