1# -*- coding: utf-8 -*- 2# This file is part of beets. 3# Copyright 2016, Adrian Sampson. 4# 5# Permission is hereby granted, free of charge, to any person obtaining 6# a copy of this software and associated documentation files (the 7# "Software"), to deal in the Software without restriction, including 8# without limitation the rights to use, copy, modify, merge, publish, 9# distribute, sublicense, and/or sell copies of the Software, and to 10# permit persons to whom the Software is furnished to do so, subject to 11# the following conditions: 12# 13# The above copyright notice and this permission notice shall be 14# included in all copies or substantial portions of the Software. 15 16"""A Web interface to beets.""" 17from __future__ import division, absolute_import, print_function 18 19from beets.plugins import BeetsPlugin 20from beets import ui 21from beets import util 22import beets.library 23import flask 24from flask import g 25from werkzeug.routing import BaseConverter, PathConverter 26import os 27from unidecode import unidecode 28import json 29import base64 30 31 32# Utilities. 33 34def _rep(obj, expand=False): 35 """Get a flat -- i.e., JSON-ish -- representation of a beets Item or 36 Album object. For Albums, `expand` dictates whether tracks are 37 included. 38 """ 39 out = dict(obj) 40 41 if isinstance(obj, beets.library.Item): 42 if app.config.get('INCLUDE_PATHS', False): 43 out['path'] = util.displayable_path(out['path']) 44 else: 45 del out['path'] 46 47 # Filter all bytes attributes and convert them to strings. 48 for key, value in out.items(): 49 if isinstance(out[key], bytes): 50 out[key] = base64.b64encode(value).decode('ascii') 51 52 # Get the size (in bytes) of the backing file. This is useful 53 # for the Tomahawk resolver API. 54 try: 55 out['size'] = os.path.getsize(util.syspath(obj.path)) 56 except OSError: 57 out['size'] = 0 58 59 return out 60 61 elif isinstance(obj, beets.library.Album): 62 del out['artpath'] 63 if expand: 64 out['items'] = [_rep(item) for item in obj.items()] 65 return out 66 67 68def json_generator(items, root, expand=False): 69 """Generator that dumps list of beets Items or Albums as JSON 70 71 :param root: root key for JSON 72 :param items: list of :class:`Item` or :class:`Album` to dump 73 :param expand: If true every :class:`Album` contains its items in the json 74 representation 75 :returns: generator that yields strings 76 """ 77 yield '{"%s":[' % root 78 first = True 79 for item in items: 80 if first: 81 first = False 82 else: 83 yield ',' 84 yield json.dumps(_rep(item, expand=expand)) 85 yield ']}' 86 87 88def is_expand(): 89 """Returns whether the current request is for an expanded response.""" 90 91 return flask.request.args.get('expand') is not None 92 93 94def resource(name): 95 """Decorates a function to handle RESTful HTTP requests for a resource. 96 """ 97 def make_responder(retriever): 98 def responder(ids): 99 entities = [retriever(id) for id in ids] 100 entities = [entity for entity in entities if entity] 101 102 if len(entities) == 1: 103 return flask.jsonify(_rep(entities[0], expand=is_expand())) 104 elif entities: 105 return app.response_class( 106 json_generator(entities, root=name), 107 mimetype='application/json' 108 ) 109 else: 110 return flask.abort(404) 111 responder.__name__ = 'get_{0}'.format(name) 112 return responder 113 return make_responder 114 115 116def resource_query(name): 117 """Decorates a function to handle RESTful HTTP queries for resources. 118 """ 119 def make_responder(query_func): 120 def responder(queries): 121 return app.response_class( 122 json_generator( 123 query_func(queries), 124 root='results', expand=is_expand() 125 ), 126 mimetype='application/json' 127 ) 128 responder.__name__ = 'query_{0}'.format(name) 129 return responder 130 return make_responder 131 132 133def resource_list(name): 134 """Decorates a function to handle RESTful HTTP request for a list of 135 resources. 136 """ 137 def make_responder(list_all): 138 def responder(): 139 return app.response_class( 140 json_generator(list_all(), root=name, expand=is_expand()), 141 mimetype='application/json' 142 ) 143 responder.__name__ = 'all_{0}'.format(name) 144 return responder 145 return make_responder 146 147 148def _get_unique_table_field_values(model, field, sort_field): 149 """ retrieve all unique values belonging to a key from a model """ 150 if field not in model.all_keys() or sort_field not in model.all_keys(): 151 raise KeyError 152 with g.lib.transaction() as tx: 153 rows = tx.query('SELECT DISTINCT "{0}" FROM "{1}" ORDER BY "{2}"' 154 .format(field, model._table, sort_field)) 155 return [row[0] for row in rows] 156 157 158class IdListConverter(BaseConverter): 159 """Converts comma separated lists of ids in urls to integer lists. 160 """ 161 162 def to_python(self, value): 163 ids = [] 164 for id in value.split(','): 165 try: 166 ids.append(int(id)) 167 except ValueError: 168 pass 169 return ids 170 171 def to_url(self, value): 172 return ','.join(value) 173 174 175class QueryConverter(PathConverter): 176 """Converts slash separated lists of queries in the url to string list. 177 """ 178 179 def to_python(self, value): 180 return value.split('/') 181 182 def to_url(self, value): 183 return ','.join(value) 184 185 186class EverythingConverter(PathConverter): 187 regex = '.*?' 188 189 190# Flask setup. 191 192app = flask.Flask(__name__) 193app.url_map.converters['idlist'] = IdListConverter 194app.url_map.converters['query'] = QueryConverter 195app.url_map.converters['everything'] = EverythingConverter 196 197 198@app.before_request 199def before_request(): 200 g.lib = app.config['lib'] 201 202 203# Items. 204 205@app.route('/item/<idlist:ids>') 206@resource('items') 207def get_item(id): 208 return g.lib.get_item(id) 209 210 211@app.route('/item/') 212@app.route('/item/query/') 213@resource_list('items') 214def all_items(): 215 return g.lib.items() 216 217 218@app.route('/item/<int:item_id>/file') 219def item_file(item_id): 220 item = g.lib.get_item(item_id) 221 222 # On Windows under Python 2, Flask wants a Unicode path. On Python 3, it 223 # *always* wants a Unicode path. 224 if os.name == 'nt': 225 item_path = util.syspath(item.path) 226 else: 227 item_path = util.py3_path(item.path) 228 229 try: 230 unicode_item_path = util.text_string(item.path) 231 except (UnicodeDecodeError, UnicodeEncodeError): 232 unicode_item_path = util.displayable_path(item.path) 233 234 base_filename = os.path.basename(unicode_item_path) 235 try: 236 # Imitate http.server behaviour 237 base_filename.encode("latin-1", "strict") 238 except UnicodeEncodeError: 239 safe_filename = unidecode(base_filename) 240 else: 241 safe_filename = base_filename 242 243 response = flask.send_file( 244 item_path, 245 as_attachment=True, 246 attachment_filename=safe_filename 247 ) 248 response.headers['Content-Length'] = os.path.getsize(item_path) 249 return response 250 251 252@app.route('/item/query/<query:queries>') 253@resource_query('items') 254def item_query(queries): 255 return g.lib.items(queries) 256 257 258@app.route('/item/path/<everything:path>') 259def item_at_path(path): 260 query = beets.library.PathQuery('path', path.encode('utf-8')) 261 item = g.lib.items(query).get() 262 if item: 263 return flask.jsonify(_rep(item)) 264 else: 265 return flask.abort(404) 266 267 268@app.route('/item/values/<string:key>') 269def item_unique_field_values(key): 270 sort_key = flask.request.args.get('sort_key', key) 271 try: 272 values = _get_unique_table_field_values(beets.library.Item, key, 273 sort_key) 274 except KeyError: 275 return flask.abort(404) 276 return flask.jsonify(values=values) 277 278 279# Albums. 280 281@app.route('/album/<idlist:ids>') 282@resource('albums') 283def get_album(id): 284 return g.lib.get_album(id) 285 286 287@app.route('/album/') 288@app.route('/album/query/') 289@resource_list('albums') 290def all_albums(): 291 return g.lib.albums() 292 293 294@app.route('/album/query/<query:queries>') 295@resource_query('albums') 296def album_query(queries): 297 return g.lib.albums(queries) 298 299 300@app.route('/album/<int:album_id>/art') 301def album_art(album_id): 302 album = g.lib.get_album(album_id) 303 if album and album.artpath: 304 return flask.send_file(album.artpath.decode()) 305 else: 306 return flask.abort(404) 307 308 309@app.route('/album/values/<string:key>') 310def album_unique_field_values(key): 311 sort_key = flask.request.args.get('sort_key', key) 312 try: 313 values = _get_unique_table_field_values(beets.library.Album, key, 314 sort_key) 315 except KeyError: 316 return flask.abort(404) 317 return flask.jsonify(values=values) 318 319 320# Artists. 321 322@app.route('/artist/') 323def all_artists(): 324 with g.lib.transaction() as tx: 325 rows = tx.query("SELECT DISTINCT albumartist FROM albums") 326 all_artists = [row[0] for row in rows] 327 return flask.jsonify(artist_names=all_artists) 328 329 330# Library information. 331 332@app.route('/stats') 333def stats(): 334 with g.lib.transaction() as tx: 335 item_rows = tx.query("SELECT COUNT(*) FROM items") 336 album_rows = tx.query("SELECT COUNT(*) FROM albums") 337 return flask.jsonify({ 338 'items': item_rows[0][0], 339 'albums': album_rows[0][0], 340 }) 341 342 343# UI. 344 345@app.route('/') 346def home(): 347 return flask.render_template('index.html') 348 349 350# Plugin hook. 351 352class WebPlugin(BeetsPlugin): 353 def __init__(self): 354 super(WebPlugin, self).__init__() 355 self.config.add({ 356 'host': u'127.0.0.1', 357 'port': 8337, 358 'cors': '', 359 'cors_supports_credentials': False, 360 'reverse_proxy': False, 361 'include_paths': False, 362 }) 363 364 def commands(self): 365 cmd = ui.Subcommand('web', help=u'start a Web interface') 366 cmd.parser.add_option(u'-d', u'--debug', action='store_true', 367 default=False, help=u'debug mode') 368 369 def func(lib, opts, args): 370 args = ui.decargs(args) 371 if args: 372 self.config['host'] = args.pop(0) 373 if args: 374 self.config['port'] = int(args.pop(0)) 375 376 app.config['lib'] = lib 377 # Normalizes json output 378 app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False 379 380 app.config['INCLUDE_PATHS'] = self.config['include_paths'] 381 382 # Enable CORS if required. 383 if self.config['cors']: 384 self._log.info(u'Enabling CORS with origin: {0}', 385 self.config['cors']) 386 from flask_cors import CORS 387 app.config['CORS_ALLOW_HEADERS'] = "Content-Type" 388 app.config['CORS_RESOURCES'] = { 389 r"/*": {"origins": self.config['cors'].get(str)} 390 } 391 CORS( 392 app, 393 supports_credentials=self.config[ 394 'cors_supports_credentials' 395 ].get(bool) 396 ) 397 398 # Allow serving behind a reverse proxy 399 if self.config['reverse_proxy']: 400 app.wsgi_app = ReverseProxied(app.wsgi_app) 401 402 # Start the web application. 403 app.run(host=self.config['host'].as_str(), 404 port=self.config['port'].get(int), 405 debug=opts.debug, threaded=True) 406 cmd.func = func 407 return [cmd] 408 409 410class ReverseProxied(object): 411 '''Wrap the application in this middleware and configure the 412 front-end server to add these headers, to let you quietly bind 413 this to a URL other than / and to an HTTP scheme that is 414 different than what is used locally. 415 416 In nginx: 417 location /myprefix { 418 proxy_pass http://192.168.0.1:5001; 419 proxy_set_header Host $host; 420 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 421 proxy_set_header X-Scheme $scheme; 422 proxy_set_header X-Script-Name /myprefix; 423 } 424 425 From: http://flask.pocoo.org/snippets/35/ 426 427 :param app: the WSGI application 428 ''' 429 def __init__(self, app): 430 self.app = app 431 432 def __call__(self, environ, start_response): 433 script_name = environ.get('HTTP_X_SCRIPT_NAME', '') 434 if script_name: 435 environ['SCRIPT_NAME'] = script_name 436 path_info = environ['PATH_INFO'] 437 if path_info.startswith(script_name): 438 environ['PATH_INFO'] = path_info[len(script_name):] 439 440 scheme = environ.get('HTTP_X_SCHEME', '') 441 if scheme: 442 environ['wsgi.url_scheme'] = scheme 443 return self.app(environ, start_response) 444