From 76220fb1486b854804c4fcdaa9c9f22c65e8c77b Mon Sep 17 00:00:00 2001 From: Cadel Watson Date: Sun, 20 Sep 2020 10:18:07 +1000 Subject: [PATCH 1/6] Add DELETE method for items and albums --- beetsplug/web/__init__.py | 50 +++++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 49149772d..175aeae89 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -21,7 +21,7 @@ from beets import ui from beets import util import beets.library import flask -from flask import g +from flask import g, make_response, jsonify from werkzeug.routing import BaseConverter, PathConverter import os from unidecode import unidecode @@ -91,6 +91,17 @@ def is_expand(): return flask.request.args.get('expand') is not None +def is_delete(): + """Returns whether the current delete request should remove the selected files.""" + + return flask.request.args.get('delete') is not None + + +def get_method(): + """Returns the HTTP method of the current request.""" + return flask.request.method + + def resource(name): """Decorates a function to handle RESTful HTTP requests for a resource. """ @@ -99,16 +110,30 @@ def resource(name): entities = [retriever(id) for id in ids] entities = [entity for entity in entities if entity] - if len(entities) == 1: - return flask.jsonify(_rep(entities[0], expand=is_expand())) - elif entities: - return app.response_class( - json_generator(entities, root=name), - mimetype='application/json' - ) + if get_method() == "DELETE": + responder.__name__ = 'delete_{0}'.format(name) + + for entity in entities: + entity.remove(delete=is_delete()) + + return flask.make_response(jsonify({'deleted': True}), 200) + + elif get_method() == "GET": + responder.__name__ = 'get_{0}'.format(name) + + if len(entities) == 1: + return flask.jsonify(_rep(entities[0], expand=is_expand())) + elif entities: + return app.response_class( + json_generator(entities, root=name), + mimetype='application/json' + ) + else: + return flask.abort(404) + else: - return flask.abort(404) - responder.__name__ = 'get_{0}'.format(name) + return flask.abort(405) + return responder return make_responder @@ -203,7 +228,7 @@ def before_request(): # Items. -@app.route('/item/') +@app.route('/item/', methods=["GET", "DELETE"]) @resource('items') def get_item(id): return g.lib.get_item(id) @@ -279,12 +304,11 @@ def item_unique_field_values(key): # Albums. -@app.route('/album/') +@app.route('/album/', 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') From 29672a434f311d70c5ebd0b434f894e1eaecae81 Mon Sep 17 00:00:00 2001 From: Cadel Watson Date: Sun, 20 Sep 2020 10:23:25 +1000 Subject: [PATCH 2/6] Add DELETE method to resource queries --- beetsplug/web/__init__.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 175aeae89..0168da990 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -143,14 +143,29 @@ def resource_query(name): """ def make_responder(query_func): def responder(queries): - return app.response_class( - json_generator( - query_func(queries), - root='results', expand=is_expand() - ), - mimetype='application/json' - ) - responder.__name__ = 'query_{0}'.format(name) + entities = query_func(queries) + + if get_method() == "DELETE": + responder.__name__ = 'delete_query_{0}'.format(name) + + for entity in entities: + entity.remove(delete=is_delete()) + + return flask.make_response(jsonify({'deleted': True}), 200) + + elif get_method() == "GET": + responder.__name__ = 'query_{0}'.format(name) + + return app.response_class( + json_generator( + entities, + root='results', expand=is_expand() + ), + mimetype='application/json' + ) + else: + return flask.abort(405) + return responder return make_responder @@ -275,7 +290,7 @@ def item_file(item_id): return response -@app.route('/item/query/') +@app.route('/item/query/', methods=["GET", "DELETE"]) @resource_query('items') def item_query(queries): return g.lib.items(queries) @@ -316,7 +331,7 @@ def all_albums(): return g.lib.albums() -@app.route('/album/query/') +@app.route('/album/query/', methods=["GET", "DELETE"]) @resource_query('albums') def album_query(queries): return g.lib.albums(queries) From afcde697e09d19c5f743ccb5ad4a6b46b112a59e Mon Sep 17 00:00:00 2001 From: Cadel Watson Date: Sun, 20 Sep 2020 10:45:12 +1000 Subject: [PATCH 3/6] Add PATCH method to Items --- beetsplug/web/__init__.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 0168da990..7801a647e 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -102,7 +102,7 @@ def get_method(): return flask.request.method -def resource(name): +def resource(name, patchable=False): """Decorates a function to handle RESTful HTTP requests for a resource. """ def make_responder(retriever): @@ -111,15 +111,15 @@ def resource(name): entities = [entity for entity in entities if entity] if get_method() == "DELETE": - responder.__name__ = 'delete_{0}'.format(name) - for entity in entities: entity.remove(delete=is_delete()) return flask.make_response(jsonify({'deleted': True}), 200) - elif get_method() == "GET": - responder.__name__ = 'get_{0}'.format(name) + elif get_method() == "PATCH" and patchable: + for entity in entities: + entity.update(flask.request.get_json()) + entity.try_sync(True, False) # write, don't move if len(entities) == 1: return flask.jsonify(_rep(entities[0], expand=is_expand())) @@ -128,12 +128,23 @@ def resource(name): json_generator(entities, root=name), mimetype='application/json' ) + + elif get_method() == "GET": + if len(entities) == 1: + return flask.jsonify(_rep(entities[0], expand=is_expand())) + elif entities: + return app.response_class( + json_generator(entities, root=name), + mimetype='application/json' + ) else: return flask.abort(404) else: return flask.abort(405) + responder.__name__ = 'get_{0}'.format(name) + return responder return make_responder @@ -146,16 +157,12 @@ def resource_query(name): entities = query_func(queries) if get_method() == "DELETE": - responder.__name__ = 'delete_query_{0}'.format(name) - for entity in entities: entity.remove(delete=is_delete()) return flask.make_response(jsonify({'deleted': True}), 200) elif get_method() == "GET": - responder.__name__ = 'query_{0}'.format(name) - return app.response_class( json_generator( entities, @@ -166,7 +173,10 @@ def resource_query(name): else: return flask.abort(405) + responder.__name__ = 'query_{0}'.format(name) + return responder + return make_responder @@ -243,8 +253,8 @@ def before_request(): # Items. -@app.route('/item/', methods=["GET", "DELETE"]) -@resource('items') +@app.route('/item/', methods=["GET", "DELETE", "PATCH"]) +@resource('items', patchable=True) def get_item(id): return g.lib.get_item(id) From a18b317240d6493443f1a0b94d70b890ad243dbd Mon Sep 17 00:00:00 2001 From: Cadel Watson Date: Sun, 20 Sep 2020 10:46:48 +1000 Subject: [PATCH 4/6] Add PATCH method to item queries --- beetsplug/web/__init__.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 7801a647e..0ce5f2c4c 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -149,7 +149,7 @@ def resource(name, patchable=False): return make_responder -def resource_query(name): +def resource_query(name, patchable=False): """Decorates a function to handle RESTful HTTP queries for resources. """ def make_responder(query_func): @@ -162,6 +162,16 @@ def resource_query(name): return flask.make_response(jsonify({'deleted': True}), 200) + elif get_method() == "PATCH" and patchable: + for entity in entities: + entity.update(flask.request.get_json()) + entity.try_sync(True, False) # write, don't move + + return app.response_class( + json_generator(entities, root=name), + mimetype='application/json' + ) + elif get_method() == "GET": return app.response_class( json_generator( @@ -170,6 +180,7 @@ def resource_query(name): ), mimetype='application/json' ) + else: return flask.abort(405) @@ -300,8 +311,8 @@ def item_file(item_id): return response -@app.route('/item/query/', methods=["GET", "DELETE"]) -@resource_query('items') +@app.route('/item/query/', methods=["GET", "DELETE", "PATCH"]) +@resource_query('items', patchable=True) def item_query(queries): return g.lib.items(queries) From 3723f8a09f66ea013826fbd76c6421decab0e570 Mon Sep 17 00:00:00 2001 From: Cadel Watson Date: Sun, 20 Sep 2020 10:54:06 +1000 Subject: [PATCH 5/6] Update docs and changelog --- docs/changelog.rst | 1 + docs/plugins/web.rst | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 3a72132ab..47b0398c0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -152,6 +152,7 @@ New features: all front images instead of blindly selecting the first one. * ``beet remove`` now also allows interactive selection of items from the query similar to ``beet modify`` +* :doc:`/plugins/web`: add DELETE and PATCH methods for modifying items Fixes: diff --git a/docs/plugins/web.rst b/docs/plugins/web.rst index 85de48dd4..4b069a944 100644 --- a/docs/plugins/web.rst +++ b/docs/plugins/web.rst @@ -183,6 +183,25 @@ representation. :: If there is no item with that id responds with a *404* status code. +``DELETE /item/6`` +++++++++++++++++++ + +Removes the item with id *6* from the beets library. If the *?delete* query string is included, +the matching file will be deleted from disk. + +``PATCH /item/6`` +++++++++++++++++++ + +Updates the item with id *6* and write the changes to the music file. The body should be a JSON object +containing the changes to the object. + +Returns the updated JSON representation. :: + + { + "id": 6, + "title": "A Song", + ... + } ``GET /item/6,12,13`` +++++++++++++++++++++ @@ -192,6 +211,8 @@ the response is the same as for `GET /item/`_. It is *not guaranteed* that the response includes all the items requested. If a track is not found it is silently dropped from the response. +This endpoint also supports *DELETE* and *PATCH* methods as above, to operate on all +items of the list. ``GET /item/path/...`` ++++++++++++++++++++++ @@ -221,6 +242,8 @@ Path elements are joined as parts of a query. For example, To specify literal path separators in a query, use a backslash instead of a slash. +This endpoint also supports *DELETE* and *PATCH* methods as above, to operate on all +items returned by the query. ``GET /item/6/file`` ++++++++++++++++++++ @@ -238,10 +261,16 @@ For albums, the following endpoints are provided: * ``GET /album/5`` +* ``DELETE /album/5`` + * ``GET /album/5,7`` +* ``DELETE /album/5,7`` + * ``GET /album/query/querystring`` +* ``DELETE /album/query/querystring`` + The interface and response format is similar to the item API, except replacing the encapsulation key ``"items"`` with ``"albums"`` when requesting ``/album/`` or ``/album/5,7``. In addition we can request the cover art of an album with From d1f93a26a6ba54efbb85efac74fe74c4b92e576a Mon Sep 17 00:00:00 2001 From: Cadel Watson Date: Sun, 20 Sep 2020 11:30:12 +1000 Subject: [PATCH 6/6] Fix lint errors --- beetsplug/web/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/beetsplug/web/__init__.py b/beetsplug/web/__init__.py index 0ce5f2c4c..a982809c4 100644 --- a/beetsplug/web/__init__.py +++ b/beetsplug/web/__init__.py @@ -21,7 +21,7 @@ from beets import ui from beets import util import beets.library import flask -from flask import g, make_response, jsonify +from flask import g, jsonify from werkzeug.routing import BaseConverter, PathConverter import os from unidecode import unidecode @@ -92,7 +92,9 @@ def is_expand(): def is_delete(): - """Returns whether the current delete request should remove the selected files.""" + """Returns whether the current delete request should remove the selected + files. + """ return flask.request.args.get('delete') is not None @@ -345,6 +347,7 @@ def item_unique_field_values(key): def get_album(id): return g.lib.get_album(id) + @app.route('/album/') @app.route('/album/query/') @resource_list('albums')