From 8a3b05fbc52fd06779a4128e43da2703a8d16bd3 Mon Sep 17 00:00:00 2001 From: Max Goltzsche Date: Sun, 24 Mar 2024 03:27:12 +0100 Subject: [PATCH] feat: serve cover art that is embedded into file Make the `getCoverArt` endpoint extract and serve the coverart image from the audio file for items that don't specify an `album_id` or refer to an album that does not specify an `artpath`. Correspondingly, make the `getSong` endpoint return the song ID as `coverArt` ID when the song does not specify an `album_id`. Extracts the cover art into a temporary file using `ffmpeg` and lets flask serve it. Also, moves the `getCoverArt` endpoint implementation from `album.py` into a separate file since it is used to serve both album and single song cover art now. --- beetsplug/beetstream/__init__.py | 1 + beetsplug/beetstream/albums.py | 31 ---------------- beetsplug/beetstream/coverart.py | 62 ++++++++++++++++++++++++++++++++ beetsplug/beetstream/utils.py | 9 +++-- 4 files changed, 70 insertions(+), 33 deletions(-) create mode 100644 beetsplug/beetstream/coverart.py diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py index 57e9e95..e3392b8 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstream/__init__.py @@ -39,6 +39,7 @@ def home(): from beetsplug.beetstream.utils import * import beetsplug.beetstream.albums import beetsplug.beetstream.artists +import beetsplug.beetstream.coverart import beetsplug.beetstream.dummy import beetsplug.beetstream.playlists import beetsplug.beetstream.search diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 1add18f..951cd74 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -111,37 +111,6 @@ def get_album_list(version): return Response(xml_to_string(root), mimetype='text/xml') -@app.route('/rest/getCoverArt', methods=["GET", "POST"]) -@app.route('/rest/getCoverArt.view', methods=["GET", "POST"]) -def cover_art_file(): - query_id = int(album_subid_to_beetid(request.values.get('id')) or -1) - size = request.values.get('size') - album = g.lib.get_album(query_id) - - # Fallback on item id. Some apps use this - if not album: - item = g.lib.get_item(query_id) - if item is not None and item.album_id is not None: - album = g.lib.get_album(item.album_id) - else: - flask.abort(404) - - if album and album.artpath: - image_path = album.artpath.decode('utf-8') - - if size is not None and int(size) > 0: - size = int(size) - with Image.open(image_path) as image: - bytes_io = io.BytesIO() - image = image.resize((size, size)) - image.convert('RGB').save(bytes_io, 'PNG') - bytes_io.seek(0) - return flask.send_file(bytes_io, mimetype='image/png') - - return flask.send_file(image_path) - else: - return flask.abort(404) - @app.route('/rest/getGenres', methods=["GET", "POST"]) @app.route('/rest/getGenres.view', methods=["GET", "POST"]) def genres(): diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py new file mode 100644 index 0000000..220145c --- /dev/null +++ b/beetsplug/beetstream/coverart.py @@ -0,0 +1,62 @@ +from beetsplug.beetstream.utils import * +from beetsplug.beetstream import app +from flask import g, request +from io import BytesIO +from PIL import Image +import flask +import os +import subprocess +import tempfile + +@app.route('/rest/getCoverArt', methods=["GET", "POST"]) +@app.route('/rest/getCoverArt.view', methods=["GET", "POST"]) +def cover_art_file(): + id = request.values.get('id') + size = request.values.get('size') + album = None + + if id[:len(ALBUM_ID_PREFIX)] == ALBUM_ID_PREFIX: + album_id = int(album_subid_to_beetid(id) or -1) + album = g.lib.get_album(album_id) + else: + item_id = int(song_subid_to_beetid(id) or -1) + item = g.lib.get_item(item_id) + + if item is not None: + if item.album_id is not None: + album = g.lib.get_album(item.album_id) + if not album or not album.artpath: + tmp_file = tempfile.NamedTemporaryFile(prefix='beetstream-cover-', suffix='.png') + tmp_file_name = tmp_file.name + try: + tmp_file.close() + subprocess.run(['ffmpeg', '-i', item.path, '-an', '-c:v', + 'copy', tmp_file_name, + '-hide_banner', '-loglevel', 'error',]) + + return _send_image(tmp_file_name, size) + finally: + os.remove(tmp_file_name) + + if album and album.artpath: + image_path = album.artpath.decode('utf-8') + + if size is not None and int(size) > 0: + return _send_image(image_path, size) + + return flask.send_file(image_path) + else: + return flask.abort(404) + +def _send_image(path_or_bytesio, size): + converted = BytesIO() + img = Image.open(path_or_bytesio) + + if size is not None and int(size) > 0: + size = int(size) + img = img.resize((size, size)) + + img.convert('RGB').save(converted, 'PNG') + converted.seek(0) + + return flask.send_file(converted, mimetype='image/png') diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 1411f3c..3603937 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -146,7 +146,7 @@ def map_song(song): "track": song["track"], "year": song["year"], "genre": song["genre"], - "coverArt": album_beetid_to_subid(str(song["album_id"])) or "", + "coverArt": _cover_art_id(song), "size": os.path.getsize(path), "contentType": path_to_content_type(path), "suffix": song["format"].lower(), @@ -175,7 +175,7 @@ def map_song_xml(xml, song): xml.set("track", str(song["track"])) xml.set("year", str(song["year"])) xml.set("genre", song["genre"]) - xml.set("coverArt", album_beetid_to_subid(str(song["album_id"])) or "") + xml.set("coverArt", _cover_art_id(song)), xml.set("size", str(os.path.getsize(path))) xml.set("contentType", path_to_content_type(path)) xml.set("suffix", song["format"].lower()) @@ -190,6 +190,11 @@ def map_song_xml(xml, song): if song["disc"]: xml.set("discNumber", str(song["disc"])) +def _cover_art_id(song): + if song['album_id']: + return album_beetid_to_subid(str(song['album_id'])) + return song_beetid_to_subid(str(song['id'])) + def map_artist(artist_name): return { "id": artist_name_to_id(artist_name),