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