update Beets

This commit is contained in:
AdeHub
2024-08-24 16:44:41 +12:00
parent a63098a919
commit 046d4d82b4
116 changed files with 17353 additions and 9964 deletions
+179 -147
View File
@@ -14,21 +14,22 @@
"""A Web interface to beets."""
from beets.plugins import BeetsPlugin
from beets import ui
from beets import util
import beets.library
import base64
import json
import os
import flask
from flask import g, jsonify
from werkzeug.routing import BaseConverter, PathConverter
import os
from unidecode import unidecode
import json
import base64
from werkzeug.routing import BaseConverter, PathConverter
import beets.library
from beets import ui, util
from beets.plugins import BeetsPlugin
# Utilities.
def _rep(obj, expand=False):
"""Get a flat -- i.e., JSON-ish -- representation of a beets Item or
Album object. For Albums, `expand` dictates whether tracks are
@@ -37,32 +38,32 @@ def _rep(obj, expand=False):
out = dict(obj)
if isinstance(obj, beets.library.Item):
if app.config.get('INCLUDE_PATHS', False):
out['path'] = util.displayable_path(out['path'])
if app.config.get("INCLUDE_PATHS", False):
out["path"] = util.displayable_path(out["path"])
else:
del out['path']
del out["path"]
# Filter all bytes attributes and convert them to strings.
for key, value in out.items():
if isinstance(out[key], bytes):
out[key] = base64.b64encode(value).decode('ascii')
out[key] = base64.b64encode(value).decode("ascii")
# Get the size (in bytes) of the backing file. This is useful
# for the Tomahawk resolver API.
try:
out['size'] = os.path.getsize(util.syspath(obj.path))
out["size"] = os.path.getsize(util.syspath(obj.path))
except OSError:
out['size'] = 0
out["size"] = 0
return out
elif isinstance(obj, beets.library.Album):
if app.config.get('INCLUDE_PATHS', False):
out['artpath'] = util.displayable_path(out['artpath'])
if app.config.get("INCLUDE_PATHS", False):
out["artpath"] = util.displayable_path(out["artpath"])
else:
del out['artpath']
del out["artpath"]
if expand:
out['items'] = [_rep(item) for item in obj.items()]
out["items"] = [_rep(item) for item in obj.items()]
return out
@@ -81,15 +82,15 @@ def json_generator(items, root, expand=False):
if first:
first = False
else:
yield ','
yield ","
yield json.dumps(_rep(item, expand=expand))
yield ']}'
yield "]}"
def is_expand():
"""Returns whether the current request is for an expanded response."""
return flask.request.args.get('expand') is not None
return flask.request.args.get("expand") is not None
def is_delete():
@@ -97,7 +98,7 @@ def is_delete():
files.
"""
return flask.request.args.get('delete') is not None
return flask.request.args.get("delete") is not None
def get_method():
@@ -106,25 +107,24 @@ def get_method():
def resource(name, patchable=False):
"""Decorates a function to handle RESTful HTTP requests for a resource.
"""
"""Decorates a function to handle RESTful HTTP requests for a resource."""
def make_responder(retriever):
def responder(ids):
entities = [retriever(id) for id in ids]
entities = [entity for entity in entities if entity]
if get_method() == "DELETE":
if app.config.get('READONLY', True):
if app.config.get("READONLY", True):
return flask.abort(405)
for entity in entities:
entity.remove(delete=is_delete())
return flask.make_response(jsonify({'deleted': True}), 200)
return flask.make_response(jsonify({"deleted": True}), 200)
elif get_method() == "PATCH" and patchable:
if app.config.get('READONLY', True):
if app.config.get("READONLY", True):
return flask.abort(405)
for entity in entities:
@@ -136,7 +136,7 @@ def resource(name, patchable=False):
elif entities:
return app.response_class(
json_generator(entities, root=name),
mimetype='application/json'
mimetype="application/json",
)
elif get_method() == "GET":
@@ -145,7 +145,7 @@ def resource(name, patchable=False):
elif entities:
return app.response_class(
json_generator(entities, root=name),
mimetype='application/json'
mimetype="application/json",
)
else:
return flask.abort(404)
@@ -153,31 +153,31 @@ def resource(name, patchable=False):
else:
return flask.abort(405)
responder.__name__ = f'get_{name}'
responder.__name__ = f"get_{name}"
return responder
return make_responder
def resource_query(name, patchable=False):
"""Decorates a function to handle RESTful HTTP queries for resources.
"""
"""Decorates a function to handle RESTful HTTP queries for resources."""
def make_responder(query_func):
def responder(queries):
entities = query_func(queries)
if get_method() == "DELETE":
if app.config.get('READONLY', True):
if app.config.get("READONLY", True):
return flask.abort(405)
for entity in entities:
entity.remove(delete=is_delete())
return flask.make_response(jsonify({'deleted': True}), 200)
return flask.make_response(jsonify({"deleted": True}), 200)
elif get_method() == "PATCH" and patchable:
if app.config.get('READONLY', True):
if app.config.get("READONLY", True):
return flask.abort(405)
for entity in entities:
@@ -186,22 +186,21 @@ def resource_query(name, patchable=False):
return app.response_class(
json_generator(entities, root=name),
mimetype='application/json'
mimetype="application/json",
)
elif get_method() == "GET":
return app.response_class(
json_generator(
entities,
root='results', expand=is_expand()
entities, root="results", expand=is_expand()
),
mimetype='application/json'
mimetype="application/json",
)
else:
return flask.abort(405)
responder.__name__ = f'query_{name}'
responder.__name__ = f"query_{name}"
return responder
@@ -212,34 +211,39 @@ def resource_list(name):
"""Decorates a function to handle RESTful HTTP request for a list of
resources.
"""
def make_responder(list_all):
def responder():
return app.response_class(
json_generator(list_all(), root=name, expand=is_expand()),
mimetype='application/json'
mimetype="application/json",
)
responder.__name__ = f'all_{name}'
responder.__name__ = f"all_{name}"
return responder
return make_responder
def _get_unique_table_field_values(model, field, sort_field):
""" retrieve all unique values belonging to a key from a model """
"""retrieve all unique values belonging to a key from a model"""
if field not in model.all_keys() or sort_field not in model.all_keys():
raise KeyError
with g.lib.transaction() as tx:
rows = tx.query('SELECT DISTINCT "{}" FROM "{}" ORDER BY "{}"'
.format(field, model._table, sort_field))
rows = tx.query(
'SELECT DISTINCT "{}" FROM "{}" ORDER BY "{}"'.format(
field, model._table, sort_field
)
)
return [row[0] for row in rows]
class IdListConverter(BaseConverter):
"""Converts comma separated lists of ids in urls to integer lists.
"""
"""Converts comma separated lists of ids in urls to integer lists."""
def to_python(self, value):
ids = []
for id in value.split(','):
for id in value.split(","):
try:
ids.append(int(id))
except ValueError:
@@ -247,98 +251,103 @@ class IdListConverter(BaseConverter):
return ids
def to_url(self, value):
return ','.join(str(v) for v in value)
return ",".join(str(v) for v in value)
class QueryConverter(PathConverter):
"""Converts slash separated lists of queries in the url to string list.
"""
"""Converts slash separated lists of queries in the url to string list."""
def to_python(self, value):
queries = value.split('/')
queries = value.split("/")
"""Do not do path substitution on regex value tests"""
return [query if '::' in query else query.replace('\\', os.sep)
for query in queries]
return [
query if "::" in query else query.replace("\\", os.sep)
for query in queries
]
def to_url(self, value):
return ','.join([v.replace(os.sep, '\\') for v in value])
return "/".join([v.replace(os.sep, "\\") for v in value])
class EverythingConverter(PathConverter):
regex = '.*?'
part_isolating = False
regex = ".*?"
# Flask setup.
app = flask.Flask(__name__)
app.url_map.converters['idlist'] = IdListConverter
app.url_map.converters['query'] = QueryConverter
app.url_map.converters['everything'] = EverythingConverter
app.url_map.converters["idlist"] = IdListConverter
app.url_map.converters["query"] = QueryConverter
app.url_map.converters["everything"] = EverythingConverter
@app.before_request
def before_request():
g.lib = app.config['lib']
g.lib = app.config["lib"]
# Items.
@app.route('/item/<idlist:ids>', methods=["GET", "DELETE", "PATCH"])
@resource('items', patchable=True)
@app.route("/item/<idlist:ids>", methods=["GET", "DELETE", "PATCH"])
@resource("items", patchable=True)
def get_item(id):
return g.lib.get_item(id)
@app.route('/item/')
@app.route('/item/query/')
@resource_list('items')
@app.route("/item/")
@app.route("/item/query/")
@resource_list("items")
def all_items():
return g.lib.items()
@app.route('/item/<int:item_id>/file')
@app.route("/item/<int:item_id>/file")
def item_file(item_id):
item = g.lib.get_item(item_id)
# On Windows under Python 2, Flask wants a Unicode path. On Python 3, it
# *always* wants a Unicode path.
if os.name == 'nt':
if os.name == "nt":
item_path = util.syspath(item.path)
else:
item_path = util.py3_path(item.path)
item_path = os.fsdecode(item.path)
try:
unicode_item_path = util.text_string(item.path)
except (UnicodeDecodeError, UnicodeEncodeError):
unicode_item_path = util.displayable_path(item.path)
base_filename = os.path.basename(item_path)
# FIXME: Arguably, this should just use `displayable_path`: The latter
# tries `_fsencoding()` first, but then falls back to `utf-8`, too.
if isinstance(base_filename, bytes):
try:
unicode_base_filename = base_filename.decode("utf-8")
except UnicodeError:
unicode_base_filename = util.displayable_path(base_filename)
else:
unicode_base_filename = base_filename
base_filename = os.path.basename(unicode_item_path)
try:
# Imitate http.server behaviour
base_filename.encode("latin-1", "strict")
except UnicodeEncodeError:
except UnicodeError:
safe_filename = unidecode(base_filename)
else:
safe_filename = base_filename
safe_filename = unicode_base_filename
response = flask.send_file(
item_path,
as_attachment=True,
attachment_filename=safe_filename
item_path, as_attachment=True, download_name=safe_filename
)
response.headers['Content-Length'] = os.path.getsize(item_path)
return response
@app.route('/item/query/<query:queries>', methods=["GET", "DELETE", "PATCH"])
@resource_query('items', patchable=True)
@app.route("/item/query/<query:queries>", methods=["GET", "DELETE", "PATCH"])
@resource_query("items", patchable=True)
def item_query(queries):
return g.lib.items(queries)
@app.route('/item/path/<everything:path>')
@app.route("/item/path/<everything:path>")
def item_at_path(path):
query = beets.library.PathQuery('path', path.encode('utf-8'))
query = beets.library.PathQuery("path", path.encode("utf-8"))
item = g.lib.items(query).get()
if item:
return flask.jsonify(_rep(item))
@@ -346,12 +355,13 @@ def item_at_path(path):
return flask.abort(404)
@app.route('/item/values/<string:key>')
@app.route("/item/values/<string:key>")
def item_unique_field_values(key):
sort_key = flask.request.args.get('sort_key', key)
sort_key = flask.request.args.get("sort_key", key)
try:
values = _get_unique_table_field_values(beets.library.Item, key,
sort_key)
values = _get_unique_table_field_values(
beets.library.Item, key, sort_key
)
except KeyError:
return flask.abort(404)
return flask.jsonify(values=values)
@@ -359,26 +369,27 @@ def item_unique_field_values(key):
# Albums.
@app.route('/album/<idlist:ids>', methods=["GET", "DELETE"])
@resource('albums')
@app.route("/album/<idlist:ids>", methods=["GET", "DELETE"])
@resource("albums")
def get_album(id):
return g.lib.get_album(id)
@app.route('/album/')
@app.route('/album/query/')
@resource_list('albums')
@app.route("/album/")
@app.route("/album/query/")
@resource_list("albums")
def all_albums():
return g.lib.albums()
@app.route('/album/query/<query:queries>', methods=["GET", "DELETE"])
@resource_query('albums')
@app.route("/album/query/<query:queries>", methods=["GET", "DELETE"])
@resource_query("albums")
def album_query(queries):
return g.lib.albums(queries)
@app.route('/album/<int:album_id>/art')
@app.route("/album/<int:album_id>/art")
def album_art(album_id):
album = g.lib.get_album(album_id)
if album and album.artpath:
@@ -387,12 +398,13 @@ def album_art(album_id):
return flask.abort(404)
@app.route('/album/values/<string:key>')
@app.route("/album/values/<string:key>")
def album_unique_field_values(key):
sort_key = flask.request.args.get('sort_key', key)
sort_key = flask.request.args.get("sort_key", key)
try:
values = _get_unique_table_field_values(beets.library.Album, key,
sort_key)
values = _get_unique_table_field_values(
beets.library.Album, key, sort_key
)
except KeyError:
return flask.abort(404)
return flask.jsonify(values=values)
@@ -400,7 +412,8 @@ def album_unique_field_values(key):
# Artists.
@app.route('/artist/')
@app.route("/artist/")
def all_artists():
with g.lib.transaction() as tx:
rows = tx.query("SELECT DISTINCT albumartist FROM albums")
@@ -410,88 +423,106 @@ def all_artists():
# Library information.
@app.route('/stats')
@app.route("/stats")
def stats():
with g.lib.transaction() as tx:
item_rows = tx.query("SELECT COUNT(*) FROM items")
album_rows = tx.query("SELECT COUNT(*) FROM albums")
return flask.jsonify({
'items': item_rows[0][0],
'albums': album_rows[0][0],
})
return flask.jsonify(
{
"items": item_rows[0][0],
"albums": album_rows[0][0],
}
)
# UI.
@app.route('/')
@app.route("/")
def home():
return flask.render_template('index.html')
return flask.render_template("index.html")
# Plugin hook.
class WebPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'host': '127.0.0.1',
'port': 8337,
'cors': '',
'cors_supports_credentials': False,
'reverse_proxy': False,
'include_paths': False,
'readonly': True,
})
self.config.add(
{
"host": "127.0.0.1",
"port": 8337,
"cors": "",
"cors_supports_credentials": False,
"reverse_proxy": False,
"include_paths": False,
"readonly": True,
}
)
def commands(self):
cmd = ui.Subcommand('web', help='start a Web interface')
cmd.parser.add_option('-d', '--debug', action='store_true',
default=False, help='debug mode')
cmd = ui.Subcommand("web", help="start a Web interface")
cmd.parser.add_option(
"-d",
"--debug",
action="store_true",
default=False,
help="debug mode",
)
def func(lib, opts, args):
args = ui.decargs(args)
if args:
self.config['host'] = args.pop(0)
self.config["host"] = args.pop(0)
if args:
self.config['port'] = int(args.pop(0))
self.config["port"] = int(args.pop(0))
app.config['lib'] = lib
app.config["lib"] = lib
# Normalizes json output
app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False
app.config["JSONIFY_PRETTYPRINT_REGULAR"] = False
app.config['INCLUDE_PATHS'] = self.config['include_paths']
app.config['READONLY'] = self.config['readonly']
app.config["INCLUDE_PATHS"] = self.config["include_paths"]
app.config["READONLY"] = self.config["readonly"]
# Enable CORS if required.
if self.config['cors']:
self._log.info('Enabling CORS with origin: {0}',
self.config['cors'])
if self.config["cors"]:
self._log.info(
"Enabling CORS with origin: {0}", self.config["cors"]
)
from flask_cors import CORS
app.config['CORS_ALLOW_HEADERS'] = "Content-Type"
app.config['CORS_RESOURCES'] = {
r"/*": {"origins": self.config['cors'].get(str)}
app.config["CORS_ALLOW_HEADERS"] = "Content-Type"
app.config["CORS_RESOURCES"] = {
r"/*": {"origins": self.config["cors"].get(str)}
}
CORS(
app,
supports_credentials=self.config[
'cors_supports_credentials'
].get(bool)
"cors_supports_credentials"
].get(bool),
)
# Allow serving behind a reverse proxy
if self.config['reverse_proxy']:
if self.config["reverse_proxy"]:
app.wsgi_app = ReverseProxied(app.wsgi_app)
# Start the web application.
app.run(host=self.config['host'].as_str(),
port=self.config['port'].get(int),
debug=opts.debug, threaded=True)
app.run(
host=self.config["host"].as_str(),
port=self.config["port"].get(int),
debug=opts.debug,
threaded=True,
)
cmd.func = func
return [cmd]
class ReverseProxied:
'''Wrap the application in this middleware and configure the
"""Wrap the application in this middleware and configure the
front-end server to add these headers, to let you quietly bind
this to a URL other than / and to an HTTP scheme that is
different than what is used locally.
@@ -508,19 +539,20 @@ class ReverseProxied:
From: http://flask.pocoo.org/snippets/35/
:param app: the WSGI application
'''
"""
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
script_name = environ.get('HTTP_X_SCRIPT_NAME', '')
script_name = environ.get("HTTP_X_SCRIPT_NAME", "")
if script_name:
environ['SCRIPT_NAME'] = script_name
path_info = environ['PATH_INFO']
environ["SCRIPT_NAME"] = script_name
path_info = environ["PATH_INFO"]
if path_info.startswith(script_name):
environ['PATH_INFO'] = path_info[len(script_name):]
environ["PATH_INFO"] = path_info[len(script_name) :]
scheme = environ.get('HTTP_X_SCHEME', '')
scheme = environ.get("HTTP_X_SCHEME", "")
if scheme:
environ['wsgi.url_scheme'] = scheme
environ["wsgi.url_scheme"] = scheme
return self.app(environ, start_response)
+3 -3
View File
@@ -274,7 +274,7 @@
},
// Fetch the model from the server. If the server's representation of the
// model differs from its current attributes, they will be overriden,
// model differs from its current attributes, they will be overridden,
// triggering a `"change"` event.
fetch : function(options) {
options || (options = {});
@@ -885,7 +885,7 @@
};
// Element lookup, scoped to DOM elements within the current view.
// This should be prefered to global lookups, if you're dealing with
// This should be preferred to global lookups, if you're dealing with
// a specific view.
var selectorDelegate = function(selector) {
return $(selector, this.el);
@@ -984,7 +984,7 @@
// Ensure that the View has a DOM element to render into.
// If `this.el` is a string, pass it through `$()`, take the first
// matching element, and re-assign it to `el`. Otherwise, create
// an element from the `id`, `className` and `tagName` proeprties.
// an element from the `id`, `className` and `tagName` properties.
_ensureElement : function() {
if (!this.el) {
var attrs = this.attributes || {};
+4 -4
View File
@@ -2278,7 +2278,7 @@ jQuery.fn.extend({
classNames = value.split( rspace );
while ( (className = classNames[ i++ ]) ) {
// check each className given, space seperated list
// check each className given, space separated list
state = isBool ? state : !self.hasClass( className );
self[ state ? "addClass" : "removeClass" ]( className );
}
@@ -3868,7 +3868,7 @@ var chunker = /((?:\((?:\([^()]+\)|[^()]+)+\)|\[(?:\[[^\[\]]*\]|['"][^'"]*['"]|[
rNonWord = /\W/;
// Here we check if the JavaScript engine is using some sort of
// optimization where it does not always call our comparision
// optimization where it does not always call our comparison
// function. If that is the case, discard the hasDuplicate value.
// Thus far that includes Google Chrome.
[0, 0].sort(function() {
@@ -4180,7 +4180,7 @@ Sizzle.error = function( msg ) {
};
/**
* Utility function for retreiving the text value of an array of DOM nodes
* Utility function for retrieving the text value of an array of DOM nodes
* @param {Array|Element} elem
*/
var getText = Sizzle.getText = function( elem ) {
@@ -8111,7 +8111,7 @@ if ( jQuery.support.ajax ) {
xml;
// Firefox throws exceptions when accessing properties
// of an xhr when a network error occured
// of an xhr when a network error occurred
// http://helpful.knobs-dials.com/index.php/Component_returned_failure_code:_0x80040111_(NS_ERROR_NOT_AVAILABLE)
try {