You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
608 lines
24 KiB
608 lines
24 KiB
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 |
|
|
|
|
|
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): |
|
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() |
|
|
|
|
|
class PysonicApi(object): |
|
def __init__(self, db, library, options): |
|
self.db = db |
|
self.library = library |
|
self.options = options |
|
|
|
@cherrypy.expose |
|
@formatresponse |
|
def ping_view(self, **kwargs): |
|
# Called when the app hits the "test connection" server option |
|
return ApiResponse() |
|
|
|
@cherrypy.expose |
|
@formatresponse |
|
def getLicense_view(self, **kwargs): |
|
# Called after ping.view |
|
response = ApiResponse() |
|
response.add_child("license", |
|
valid="true", |
|
email="admin@localhost", |
|
licenseExpires="2100-01-01T00:00:00.000Z", |
|
trialExpires="2100-01-01T01:01:00.000Z") |
|
return response |
|
|
|
@cherrypy.expose |
|
@formatresponse |
|
def getMusicFolders_view(self, **kwargs): |
|
response = ApiResponse() |
|
response.add_child("musicFolders") |
|
for folder in self.library.get_libraries(): |
|
response.add_child("musicFolder", _parent="musicFolders", id=folder["id"], name=folder["name"]) |
|
return response |
|
|
|
@cherrypy.expose |
|
@formatresponse |
|
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 artists: |
|
if artist["name"][0].lower() in letter: |
|
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() |
|
if type == "random": |
|
shuffle(albums) |
|
elif type == "alphabeticalByName": |
|
albums.sort(key=lambda item: item.get("id3_album", item["album"] if item["album"] else "zzzzzUnsortable")) |
|
else: |
|
raise NotImplemented() |
|
albumset = albums[0 + int(offset):int(size) + int(offset)] |
|
|
|
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"]), |
|
# 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 |
|
|
|
@cherrypy.expose |
|
@formatresponse |
|
def getMusicDirectory_view(self, id, **kwargs): |
|
""" |
|
List an artist dir |
|
""" |
|
dir_id = int(id) |
|
|
|
cherrypy.response.headers['Content-Type'] = 'text/xml; charset=utf-8' |
|
|
|
response = ApiResponse() |
|
response.add_child("directory") |
|
|
|
dirtype, dirinfo, entity = self.library.db.get_musicdir(dirid=dir_id) |
|
|
|
from pprint import pprint |
|
pprint(dirinfo) |
|
pprint(entity) |
|
|
|
response.set_attrs(_path="directory", name=entity['name'], id=entity['id'], |
|
parent=dirinfo['parent'], playCount=420) |
|
|
|
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'] |
|
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) |
|
|
|
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: |
|
""" |
|
raise Exception("stop using this") |
|
child = dict(id=item["id"], |
|
parent=item["id"], |
|
isDir="true" if "file" not in item 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 |
|
song = self.library.get_song(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'])) |
|
cherrypy.response.headers['X-Content-Kbitrate'] = str(to_bitrate) |
|
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: |
|
data = f.read(16 * 1024) |
|
if not data: |
|
break |
|
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])) |
|
print(fpath) |
|
transcode_args = ["ffmpeg", "-i", fpath, "-map", "0:0", "-b:a", |
|
"{}k".format(to_bitrate), |
|
"-v", "0", "-f", "mp3", "-"] |
|
logging.info(' '.join(transcode_args)) |
|
proc = subprocess.Popen(transcode_args, stdin=subprocess.PIPE, |
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
|
|
|
def content(proc): |
|
length = 0 |
|
# completed = False |
|
start = time() |
|
try: |
|
while True: |
|
data = proc.stdout.read(16 * 1024) |
|
if not data: |
|
# completed = True |
|
break |
|
yield data |
|
length += len(data) |
|
finally: |
|
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) |
|
else: |
|
logging.error("transcode of {} exited with code {} after {}s".format(id, proc.returncode, |
|
int(time() - start))) |
|
|
|
def stopit(proc): |
|
try: |
|
proc.wait(timeout=90) |
|
except subprocess.TimeoutExpired: |
|
logging.warning("killing timed-out transcoder") |
|
proc.kill() |
|
proc.wait() |
|
|
|
Thread(target=stopit, args=(proc, )).start() |
|
|
|
return content(proc) |
|
stream_view._cp_config = {'response.stream': True} |
|
|
|
@cherrypy.expose |
|
def getCoverArt_view(self, id, **kwargs): |
|
cover = self.library.get_cover(id) |
|
fpath = cover["_fullpath"] |
|
type2ct = { |
|
'jpg': 'image/jpeg', |
|
'png': 'image/png', |
|
'gif': 'image/gif' |
|
} |
|
cherrypy.response.headers['Content-Type'] = type2ct[fpath[-3:]] |
|
|
|
def content(): |
|
total = 0 |
|
with open(fpath, "rb") as f: |
|
while True: |
|
data = f.read(8192) |
|
if not data: |
|
break |
|
total += len(data) |
|
yield data |
|
logging.info("\nSent {} bytes for {}".format(total, fpath)) |
|
return content() |
|
|
|
getCoverArt_view._cp_config = {'response.stream': True} |
|
|
|
@cherrypy.expose |
|
@formatresponse |
|
def getArtistInfo_view(self, id, includeNotPresent="true", **kwargs): |
|
info = self.library.get_artist_info(id) |
|
response = ApiResponse() |
|
response.add_child("artistInfo") |
|
response.set_attrs("artistInfo", **info) |
|
return response |
|
|
|
@cherrypy.expose |
|
@formatresponse |
|
def getUser_view(self, username, **kwargs): |
|
user = {} if self.options.disable_auth else self.library.db.get_user(cherrypy.request.login) |
|
response = ApiResponse() |
|
response.add_child("user", |
|
username=user["username"], |
|
email=user["email"], |
|
scrobblingEnabled="false", |
|
adminRole="true" if user["admin"] else "false", |
|
settingsRole="false", |
|
downloadRole="true", |
|
uploadRole="false", |
|
playlistRole="true", |
|
coverArtRole="false", |
|
commentRole="false", |
|
podcastRole="false", |
|
streamRole="true", |
|
jukeboxRole="false", |
|
shareRole="true", |
|
videoConversionRole="false", |
|
avatarLastChanged="2017-08-07T20:16:24.596Z", |
|
folder=0) |
|
return response |
|
|
|
@cherrypy.expose |
|
@formatresponse |
|
def star_view(self, id, **kwargs): |
|
self.library.set_starred(cherrypy.request.login, int(id), starred=True) |
|
return ApiResponse() |
|
|
|
@cherrypy.expose |
|
@formatresponse |
|
def unstar_view(self, id, **kwargs): |
|
self.library.set_starred(cherrypy.request.login, int(id), starred=False) |
|
return ApiResponse() |
|
|
|
@cherrypy.expose |
|
@formatresponse |
|
def getStarred_view(self, **kwargs): |
|
children = self.library.get_starred(cherrypy.request.login) |
|
response = ApiResponse() |
|
response.add_child("starred") |
|
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="starred", **self.render_node(item, item_meta, {}, {})) |
|
return response |
|
|
|
@cherrypy.expose |
|
@formatresponse |
|
def getRandomSongs_view(self, size=50, genre=None, fromYear=0, toYear=0, **kwargs): |
|
""" |
|
Get a playlist of random songs |
|
:param genre: genre name to find songs under |
|
:type genre: str |
|
""" |
|
response = ApiResponse() |
|
response.add_child("randomSongs") |
|
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 |
|
@formatresponse |
|
def getGenres_view(self, **kwargs): |
|
response = ApiResponse() |
|
response.add_child("genres") |
|
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 |
|
@formatresponse |
|
def scrobble_view(self, id, submission, **kwargs): |
|
""" |
|
:param id: song id being played |
|
: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 |
|
return ApiResponse() |
|
|
|
@cherrypy.expose |
|
@formatresponse |
|
def search2_view(self, query, artistCount, albumCount, songCount, **kwargs): |
|
response = ApiResponse() |
|
response.add_child("searchResult2") |
|
|
|
artistCount = int(artistCount) |
|
albumCount = int(albumCount) |
|
songCount = int(songCount) |
|
|
|
query = query.replace("*", "") # TODO handle this |
|
|
|
artists = 0 |
|
for item in self.library.get_artists(): |
|
if query in item["name"].lower(): |
|
response.add_child("artist", _parent="searchResult2", id=item["id"], name=item["name"]) |
|
artists += 1 |
|
if artists >= artistCount: |
|
break |
|
|
|
# TODO make this more efficient |
|
albums = 0 |
|
for item in self.library.get_artists(): |
|
if query in item["name"].lower(): |
|
response.add_child("album", _parent="searchResult2", **self.render_node(item, item["metadata"], {}, {})) |
|
albums += 1 |
|
if albums >= albumCount: |
|
break |
|
|
|
# TODO make this more efficient |
|
songs = 0 |
|
for item in self.library.get_songs(limit=9999999, shuffle=False): |
|
if query in item["name"].lower(): |
|
response.add_child("song", _parent="searchResult2", **self.render_node(item, item["metadata"], {}, {})) |
|
songs += 1 |
|
if songs > songCount: |
|
break |
|
|
|
return response |
|
|
|
@cherrypy.expose |
|
@formatresponse |
|
def setRating_view(self, id, rating): |
|
# rating is 1-5 |
|
pass
|
|
|