From e891cb80d2813610a79a17920981662b30cfdc27 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Wed, 19 Mar 2025 22:24:15 +0000 Subject: [PATCH 01/85] Implemented month, day subsorting, and split comma-separated genres --- beetsplug/beetstream/albums.py | 81 ++++++++++++++++------------------ 1 file changed, 37 insertions(+), 44 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 951cd74..29e402c 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -50,32 +50,28 @@ def get_album_list(version): sort_by = request.values.get('type') or 'alphabeticalByName' size = int(request.values.get('size') or 10) offset = int(request.values.get('offset') or 0) - fromYear = int(request.values.get('fromYear') or 0) - toYear = int(request.values.get('toYear') or 3000) + from_year = int(request.values.get('fromYear') or 0) + to_year = int(request.values.get('toYear') or 3000) genre = request.values.get('genre') albums = list(g.lib.albums()) if sort_by == 'newest': - albums.sort(key=lambda album: int(dict(album)['added']), reverse=True) + albums.sort(key=lambda a: int(a['added']), reverse=True) elif sort_by == 'alphabeticalByName': - albums.sort(key=lambda album: strip_accents(dict(album)['album']).upper()) + albums.sort(key=lambda a: strip_accents(a['album']).upper()) elif sort_by == 'alphabeticalByArtist': - albums.sort(key=lambda album: strip_accents(dict(album)['albumartist']).upper()) + albums.sort(key=lambda a: strip_accents(a['albumartist']).upper()) elif sort_by == 'alphabeticalByArtist': - albums.sort(key=lambda album: strip_accents(dict(album)['albumartist']).upper()) + albums.sort(key=lambda a: strip_accents(a['albumartist']).upper()) elif sort_by == 'recent': - albums.sort(key=lambda album: dict(album)['year'], reverse=True) + albums.sort(key=lambda a: a['year'], reverse=True) elif sort_by == 'byGenre': - albums = list(filter(lambda album: dict(album)['genre'].lower() == genre.lower(), albums)) + # albums = list(filter(lambda a: genre.lower() in a['genre'].lower(), albums)) + albums = list(filter(lambda a: genre.lower().strip() in map(str.strip, a['genre'].lower().split(',')), albums)) elif sort_by == 'byYear': - # TODO use month and day data to sort - if fromYear <= toYear: - albums = list(filter(lambda album: dict(album)['year'] >= fromYear and dict(album)['year'] <= toYear, albums)) - albums.sort(key=lambda album: int(dict(album)['year'])) - else: - albums = list(filter(lambda album: dict(album)['year'] >= toYear and dict(album)['year'] <= fromYear, albums)) - albums.sort(key=lambda album: int(dict(album)['year']), reverse=True) + albums = list(filter(lambda a: min(from_year, to_year) <= a['year'] <= max(from_year, to_year), albums)) + albums.sort(key=lambda a: (a['year'], a['month'], a['day']), reverse=(from_year > to_year)) elif sort_by == 'random': shuffle(albums) @@ -116,42 +112,39 @@ def get_album_list(version): def genres(): res_format = request.values.get('f') or 'xml' with g.lib.transaction() as tx: - mixed_genres = list(tx.query(""" + mixed_genres = list(tx.query( + """ SELECT genre, COUNT(*) AS n_song, "" AS n_album FROM items GROUP BY genre UNION ALL SELECT genre, "" AS n_song, COUNT(*) AS n_album FROM albums GROUP BY genre - """)) - - genres = {} - for genre in mixed_genres: - key = genre[0] - if (not key in genres.keys()): - genres[key] = (genre[1], 0) - if (genre[2]): - genres[key] = (genres[key][0], genre[2]) - - genres = [(k, v[0], v[1]) for k, v in genres.items()] - # genres.sort(key=lambda genre: strip_accents(genre[0]).upper()) - genres.sort(key=lambda genre: genre[1]) - genres.reverse() - genres = filter(lambda genre: genre[0] != u"", genres) - - if (is_json(res_format)): - def map_genre(genre): - return { - "value": genre[0], - "songCount": genre[1], - "albumCount": genre[2] - } - - return jsonpify(request, wrap_res("genres", { - "genre": list(map(map_genre, genres)) - })) + """)) + + g_dict = {} + for row in mixed_genres: + genre_field, n_song, n_album = row + for key in [g.strip() for g in genre_field.split(',')]: + if key not in g_dict: + g_dict[key] = [0, 0] + if n_song: # Update song count if present + g_dict[key][0] += int(n_song) + if n_album: # Update album count if present + g_dict[key][1] += int(n_album) + + # And convert to list of tuples (only non-empty genres) + g_list = [(k, *v) for k, v in g_dict.items() if k] + # g_list.sort(key=lambda g: strip_accents(g[0]).upper()) + g_list.sort(key=lambda g: g[1], reverse=True) + + if is_json(res_format): + return jsonpify(request, wrap_res( + key="genres", + json={ "genre": [dict(zip(["value", "songCount", "albumCount"], g)) for g in g_list] } + )) else: root = get_xml_root() genres_xml = ET.SubElement(root, 'genres') - for genre in genres: + for genre in g_list: genre_xml = ET.SubElement(genres_xml, 'genre') genre_xml.text = genre[0] genre_xml.set("songCount", str(genre[1])) From 77418aa3a449503924dcbdfa5676aeda23466a30 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 20 Mar 2025 01:30:25 +0000 Subject: [PATCH 02/85] refactoring of the requests construction --- beetsplug/beetstream/utils.py | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 86649af..f7633e5 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -31,6 +31,52 @@ def timestamp_to_iso(timestamp): def is_json(res_format): return res_format == 'json' or res_format == 'jsonp' + +def dict_to_xml(tag, d): + """ Recursively converts a json-like dict to an XML tree """ + elem = ET.Element(tag) + if isinstance(d, dict): + for key, val in d.items(): + if isinstance(val, (dict, list)): + child = dict_to_xml(key, val) + elem.append(child) + else: + child = ET.Element(key) + child.text = str(val) + elem.append(child) + elif isinstance(d, list): + for item in d: + child = dict_to_xml(tag, item) + elem.append(child) + else: + elem.text = str(d) + return elem + +def subsonic_response(d: dict, format: str): + """ Wrap any json-like dict with the subsonic response elements + and output the appropriate 'format' (json or xml) """ + + STATUS = 'ok' + VERSION = '1.16.1' + + if format == 'json' or format == 'jsonp': + wrapped = { + "subsonic-response": { + "status": STATUS, + "version": VERSION, + **d + } + } + return jsonpify(flask.request, wrapped) + + else: + root = dict_to_xml("subsonic-response", d) + root.set("xmlns", "http://subsonic.org/restapi") + root.set("status", STATUS) + root.set("version", VERSION) + + return flask.Response(xml_to_string(root), mimetype="text/xml") + def wrap_res(key, json): return { "subsonic-response": { From 24137ca859aa1150867cca1cfe21a7fb4bc0b3da Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 20 Mar 2025 01:30:48 +0000 Subject: [PATCH 03/85] SQL-based albums list querying --- beetsplug/beetstream/albums.py | 248 ++++++++++++++------------------- 1 file changed, 108 insertions(+), 140 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 29e402c..eec7c30 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -1,117 +1,107 @@ from beetsplug.beetstream.utils import * from beetsplug.beetstream import app import flask -from flask import g, request, Response -import xml.etree.cElementTree as ET -from PIL import Image -import io -from random import shuffle @app.route('/rest/getAlbum', methods=["GET", "POST"]) @app.route('/rest/getAlbum.view', methods=["GET", "POST"]) def get_album(): - res_format = request.values.get('f') or 'xml' - id = int(album_subid_to_beetid(request.values.get('id'))) + r = flask.request.values - album = g.lib.get_album(id) + id = int(album_subid_to_beetid(r.get('id'))) + + album = flask.g.lib.get_album(id) songs = sorted(album.items(), key=lambda song: song.track) - if (is_json(res_format)): - res = wrap_res("album", { + payload = { + "album": { **map_album(album), - **{ "song": list(map(map_song, songs)) } - }) - return jsonpify(request, res) - else: - root = get_xml_root() - albumXml = ET.SubElement(root, 'album') - map_album_xml(albumXml, album) - - for song in songs: - s = ET.SubElement(albumXml, 'song') - map_song_xml(s, song) - - return Response(xml_to_string(root), mimetype='text/xml') + **{"song": list(map(map_song, songs))} + } + } + res_format = r.get('f') or 'xml' + return subsonic_response(payload, res_format) @app.route('/rest/getAlbumList', methods=["GET", "POST"]) @app.route('/rest/getAlbumList.view', methods=["GET", "POST"]) def album_list(): - return get_album_list(1) - + return get_album_list() @app.route('/rest/getAlbumList2', methods=["GET", "POST"]) @app.route('/rest/getAlbumList2.view', methods=["GET", "POST"]) def album_list_2(): - return get_album_list(2) + return get_album_list(ver=2) + +def get_album_list(ver=None): + + r = flask.request.values -def get_album_list(version): - res_format = request.values.get('f') or 'xml' - # TODO type == 'starred' and type == 'frequent' - sort_by = request.values.get('type') or 'alphabeticalByName' - size = int(request.values.get('size') or 10) - offset = int(request.values.get('offset') or 0) - from_year = int(request.values.get('fromYear') or 0) - to_year = int(request.values.get('toYear') or 3000) - genre = request.values.get('genre') + sort_by = r.get('type') or 'alphabeticalByName' + size = int(r.get('size') or 10) + offset = int(r.get('offset') or 0) + from_year = int(r.get('fromYear') or 0) + to_year = int(r.get('toYear') or 3000) + genre_filter = r.get('genre') - albums = list(g.lib.albums()) + # Start building the base query + query = "SELECT * FROM albums" + conditions = [] + params = [] + # Apply filtering conditions: + if sort_by == 'byYear': + conditions.append("year BETWEEN ? AND ?") + params.extend([min(from_year, to_year), max(from_year, to_year)]) + + # For genre filtering (if requested), use a simple LIKE clause + if sort_by == 'byGenre' and genre_filter: + conditions.append("lower(genre) LIKE ?") + params.append("%" + genre_filter.lower().strip() + "%") + + if conditions: + query += " WHERE " + " AND ".join(conditions) + + # ordering based on sort_by parameter if sort_by == 'newest': - albums.sort(key=lambda a: int(a['added']), reverse=True) + query += " ORDER BY added DESC" elif sort_by == 'alphabeticalByName': - albums.sort(key=lambda a: strip_accents(a['album']).upper()) - elif sort_by == 'alphabeticalByArtist': - albums.sort(key=lambda a: strip_accents(a['albumartist']).upper()) + query += " ORDER BY album COLLATE NOCASE" elif sort_by == 'alphabeticalByArtist': - albums.sort(key=lambda a: strip_accents(a['albumartist']).upper()) + query += " ORDER BY albumartist COLLATE NOCASE" elif sort_by == 'recent': - albums.sort(key=lambda a: a['year'], reverse=True) - elif sort_by == 'byGenre': - # albums = list(filter(lambda a: genre.lower() in a['genre'].lower(), albums)) - albums = list(filter(lambda a: genre.lower().strip() in map(str.strip, a['genre'].lower().split(',')), albums)) + query += " ORDER BY year DESC" elif sort_by == 'byYear': - albums = list(filter(lambda a: min(from_year, to_year) <= a['year'] <= max(from_year, to_year), albums)) - albums.sort(key=lambda a: (a['year'], a['month'], a['day']), reverse=(from_year > to_year)) - elif sort_by == 'random': - shuffle(albums) - - albums = handleSizeAndOffset(albums, size, offset) - - if version == 1: - if (is_json(res_format)): - return jsonpify(request, wrap_res("albumList", { - "album": list(map(map_album_list, albums)) - })) + # Order by year, then by month and day + if from_year <= to_year: + query += " ORDER BY year ASC, month ASC, day ASC" else: - root = get_xml_root() - album_list_xml = ET.SubElement(root, 'albumList') + query += " ORDER BY year DESC, month DESC, day DESC" + elif sort_by == 'random': + query += " ORDER BY RANDOM()" - for album in albums: - a = ET.SubElement(album_list_xml, 'album') - map_album_list_xml(a, album) + # Add LIMIT and OFFSET for pagination + query += " LIMIT ? OFFSET ?" + params.extend([size, offset]) - return Response(xml_to_string(root), mimetype='text/xml') + # Execute the query within a transaction + with flask.g.lib.transaction() as tx: + albums = list(tx.query(query, params)) - elif version == 2: - if (is_json(res_format)): - return jsonpify(request, wrap_res("albumList2", { - "album": list(map(map_album, albums)) - })) - else: - root = get_xml_root() - album_list_xml = ET.SubElement(root, 'albumList2') + tag = f"albumList{ver if ver else ''}" + payload = { + tag: { + "album": list(map(map_album, albums)) + } + } - for album in albums: - a = ET.SubElement(album_list_xml, 'album') - map_album_xml(a, album) + res_format = r.get('f') or 'xml' + return subsonic_response(payload, res_format) - return Response(xml_to_string(root), mimetype='text/xml') @app.route('/rest/getGenres', methods=["GET", "POST"]) @app.route('/rest/getGenres.view', methods=["GET", "POST"]) def genres(): - res_format = request.values.get('f') or 'xml' - with g.lib.transaction() as tx: + + with flask.g.lib.transaction() as tx: mixed_genres = list(tx.query( """ SELECT genre, COUNT(*) AS n_song, "" AS n_album FROM items GROUP BY genre @@ -132,89 +122,67 @@ def genres(): # And convert to list of tuples (only non-empty genres) g_list = [(k, *v) for k, v in g_dict.items() if k] - # g_list.sort(key=lambda g: strip_accents(g[0]).upper()) g_list.sort(key=lambda g: g[1], reverse=True) - if is_json(res_format): - return jsonpify(request, wrap_res( - key="genres", - json={ "genre": [dict(zip(["value", "songCount", "albumCount"], g)) for g in g_list] } - )) - else: - root = get_xml_root() - genres_xml = ET.SubElement(root, 'genres') + payload = { + "genres": { + "genre": [dict(zip(["value", "songCount", "albumCount"], g)) for g in g_list] + } + } + res_format = flask.request.values.get('f') or 'xml' + return subsonic_response(payload, res_format) - for genre in g_list: - genre_xml = ET.SubElement(genres_xml, 'genre') - genre_xml.text = genre[0] - genre_xml.set("songCount", str(genre[1])) - genre_xml.set("albumCount", str(genre[2])) - - return Response(xml_to_string(root), mimetype='text/xml') @app.route('/rest/getMusicDirectory', methods=["GET", "POST"]) @app.route('/rest/getMusicDirectory.view', methods=["GET", "POST"]) def musicDirectory(): # Works pretty much like a file system - # Usually Artist first, than Album, than Songs - res_format = request.values.get('f') or 'xml' - id = request.values.get('id') + # Usually Artist first, then Album, then Songs + r = flask.request.values + + req_id = r.get('id') - if id.startswith(ARTIST_ID_PREFIX): - artist_id = id + if req_id.startswith(ARTIST_ID_PREFIX): + # Artist + artist_id = req_id artist_name = artist_id_to_name(artist_id) - albums = g.lib.albums(artist_name.replace("'", "\\'")) - albums = filter(lambda album: album.albumartist == artist_name, albums) + albums = flask.g.lib.albums(artist_name.replace("'", "\\'")) + albums = filter(lambda a: a.albumartist == artist_name, albums) - if (is_json(res_format)): - return jsonpify(request, wrap_res("directory", { + payload = { + "directory": { "id": artist_id, "name": artist_name, "child": list(map(map_album, albums)) - })) - else: - root = get_xml_root() - artist_xml = ET.SubElement(root, 'directory') - artist_xml.set("id", artist_id) - artist_xml.set("name", artist_name) + } + } - for album in albums: - a = ET.SubElement(artist_xml, 'child') - map_album_xml(a, album) - - return Response(xml_to_string(root), mimetype='text/xml') - elif id.startswith(ALBUM_ID_PREFIX): + elif req_id.startswith(ALBUM_ID_PREFIX): # Album - id = int(album_subid_to_beetid(id)) - album = g.lib.get_album(id) - songs = sorted(album.items(), key=lambda song: song.track) + album_id = int(album_subid_to_beetid(req_id)) + album = flask.g.lib.get_album(album_id) + songs = sorted(album.items(), key=lambda s: s.track) - if (is_json(res_format)): - res = wrap_res("directory", { + payload = { + "directory": { **map_album(album), **{ "child": list(map(map_song, songs)) } - }) - return jsonpify(request, res) - else: - root = get_xml_root() - albumXml = ET.SubElement(root, 'directory') - map_album_xml(albumXml, album) - - for song in songs: - s = ET.SubElement(albumXml, 'child') - map_song_xml(s, song) + } + } - return Response(xml_to_string(root), mimetype='text/xml') - elif id.startswith(SONG_ID_PREFIX): + elif req_id.startswith(SONG_ID_PREFIX): # Song - id = int(song_subid_to_beetid(id)) - song = g.lib.get_item(id) + song_id = int(song_subid_to_beetid(req_id)) + song = flask.g.lib.get_item(song_id) - if (is_json(res_format)): - return jsonpify(request, wrap_res("directory", map_song(song))) - else: - root = get_xml_root() - s = ET.SubElement(root, 'directory') - map_song_xml(s, song) + payload = { + "directory": { + **map_song(song) + } + } + + else: + return flask.abort(404) - return Response(xml_to_string(root), mimetype='text/xml') + res_format = r.get('f') or 'xml' + return subsonic_response(payload, res_format) \ No newline at end of file From da8167ce1d51c9648cadd2a980bcc2b24339c918 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 20 Mar 2025 01:37:02 +0000 Subject: [PATCH 04/85] SQL-based albums list querying --- beetsplug/beetstream/albums.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index eec7c30..d1e1301 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -52,10 +52,9 @@ def get_album_list(ver=None): conditions.append("year BETWEEN ? AND ?") params.extend([min(from_year, to_year), max(from_year, to_year)]) - # For genre filtering (if requested), use a simple LIKE clause if sort_by == 'byGenre' and genre_filter: conditions.append("lower(genre) LIKE ?") - params.append("%" + genre_filter.lower().strip() + "%") + params.append(f"%{genre_filter.lower().strip()}%") if conditions: query += " WHERE " + " AND ".join(conditions) @@ -71,10 +70,8 @@ def get_album_list(ver=None): query += " ORDER BY year DESC" elif sort_by == 'byYear': # Order by year, then by month and day - if from_year <= to_year: - query += " ORDER BY year ASC, month ASC, day ASC" - else: - query += " ORDER BY year DESC, month DESC, day DESC" + sort_dir = 'ASC' if from_year <= to_year else 'DESC' + query += f" ORDER BY year {sort_dir}, month {sort_dir}, day {sort_dir}" elif sort_by == 'random': query += " ORDER BY RANDOM()" From 46bc23ca20550a0ea9d3a572614bfd4d0e3e0d99 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 20 Mar 2025 03:42:01 +0000 Subject: [PATCH 05/85] Added support for native ffmpeg binary --- beetsplug/beetstream/stream.py | 54 ++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/beetsplug/beetstream/stream.py b/beetsplug/beetstream/stream.py index 9af1bd9..f2a7ed7 100644 --- a/beetsplug/beetstream/stream.py +++ b/beetsplug/beetstream/stream.py @@ -1,31 +1,47 @@ from beetsplug.beetstream.utils import path_to_content_type -from flask import send_file, Response - +import flask +import shutil import importlib -have_ffmpeg = importlib.util.find_spec("ffmpeg") is not None -if have_ffmpeg: +ffmpeg_bin = shutil.which("ffmpeg") is not None +ffmpeg_python = importlib.util.find_spec("ffmpeg") is not None + +if ffmpeg_python: import ffmpeg +elif ffmpeg_bin: + import subprocess -def send_raw_file(filePath): - return send_file(filePath, mimetype=path_to_content_type(filePath)) +have_ffmpeg = ffmpeg_python or ffmpeg_bin -def transcode_and_stream(filePath, maxBitrate): - if not have_ffmpeg: - raise RuntimeError("Can't transcode, ffmpeg-python is not available") - outputStream = ( - ffmpeg - .input(filePath) - .audio - .output('pipe:', format="mp3", audio_bitrate=maxBitrate * 1000) - .run_async(pipe_stdout=True, quiet=True) - ) +def direct(filePath): + return flask.send_file(filePath, mimetype=path_to_content_type(filePath)) + +def transcode(filePath, maxBitrate): + if ffmpeg_python: + output_stream = ( + ffmpeg + .input(filePath) + .audio + .output('pipe:', format="mp3", audio_bitrate=maxBitrate * 1000) + .run_async(pipe_stdout=True, quiet=True) + ) + elif ffmpeg_bin: + command = [ + "ffmpeg", + "-i", filePath, + "-f", "mp3", + "-b:a", f"{maxBitrate}k", + "pipe:1" + ] + output_stream = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + else: + raise RuntimeError("Can't transcode, ffmpeg is not available.") - return Response(outputStream.stdout, mimetype='audio/mpeg') + return flask.Response(output_stream.stdout, mimetype='audio/mpeg') def try_to_transcode(filePath, maxBitrate): if have_ffmpeg: - return transcode_and_stream(filePath, maxBitrate) + return transcode(filePath, maxBitrate) else: - return send_raw_file(filePath) + return direct(filePath) From f764f2ed062a7cd294ffa1df245126dbb253f024 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 20 Mar 2025 03:43:26 +0000 Subject: [PATCH 06/85] Continuing implementing paged queries in SQL, and refactored request formatting --- beetsplug/beetstream/albums.py | 65 +++++------ beetsplug/beetstream/artists.py | 149 ++++++++++---------------- beetsplug/beetstream/songs.py | 184 ++++++++++++++++++-------------- 3 files changed, 183 insertions(+), 215 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index d1e1301..7726b9c 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -1,16 +1,14 @@ from beetsplug.beetstream.utils import * from beetsplug.beetstream import app import flask +from beetsplug.beetstream.artists import artist_payload +from beetsplug.beetstream.songs import song_payload -@app.route('/rest/getAlbum', methods=["GET", "POST"]) -@app.route('/rest/getAlbum.view', methods=["GET", "POST"]) -def get_album(): - r = flask.request.values - id = int(album_subid_to_beetid(r.get('id'))) - - album = flask.g.lib.get_album(id) - songs = sorted(album.items(), key=lambda song: song.track) +def album_payload(album_id: str) -> dict: + album_id = int(album_subid_to_beetid(album_id)) + album = flask.g.lib.get_album(album_id) + songs = sorted(album.items(), key=lambda s: s.track) payload = { "album": { @@ -18,6 +16,17 @@ def get_album(): **{"song": list(map(map_song, songs))} } } + return payload + + +@app.route('/rest/getAlbum', methods=["GET", "POST"]) +@app.route('/rest/getAlbum.view', methods=["GET", "POST"]) +def get_album(): + r = flask.request.values + + album_id = r.get('id') + payload = album_payload(album_id) + res_format = r.get('f') or 'xml' return subsonic_response(payload, res_format) @@ -140,43 +149,17 @@ def musicDirectory(): req_id = r.get('id') if req_id.startswith(ARTIST_ID_PREFIX): - # Artist - artist_id = req_id - artist_name = artist_id_to_name(artist_id) - albums = flask.g.lib.albums(artist_name.replace("'", "\\'")) - albums = filter(lambda a: a.albumartist == artist_name, albums) - - payload = { - "directory": { - "id": artist_id, - "name": artist_name, - "child": list(map(map_album, albums)) - } - } + payload = artist_payload(req_id) + payload['directory'] = payload.pop('artist') elif req_id.startswith(ALBUM_ID_PREFIX): - # Album - album_id = int(album_subid_to_beetid(req_id)) - album = flask.g.lib.get_album(album_id) - songs = sorted(album.items(), key=lambda s: s.track) - - payload = { - "directory": { - **map_album(album), - **{ "child": list(map(map_song, songs)) } - } - } + payload = album_payload(req_id) + payload['directory'] = payload.pop('album') + payload['directory']['child'] = payload['directory'].pop('song') elif req_id.startswith(SONG_ID_PREFIX): - # Song - song_id = int(song_subid_to_beetid(req_id)) - song = flask.g.lib.get_item(song_id) - - payload = { - "directory": { - **map_song(song) - } - } + payload = song_payload(req_id) + payload['directory'] = payload.pop('song') else: return flask.abort(404) diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index ef88382..b9aad7d 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -1,8 +1,25 @@ import time +from collections import defaultdict from beetsplug.beetstream.utils import * from beetsplug.beetstream import app -from flask import g, request, Response -import xml.etree.cElementTree as ET +import flask + + +def artist_payload(artist_id: str) -> dict: + + artist_name = artist_id_to_name(artist_id).replace("'", "\\'") + + albums = flask.g.lib.albums(artist_name) + albums = filter(lambda a: a.albumartist == artist_name, albums) + + payload = { + "artist": { + "id": artist_id, + "name": artist_name, + "child": list(map(map_album, albums)) + } + } + return payload @app.route('/rest/getArtists', methods=["GET", "POST"]) @app.route('/rest/getArtists.view', methods=["GET", "POST"]) @@ -14,114 +31,64 @@ def all_artists(): def indexes(): return get_artists("indexes") -def get_artists(version): - res_format = request.values.get('f') or 'xml' - with g.lib.transaction() as tx: - rows = tx.query("SELECT DISTINCT albumartist FROM albums") - all_artists = [row[0] for row in rows] - all_artists.sort(key=lambda name: strip_accents(name).upper()) - all_artists = filter(lambda name: len(name) > 0, all_artists) +def get_artists(version: str): + r = flask.request.values - indicies_dict = {} + with flask.g.lib.transaction() as tx: + rows = tx.query("SELECT DISTINCT albumartist FROM albums") - for name in all_artists: - index = strip_accents(name[0]).upper() - if index not in indicies_dict: - indicies_dict[index] = [] - indicies_dict[index].append(name) + all_artists = [r[0] for r in rows if r[0]] + all_artists.sort(key=lambda name: strip_accents(name).upper()) - if (is_json(res_format)): - indicies = [] - for index, artist_names in indicies_dict.items(): - indicies.append({ - "name": index, - "artist": list(map(map_artist, artist_names)) - }) + alphanum_dict = defaultdict(list) + for artist in all_artists: + ind = strip_accents(artist[0]).upper() + alphanum_dict[ind].append(artist) - return jsonpify(request, wrap_res(version, { + payload = { + version: { "ignoredArticles": "", "lastModified": int(time.time() * 1000), - "index": indicies - })) - else: - root = get_xml_root() - indexes_xml = ET.SubElement(root, version) - indexes_xml.set('ignoredArticles', "") - - indicies = [] - for index, artist_names in indicies_dict.items(): - indicies.append({ - "name": index, - "artist": artist_names - }) - - for index in indicies: - index_xml = ET.SubElement(indexes_xml, 'index') - index_xml.set('name', index["name"]) - - for a in index["artist"]: - artist = ET.SubElement(index_xml, 'artist') - map_artist_xml(artist, a) - - return Response(xml_to_string(root), mimetype='text/xml') + "index": [ + {"name": char, "artist": list(map(map_artist, artists))} + for char, artists in sorted(alphanum_dict.items()) + ] + } + } + + res_format = r.get('f') or 'xml' + return subsonic_response(payload, res_format) @app.route('/rest/getArtist', methods=["GET", "POST"]) @app.route('/rest/getArtist.view', methods=["GET", "POST"]) -def artist(): - res_format = request.values.get('f') or 'xml' - artist_id = request.values.get('id') - artist_name = artist_id_to_name(artist_id) - albums = g.lib.albums(artist_name.replace("'", "\\'")) - albums = filter(lambda album: album.albumartist == artist_name, albums) - - if (is_json(res_format)): - return jsonpify(request, wrap_res("artist", { - "id": artist_id, - "name": artist_name, - "album": list(map(map_album, albums)) - })) - else: - root = get_xml_root() - artist_xml = ET.SubElement(root, 'artist') - artist_xml.set("id", artist_id) - artist_xml.set("name", artist_name) +def get_artist(): + r = flask.request.values - for album in albums: - a = ET.SubElement(artist_xml, 'album') - map_album_xml(a, album) + artist_id = r.get('id') + payload = artist_payload(artist_id) - return Response(xml_to_string(root), mimetype='text/xml') + res_format = r.get('f') or 'xml' + return subsonic_response(payload, res_format) @app.route('/rest/getArtistInfo2', methods=["GET", "POST"]) @app.route('/rest/getArtistInfo2.view', methods=["GET", "POST"]) def artistInfo2(): - res_format = request.values.get('f') or 'xml' - artist_name = artist_id_to_name(request.values.get('id')) + # TODO + + r = flask.request.values - if (is_json(res_format)): - return jsonpify(request, wrap_res("artistInfo2", { + artist_name = artist_id_to_name(r.get('id')) + + payload = { + "artistInfo2": { "biography": f"wow. much artist. very {artist_name}", "musicBrainzId": "", "lastFmUrl": "", "smallImageUrl": "", "mediumImageUrl": "", "largeImageUrl": "" - })) - else: - root = get_xml_root() - artist_xml = ET.SubElement(root, 'artistInfo2') - - biography = ET.SubElement(artist_xml, "biography") - biography.text = f"wow. much artist very {artist_name}." - musicBrainzId = ET.SubElement(artist_xml, "musicBrainzId") - musicBrainzId.text = "" - lastFmUrl = ET.SubElement(artist_xml, "lastFmUrl") - lastFmUrl.text = "" - smallImageUrl = ET.SubElement(artist_xml, "smallImageUrl") - smallImageUrl.text = "" - mediumImageUrl = ET.SubElement(artist_xml, "mediumImageUrl") - mediumImageUrl.text = "" - largeImageUrl = ET.SubElement(artist_xml, "largeImageUrl") - largeImageUrl.text = "" - - return Response(xml_to_string(root), mimetype='text/xml') + } + } + + res_format = r.get('f') or 'xml' + return subsonic_response(payload, res_format) \ No newline at end of file diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index 75eb0f1..062b038 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -1,130 +1,148 @@ from beetsplug.beetstream.utils import * from beetsplug.beetstream import app, stream -from flask import g, request, Response -from beets.random import random_objs -import xml.etree.cElementTree as ET +import flask + + +def song_payload(song_id: str) -> dict: + song_id = int(song_subid_to_beetid(song_id)) + song_item = flask.g.lib.get_item(song_id) + + payload = { + 'song': map_song(song_item) + } + return payload + @app.route('/rest/getSong', methods=["GET", "POST"]) @app.route('/rest/getSong.view', methods=["GET", "POST"]) -def song(): - res_format = request.values.get('f') or 'xml' - id = int(song_subid_to_beetid(request.values.get('id'))) - song = g.lib.get_item(id) +def get_song(): + r = flask.request.values + song_id = r.get('id') + payload = song_payload(song_id) - if (is_json(res_format)): - return jsonpify(request, wrap_res("song", map_song(song))) - else: - root = get_xml_root() - s = ET.SubElement(root, 'song') - map_song_xml(s, song) - - return Response(xml_to_string(root), mimetype='text/xml') + res_format = r.get('f') or 'xml' + return subsonic_response(payload, res_format) @app.route('/rest/getSongsByGenre', methods=["GET", "POST"]) @app.route('/rest/getSongsByGenre.view', methods=["GET", "POST"]) def songs_by_genre(): - res_format = request.values.get('f') or 'xml' - genre = request.values.get('genre') - count = int(request.values.get('count') or 10) - offset = int(request.values.get('offset') or 0) + r = flask.request.values + + genre = r.get('genre').replace("'", "\\'") + count = int(r.get('count') or 10) + offset = int(r.get('offset') or 0) - songs = handleSizeAndOffset(list(g.lib.items('genre:' + genre.replace("'", "\\'"))), count, offset) + genre_pattern = f"%{genre}%" + with flask.g.lib.transaction() as tx: + songs = list(tx.query( + "SELECT * FROM items WHERE lower(genre) LIKE lower(?) ORDER BY title LIMIT ? OFFSET ?", + (genre_pattern, count, offset) + )) - if (is_json(res_format)): - return jsonpify(request, wrap_res("songsByGenre", { + payload = { + "songsByGenre": { "song": list(map(map_song, songs)) - })) - else: - root = get_xml_root() - songs_by_genre = ET.SubElement(root, 'songsByGenre') + } + } + res_format = r.get('f') or 'xml' + return subsonic_response(payload, res_format) - for song in songs: - s = ET.SubElement(songs_by_genre, 'song') - map_song_xml(s, song) - return Response(xml_to_string(root), mimetype='text/xml') +@app.route('/rest/getRandomSongs', methods=["GET", "POST"]) +@app.route('/rest/getRandomSongs.view', methods=["GET", "POST"]) +def random_songs(): + r = flask.request.values + + size = int(r.get('size') or 10) + + with flask.g.lib.transaction() as tx: + # Advance the SQL random generator state + _ = list(tx.query("SELECT RANDOM()")) + # Now fetch the random songs + songs = list(tx.query( + "SELECT * FROM items ORDER BY RANDOM() LIMIT ?", + (size,) + )) + + payload = { + "randomSongs": { + "song": list(map(map_song, songs)) + } + } + res_format = r.get('f') or 'xml' + return subsonic_response(payload, res_format) + @app.route('/rest/stream', methods=["GET", "POST"]) @app.route('/rest/stream.view', methods=["GET", "POST"]) def stream_song(): - maxBitrate = int(request.values.get('maxBitRate') or 0) - format = request.values.get('format') + r = flask.request.values + + maxBitrate = int(r.get('maxBitRate') or 0) + format = r.get('format') - id = int(song_subid_to_beetid(request.values.get('id'))) - item = g.lib.get_item(id) + id = int(song_subid_to_beetid(r.get('id'))) + item = flask.g.lib.get_item(id) itemPath = item.path.decode('utf-8') if app.config['never_transcode'] or format == 'raw' or maxBitrate <= 0 or item.bitrate <= maxBitrate * 1000: - return stream.send_raw_file(itemPath) + return stream.direct(itemPath) else: return stream.try_to_transcode(itemPath, maxBitrate) @app.route('/rest/download', methods=["GET", "POST"]) @app.route('/rest/download.view', methods=["GET", "POST"]) def download_song(): - id = int(song_subid_to_beetid(request.values.get('id'))) - item = g.lib.get_item(id) + r = flask.request.values - return stream.send_raw_file(item.path.decode('utf-8')) + id = int(song_subid_to_beetid(r.get('id'))) + item = flask.g.lib.get_item(id) -@app.route('/rest/getRandomSongs', methods=["GET", "POST"]) -@app.route('/rest/getRandomSongs.view', methods=["GET", "POST"]) -def random_songs(): - res_format = request.values.get('f') or 'xml' - size = int(request.values.get('size') or 10) - songs = list(g.lib.items()) - songs = random_objs(songs, -1, size) - - if (is_json(res_format)): - return jsonpify(request, wrap_res("randomSongs", { - "song": list(map(map_song, songs)) - })) - else: - root = get_xml_root() - album = ET.SubElement(root, 'randomSongs') + return stream.direct(item.path.decode('utf-8')) - for song in songs: - s = ET.SubElement(album, 'song') - map_song_xml(s, song) - - return Response(xml_to_string(root), mimetype='text/xml') # TODO link with Last.fm or ListenBrainz @app.route('/rest/getTopSongs', methods=["GET", "POST"]) @app.route('/rest/getTopSongs.view', methods=["GET", "POST"]) def top_songs(): - res_format = request.values.get('f') or 'xml' - if (is_json(res_format)): - return jsonpify(request, wrap_res("topSongs", {})) - else: - root = get_xml_root() - ET.SubElement(root, 'topSongs') - return Response(xml_to_string(root), mimetype='text/xml') + # TODO + + r = flask.request.values + + payload = { + 'topSongs': {} + } + res_format = r.get('f') or 'xml' + return subsonic_response(payload, res_format) @app.route('/rest/getStarred', methods=["GET", "POST"]) @app.route('/rest/getStarred.view', methods=["GET", "POST"]) def starred_songs(): - res_format = request.values.get('f') or 'xml' - if (is_json(res_format)): - return jsonpify(request, wrap_res("starred", { - "song": [] - })) - else: - root = get_xml_root() - ET.SubElement(root, 'starred') - return Response(xml_to_string(root), mimetype='text/xml') + # TODO + + r = flask.request.values + + payload = { + 'starred': { + 'song': [] + } + } + res_format = r.get('f') or 'xml' + return subsonic_response(payload, res_format) @app.route('/rest/getStarred2', methods=["GET", "POST"]) @app.route('/rest/getStarred2.view', methods=["GET", "POST"]) def starred2_songs(): - res_format = request.values.get('f') or 'xml' - if (is_json(res_format)): - return jsonpify(request, wrap_res("starred2", { - "song": [] - })) - else: - root = get_xml_root() - ET.SubElement(root, 'starred2') - return Response(xml_to_string(root), mimetype='text/xml') + # TODO + + r = flask.request.values + + payload = { + 'starred2': { + 'song': [] + } + } + res_format = r.get('f') or 'xml' + return subsonic_response(payload, res_format) From e4a0b7047f72d435e48d33437e48594e106a331a Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 20 Mar 2025 04:46:41 +0000 Subject: [PATCH 07/85] Added getAlbumInfo 1 and 2 --- beetsplug/beetstream/albums.py | 30 ++++++++++++++++++++++++++++++ beetsplug/beetstream/coverart.py | 11 +++++++++++ 2 files changed, 41 insertions(+) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 7726b9c..8eb567a 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -30,6 +30,36 @@ def get_album(): res_format = r.get('f') or 'xml' return subsonic_response(payload, res_format) +@app.route('/rest/getAlbumInfo', methods=["GET", "POST"]) +@app.route('/rest/getAlbumInfo.view', methods=["GET", "POST"]) +def album_info(): + return get_album_info() + +@app.route('/rest/getAlbumInfo2', methods=["GET", "POST"]) +@app.route('/rest/getAlbumInfo2.view', methods=["GET", "POST"]) +def album_info_2(): + return get_album_info(ver=2) + +def get_album_info(ver=None): + r = flask.request.values + + album_id = int(album_subid_to_beetid(r.get('id'))) + album = flask.g.lib.get_album(album_id) + + image_url = flask.url_for('album_art', album_id=album_id, _external=True) + + tag = f"albumInfo{ver if ver else ''}" + payload = { + tag: { + 'notes': album.get('comments', ''), + 'musicBrainzId': album.get('mb_albumid', ''), + 'largeImageUrl': image_url + } + } + + res_format = r.get('f') or 'xml' + return subsonic_response(payload, res_format) + @app.route('/rest/getAlbumList', methods=["GET", "POST"]) @app.route('/rest/getAlbumList.view', methods=["GET", "POST"]) def album_list(): diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index 220145c..3da4163 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -8,6 +8,17 @@ import subprocess import tempfile + +@app.route('/albumart/') +def album_art(album_id): + album = flask.g.lib.get_album(album_id) + art_path = album.get('artpath', b'').decode('utf-8') + if art_path and os.path.exists(art_path): + return flask.send_file(art_path, mimetype=path_to_content_type(art_path)) + else: + flask.abort(404) + + @app.route('/rest/getCoverArt', methods=["GET", "POST"]) @app.route('/rest/getCoverArt.view', methods=["GET", "POST"]) def cover_art_file(): From 7f0a44846470ce38b4d8523bb82228ef458a6388 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 20 Mar 2025 05:03:31 +0000 Subject: [PATCH 08/85] Formatting consistency --- beetsplug/beetstream/albums.py | 16 +++----- beetsplug/beetstream/artists.py | 9 ++--- beetsplug/beetstream/dummy.py | 65 ++++++++++++--------------------- beetsplug/beetstream/songs.py | 18 +++------ beetsplug/beetstream/utils.py | 3 +- 5 files changed, 40 insertions(+), 71 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 8eb567a..0d7ea8a 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -27,8 +27,7 @@ def get_album(): album_id = r.get('id') payload = album_payload(album_id) - res_format = r.get('f') or 'xml' - return subsonic_response(payload, res_format) + return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getAlbumInfo', methods=["GET", "POST"]) @app.route('/rest/getAlbumInfo.view', methods=["GET", "POST"]) @@ -57,8 +56,7 @@ def get_album_info(ver=None): } } - res_format = r.get('f') or 'xml' - return subsonic_response(payload, res_format) + return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getAlbumList', methods=["GET", "POST"]) @app.route('/rest/getAlbumList.view', methods=["GET", "POST"]) @@ -129,13 +127,13 @@ def get_album_list(ver=None): } } - res_format = r.get('f') or 'xml' - return subsonic_response(payload, res_format) + return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getGenres', methods=["GET", "POST"]) @app.route('/rest/getGenres.view', methods=["GET", "POST"]) def genres(): + r = flask.request.values with flask.g.lib.transaction() as tx: mixed_genres = list(tx.query( @@ -165,8 +163,7 @@ def genres(): "genre": [dict(zip(["value", "songCount", "albumCount"], g)) for g in g_list] } } - res_format = flask.request.values.get('f') or 'xml' - return subsonic_response(payload, res_format) + return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getMusicDirectory', methods=["GET", "POST"]) @@ -194,5 +191,4 @@ def musicDirectory(): else: return flask.abort(404) - res_format = r.get('f') or 'xml' - return subsonic_response(payload, res_format) \ No newline at end of file + return subsonic_response(payload, r.get('f', 'xml')) \ No newline at end of file diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index b9aad7d..6873aae 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -56,8 +56,7 @@ def get_artists(version: str): } } - res_format = r.get('f') or 'xml' - return subsonic_response(payload, res_format) + return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getArtist', methods=["GET", "POST"]) @app.route('/rest/getArtist.view', methods=["GET", "POST"]) @@ -67,8 +66,7 @@ def get_artist(): artist_id = r.get('id') payload = artist_payload(artist_id) - res_format = r.get('f') or 'xml' - return subsonic_response(payload, res_format) + return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getArtistInfo2', methods=["GET", "POST"]) @app.route('/rest/getArtistInfo2.view', methods=["GET", "POST"]) @@ -90,5 +88,4 @@ def artistInfo2(): } } - res_format = r.get('f') or 'xml' - return subsonic_response(payload, res_format) \ No newline at end of file + return subsonic_response(payload, r.get('f', 'xml')) \ No newline at end of file diff --git a/beetsplug/beetstream/dummy.py b/beetsplug/beetstream/dummy.py index 62504ec..0486e63 100644 --- a/beetsplug/beetstream/dummy.py +++ b/beetsplug/beetstream/dummy.py @@ -1,61 +1,42 @@ from beetsplug.beetstream.utils import * from beetsplug.beetstream import app -from flask import request, Response -import xml.etree.cElementTree as ET +import flask + # Fake endpoint to avoid some apps errors @app.route('/rest/scrobble', methods=["GET", "POST"]) @app.route('/rest/scrobble.view', methods=["GET", "POST"]) + + @app.route('/rest/ping', methods=["GET", "POST"]) @app.route('/rest/ping.view', methods=["GET", "POST"]) def ping(): - res_format = request.values.get('f') or 'xml' - - if (is_json(res_format)): - return jsonpify(request, { - "subsonic-response": { - "status": "ok", - "version": "1.16.1" - } - }) - else: - root = get_xml_root() - return Response(xml_to_string(root), mimetype='text/xml') + r = flask.request.values + return subsonic_response({}, r.get('f', 'xml')) @app.route('/rest/getLicense', methods=["GET", "POST"]) @app.route('/rest/getLicense.view', methods=["GET", "POST"]) -def getLicense(): - res_format = request.values.get('f') or 'xml' - - if (is_json(res_format)): - return jsonpify(request, wrap_res("license", { - "valid": True, - "email": "foo@example.com", - "trialExpires": "3000-01-01T00:00:00.000Z" - })) - else: - root = get_xml_root() - l = ET.SubElement(root, 'license') - l.set("valid", "true") - l.set("email", "foo@example.com") - l.set("trialExpires", "3000-01-01T00:00:00.000Z") - return Response(xml_to_string(root), mimetype='text/xml') +def get_license(): + r = flask.request.values + + payload = { + 'license': { + "valid": True + } + } + return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getMusicFolders', methods=["GET", "POST"]) @app.route('/rest/getMusicFolders.view', methods=["GET", "POST"]) def music_folder(): - res_format = request.values.get('f') or 'xml' - if (is_json(res_format)): - return jsonpify(request, wrap_res("musicFolders", { + r = flask.request.values + + payload = { + 'musicFolders': { "musicFolder": [{ "id": 0, - "name": "Music" + "name": "Music" # TODO - This needs to be the real name of beets's config 'directory' key }] - })) - else: - root = get_xml_root() - folder = ET.SubElement(root, 'musicFolders') - folder.set("id", "0") - folder.set("name", "Music") - - return Response(xml_to_string(root), mimetype='text/xml') + } + } + return subsonic_response(payload, r.get('f', 'xml')) diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index 062b038..658d6ae 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -20,8 +20,7 @@ def get_song(): song_id = r.get('id') payload = song_payload(song_id) - res_format = r.get('f') or 'xml' - return subsonic_response(payload, res_format) + return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getSongsByGenre', methods=["GET", "POST"]) @app.route('/rest/getSongsByGenre.view', methods=["GET", "POST"]) @@ -44,8 +43,7 @@ def songs_by_genre(): "song": list(map(map_song, songs)) } } - res_format = r.get('f') or 'xml' - return subsonic_response(payload, res_format) + return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getRandomSongs', methods=["GET", "POST"]) @@ -69,8 +67,7 @@ def random_songs(): "song": list(map(map_song, songs)) } } - res_format = r.get('f') or 'xml' - return subsonic_response(payload, res_format) + return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/stream', methods=["GET", "POST"]) @@ -113,8 +110,7 @@ def top_songs(): payload = { 'topSongs': {} } - res_format = r.get('f') or 'xml' - return subsonic_response(payload, res_format) + return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getStarred', methods=["GET", "POST"]) @@ -129,8 +125,7 @@ def starred_songs(): 'song': [] } } - res_format = r.get('f') or 'xml' - return subsonic_response(payload, res_format) + return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getStarred2', methods=["GET", "POST"]) @app.route('/rest/getStarred2.view', methods=["GET", "POST"]) @@ -144,5 +139,4 @@ def starred2_songs(): 'song': [] } } - res_format = r.get('f') or 'xml' - return subsonic_response(payload, res_format) + return subsonic_response(payload, r.get('f', 'xml')) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index f7633e5..d5a7743 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -1,6 +1,7 @@ from beetsplug.beetstream import ALBUM_ID_PREFIX, ARTIST_ID_PREFIX, SONG_ID_PREFIX import unicodedata from datetime import datetime +from typing import Union import flask import json import base64 @@ -52,7 +53,7 @@ def dict_to_xml(tag, d): elem.text = str(d) return elem -def subsonic_response(d: dict, format: str): +def subsonic_response(d: dict = {}, format: str = 'xml'): """ Wrap any json-like dict with the subsonic response elements and output the appropriate 'format' (json or xml) """ From c633a026b81a198815c0eb9a89e602585f4ac265 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 20 Mar 2025 10:31:50 +0000 Subject: [PATCH 09/85] Slightly better genre formatter --- beetsplug/beetstream/albums.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 0d7ea8a..a18cb88 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -1,6 +1,8 @@ from beetsplug.beetstream.utils import * from beetsplug.beetstream import app import flask +import re +from typing import List from beetsplug.beetstream.artists import artist_payload from beetsplug.beetstream.songs import song_payload @@ -72,11 +74,11 @@ def get_album_list(ver=None): r = flask.request.values - sort_by = r.get('type') or 'alphabeticalByName' - size = int(r.get('size') or 10) - offset = int(r.get('offset') or 0) - from_year = int(r.get('fromYear') or 0) - to_year = int(r.get('toYear') or 3000) + sort_by = r.get('type', 'alphabeticalByName') + size = int(r.get('size', 10)) + offset = int(r.get('offset', 0)) + from_year = int(r.get('fromYear', 0)) + to_year = int(r.get('toYear', 3000)) genre_filter = r.get('genre') # Start building the base query @@ -130,6 +132,10 @@ def get_album_list(ver=None): return subsonic_response(payload, r.get('f', 'xml')) +def genre_string_cleaner(genre: str) -> List[str]: + delimiters = '|'.join([';', ',', '/', '\|']) + + @app.route('/rest/getGenres', methods=["GET", "POST"]) @app.route('/rest/getGenres.view', methods=["GET", "POST"]) def genres(): @@ -143,10 +149,15 @@ def genres(): SELECT genre, "" AS n_song, COUNT(*) AS n_album FROM albums GROUP BY genre """)) + delimiters = re.compile('|'.join([';', ',', '/', '\\|'])) + g_dict = {} for row in mixed_genres: genre_field, n_song, n_album = row - for key in [g.strip() for g in genre_field.split(',')]: + for key in [g.strip().title() + .replace('Post ', 'Post-') + .replace('Prog ', 'Prog-') + .replace('.', ' ') for g in re.split(delimiters, genre_field)]: if key not in g_dict: g_dict[key] = [0, 0] if n_song: # Update song count if present From 58ce768d0713583f3d1d912cbe215039549184c5 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 20 Mar 2025 22:21:42 +0000 Subject: [PATCH 10/85] using url-safe version of base64 encode/decode --- beetsplug/beetstream/utils.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index d5a7743..d31ba4e 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -62,9 +62,12 @@ def subsonic_response(d: dict = {}, format: str = 'xml'): if format == 'json' or format == 'jsonp': wrapped = { - "subsonic-response": { - "status": STATUS, - "version": VERSION, + 'subsonic-response': { + 'status': STATUS, + 'version': VERSION, + 'type': 'Beetstream', + 'serverVersion': '1.4.5', + 'openSubsonic': True, **d } } @@ -75,6 +78,9 @@ def subsonic_response(d: dict = {}, format: str = 'xml'): root.set("xmlns", "http://subsonic.org/restapi") root.set("status", STATUS) root.set("version", VERSION) + root.set("type", 'Beetstream') + root.set("serverVersion", '1.4.5') + root.set("openSubsonic", 'true') return flask.Response(xml_to_string(root), mimetype="text/xml") @@ -283,12 +289,12 @@ def map_playlist_xml(xml, playlist): xml.set('created', timestamp_to_iso(playlist.modified)) def artist_name_to_id(name): - base64_name = base64.b64encode(name.encode('utf-8')).decode('utf-8') + base64_name = base64.urlsafe_b64encode(name.encode('utf-8')).decode('utf-8') return f"{ARTIST_ID_PREFIX}{base64_name}" def artist_id_to_name(id): base64_id = id[len(ARTIST_ID_PREFIX):] - return base64.b64decode(base64_id.encode('utf-8')).decode('utf-8') + return base64.urlsafe_b64decode(base64_id.encode('utf-8')).decode('utf-8') def album_beetid_to_subid(id): return f"{ALBUM_ID_PREFIX}{id}" From e46aa48131a15cca7c395607ad6145c1ea1a382f Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 20 Mar 2025 23:22:37 +0000 Subject: [PATCH 11/85] Added logo images --- beetstream.png | Bin 0 -> 27861 bytes beetstream.svg | 14 ++++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 beetstream.png create mode 100644 beetstream.svg diff --git a/beetstream.png b/beetstream.png new file mode 100644 index 0000000000000000000000000000000000000000..57a3f04dd114f0584dc14525335217c65399968c GIT binary patch literal 27861 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 literal 0 HcmV?d00001 diff --git a/beetstream.svg b/beetstream.svg new file mode 100644 index 0000000..402cee9 --- /dev/null +++ b/beetstream.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + From bafca2252731c22a83a65bf2a1591fb45b85f409 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Fri, 21 Mar 2025 02:42:48 +0000 Subject: [PATCH 12/85] Lil speed boost for cover art fetching --- beetsplug/beetstream/__init__.py | 6 +- beetsplug/beetstream/albums.py | 8 +- beetsplug/beetstream/coverart.py | 123 ++++++++++++++++--------------- beetsplug/beetstream/utils.py | 24 +++--- 4 files changed, 82 insertions(+), 79 deletions(-) diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py index 0e798e0..5955d98 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstream/__init__.py @@ -21,9 +21,9 @@ from flask import g from flask_cors import CORS -ARTIST_ID_PREFIX = "1" -ALBUM_ID_PREFIX = "2" -SONG_ID_PREFIX = "3" +ARTIST_ID_PREFIX = "ar-" +ALBUM_ID_PREFIX = "al-" +SONG_ID_PREFIX = "sg-" # Flask setup. app = flask.Flask(__name__) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index a18cb88..9c8dd13 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -28,7 +28,6 @@ def get_album(): album_id = r.get('id') payload = album_payload(album_id) - return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getAlbumInfo', methods=["GET", "POST"]) @@ -44,10 +43,11 @@ def album_info_2(): def get_album_info(ver=None): r = flask.request.values - album_id = int(album_subid_to_beetid(r.get('id'))) + req_id = r.get('id') + album_id = int(album_subid_to_beetid(req_id)) album = flask.g.lib.get_album(album_id) - image_url = flask.url_for('album_art', album_id=album_id, _external=True) + image_url = flask.url_for('cover_art_file', id=album_id, _external=True) tag = f"albumInfo{ver if ver else ''}" payload = { @@ -55,7 +55,7 @@ def get_album_info(ver=None): 'notes': album.get('comments', ''), 'musicBrainzId': album.get('mb_albumid', ''), 'largeImageUrl': image_url - } + } } return subsonic_response(payload, r.get('f', 'xml')) diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index 3da4163..64aee7e 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -1,73 +1,76 @@ 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('/albumart/') -def album_art(album_id): - album = flask.g.lib.get_album(album_id) - art_path = album.get('artpath', b'').decode('utf-8') - if art_path and os.path.exists(art_path): - return flask.send_file(art_path, mimetype=path_to_content_type(art_path)) - else: - flask.abort(404) +# TODO - Use python ffmpeg module if available (like in stream.py) + +def extract_cover(path) -> BytesIO: + command = [ + 'ffmpeg', + '-i', path, + '-vframes', '1', # extract only one frame + '-f', 'image2pipe', # output format is image2pipe + '-c:v', 'mjpeg', + '-q:v', '2', # jpg quality (lower is better) + 'pipe:1' + ] + proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + img_bytes, _ = proc.communicate() + return BytesIO(img_bytes) + + +def resize_image(data: BytesIO, size: int) -> BytesIO: + + img = Image.open(data) + img.thumbnail((size, size)) + + buf = BytesIO() + img.save(buf, format='JPEG') + + return buf @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') +def get_cover_art(): + r = flask.request.values + + req_id = r.get('id') + size = r.get('size', None) + + if req_id.startswith(ALBUM_ID_PREFIX): + + album_id = int(album_subid_to_beetid(req_id)) + album = flask.g.lib.get_album(album_id) + + art_path = album.get('artpath', b'').decode('utf-8') + + if os.path.isfile(art_path): + if size: + cover = resize_image(art_path, int(size)) + return flask.send_file(cover, mimetype='image/jpg') + return flask.send_file(art_path, mimetype=path_to_content_type(art_path)) + + # TODO - Query from coverartarchive.org if no local file found + + elif req_id.startswith(SONG_ID_PREFIX): + item_id = int(song_subid_to_beetid(req_id)) + item = flask.g.lib.get_item(item_id) + + # TODO - try to get the album's cover first, then extract only if needed + cover = extract_cover(item.path) + if size: + cover = resize_image(cover, int(size)) + + return flask.send_file(cover, mimetype='image/jpg') + + + # TODO - Get artist image if req_id is 'ar-' + + # Fallback: return an empty 'ok' response + return subsonic_response({}, r.get('f', 'xml')) \ No newline at end of file diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index d31ba4e..d54bbef 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -288,25 +288,25 @@ def map_playlist_xml(xml, playlist): xml.set('comment', playlist.artists) xml.set('created', timestamp_to_iso(playlist.modified)) -def artist_name_to_id(name): - base64_name = base64.urlsafe_b64encode(name.encode('utf-8')).decode('utf-8') +def artist_name_to_id(artist_name: str): + base64_name = base64.urlsafe_b64encode(artist_name.encode('utf-8')).decode('utf-8') return f"{ARTIST_ID_PREFIX}{base64_name}" -def artist_id_to_name(id): - base64_id = id[len(ARTIST_ID_PREFIX):] +def artist_id_to_name(artist_id: str): + base64_id = artist_id[len(ARTIST_ID_PREFIX):] return base64.urlsafe_b64decode(base64_id.encode('utf-8')).decode('utf-8') -def album_beetid_to_subid(id): - return f"{ALBUM_ID_PREFIX}{id}" +def album_beetid_to_subid(album_id: str): + return ALBUM_ID_PREFIX + album_id -def album_subid_to_beetid(id): - return id[len(ALBUM_ID_PREFIX):] +def album_subid_to_beetid(album_id: str): + return album_id[len(ALBUM_ID_PREFIX):] -def song_beetid_to_subid(id): - return f"{SONG_ID_PREFIX}{id}" +def song_beetid_to_subid(song_id: str): + return SONG_ID_PREFIX + song_id -def song_subid_to_beetid(id): - return id[len(SONG_ID_PREFIX):] +def song_subid_to_beetid(song_id: str): + return song_id[len(SONG_ID_PREFIX):] def path_to_content_type(path): result = mimetypes.guess_type(path)[0] From c316054293ed5a5bba9e37378518eebfca5ff7a7 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Fri, 21 Mar 2025 02:45:06 +0000 Subject: [PATCH 13/85] renamed 'id' for consistency and clarity --- beetsplug/beetstream/songs.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index 658d6ae..b0d88f2 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -78,8 +78,8 @@ def stream_song(): maxBitrate = int(r.get('maxBitRate') or 0) format = r.get('format') - id = int(song_subid_to_beetid(r.get('id'))) - item = flask.g.lib.get_item(id) + song_id = int(song_subid_to_beetid(r.get('id'))) + item = flask.g.lib.get_item(song_id) itemPath = item.path.decode('utf-8') @@ -93,8 +93,8 @@ def stream_song(): def download_song(): r = flask.request.values - id = int(song_subid_to_beetid(r.get('id'))) - item = flask.g.lib.get_item(id) + song_id = int(song_subid_to_beetid(r.get('id'))) + item = flask.g.lib.get_item(song_id) return stream.direct(item.path.decode('utf-8')) From 789eb765fc08c6aeaf07b9e05750a41e7c731c77 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Fri, 21 Mar 2025 03:06:29 +0000 Subject: [PATCH 14/85] refactored playlists.py like the rest --- beetsplug/beetstream/playlists.py | 58 ++++++++++++++----------------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/beetsplug/beetstream/playlists.py b/beetsplug/beetstream/playlists.py index 4cffd89..80ed476 100644 --- a/beetsplug/beetstream/playlists.py +++ b/beetsplug/beetstream/playlists.py @@ -1,7 +1,6 @@ -import xml.etree.cElementTree as ET from beetsplug.beetstream.utils import * from beetsplug.beetstream import app -from flask import g, request, Response +import flask from .playlistprovider import PlaylistProvider _playlist_provider = PlaylistProvider('') @@ -10,43 +9,38 @@ @app.route('/rest/getPlaylists', methods=['GET', 'POST']) @app.route('/rest/getPlaylists.view', methods=['GET', 'POST']) def playlists(): - res_format = request.values.get('f') or 'xml' + r = flask.request.values + playlists = playlist_provider().playlists() - if (is_json(res_format)): - return jsonpify(request, wrap_res('playlists', { + + payload = { + 'playlists': { 'playlist': [map_playlist(p) for p in playlists] - })) - else: - root = get_xml_root() - playlists_el = ET.SubElement(root, 'playlists') - for p in playlists: - playlist_el = ET.SubElement(playlists_el, 'playlist') - map_playlist_xml(playlist_el, p) - return Response(xml_to_string(root), mimetype='text/xml') + } + } + return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getPlaylist', methods=['GET', 'POST']) @app.route('/rest/getPlaylist.view', methods=['GET', 'POST']) def playlist(): - res_format = request.values.get('f') or 'xml' - id = request.values.get('id') - playlist = playlist_provider().playlist(id) + r = flask.request.values + + playlist_id = r.get('id') + playlist = playlist_provider().playlist(playlist_id) items = playlist.items() - if (is_json(res_format)): - p = map_playlist(playlist) - p['entry'] = [_song(item.attrs['id']) for item in items] - return jsonpify(request, wrap_res('playlist', p)) - else: - root = get_xml_root() - playlist_xml = ET.SubElement(root, 'playlist') - map_playlist_xml(playlist_xml, playlist) - for item in items: - song = g.lib.get_item(item.attrs['id']) - entry = ET.SubElement(playlist_xml, 'entry') - map_song_xml(entry, song) - return Response(xml_to_string(root), mimetype='text/xml') - -def _song(id): - return map_song(g.lib.get_item(int(id))) + + payload = { + 'playlist': { + 'entry': [ + map_song( + flask.g.lib.get_item(int(item.attrs['id'])) + ) + for item in items + ] + } + } + return subsonic_response(payload, r.get('f', 'xml')) + def playlist_provider(): if 'playlist_dir' in app.config: From 8208bd5a021eb2fb44c59d25f5ebd2602a829b73 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Fri, 21 Mar 2025 22:30:56 +0000 Subject: [PATCH 15/85] Cleanup --- beetsplug/beetstream/albums.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 9c8dd13..3ba11d0 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -132,10 +132,6 @@ def get_album_list(ver=None): return subsonic_response(payload, r.get('f', 'xml')) -def genre_string_cleaner(genre: str) -> List[str]: - delimiters = '|'.join([';', ',', '/', '\|']) - - @app.route('/rest/getGenres', methods=["GET", "POST"]) @app.route('/rest/getGenres.view', methods=["GET", "POST"]) def genres(): From d64f9741369d3bbf6d2f5ff36064e0e078d46ae8 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 02:55:30 +0000 Subject: [PATCH 16/85] Minor changes --- beetsplug/beetstream/albums.py | 15 ++++------ beetsplug/beetstream/coverart.py | 4 +-- beetsplug/beetstream/utils.py | 48 +++++++++++--------------------- 3 files changed, 24 insertions(+), 43 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 3ba11d0..aaf3457 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -47,7 +47,7 @@ def get_album_info(ver=None): album_id = int(album_subid_to_beetid(req_id)) album = flask.g.lib.get_album(album_id) - image_url = flask.url_for('cover_art_file', id=album_id, _external=True) + image_url = flask.url_for('get_cover_art', id=album_id, _external=True) tag = f"albumInfo{ver if ver else ''}" payload = { @@ -145,15 +145,10 @@ def genres(): SELECT genre, "" AS n_song, COUNT(*) AS n_album FROM albums GROUP BY genre """)) - delimiters = re.compile('|'.join([';', ',', '/', '\\|'])) - g_dict = {} for row in mixed_genres: genre_field, n_song, n_album = row - for key in [g.strip().title() - .replace('Post ', 'Post-') - .replace('Prog ', 'Prog-') - .replace('.', ' ') for g in re.split(delimiters, genre_field)]: + for key in genres_splitter(genre_field): if key not in g_dict: g_dict[key] = [0, 0] if n_song: # Update song count if present @@ -182,16 +177,16 @@ def musicDirectory(): req_id = r.get('id') - if req_id.startswith(ARTIST_ID_PREFIX): + if req_id.startswith(ART_ID_PREF): payload = artist_payload(req_id) payload['directory'] = payload.pop('artist') - elif req_id.startswith(ALBUM_ID_PREFIX): + elif req_id.startswith(ALB_ID_PREF): payload = album_payload(req_id) payload['directory'] = payload.pop('album') payload['directory']['child'] = payload['directory'].pop('song') - elif req_id.startswith(SONG_ID_PREFIX): + elif req_id.startswith(SNG_ID_PREF): payload = song_payload(req_id) payload['directory'] = payload.pop('song') diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index 64aee7e..e5d28c3 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -43,7 +43,7 @@ def get_cover_art(): req_id = r.get('id') size = r.get('size', None) - if req_id.startswith(ALBUM_ID_PREFIX): + if req_id.startswith(ALB_ID_PREF): album_id = int(album_subid_to_beetid(req_id)) album = flask.g.lib.get_album(album_id) @@ -58,7 +58,7 @@ def get_cover_art(): # TODO - Query from coverartarchive.org if no local file found - elif req_id.startswith(SONG_ID_PREFIX): + elif req_id.startswith(SNG_ID_PREF): item_id = int(song_subid_to_beetid(req_id)) item = flask.g.lib.get_item(item_id) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index d54bbef..dd9db87 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -1,12 +1,14 @@ -from beetsplug.beetstream import ALBUM_ID_PREFIX, ARTIST_ID_PREFIX, SONG_ID_PREFIX +from beetsplug.beetstream import ALB_ID_PREF, ART_ID_PREF, SNG_ID_PREF import unicodedata from datetime import datetime from typing import Union +import beets import flask import json import base64 import mimetypes import os +import re import posixpath import xml.etree.cElementTree as ET from math import ceil @@ -172,23 +174,6 @@ def map_album_list(album): "starred": "" } -def map_album_list_xml(xml, album): - album = dict(album) - xml.set("id", album_beetid_to_subid(str(album["id"]))) - xml.set("parent", artist_name_to_id(album["albumartist"])) - xml.set("isDir", "true") - xml.set("title", album["album"]) - xml.set("album", album["album"]) - xml.set("artist", album["albumartist"]) - xml.set("year", str(album["year"])) - xml.set("genre", album["genre"]) - xml.set("coverArt", album_beetid_to_subid(str(album["id"])) or "") - xml.set("userRating", "5") # TODO - xml.set("averageRating", "5") # TODO - xml.set("playCount", "1") # TODO - xml.set("created", timestamp_to_iso(album["added"])) - xml.set("starred", "") - def map_song(song): song = dict(song) path = song["path"].decode('utf-8') @@ -280,33 +265,26 @@ def map_playlist(playlist): 'created': timestamp_to_iso(playlist.modified), } -def map_playlist_xml(xml, playlist): - xml.set('id', playlist.id) - xml.set('name', playlist.name) - xml.set('songCount', str(playlist.count)) - xml.set('duration', str(ceil(playlist.duration))) - xml.set('comment', playlist.artists) - xml.set('created', timestamp_to_iso(playlist.modified)) def artist_name_to_id(artist_name: str): base64_name = base64.urlsafe_b64encode(artist_name.encode('utf-8')).decode('utf-8') - return f"{ARTIST_ID_PREFIX}{base64_name}" + return f"{ART_ID_PREF}{base64_name}" def artist_id_to_name(artist_id: str): - base64_id = artist_id[len(ARTIST_ID_PREFIX):] + base64_id = artist_id[len(ART_ID_PREF):] return base64.urlsafe_b64decode(base64_id.encode('utf-8')).decode('utf-8') def album_beetid_to_subid(album_id: str): - return ALBUM_ID_PREFIX + album_id + return ALB_ID_PREF + album_id def album_subid_to_beetid(album_id: str): - return album_id[len(ALBUM_ID_PREFIX):] + return album_id[len(ALB_ID_PREF):] def song_beetid_to_subid(song_id: str): - return SONG_ID_PREFIX + song_id + return SNG_ID_PREF + song_id def song_subid_to_beetid(song_id: str): - return song_id[len(SONG_ID_PREFIX):] + return song_id[len(SNG_ID_PREF):] def path_to_content_type(path): result = mimetypes.guess_type(path)[0] @@ -333,3 +311,11 @@ def handleSizeAndOffset(collection, size, offset): return collection[0:size] else: return collection + +def genres_splitter(genres_string): + delimiters = re.compile('|'.join([';', ',', '/', '\\|'])) + return [g.strip().title() + .replace('Post ', 'Post-') + .replace('Prog ', 'Prog-') + .replace('.', ' ') + for g in re.split(delimiters, genres_string)] \ No newline at end of file From 1e68c8196c4b567cecf005bb0b442ab6d9bc33b9 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 02:55:51 +0000 Subject: [PATCH 17/85] Preparing support for playlists and smartplaylist plugins --- beetsplug/beetstream/__init__.py | 58 +++++++++++++++++--------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py index 5955d98..c04d2bb 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstream/__init__.py @@ -14,18 +14,20 @@ # included in all copies or substantial portions of the Software. """Beetstream is a Beets.io plugin that exposes SubSonic API endpoints.""" + from beets.plugins import BeetsPlugin from beets import config from beets import ui import flask from flask import g from flask_cors import CORS +from pathlib import Path -ARTIST_ID_PREFIX = "ar-" -ALBUM_ID_PREFIX = "al-" -SONG_ID_PREFIX = "sg-" +ART_ID_PREF = "ar-" +ALB_ID_PREF = "al-" +SNG_ID_PREF = "sg-" -# Flask setup. +# Flask setup app = flask.Flask(__name__) @app.before_request @@ -46,12 +48,12 @@ def home(): import beetsplug.beetstream.songs import beetsplug.beetstream.users -# Plugin hook. +# Plugin hook class BeetstreamPlugin(BeetsPlugin): def __init__(self): super(BeetstreamPlugin, self).__init__() self.config.add({ - 'host': u'0.0.0.0', + 'host': '0.0.0.0', 'port': 8080, 'cors': '*', 'cors_supports_credentials': True, @@ -62,9 +64,8 @@ def __init__(self): }) def commands(self): - cmd = ui.Subcommand('beetstream', help=u'run Beetstream server, exposing SubSonic API') - cmd.parser.add_option(u'-d', u'--debug', action='store_true', - default=False, help=u'debug mode') + cmd = ui.Subcommand('beetstream', help='run Beetstream server, exposing SubSonic API') + cmd.parser.add_option('-d', '--debug', action='store_true', default=False, help='debug mode') def func(lib, opts, args): args = ui.decargs(args) @@ -74,23 +75,23 @@ def func(lib, opts, args): self.config['port'] = int(args.pop(0)) app.config['lib'] = lib + # Normalizes json output - app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False - - app.config['INCLUDE_PATHS'] = self.config['include_paths'] - app.config['never_transcode'] = self.config['never_transcode'] - playlist_dir = self.config['playlist_dir'] - if not playlist_dir: - try: - playlist_dir = config['smartplaylist']['playlist_dir'].get() - except: - pass - app.config['playlist_dir'] = playlist_dir - - # Enable CORS if required. + # app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False + + # app.config['INCLUDE_PATHS'] = self.config['include_paths'] + + app.config['never_transcode'] = self.config['never_transcode'].get(False) + + playlist_directories = [self.config['playlist_dir'].get(None), # Beetstream's own + config['playlist']['playlist_dir'].get(None), # Playlists plugin + config['smartplaylist']['playlist_dir'].get(None)] # Smartplaylists plugin + + app.config['playlist_dirs'] = set(Path(d) for d in playlist_directories if d and os.path.isdir(d)) + + # Enable CORS if required if self.config['cors']: - self._log.info(u'Enabling CORS with origin: {0}', - self.config['cors']) + self._log.info(f'Enabling CORS with origin: {self.config["cors"]}') app.config['CORS_ALLOW_HEADERS'] = "Content-Type" app.config['CORS_RESOURCES'] = { r"/*": {"origins": self.config['cors'].get(str)} @@ -106,15 +107,16 @@ def func(lib, opts, args): if self.config['reverse_proxy']: app.wsgi_app = ReverseProxied(app.wsgi_app) - # Start the web application. + # Start the web application 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(object): - '''Wrap the application in this middleware and configure the + +class ReverseProxied: + """ 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. @@ -131,7 +133,7 @@ class ReverseProxied(object): From: http://flask.pocoo.org/snippets/35/ :param app: the WSGI application - ''' + """ def __init__(self, app): self.app = app From 0fa965581d71dd55d0626ec8c56c53808d4bb8c1 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 13:38:44 +0000 Subject: [PATCH 18/85] Rewrote playlist for native beets (smart)playlists plugin support --- beetsplug/beetstream/playlistprovider.py | 272 ++++++++++++----------- beetsplug/beetstream/playlists.py | 59 ++--- beetsplug/beetstream/utils.py | 42 +++- 3 files changed, 211 insertions(+), 162 deletions(-) diff --git a/beetsplug/beetstream/playlistprovider.py b/beetsplug/beetstream/playlistprovider.py index dc8db67..9bc8c41 100644 --- a/beetsplug/beetstream/playlistprovider.py +++ b/beetsplug/beetstream/playlistprovider.py @@ -1,140 +1,154 @@ -import glob -import os -import pathlib -import re -import sys -from beetsplug.beetstream.utils import strip_accents -from flask import current_app as app -from werkzeug.utils import safe_join - -extinf_regex = re.compile(r'^#EXTINF:([0-9]+)( [^,]+)?,[\s]*(.*)') -highint32 = 1<<31 +from beetsplug.beetstream.utils import genres_splitter, creation_date +from beetsplug.beetstream import app +import flask +from typing import Union, List +from pathlib import Path +from itertools import chain -class PlaylistProvider: - def __init__(self, dir): - self.dir = dir - self._playlists = {} +PL_ID_PREF = 'plid-' - def _refresh(self): - self._playlists = {p.id: p for p in self._load_playlists()} - app.logger.debug(f"Loaded {len(self._playlists)} playlists") - - def _load_playlists(self): - if not self.dir: - return - paths = glob.glob(os.path.join(self.dir, "**.m3u8")) - paths += glob.glob(os.path.join(self.dir, "**.m3u")) - paths.sort() - for path in paths: - try: - yield self._playlist(path) - except Exception as e: - app.logger.error(f"Failed to load playlist {filepath}: {e}") - - def playlists(self): - self._refresh() - playlists = self._playlists - ids = [k for k, v in playlists.items() if v] - ids.sort() - return [playlists[id] for id in ids] - - def playlist(self, id): - filepath = safe_join(self.dir, id) - playlist = self._playlist(filepath) - if playlist.id not in self._playlists: # add to cache - playlists = self._playlists.copy() - playlists[playlist.id] = playlist - self._playlists = playlists - return playlist - def _playlist(self, filepath): - id = self._path2id(filepath) - name = pathlib.Path(os.path.basename(filepath)).stem - playlist = self._playlists.get(id) - mtime = pathlib.Path(filepath).stat().st_mtime - if playlist and playlist.modified == mtime: - return playlist # cached metadata - app.logger.debug(f"Loading playlist {filepath}") - return Playlist(id, name, mtime, filepath) +def parse_m3u(filepath): + """ Parses a playlist (m3u, m3u8 or m3a) and yields its entries """ - def _path2id(self, filepath): - return os.path.relpath(filepath, self.dir) + with open(filepath, 'r', encoding='UTF-8') as f: + curr_entry = {} -class Playlist: - def __init__(self, id, name, modified, path): - self.id = id - self.name = name - self.modified = modified - self.path = path - self.count = 0 - self.duration = 0 - artists = {} - max_artists = 10 - for item in self.items(): - self.count += 1 - self.duration += item.duration - artist = Artist(item.title.split(' - ')[0]) - found = artists.get(artist.key) - if found: - found.count += 1 - else: - if len(artists) > max_artists: - l = _sortedartists(artists)[:max_artists] - artists = {a.key: a for a in l} - artists[artist.key] = artist - self.artists = ', '.join([a.name for a in _sortedartists(artists)]) - - def items(self): - return parse_m3u_playlist(self.path) - -def _sortedartists(artists): - l = [a for _,a in artists.items()] - l.sort(key=lambda a: (highint32-a.count, a.name)) - return l - -class Artist: - def __init__(self, name): - self.key = strip_accents(name.lower()) - self.name = name - self.count = 1 - -def parse_m3u_playlist(filepath): - ''' - Parses an M3U playlist and yields its items, one at a time. - CAUTION: Attribute values that contain ',' or ' ' are not supported! - ''' - with open(filepath, 'r', encoding='UTF-8') as file: - linenum = 0 - item = PlaylistItem() - while line := file.readline(): - line = line.rstrip() - linenum += 1 - if linenum == 1: - assert line == '#EXTM3U', f"File {filepath} is not an EXTM3U playlist!" + for line in f: + line = line.strip() + + if not line: + continue + + if line.startswith('#EXTM3U'): + continue + + if line.startswith('#EXTINF:'): + left_part, info = line[8:].split(",", 1) + duration_and_props = left_part.split() + curr_entry['info'] = info.strip() + curr_entry['runtime'] = int(duration_and_props[0].strip()) + curr_entry['props'] = {k.strip(): v.strip('"').strip() + for k, v in (p.split('=', 1) for p in duration_and_props[1:])} + continue + + # Add content from any additional m3u directives + elif line.startswith('#PLAYLIST:'): + curr_entry['name'] = line[10:].strip() continue - if len(line.strip()) == 0: + + elif line.startswith('#EXTGRP:'): + curr_entry['group'] = line[8:].strip() + continue + + elif line.startswith('#EXTALB:'): + curr_entry['album'] = line[8:].strip() + continue + + elif line.startswith('#EXTART:'): + curr_entry['artist'] = line[8:].strip() + continue + + elif line.startswith('#EXTGENRE:'): + curr_entry['genres'] = genres_splitter(line[10:]) continue - m = extinf_regex.match(line) - if m: - item = PlaylistItem() - duration = m.group(1) - item.duration = int(duration) - attrs = m.group(2) - if attrs: - item.attrs = {k: v.strip('"') for k,v in [kv.split('=') for kv in attrs.strip().split(' ')]} - else: - item.attrs = {} - item.title = m.group(3) + + elif line.startswith('#EXTM3A'): + curr_entry['m3a'] = True + continue + + elif line.startswith('#EXTBYT:'): + curr_entry['size'] = int(line[8:].strip()) continue - if line.startswith('#'): + + elif line.startswith('#EXTBIN:'): + # Skip the binary mp3 content continue - item.uri = line - yield item - item = PlaylistItem() -class PlaylistItem(): + elif line.startswith('#EXTALBUMARTURL:'): + curr_entry['artpath'] = line[16:].strip() + continue + + elif line.startswith('#EXT-X-'): + # We ignore HLS M3U fields + continue + + curr_entry['uri'] = line + yield curr_entry + curr_entry = {} + + +class Playlist: + def __init__(self, path): + self.id = f'{PL_ID_PREF}{path.parent.stem.lower()}-{path.name}' + self.name = path.stem + self.ctime = creation_date(path) + self.mtime = path.stat().st_mtime + self.path = path + self.songs = [] + self.duration = 0 + for entry in parse_m3u(path): + + entry_path = (path.parent / Path(entry['uri'])).resolve() + entry_id = entry.get('props', {}).get('id', None) + + if entry_id: + song = [flask.g.lib.get_item(entry_id)] + else: + with flask.g.lib.transaction() as tx: + song = tx.query("SELECT * FROM items WHERE (path) LIKE (?) LIMIT 1", (entry_path.as_posix(),)) + + if song: + self.songs.append(dict(song[0])) + self.duration += int(song[0]['length'] or 0) + + +class PlaylistProvider: def __init__(self): - self.title = None - self.duration = None - self.uri = None - self.attrs = None + + self.playlist_dirs = app.config.get('playlist_dirs', set()) + self._playlists = {} + + if len(self.playlist_dirs) == 0: + app.logger.warning('No playlist directories could be found.') + + else: + for path in chain.from_iterable(Path(d).glob('*.m3u*') for d in self.playlist_dirs): + try: + self._load_playlist(path) + except Exception as e: + app.logger.error(f"Failed to load playlist {path.name}: {e}") + + app.logger.debug(f"Loaded {len(self._playlists)} playlists.") + + def _load_playlist(self, filepath): + """ Load playlist data from a file, or from cache if it exists """ + + file_mtime = filepath.stat().st_mtime + playlist_id = f'{PL_ID_PREF}{'-'.join(filepath.parts[-2:]).lower()}' + + # Get potential cached version + playlist = self._playlists.get(playlist_id) + + # If the playlist is not found in cache, or if the cached version is outdated + if not playlist or playlist.mtime < file_mtime: + # Load new data from file + playlist = Playlist(filepath) + # And cache it + self._playlists[playlist_id] = playlist + + return playlist + + def get(self, playlist_id: str) -> Union[Playlist, None]: + """ Get a playlist by its id """ + folder, file = playlist_id.rsplit('-')[1:] + filepath = next(dir_path / file for dir_path in self.playlist_dirs if dir_path.stem.lower() == folder) + + if filepath.is_file(): + return self._load_playlist(filepath) + else: + return None + + def getall(self) -> List[Playlist]: + """ Get all playlists """ + return list(self._playlists.values()) \ No newline at end of file diff --git a/beetsplug/beetstream/playlists.py b/beetsplug/beetstream/playlists.py index 80ed476..d96cd58 100644 --- a/beetsplug/beetstream/playlists.py +++ b/beetsplug/beetstream/playlists.py @@ -1,17 +1,20 @@ from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app import flask +from beetsplug.beetstream import app from .playlistprovider import PlaylistProvider -_playlist_provider = PlaylistProvider('') -# TODO link with https://beets.readthedocs.io/en/stable/plugins/playlist.html @app.route('/rest/getPlaylists', methods=['GET', 'POST']) @app.route('/rest/getPlaylists.view', methods=['GET', 'POST']) -def playlists(): +def get_playlists(): + r = flask.request.values - playlists = playlist_provider().playlists() + # Lazily initialize the playlist provider the first time it's needed + if not hasattr(flask.g, 'playlist_provider'): + flask.g.playlist_provider = PlaylistProvider() + + playlists = flask.g.playlist_provider.getall() payload = { 'playlists': { @@ -20,31 +23,31 @@ def playlists(): } return subsonic_response(payload, r.get('f', 'xml')) + @app.route('/rest/getPlaylist', methods=['GET', 'POST']) @app.route('/rest/getPlaylist.view', methods=['GET', 'POST']) -def playlist(): +def get_playlist(): r = flask.request.values playlist_id = r.get('id') - playlist = playlist_provider().playlist(playlist_id) - items = playlist.items() - - payload = { - 'playlist': { - 'entry': [ - map_song( - flask.g.lib.get_item(int(item.attrs['id'])) - ) - for item in items - ] - } - } - return subsonic_response(payload, r.get('f', 'xml')) - - -def playlist_provider(): - if 'playlist_dir' in app.config: - _playlist_provider.dir = app.config['playlist_dir'] - if not _playlist_provider.dir: - app.logger.warning('No playlist_dir configured') - return _playlist_provider + if playlist_id: + + # Lazily initialize the playlist provider the first time it's needed + if not hasattr(flask.g, 'playlist_provider'): + flask.g.playlist_provider = PlaylistProvider() + + playlist = flask.g.playlist_provider.get(playlist_id) + + if playlist is not None: + payload = { + 'playlist': { + 'entry': [ + map_song( + flask.g.lib.get_item(int(song['id'])) + ) + for song in playlist.songs + ] + } + } + return subsonic_response(payload, r.get('f', 'xml')) + flask.abort(404) \ No newline at end of file diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index dd9db87..f7849c3 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -3,6 +3,8 @@ from datetime import datetime from typing import Union import beets +import subprocess +import platform import flask import json import base64 @@ -259,13 +261,14 @@ def map_playlist(playlist): return { 'id': playlist.id, 'name': playlist.name, - 'songCount': playlist.count, + 'songCount': len(playlist.songs), 'duration': playlist.duration, - 'comment': playlist.artists, - 'created': timestamp_to_iso(playlist.modified), + 'created': timestamp_to_iso(playlist.ctime), + 'changed': timestamp_to_iso(playlist.mtime), + # 'owner': 'userA', # TODO + # 'public': True, } - def artist_name_to_id(artist_name: str): base64_name = base64.urlsafe_b64encode(artist_name.encode('utf-8')).decode('utf-8') return f"{ART_ID_PREF}{base64_name}" @@ -318,4 +321,33 @@ def genres_splitter(genres_string): .replace('Post ', 'Post-') .replace('Prog ', 'Prog-') .replace('.', ' ') - for g in re.split(delimiters, genres_string)] \ No newline at end of file + for g in re.split(delimiters, genres_string)] + + +def creation_date(filepath): + """ Get a file's creation date + See: http://stackoverflow.com/a/39501288/1709587 """ + if platform.system() == 'Windows': + return os.path.getctime(filepath) + elif platform.system() == 'Darwin': + stat = os.stat(filepath) + return stat.st_birthtime + else: + stat = os.stat(filepath) + try: + # On some Unix systems, st_birthtime is available so try it + return stat.st_birthtime + except AttributeError: + try: + # Run stat twice because it's faster and easier than parsing the %W format... + ret = subprocess.run(['stat', '--format=%W', filepath], stdout=subprocess.PIPE) + timestamp = ret.stdout.decode('utf-8').strip() + + # ...but we still want millisecond precision :) + ret = subprocess.run(['stat', '--format=%w', filepath], stdout=subprocess.PIPE) + millis = ret.stdout.decode('utf-8').rsplit('.', 1)[1].split()[0].strip() + + return float(f'{timestamp}.{millis}') + except: + # If that did not work, settle for last modification time + return stat.st_mtime \ No newline at end of file From bc927d99c95addbe5bf6ca6f42d88536c57afcb0 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 13:53:56 +0000 Subject: [PATCH 19/85] Fix for resized images not showing up --- beetsplug/beetstream/coverart.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index e5d28c3..cb0ccb0 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -31,6 +31,7 @@ def resize_image(data: BytesIO, size: int) -> BytesIO: buf = BytesIO() img.save(buf, format='JPEG') + buf.seek(0) return buf @@ -49,6 +50,7 @@ def get_cover_art(): album = flask.g.lib.get_album(album_id) art_path = album.get('artpath', b'').decode('utf-8') + print(art_path) if os.path.isfile(art_path): if size: From 5b1caaf1e8dc20fd3fcf3b39ade95e7b969ff909 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 13:54:31 +0000 Subject: [PATCH 20/85] Added support for returning failed requests status --- beetsplug/beetstream/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index f7849c3..568ceda 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -57,11 +57,11 @@ def dict_to_xml(tag, d): elem.text = str(d) return elem -def subsonic_response(d: dict = {}, format: str = 'xml'): +def subsonic_response(d: dict = {}, format: str = 'xml', failed=False): """ Wrap any json-like dict with the subsonic response elements and output the appropriate 'format' (json or xml) """ - STATUS = 'ok' + STATUS = 'failed' if failed else 'ok' VERSION = '1.16.1' if format == 'json' or format == 'jsonp': From 94aa533b0f5872c00a87b8ba18d83c504dbf8bd6 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 13:54:37 +0000 Subject: [PATCH 21/85] Added support for returning failed requests status --- beetsplug/beetstream/coverart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index cb0ccb0..098ac7d 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -75,4 +75,4 @@ def get_cover_art(): # TODO - Get artist image if req_id is 'ar-' # Fallback: return an empty 'ok' response - return subsonic_response({}, r.get('f', 'xml')) \ No newline at end of file + return subsonic_response({}, r.get('f', 'xml'), failed=True) \ No newline at end of file From 6ea96ff821fdcaba8ce1fb6bc30d910fbecee219 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 16:30:40 +0000 Subject: [PATCH 22/85] Added fallback to coverarchive.org if art not found --- beetsplug/beetstream/coverart.py | 53 ++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index 098ac7d..763b07a 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -4,6 +4,7 @@ from PIL import Image import flask import os +import requests import subprocess @@ -36,6 +37,35 @@ def resize_image(data: BytesIO, size: int) -> BytesIO: return buf +def send_album_art(album_id, size=None): + """ Generate a response with the album art for given album ID and (optional) size + (Local file first, then fallback to redirecting to coverarchive.org) """ + album = flask.g.lib.get_album(album_id) + art_path = album.get('artpath', b'').decode('utf-8') + if os.path.isfile(art_path): + if size: + cover = resize_image(art_path, int(size)) + return flask.send_file(cover, mimetype='image/jpeg') + return flask.send_file(art_path, mimetype=path_to_content_type(art_path)) + else: + mbid = album.get('mb_albumid', None) + if mbid: + art_url = f'https://coverartarchive.org/release/{mbid}/front' + if size: + # If requested size is one of coverarchive's available sizes, query it directly + if size in (250, 500, 1200): + return flask.redirect(f'{art_url}-{size}') + else: + response = requests.get(art_url) + cover = resize_image(BytesIO(response.content), int(size)) + return flask.send_file(cover, mimetype='image/jpeg') + return flask.redirect(art_url) + + # If nothing found: return empty XML document on error + # https://opensubsonic.netlify.app/docs/endpoints/getcoverart/ + return subsonic_response({}, 'xml', failed=True) + + @app.route('/rest/getCoverArt', methods=["GET", "POST"]) @app.route('/rest/getCoverArt.view', methods=["GET", "POST"]) def get_cover_art(): @@ -45,32 +75,21 @@ def get_cover_art(): size = r.get('size', None) if req_id.startswith(ALB_ID_PREF): - album_id = int(album_subid_to_beetid(req_id)) - album = flask.g.lib.get_album(album_id) - - art_path = album.get('artpath', b'').decode('utf-8') - print(art_path) - - if os.path.isfile(art_path): - if size: - cover = resize_image(art_path, int(size)) - return flask.send_file(cover, mimetype='image/jpg') - return flask.send_file(art_path, mimetype=path_to_content_type(art_path)) - - # TODO - Query from coverartarchive.org if no local file found + return send_album_art(album_id, size) elif req_id.startswith(SNG_ID_PREF): item_id = int(song_subid_to_beetid(req_id)) item = flask.g.lib.get_item(item_id) - # TODO - try to get the album's cover first, then extract only if needed + album_id = item.get('album_id', None) + if album_id: + return send_album_art(album_id, size) + cover = extract_cover(item.path) if size: cover = resize_image(cover, int(size)) - - return flask.send_file(cover, mimetype='image/jpg') - + return flask.send_file(cover, mimetype='image/jpeg') # TODO - Get artist image if req_id is 'ar-' From 193e1e27d83c74a7f15b06bb24f2d6e331a51b8d Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 16:50:36 +0000 Subject: [PATCH 23/85] Small safety checks for missing art data --- beetsplug/beetstream/coverart.py | 35 +++++++++++++++++++------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index 763b07a..4e5c3b2 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -10,7 +10,7 @@ # TODO - Use python ffmpeg module if available (like in stream.py) -def extract_cover(path) -> BytesIO: +def extract_cover(path) -> Union[BytesIO, None]: command = [ 'ffmpeg', '-i', path, @@ -22,7 +22,10 @@ def extract_cover(path) -> BytesIO: ] proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) img_bytes, _ = proc.communicate() - return BytesIO(img_bytes) + if img_bytes: + return BytesIO(img_bytes) + else: + return None def resize_image(data: BytesIO, size: int) -> BytesIO: @@ -53,17 +56,14 @@ def send_album_art(album_id, size=None): art_url = f'https://coverartarchive.org/release/{mbid}/front' if size: # If requested size is one of coverarchive's available sizes, query it directly - if size in (250, 500, 1200): + if int(size) in (250, 500, 1200): return flask.redirect(f'{art_url}-{size}') else: response = requests.get(art_url) cover = resize_image(BytesIO(response.content), int(size)) return flask.send_file(cover, mimetype='image/jpeg') return flask.redirect(art_url) - - # If nothing found: return empty XML document on error - # https://opensubsonic.netlify.app/docs/endpoints/getcoverart/ - return subsonic_response({}, 'xml', failed=True) + return None @app.route('/rest/getCoverArt', methods=["GET", "POST"]) @@ -76,7 +76,9 @@ def get_cover_art(): if req_id.startswith(ALB_ID_PREF): album_id = int(album_subid_to_beetid(req_id)) - return send_album_art(album_id, size) + response = send_album_art(album_id, size) + if response is not None: + return response elif req_id.startswith(SNG_ID_PREF): item_id = int(song_subid_to_beetid(req_id)) @@ -84,14 +86,19 @@ def get_cover_art(): album_id = item.get('album_id', None) if album_id: - return send_album_art(album_id, size) + response = send_album_art(album_id, size) + if response is not None: + return response + # If no album art found and nothing found on coverarchive.org, try to it extract from the song file cover = extract_cover(item.path) - if size: - cover = resize_image(cover, int(size)) - return flask.send_file(cover, mimetype='image/jpeg') + if cover is not None: + if size: + cover = resize_image(cover, int(size)) + return flask.send_file(cover, mimetype='image/jpeg') # TODO - Get artist image if req_id is 'ar-' - # Fallback: return an empty 'ok' response - return subsonic_response({}, r.get('f', 'xml'), failed=True) \ No newline at end of file + # If nothing found: return empty XML document on error + # https://opensubsonic.netlify.app/docs/endpoints/getcoverart/ + return subsonic_response({}, 'xml', failed=True) \ No newline at end of file From b1eef8676b6085f39cd9b53709c6f2e2ebcd7c4c Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 17:12:43 +0000 Subject: [PATCH 24/85] Added support for ffmpeg-python in the coverart functions --- beetsplug/beetstream/coverart.py | 111 +++++++++++++++++++------------ beetsplug/beetstream/stream.py | 4 +- beetsplug/beetstream/utils.py | 6 +- 3 files changed, 72 insertions(+), 49 deletions(-) diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index 4e5c3b2..4a03431 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -4,65 +4,86 @@ from PIL import Image import flask import os +from typing import Union import requests -import subprocess +import shutil +import importlib +ffmpeg_bin = shutil.which("ffmpeg") is not None +ffmpeg_python = importlib.util.find_spec("ffmpeg") is not None + +if ffmpeg_python: + import ffmpeg +elif ffmpeg_bin: + import subprocess -# TODO - Use python ffmpeg module if available (like in stream.py) def extract_cover(path) -> Union[BytesIO, None]: - command = [ - 'ffmpeg', - '-i', path, - '-vframes', '1', # extract only one frame - '-f', 'image2pipe', # output format is image2pipe - '-c:v', 'mjpeg', - '-q:v', '2', # jpg quality (lower is better) - 'pipe:1' - ] - proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) - img_bytes, _ = proc.communicate() - if img_bytes: - return BytesIO(img_bytes) + + if ffmpeg_python: + img_bytes, err = ( + ffmpeg + .input(path) + # extract only 1 frame, format image2pipe, jpeg in quality 2 (lower is better) + .output('pipe:', vframes=1, format='image2pipe', vcodec='mjpeg', **{'q:v': 2}) + .run(capture_stdout=True, capture_stderr=True) + ) + if err: + app.logger.error(err.decode('utf-8', 'ignore')) + return None + + elif ffmpeg_bin: + command = [ + 'ffmpeg', + '-i', path, + # extract only 1 frame, format image2pipe, jpeg in quality 2 (lower is better) + '-vframes', '1', '-f', 'image2pipe', '-c:v', 'mjpeg', '-q:v', '2', + 'pipe:1' + ] + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + img_bytes, err = process.communicate() + if process.returncode != 0: + app.logger.error(err.decode('utf-8', 'ignore') if err else "unknown error") + return None else: - return None + app.logger.error("Can't extract cover art, ffmpeg is not available.") + img_bytes = b'' + return BytesIO(img_bytes) if img_bytes else None def resize_image(data: BytesIO, size: int) -> BytesIO: - img = Image.open(data) img.thumbnail((size, size)) - buf = BytesIO() img.save(buf, format='JPEG') buf.seek(0) - return buf def send_album_art(album_id, size=None): - """ Generate a response with the album art for given album ID and (optional) size - (Local file first, then fallback to redirecting to coverarchive.org) """ + """ Generates a response with the album art for the given album ID and (optional) size + Uses the local file first, then falls back to coverartarchive.org """ + album = flask.g.lib.get_album(album_id) art_path = album.get('artpath', b'').decode('utf-8') if os.path.isfile(art_path): if size: - cover = resize_image(art_path, int(size)) + cover = resize_image(art_path, size) return flask.send_file(cover, mimetype='image/jpeg') - return flask.send_file(art_path, mimetype=path_to_content_type(art_path)) - else: - mbid = album.get('mb_albumid', None) - if mbid: - art_url = f'https://coverartarchive.org/release/{mbid}/front' - if size: - # If requested size is one of coverarchive's available sizes, query it directly - if int(size) in (250, 500, 1200): - return flask.redirect(f'{art_url}-{size}') - else: - response = requests.get(art_url) - cover = resize_image(BytesIO(response.content), int(size)) - return flask.send_file(cover, mimetype='image/jpeg') - return flask.redirect(art_url) + return flask.send_file(art_path, mimetype=path_to_mimetype(art_path)) + + mbid = album.get('mb_albumid') + if mbid: + art_url = f'https://coverartarchive.org/release/{mbid}/front' + if size: + # If requested size is one of coverarchive's available sizes, query it directly + if size in (250, 500, 1200): + return flask.redirect(f'{art_url}-{size}') + response = requests.get(art_url) + cover = resize_image(BytesIO(response.content), size) + return flask.send_file(cover, mimetype='image/jpeg') + return flask.redirect(art_url) + return None @@ -72,33 +93,35 @@ def get_cover_art(): r = flask.request.values req_id = r.get('id') - size = r.get('size', None) + size = int(r.get('size')) if r.get('size') else None + # album requests if req_id.startswith(ALB_ID_PREF): album_id = int(album_subid_to_beetid(req_id)) response = send_album_art(album_id, size) if response is not None: return response + # song requests elif req_id.startswith(SNG_ID_PREF): item_id = int(song_subid_to_beetid(req_id)) item = flask.g.lib.get_item(item_id) - - album_id = item.get('album_id', None) + album_id = item.get('album_id') if album_id: response = send_album_art(album_id, size) if response is not None: return response - # If no album art found and nothing found on coverarchive.org, try to it extract from the song file + # Fallback: try to extract cover from the song file cover = extract_cover(item.path) if cover is not None: if size: - cover = resize_image(cover, int(size)) + cover = resize_image(cover, size) return flask.send_file(cover, mimetype='image/jpeg') - # TODO - Get artist image if req_id is 'ar-' + # artist requests + elif req_id.startswith(ART_ID_PREF): + pass - # If nothing found: return empty XML document on error - # https://opensubsonic.netlify.app/docs/endpoints/getcoverart/ + # Fallback: return empty XML document on error return subsonic_response({}, 'xml', failed=True) \ No newline at end of file diff --git a/beetsplug/beetstream/stream.py b/beetsplug/beetstream/stream.py index f2a7ed7..ed22e58 100644 --- a/beetsplug/beetstream/stream.py +++ b/beetsplug/beetstream/stream.py @@ -1,4 +1,4 @@ -from beetsplug.beetstream.utils import path_to_content_type +from beetsplug.beetstream.utils import path_to_mimetype import flask import shutil import importlib @@ -15,7 +15,7 @@ def direct(filePath): - return flask.send_file(filePath, mimetype=path_to_content_type(filePath)) + return flask.send_file(filePath, mimetype=path_to_mimetype(filePath)) def transcode(filePath, maxBitrate): if ffmpeg_python: diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 568ceda..c6728bb 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -192,7 +192,7 @@ def map_song(song): "genre": song["genre"], "coverArt": _cover_art_id(song), "size": os.path.getsize(path), - "contentType": path_to_content_type(path), + "contentType": path_to_mimetype(path), "suffix": song["format"].lower(), "duration": ceil(song["length"]), "bitRate": ceil(song["bitrate"]/1000), @@ -221,7 +221,7 @@ def map_song_xml(xml, song): xml.set("genre", song["genre"]) 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("contentType", path_to_mimetype(path)) xml.set("suffix", song["format"].lower()) xml.set("duration", str(ceil(song["length"]))) xml.set("bitRate", str(ceil(song["bitrate"]/1000))) @@ -289,7 +289,7 @@ def song_beetid_to_subid(song_id: str): def song_subid_to_beetid(song_id: str): return song_id[len(SNG_ID_PREF):] -def path_to_content_type(path): +def path_to_mimetype(path): result = mimetypes.guess_type(path)[0] if result: From 12be69ec15b719365177dc18f463708c10e9e23f Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 17:18:48 +0000 Subject: [PATCH 25/85] Removed obnoxious errors capture in covertart --- beetsplug/beetstream/coverart.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index 4a03431..7a69d15 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -21,16 +21,13 @@ def extract_cover(path) -> Union[BytesIO, None]: if ffmpeg_python: - img_bytes, err = ( + img_bytes, _ = ( ffmpeg .input(path) # extract only 1 frame, format image2pipe, jpeg in quality 2 (lower is better) .output('pipe:', vframes=1, format='image2pipe', vcodec='mjpeg', **{'q:v': 2}) - .run(capture_stdout=True, capture_stderr=True) + .run(capture_stdout=True, capture_stderr=False, quiet=True) ) - if err: - app.logger.error(err.decode('utf-8', 'ignore')) - return None elif ffmpeg_bin: command = [ @@ -40,14 +37,12 @@ def extract_cover(path) -> Union[BytesIO, None]: '-vframes', '1', '-f', 'image2pipe', '-c:v', 'mjpeg', '-q:v', '2', 'pipe:1' ] - process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - img_bytes, err = process.communicate() - if process.returncode != 0: - app.logger.error(err.decode('utf-8', 'ignore') if err else "unknown error") - return None + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + img_bytes, _ = process.communicate() + else: - app.logger.error("Can't extract cover art, ffmpeg is not available.") img_bytes = b'' + return BytesIO(img_bytes) if img_bytes else None @@ -106,11 +101,11 @@ def get_cover_art(): elif req_id.startswith(SNG_ID_PREF): item_id = int(song_subid_to_beetid(req_id)) item = flask.g.lib.get_item(item_id) - album_id = item.get('album_id') - if album_id: - response = send_album_art(album_id, size) - if response is not None: - return response + # album_id = item.get('album_id') + # if album_id: + # response = send_album_art(album_id, size) + # if response is not None: + # return response # Fallback: try to extract cover from the song file cover = extract_cover(item.path) From 1034c704105d19bfb8a41fa3c43646b5d82c5a51 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 17:19:31 +0000 Subject: [PATCH 26/85] Forgot to uncomment a debug comment --- beetsplug/beetstream/coverart.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index 7a69d15..2f988bc 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -101,11 +101,11 @@ def get_cover_art(): elif req_id.startswith(SNG_ID_PREF): item_id = int(song_subid_to_beetid(req_id)) item = flask.g.lib.get_item(item_id) - # album_id = item.get('album_id') - # if album_id: - # response = send_album_art(album_id, size) - # if response is not None: - # return response + album_id = item.get('album_id') + if album_id: + response = send_album_art(album_id, size) + if response is not None: + return response # Fallback: try to extract cover from the song file cover = extract_cover(item.path) From 9800e69e5de0e50043db8e86be9bbb2bdef2cc57 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 18:29:49 +0000 Subject: [PATCH 27/85] Full getAlbumInfo(2) response --- beetsplug/beetstream/albums.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index aaf3457..a9d8071 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -3,6 +3,7 @@ import flask import re from typing import List +import urllib.parse from beetsplug.beetstream.artists import artist_payload from beetsplug.beetstream.songs import song_payload @@ -47,17 +48,20 @@ def get_album_info(ver=None): album_id = int(album_subid_to_beetid(req_id)) album = flask.g.lib.get_album(album_id) - image_url = flask.url_for('get_cover_art', id=album_id, _external=True) + artist_quot = urllib.parse.quote(album.get('albumartist', '')) + album_quot = urllib.parse.quote(album.get('album', '')) + lastfm_url = f'https://www.last.fm/music/{artist_quot}/{album_quot}' if artist_quot and album_quot else '' tag = f"albumInfo{ver if ver else ''}" payload = { tag: { - 'notes': album.get('comments', ''), 'musicBrainzId': album.get('mb_albumid', ''), - 'largeImageUrl': image_url + 'lastFmUrl': lastfm_url, + 'largeImageUrl': flask.url_for('get_cover_art', id=album_id, size=1200, _external=False), + 'mediumImageUrl': flask.url_for('get_cover_art', id=album_id, size=500, _external=False), + 'smallImageUrl': flask.url_for('get_cover_art', id=album_id, size=250, _external=False) } } - return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getAlbumList', methods=["GET", "POST"]) From a0a90265b2c8dccf57303e01c5875710cc030f04 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 18:30:35 +0000 Subject: [PATCH 28/85] Updated missing-endpoints.md --- missing-endpoints.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/missing-endpoints.md b/missing-endpoints.md index e356ed9..4b53d50 100644 --- a/missing-endpoints.md +++ b/missing-endpoints.md @@ -2,8 +2,6 @@ To be implemented: - `getArtistInfo` -- `getAlbumInfo` -- `getAlbumInfo2` - `getSimilarSongs` - `getSimilarSongs2` - `search` From ac808192bdb22c130f9de02bcd74c2f32905e947 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 18:38:49 +0000 Subject: [PATCH 29/85] Minor naming changes for consistency --- beetsplug/beetstream/albums.py | 10 +++++----- beetsplug/beetstream/artists.py | 10 +++++----- beetsplug/beetstream/search.py | 6 +++--- beetsplug/beetstream/songs.py | 26 +++++++++++--------------- 4 files changed, 24 insertions(+), 28 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index a9d8071..7f4ac9b 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -33,15 +33,15 @@ def get_album(): @app.route('/rest/getAlbumInfo', methods=["GET", "POST"]) @app.route('/rest/getAlbumInfo.view', methods=["GET", "POST"]) -def album_info(): - return get_album_info() +def get_album_info(): + return _album_info() @app.route('/rest/getAlbumInfo2', methods=["GET", "POST"]) @app.route('/rest/getAlbumInfo2.view', methods=["GET", "POST"]) -def album_info_2(): - return get_album_info(ver=2) +def get_album_info_2(): + return _album_info(ver=2) -def get_album_info(ver=None): +def _album_info(ver=None): r = flask.request.values req_id = r.get('id') diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index 6873aae..4b61fb9 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -23,15 +23,15 @@ def artist_payload(artist_id: str) -> dict: @app.route('/rest/getArtists', methods=["GET", "POST"]) @app.route('/rest/getArtists.view', methods=["GET", "POST"]) -def all_artists(): - return get_artists("artists") +def get_artists(): + return _artists("artists") @app.route('/rest/getIndexes', methods=["GET", "POST"]) @app.route('/rest/getIndexes.view', methods=["GET", "POST"]) -def indexes(): - return get_artists("indexes") +def get_indexes(): + return _artists("indexes") -def get_artists(version: str): +def _artists(version: str): r = flask.request.values with flask.g.lib.transaction() as tx: diff --git a/beetsplug/beetstream/search.py b/beetsplug/beetstream/search.py index ed13598..da55076 100644 --- a/beetsplug/beetstream/search.py +++ b/beetsplug/beetstream/search.py @@ -6,14 +6,14 @@ @app.route('/rest/search2', methods=["GET", "POST"]) @app.route('/rest/search2.view', methods=["GET", "POST"]) def search2(): - return search(2) + return search(ver=2) @app.route('/rest/search3', methods=["GET", "POST"]) @app.route('/rest/search3.view', methods=["GET", "POST"]) def search3(): - return search(3) + return search(ver=3) -def search(version): +def search(ver=None): res_format = request.values.get('f') or 'xml' query = request.values.get('query') or "" artistCount = int(request.values.get('artistCount') or 20) diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index b0d88f2..63a7855 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -48,7 +48,7 @@ def songs_by_genre(): @app.route('/rest/getRandomSongs', methods=["GET", "POST"]) @app.route('/rest/getRandomSongs.view', methods=["GET", "POST"]) -def random_songs(): +def get_random_songs(): r = flask.request.values size = int(r.get('size') or 10) @@ -102,7 +102,7 @@ def download_song(): # TODO link with Last.fm or ListenBrainz @app.route('/rest/getTopSongs', methods=["GET", "POST"]) @app.route('/rest/getTopSongs.view', methods=["GET", "POST"]) -def top_songs(): +def get_top_songs(): # TODO r = flask.request.values @@ -115,27 +115,23 @@ def top_songs(): @app.route('/rest/getStarred', methods=["GET", "POST"]) @app.route('/rest/getStarred.view', methods=["GET", "POST"]) -def starred_songs(): - # TODO - - r = flask.request.values - - payload = { - 'starred': { - 'song': [] - } - } - return subsonic_response(payload, r.get('f', 'xml')) +def get_starred_songs(): + return _starred_songs() @app.route('/rest/getStarred2', methods=["GET", "POST"]) @app.route('/rest/getStarred2.view', methods=["GET", "POST"]) -def starred2_songs(): +def get_starred2_songs(): + return _starred_songs(ver=2) + + +def _starred_songs(ver=None): # TODO r = flask.request.values + tag = f'starred{ver if ver else ''}' payload = { - 'starred2': { + tag: { 'song': [] } } From 768b05fde0c6386aaf45acb242dcdf17076ae7b7 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 19:23:48 +0000 Subject: [PATCH 30/85] Finished porting search, users to the new response format --- beetsplug/beetstream/__init__.py | 8 +-- beetsplug/beetstream/artists.py | 1 - beetsplug/beetstream/search.py | 94 +++++++++++++++++--------------- beetsplug/beetstream/users.py | 38 +++---------- 4 files changed, 61 insertions(+), 80 deletions(-) diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py index c04d2bb..f38d82a 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstream/__init__.py @@ -75,12 +75,8 @@ def func(lib, opts, args): self.config['port'] = int(args.pop(0)) app.config['lib'] = lib - - # Normalizes json output - # app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False - - # app.config['INCLUDE_PATHS'] = self.config['include_paths'] - + app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False + app.config['INCLUDE_PATHS'] = self.config['include_paths'] app.config['never_transcode'] = self.config['never_transcode'].get(False) playlist_directories = [self.config['playlist_dir'].get(None), # Beetstream's own diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index 4b61fb9..8bc3d08 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -55,7 +55,6 @@ def _artists(version: str): ] } } - return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getArtist', methods=["GET", "POST"]) diff --git a/beetsplug/beetstream/search.py b/beetsplug/beetstream/search.py index da55076..407c4b2 100644 --- a/beetsplug/beetstream/search.py +++ b/beetsplug/beetstream/search.py @@ -1,58 +1,64 @@ from beetsplug.beetstream.utils import * from beetsplug.beetstream import app -from flask import g, request, Response -import xml.etree.cElementTree as ET + @app.route('/rest/search2', methods=["GET", "POST"]) @app.route('/rest/search2.view', methods=["GET", "POST"]) def search2(): - return search(ver=2) + return _search(ver=2) @app.route('/rest/search3', methods=["GET", "POST"]) @app.route('/rest/search3.view', methods=["GET", "POST"]) def search3(): - return search(ver=3) - -def search(ver=None): - res_format = request.values.get('f') or 'xml' - query = request.values.get('query') or "" - artistCount = int(request.values.get('artistCount') or 20) - artistOffset = int(request.values.get('artistOffset') or 0) - albumCount = int(request.values.get('albumCount') or 20) - albumOffset = int(request.values.get('albumOffset') or 0) - songCount = int(request.values.get('songCount') or 20) - songOffset = int(request.values.get('songOffset') or 0) - - songs = handleSizeAndOffset(list(g.lib.items("title:{}".format(query.replace("'", "\\'")))), songCount, songOffset) - albums = handleSizeAndOffset(list(g.lib.albums("album:{}".format(query.replace("'", "\\'")))), albumCount, albumOffset) - - with g.lib.transaction() as tx: - rows = tx.query("SELECT DISTINCT albumartist FROM albums") - artists = [row[0] for row in rows] - artists = list(filter(lambda artist: strip_accents(query).lower() in strip_accents(artist).lower(), artists)) - artists.sort(key=lambda name: strip_accents(name).upper()) - artists = handleSizeAndOffset(artists, artistCount, artistOffset) - - if (is_json(res_format)): - return jsonpify(request, wrap_res("searchResult{}".format(version), { - "artist": list(map(map_artist, artists)), - "album": list(map(map_album, albums)), - "song": list(map(map_song, songs)) - })) - else: - root = get_xml_root() - search_result = ET.SubElement(root, 'searchResult{}'.format(version)) + return _search(ver=3) + + +def _search(ver=None): + r = flask.request.values - for artist in artists: - a = ET.SubElement(search_result, 'artist') - map_artist_xml(a, artist) + song_count = int(r.get('songCount', 20)) + song_offset = int(r.get('songOffset', 0)) + album_count = int(r.get('albumCount', 20)) + album_offset = int(r.get('albumOffset', 0)) + artist_count = int(r.get('artistCount', 20)) + artist_offset = int(r.get('artistOffset', 0)) - for album in albums: - a = ET.SubElement(search_result, 'album') - map_album_xml(a, album) + query = r.get('query') or '' - for song in songs: - s = ET.SubElement(search_result, 'song') - map_song_xml(s, song) + if not query: + if ver == 2: + # search2 does not support empty queries: return an empty response + return subsonic_response({}, r.get('f', 'xml'), failed=True) + + # search3 "must support an empty query and return all the data" + # https://opensubsonic.netlify.app/docs/endpoints/search3/ + pattern = "%" + else: + pattern = f"%{query.lower()}%" + + with flask.g.lib.transaction() as tx: + songs = list(tx.query( + "SELECT * FROM items WHERE lower(title) LIKE ? ORDER BY title LIMIT ? OFFSET ?", + (pattern, song_count, song_offset) + )) + albums = list(tx.query( + "SELECT * FROM albums WHERE lower(album) LIKE ? ORDER BY album LIMIT ? OFFSET ?", + (pattern, album_count, album_offset) + )) + artist_rows = list(tx.query( + "SELECT DISTINCT albumartist FROM albums WHERE lower(albumartist) LIKE ? ORDER BY albumartist LIMIT ? OFFSET ?", + (pattern, artist_count, artist_offset) + )) + + artists = [row[0] for row in artist_rows if row[0]] + artists.sort(key=lambda name: strip_accents(name).upper()) - return Response(xml_to_string(root), mimetype='text/xml') + tag = f'searchResult{ver if ver else ""}' + payload = { + tag: { + 'artist': list(map(map_artist, artists)), + 'album': list(map(map_album, albums)), + 'song': list(map(map_song, songs)) + } + } + return subsonic_response(payload, r.get('f', 'xml')) diff --git a/beetsplug/beetstream/users.py b/beetsplug/beetstream/users.py index 669e70a..53ac83a 100644 --- a/beetsplug/beetstream/users.py +++ b/beetsplug/beetstream/users.py @@ -1,14 +1,14 @@ from beetsplug.beetstream.utils import * from beetsplug.beetstream import app -from flask import g, request, Response -import xml.etree.cElementTree as ET +import flask @app.route('/rest/getUser', methods=["GET", "POST"]) @app.route('/rest/getUser.view', methods=["GET", "POST"]) -def user(): - res_format = request.values.get('f') or 'xml' - if (is_json(res_format)): - return jsonpify(request, wrap_res("user", { +def get_user(): + r = flask.request.values + + payload = { + 'user': { "username" : "admin", "email" : "foo@example.com", "scrobblingEnabled" : True, @@ -26,27 +26,7 @@ def user(): "videoConversionRole" : True, "avatarLastChanged" : "1970-01-01T00:00:00.000Z", "folder" : [ 0 ] - })) - else: - root = get_xml_root() - u = ET.SubElement(root, 'user') - u.set("username", "admin") - u.set("email", "foo@example.com") - u.set("scrobblingEnabled", "true") - u.set("adminRole", "true") - u.set("settingsRole", "true") - u.set("downloadRole", "true") - u.set("uploadRole", "true") - u.set("playlistRole", "true") - u.set("coverArtRole", "true") - u.set("commentRole", "true") - u.set("podcastRole", "true") - u.set("streamRole", "true") - u.set("jukeboxRole", "true") - u.set("shareRole", "true") - u.set("videoConversionRole", "true") - u.set("avatarLastChanged", "1970-01-01T00:00:00.000Z") - f = ET.SubElement(u, 'folder') - f.text = "0" + } + } + return subsonic_response(payload, r.get('f', 'xml')) - return Response(xml_to_string(root), mimetype='text/xml') From 9bada762748e33bb23f2a28cfbab5bd85dae40e9 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 19:24:12 +0000 Subject: [PATCH 31/85] Cleaned up functions that are not needed anymore --- beetsplug/beetstream/utils.py | 138 +++++++--------------------------- 1 file changed, 27 insertions(+), 111 deletions(-) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index c6728bb..45e9c78 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -16,6 +16,8 @@ from math import ceil from xml.dom import minidom +API_VERSION = '1.16.1' + DEFAULT_MIME_TYPE = 'application/octet-stream' EXTENSION_TO_MIME_TYPE_FALLBACK = { '.aac' : 'audio/aac', @@ -33,15 +35,11 @@ def strip_accents(s): def timestamp_to_iso(timestamp): return datetime.fromtimestamp(int(timestamp)).isoformat() -def is_json(res_format): - return res_format == 'json' or res_format == 'jsonp' - - -def dict_to_xml(tag, d): +def dict_to_xml(tag: str, data): """ Recursively converts a json-like dict to an XML tree """ elem = ET.Element(tag) - if isinstance(d, dict): - for key, val in d.items(): + if isinstance(data, dict): + for key, val in data.items(): if isinstance(val, (dict, list)): child = dict_to_xml(key, val) elem.append(child) @@ -49,71 +47,52 @@ def dict_to_xml(tag, d): child = ET.Element(key) child.text = str(val) elem.append(child) - elif isinstance(d, list): - for item in d: + elif isinstance(data, list): + for item in data: child = dict_to_xml(tag, item) elem.append(child) else: - elem.text = str(d) + elem.text = str(data) return elem -def subsonic_response(d: dict = {}, format: str = 'xml', failed=False): +def jsonpify(format: str, data: dict): + if format == 'jsonp': + callback = flask.request.values.get("callback") + return f"{callback}({json.dumps(data)});" + else: + return flask.jsonify(data) + +def subsonic_response(data: dict = {}, format: str = 'xml', failed=False): """ Wrap any json-like dict with the subsonic response elements and output the appropriate 'format' (json or xml) """ - STATUS = 'failed' if failed else 'ok' - VERSION = '1.16.1' - - if format == 'json' or format == 'jsonp': + if format.startswith('json'): wrapped = { 'subsonic-response': { - 'status': STATUS, - 'version': VERSION, + 'status': 'failed' if failed else 'ok', + 'version': API_VERSION, 'type': 'Beetstream', 'serverVersion': '1.4.5', 'openSubsonic': True, - **d + **data } } - return jsonpify(flask.request, wrapped) + return jsonpify(format, wrapped) else: - root = dict_to_xml("subsonic-response", d) + root = dict_to_xml("subsonic-response", data) root.set("xmlns", "http://subsonic.org/restapi") - root.set("status", STATUS) - root.set("version", VERSION) + root.set("status", 'failed' if failed else 'ok') + root.set("version", API_VERSION) root.set("type", 'Beetstream') root.set("serverVersion", '1.4.5') root.set("openSubsonic", 'true') - return flask.Response(xml_to_string(root), mimetype="text/xml") + xml_str = minidom.parseString(ET.tostring(root, encoding='unicode', + method='xml', xml_declaration=True)).toprettyxml() -def wrap_res(key, json): - return { - "subsonic-response": { - "status": "ok", - "version": "1.16.1", - key: json - } - } + return flask.Response(xml_str, mimetype="text/xml") -def jsonpify(request, data): - if request.values.get("f") == "jsonp": - callback = request.values.get("callback") - return f"{callback}({json.dumps(data)});" - else: - return flask.jsonify(data) - -def get_xml_root(): - root = ET.Element('subsonic-response') - root.set('xmlns', 'http://subsonic.org/restapi') - root.set('status', 'ok') - root.set('version', '1.16.1') - return root - -def xml_to_string(xml): - # Add declaration: - return minidom.parseString(ET.tostring(xml, encoding='unicode', method='xml', xml_declaration=True)).toprettyxml() def map_album(album): album = dict(album) @@ -137,26 +116,6 @@ def map_album(album): "averageRating": 0 # TODO } -def map_album_xml(xml, album): - album = dict(album) - xml.set("id", album_beetid_to_subid(str(album["id"]))) - xml.set("name", album["album"]) - xml.set("title", album["album"]) - xml.set("album", album["album"]) - xml.set("artist", album["albumartist"]) - xml.set("artistId", artist_name_to_id(album["albumartist"])) - xml.set("parent", artist_name_to_id(album["albumartist"])) - xml.set("isDir", "true") - xml.set("coverArt", album_beetid_to_subid(str(album["id"])) or "") - xml.set("songCount", str(1)) # TODO - xml.set("duration", str(1)) # TODO - xml.set("playCount", str(1)) # TODO - xml.set("created", timestamp_to_iso(album["added"])) - xml.set("year", str(album["year"])) - xml.set("genre", album["genre"]) - xml.set("starred", "1970-01-01T00:00:00.000Z") # TODO - xml.set("averageRating", "0") # TODO - def map_album_list(album): album = dict(album) return { @@ -206,34 +165,6 @@ def map_song(song): "discNumber": song["disc"] } -def map_song_xml(xml, song): - song = dict(song) - path = song["path"].decode('utf-8') - xml.set("id", song_beetid_to_subid(str(song["id"]))) - xml.set("parent", album_beetid_to_subid(str(song["album_id"]))) - xml.set("isDir", "false") - xml.set("title", song["title"]) - xml.set("name", song["title"]) - xml.set("album", song["album"]) - xml.set("artist", song["albumartist"]) - xml.set("track", str(song["track"])) - xml.set("year", str(song["year"])) - xml.set("genre", song["genre"]) - xml.set("coverArt", _cover_art_id(song)), - xml.set("size", str(os.path.getsize(path))) - xml.set("contentType", path_to_mimetype(path)) - xml.set("suffix", song["format"].lower()) - xml.set("duration", str(ceil(song["length"]))) - xml.set("bitRate", str(ceil(song["bitrate"]/1000))) - xml.set("path", path) - xml.set("playCount", str(1)) #TODO - xml.set("created", timestamp_to_iso(song["added"])) - xml.set("albumId", album_beetid_to_subid(str(song["album_id"]))) - xml.set("artistId", artist_name_to_id(song["albumartist"])) - xml.set("type", "music") - 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'])) @@ -250,13 +181,6 @@ def map_artist(artist_name): "artistImageUrl": "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg" } -def map_artist_xml(xml, artist_name): - xml.set("id", artist_name_to_id(artist_name)) - xml.set("name", artist_name) - xml.set("coverArt", "") - xml.set("albumCount", "1") - xml.set("artistImageUrl", "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg") - def map_playlist(playlist): return { 'id': playlist.id, @@ -306,14 +230,6 @@ def path_to_mimetype(path): return DEFAULT_MIME_TYPE -def handleSizeAndOffset(collection, size, offset): - if size is not None: - if offset is not None: - return collection[offset:offset + size] - else: - return collection[0:size] - else: - return collection def genres_splitter(genres_string): delimiters = re.compile('|'.join([';', ',', '/', '\\|'])) From 2b399b6787cc7dae2ee9c506c888786bb9fc3b40 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 19:48:07 +0000 Subject: [PATCH 32/85] Formatting, cleaning, commenting --- beetsplug/beetstream/__init__.py | 4 - beetsplug/beetstream/albums.py | 69 -------- beetsplug/beetstream/artists.py | 4 +- beetsplug/beetstream/coverart.py | 31 ++-- beetsplug/beetstream/general.py | 69 ++++++++ beetsplug/beetstream/playlistprovider.py | 8 +- beetsplug/beetstream/songs.py | 7 +- beetsplug/beetstream/stream.py | 21 +-- beetsplug/beetstream/users.py | 3 + beetsplug/beetstream/utils.py | 192 +++++++++++++---------- 10 files changed, 208 insertions(+), 200 deletions(-) create mode 100644 beetsplug/beetstream/general.py diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py index f38d82a..bc35085 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstream/__init__.py @@ -23,10 +23,6 @@ from flask_cors import CORS from pathlib import Path -ART_ID_PREF = "ar-" -ALB_ID_PREF = "al-" -SNG_ID_PREF = "sg-" - # Flask setup app = flask.Flask(__name__) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 7f4ac9b..8b61eef 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -1,11 +1,7 @@ from beetsplug.beetstream.utils import * from beetsplug.beetstream import app import flask -import re -from typing import List import urllib.parse -from beetsplug.beetstream.artists import artist_payload -from beetsplug.beetstream.songs import song_payload def album_payload(album_id: str) -> dict: @@ -132,69 +128,4 @@ def get_album_list(ver=None): "album": list(map(map_album, albums)) } } - - return subsonic_response(payload, r.get('f', 'xml')) - - -@app.route('/rest/getGenres', methods=["GET", "POST"]) -@app.route('/rest/getGenres.view', methods=["GET", "POST"]) -def genres(): - r = flask.request.values - - with flask.g.lib.transaction() as tx: - mixed_genres = list(tx.query( - """ - SELECT genre, COUNT(*) AS n_song, "" AS n_album FROM items GROUP BY genre - UNION ALL - SELECT genre, "" AS n_song, COUNT(*) AS n_album FROM albums GROUP BY genre - """)) - - g_dict = {} - for row in mixed_genres: - genre_field, n_song, n_album = row - for key in genres_splitter(genre_field): - if key not in g_dict: - g_dict[key] = [0, 0] - if n_song: # Update song count if present - g_dict[key][0] += int(n_song) - if n_album: # Update album count if present - g_dict[key][1] += int(n_album) - - # And convert to list of tuples (only non-empty genres) - g_list = [(k, *v) for k, v in g_dict.items() if k] - g_list.sort(key=lambda g: g[1], reverse=True) - - payload = { - "genres": { - "genre": [dict(zip(["value", "songCount", "albumCount"], g)) for g in g_list] - } - } - return subsonic_response(payload, r.get('f', 'xml')) - - -@app.route('/rest/getMusicDirectory', methods=["GET", "POST"]) -@app.route('/rest/getMusicDirectory.view', methods=["GET", "POST"]) -def musicDirectory(): - # Works pretty much like a file system - # Usually Artist first, then Album, then Songs - r = flask.request.values - - req_id = r.get('id') - - if req_id.startswith(ART_ID_PREF): - payload = artist_payload(req_id) - payload['directory'] = payload.pop('artist') - - elif req_id.startswith(ALB_ID_PREF): - payload = album_payload(req_id) - payload['directory'] = payload.pop('album') - payload['directory']['child'] = payload['directory'].pop('song') - - elif req_id.startswith(SNG_ID_PREF): - payload = song_payload(req_id) - payload['directory'] = payload.pop('song') - - else: - return flask.abort(404) - return subsonic_response(payload, r.get('f', 'xml')) \ No newline at end of file diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index 8bc3d08..fce318a 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -1,7 +1,7 @@ -import time -from collections import defaultdict from beetsplug.beetstream.utils import * from beetsplug.beetstream import app +import time +from collections import defaultdict import flask diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index 2f988bc..e0f0a88 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -1,26 +1,19 @@ from beetsplug.beetstream.utils import * from beetsplug.beetstream import app -from io import BytesIO -from PIL import Image -import flask import os from typing import Union import requests -import shutil -import importlib +from io import BytesIO +from PIL import Image +import flask -ffmpeg_bin = shutil.which("ffmpeg") is not None -ffmpeg_python = importlib.util.find_spec("ffmpeg") is not None -if ffmpeg_python: - import ffmpeg -elif ffmpeg_bin: - import subprocess +have_ffmpeg = FFMPEG_PYTHON or FFMPEG_BIN def extract_cover(path) -> Union[BytesIO, None]: - if ffmpeg_python: + if FFMPEG_PYTHON: img_bytes, _ = ( ffmpeg .input(path) @@ -29,7 +22,7 @@ def extract_cover(path) -> Union[BytesIO, None]: .run(capture_stdout=True, capture_stderr=False, quiet=True) ) - elif ffmpeg_bin: + elif FFMPEG_BIN: command = [ 'ffmpeg', '-i', path, @@ -108,14 +101,16 @@ def get_cover_art(): return response # Fallback: try to extract cover from the song file - cover = extract_cover(item.path) - if cover is not None: - if size: - cover = resize_image(cover, size) - return flask.send_file(cover, mimetype='image/jpeg') + if have_ffmpeg: + cover = extract_cover(item.path) + if cover is not None: + if size: + cover = resize_image(cover, size) + return flask.send_file(cover, mimetype='image/jpeg') # artist requests elif req_id.startswith(ART_ID_PREF): + # TODO pass # Fallback: return empty XML document on error diff --git a/beetsplug/beetstream/general.py b/beetsplug/beetstream/general.py new file mode 100644 index 0000000..c3b6f91 --- /dev/null +++ b/beetsplug/beetstream/general.py @@ -0,0 +1,69 @@ +from beetsplug.beetstream.utils import * +from beetsplug.beetstream import app +from beetsplug.beetstream.artists import artist_payload +from beetsplug.beetstream.albums import album_payload +from beetsplug.beetstream.songs import song_payload +import flask + +@app.route('/rest/getGenres', methods=["GET", "POST"]) +@app.route('/rest/getGenres.view', methods=["GET", "POST"]) +def genres(): + r = flask.request.values + + with flask.g.lib.transaction() as tx: + mixed_genres = list(tx.query( + """ + SELECT genre, COUNT(*) AS n_song, "" AS n_album FROM items GROUP BY genre + UNION ALL + SELECT genre, "" AS n_song, COUNT(*) AS n_album FROM albums GROUP BY genre + """)) + + g_dict = {} + for row in mixed_genres: + genre_field, n_song, n_album = row + for key in genres_splitter(genre_field): + if key not in g_dict: + g_dict[key] = [0, 0] + if n_song: # Update song count if present + g_dict[key][0] += int(n_song) + if n_album: # Update album count if present + g_dict[key][1] += int(n_album) + + # And convert to list of tuples (only non-empty genres) + g_list = [(k, *v) for k, v in g_dict.items() if k] + g_list.sort(key=lambda g: g[1], reverse=True) + + payload = { + "genres": { + "genre": [dict(zip(["value", "songCount", "albumCount"], g)) for g in g_list] + } + } + return subsonic_response(payload, r.get('f', 'xml')) + + +@app.route('/rest/getMusicDirectory', methods=["GET", "POST"]) +@app.route('/rest/getMusicDirectory.view', methods=["GET", "POST"]) +def musicDirectory(): + # Works pretty much like a file system + # Usually Artist first, then Album, then Songs + r = flask.request.values + + req_id = r.get('id') + + if req_id.startswith(ART_ID_PREF): + payload = artist_payload(req_id) + payload['directory'] = payload.pop('artist') + + elif req_id.startswith(ALB_ID_PREF): + payload = album_payload(req_id) + payload['directory'] = payload.pop('album') + payload['directory']['child'] = payload['directory'].pop('song') + + elif req_id.startswith(SNG_ID_PREF): + payload = song_payload(req_id) + payload['directory'] = payload.pop('song') + + else: + return flask.abort(404) + + return subsonic_response(payload, r.get('f', 'xml')) \ No newline at end of file diff --git a/beetsplug/beetstream/playlistprovider.py b/beetsplug/beetstream/playlistprovider.py index 9bc8c41..5cce3b9 100644 --- a/beetsplug/beetstream/playlistprovider.py +++ b/beetsplug/beetstream/playlistprovider.py @@ -1,12 +1,10 @@ -from beetsplug.beetstream.utils import genres_splitter, creation_date +from beetsplug.beetstream.utils import PLY_ID_PREF, genres_splitter, creation_date from beetsplug.beetstream import app import flask from typing import Union, List from pathlib import Path from itertools import chain -PL_ID_PREF = 'plid-' - def parse_m3u(filepath): """ Parses a playlist (m3u, m3u8 or m3a) and yields its entries """ @@ -80,7 +78,7 @@ def parse_m3u(filepath): class Playlist: def __init__(self, path): - self.id = f'{PL_ID_PREF}{path.parent.stem.lower()}-{path.name}' + self.id = f'{PLY_ID_PREF}{path.parent.stem.lower()}-{path.name}' self.name = path.stem self.ctime = creation_date(path) self.mtime = path.stat().st_mtime @@ -125,7 +123,7 @@ def _load_playlist(self, filepath): """ Load playlist data from a file, or from cache if it exists """ file_mtime = filepath.stat().st_mtime - playlist_id = f'{PL_ID_PREF}{'-'.join(filepath.parts[-2:]).lower()}' + playlist_id = f'{PLY_ID_PREF}{'-'.join(filepath.parts[-2:]).lower()}' # Get potential cached version playlist = self._playlists.get(playlist_id) diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index 63a7855..be43c9c 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -81,12 +81,13 @@ def stream_song(): song_id = int(song_subid_to_beetid(r.get('id'))) item = flask.g.lib.get_item(song_id) - itemPath = item.path.decode('utf-8') + item_path = item.get('path', b'').decode('utf-8') + if not item_path: if app.config['never_transcode'] or format == 'raw' or maxBitrate <= 0 or item.bitrate <= maxBitrate * 1000: - return stream.direct(itemPath) + return stream.direct(item_path) else: - return stream.try_to_transcode(itemPath, maxBitrate) + return stream.try_transcode(item_path, maxBitrate) @app.route('/rest/download', methods=["GET", "POST"]) @app.route('/rest/download.view', methods=["GET", "POST"]) diff --git a/beetsplug/beetstream/stream.py b/beetsplug/beetstream/stream.py index ed22e58..76c97d5 100644 --- a/beetsplug/beetstream/stream.py +++ b/beetsplug/beetstream/stream.py @@ -1,24 +1,15 @@ -from beetsplug.beetstream.utils import path_to_mimetype +from beetsplug.beetstream.utils import * +import subprocess import flask -import shutil -import importlib -ffmpeg_bin = shutil.which("ffmpeg") is not None -ffmpeg_python = importlib.util.find_spec("ffmpeg") is not None - -if ffmpeg_python: - import ffmpeg -elif ffmpeg_bin: - import subprocess - -have_ffmpeg = ffmpeg_python or ffmpeg_bin +have_ffmpeg = FFMPEG_PYTHON or FFMPEG_BIN def direct(filePath): return flask.send_file(filePath, mimetype=path_to_mimetype(filePath)) def transcode(filePath, maxBitrate): - if ffmpeg_python: + if FFMPEG_PYTHON: output_stream = ( ffmpeg .input(filePath) @@ -26,7 +17,7 @@ def transcode(filePath, maxBitrate): .output('pipe:', format="mp3", audio_bitrate=maxBitrate * 1000) .run_async(pipe_stdout=True, quiet=True) ) - elif ffmpeg_bin: + elif FFMPEG_BIN: command = [ "ffmpeg", "-i", filePath, @@ -40,7 +31,7 @@ def transcode(filePath, maxBitrate): return flask.Response(output_stream.stdout, mimetype='audio/mpeg') -def try_to_transcode(filePath, maxBitrate): +def try_transcode(filePath, maxBitrate): if have_ffmpeg: return transcode(filePath, maxBitrate) else: diff --git a/beetsplug/beetstream/users.py b/beetsplug/beetstream/users.py index 53ac83a..ba9dd7a 100644 --- a/beetsplug/beetstream/users.py +++ b/beetsplug/beetstream/users.py @@ -2,11 +2,14 @@ from beetsplug.beetstream import app import flask + @app.route('/rest/getUser', methods=["GET", "POST"]) @app.route('/rest/getUser.view', methods=["GET", "POST"]) def get_user(): r = flask.request.values + # TODO - Proper user management + payload = { 'user': { "username" : "admin", diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 45e9c78..6f0bb5b 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -1,9 +1,5 @@ -from beetsplug.beetstream import ALB_ID_PREF, ART_ID_PREF, SNG_ID_PREF import unicodedata from datetime import datetime -from typing import Union -import beets -import subprocess import platform import flask import json @@ -12,11 +8,22 @@ import os import re import posixpath -import xml.etree.cElementTree as ET from math import ceil +import xml.etree.cElementTree as ET from xml.dom import minidom +import shutil +import importlib + API_VERSION = '1.16.1' +BEETSTREAM_VERSION = '1.4.5' + +# Prefixes for Beetstream's internal IDs +ART_ID_PREF = 'ar-' +ALB_ID_PREF = 'al-' +SNG_ID_PREF = 'sg-' +PLY_ID_PREF = 'pl-' + DEFAULT_MIME_TYPE = 'application/octet-stream' EXTENSION_TO_MIME_TYPE_FALLBACK = { @@ -29,70 +36,41 @@ '.opus' : 'audio/opus', } -def strip_accents(s): - return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn') +FFMPEG_BIN = shutil.which("ffmpeg") is not None +FFMPEG_PYTHON = importlib.util.find_spec("ffmpeg") is not None -def timestamp_to_iso(timestamp): - return datetime.fromtimestamp(int(timestamp)).isoformat() +if FFMPEG_PYTHON: + import ffmpeg +elif FFMPEG_BIN: + import subprocess -def dict_to_xml(tag: str, data): - """ Recursively converts a json-like dict to an XML tree """ - elem = ET.Element(tag) - if isinstance(data, dict): - for key, val in data.items(): - if isinstance(val, (dict, list)): - child = dict_to_xml(key, val) - elem.append(child) - else: - child = ET.Element(key) - child.text = str(val) - elem.append(child) - elif isinstance(data, list): - for item in data: - child = dict_to_xml(tag, item) - elem.append(child) - else: - elem.text = str(data) - return elem -def jsonpify(format: str, data: dict): - if format == 'jsonp': - callback = flask.request.values.get("callback") - return f"{callback}({json.dumps(data)});" - else: - return flask.jsonify(data) +# === Beetstream internal IDs makers/readers === +# These IDs are sent to the client once (when it accesses endpoints such as getArtists or getAlbumList +# and the client will then use these to access a specific item via endpoints that need an ID -def subsonic_response(data: dict = {}, format: str = 'xml', failed=False): - """ Wrap any json-like dict with the subsonic response elements - and output the appropriate 'format' (json or xml) """ +def artist_name_to_id(artist_name: str): + base64_name = base64.urlsafe_b64encode(artist_name.encode('utf-8')).decode('utf-8') + return f"{ART_ID_PREF}{base64_name}" - if format.startswith('json'): - wrapped = { - 'subsonic-response': { - 'status': 'failed' if failed else 'ok', - 'version': API_VERSION, - 'type': 'Beetstream', - 'serverVersion': '1.4.5', - 'openSubsonic': True, - **data - } - } - return jsonpify(format, wrapped) +def artist_id_to_name(artist_id: str): + base64_id = artist_id[len(ART_ID_PREF):] + return base64.urlsafe_b64decode(base64_id.encode('utf-8')).decode('utf-8') - else: - root = dict_to_xml("subsonic-response", data) - root.set("xmlns", "http://subsonic.org/restapi") - root.set("status", 'failed' if failed else 'ok') - root.set("version", API_VERSION) - root.set("type", 'Beetstream') - root.set("serverVersion", '1.4.5') - root.set("openSubsonic", 'true') +def album_beetid_to_subid(album_id: str): + return ALB_ID_PREF + album_id - xml_str = minidom.parseString(ET.tostring(root, encoding='unicode', - method='xml', xml_declaration=True)).toprettyxml() +def album_subid_to_beetid(album_id: str): + return album_id[len(ALB_ID_PREF):] + +def song_beetid_to_subid(song_id: str): + return SNG_ID_PREF + song_id + +def song_subid_to_beetid(song_id: str): + return song_id[len(SNG_ID_PREF):] - return flask.Response(xml_str, mimetype="text/xml") +# === Mapping functions to translate Beets to Subsonic dict-like structures === def map_album(album): album = dict(album) @@ -165,11 +143,6 @@ def map_song(song): "discNumber": 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), @@ -193,36 +166,84 @@ def map_playlist(playlist): # 'public': True, } -def artist_name_to_id(artist_name: str): - base64_name = base64.urlsafe_b64encode(artist_name.encode('utf-8')).decode('utf-8') - return f"{ART_ID_PREF}{base64_name}" -def artist_id_to_name(artist_id: str): - base64_id = artist_id[len(ART_ID_PREF):] - return base64.urlsafe_b64decode(base64_id.encode('utf-8')).decode('utf-8') +# === Core response-formatting functions === -def album_beetid_to_subid(album_id: str): - return ALB_ID_PREF + album_id +def dict_to_xml(tag: str, data): + """ Recursively converts a json-like dict to an XML tree """ + elem = ET.Element(tag) + if isinstance(data, dict): + for key, val in data.items(): + if isinstance(val, (dict, list)): + child = dict_to_xml(key, val) + elem.append(child) + else: + child = ET.Element(key) + child.text = str(val) + elem.append(child) + elif isinstance(data, list): + for item in data: + child = dict_to_xml(tag, item) + elem.append(child) + else: + elem.text = str(data) + return elem -def album_subid_to_beetid(album_id: str): - return album_id[len(ALB_ID_PREF):] +def jsonpify(format: str, data: dict): + if format == 'jsonp': + callback = flask.request.values.get("callback") + return f"{callback}({json.dumps(data)});" + else: + return flask.jsonify(data) -def song_beetid_to_subid(song_id: str): - return SNG_ID_PREF + song_id +def subsonic_response(data: dict = {}, format: str = 'xml', failed=False): + """ Wrap any json-like dict with the subsonic response elements + and output the appropriate 'format' (json or xml) """ -def song_subid_to_beetid(song_id: str): - return song_id[len(SNG_ID_PREF):] + if format.startswith('json'): + wrapped = { + 'subsonic-response': { + 'status': 'failed' if failed else 'ok', + 'version': API_VERSION, + 'type': 'Beetstream', + 'serverVersion': BEETSTREAM_VERSION, + 'openSubsonic': True, + **data + } + } + return jsonpify(format, wrapped) + + else: + root = dict_to_xml("subsonic-response", data) + root.set("xmlns", "http://subsonic.org/restapi") + root.set("status", 'failed' if failed else 'ok') + root.set("version", API_VERSION) + root.set("type", 'Beetstream') + root.set("serverVersion", BEETSTREAM_VERSION) + root.set("openSubsonic", 'true') + + xml_str = minidom.parseString(ET.tostring(root, encoding='unicode', + method='xml', xml_declaration=True)).toprettyxml() + + return flask.Response(xml_str, mimetype="text/xml") + + +# === Various other utility functions === + +def strip_accents(s): + return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn') + +def timestamp_to_iso(timestamp): + return datetime.fromtimestamp(int(timestamp)).isoformat() def path_to_mimetype(path): result = mimetypes.guess_type(path)[0] - if result: return result # our mimetype database didn't have information about this file extension. base, ext = posixpath.splitext(path) result = EXTENSION_TO_MIME_TYPE_FALLBACK.get(ext) - if result: return result @@ -230,7 +251,6 @@ def path_to_mimetype(path): return DEFAULT_MIME_TYPE - def genres_splitter(genres_string): delimiters = re.compile('|'.join([';', ',', '/', '\\|'])) return [g.strip().title() @@ -239,7 +259,6 @@ def genres_splitter(genres_string): .replace('.', ' ') for g in re.split(delimiters, genres_string)] - def creation_date(filepath): """ Get a file's creation date See: http://stackoverflow.com/a/39501288/1709587 """ @@ -266,4 +285,9 @@ def creation_date(filepath): return float(f'{timestamp}.{millis}') except: # If that did not work, settle for last modification time - return stat.st_mtime \ No newline at end of file + return stat.st_mtime + +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'])) From f6bb2b1b7f24c7a7c5a19bb33fc3d43f04512cc1 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 22 Mar 2025 19:49:06 +0000 Subject: [PATCH 33/85] 404 when stream item not found --- beetsplug/beetstream/songs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index be43c9c..6375afe 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -83,6 +83,7 @@ def stream_song(): item_path = item.get('path', b'').decode('utf-8') if not item_path: + flask.abort(404) if app.config['never_transcode'] or format == 'raw' or maxBitrate <= 0 or item.bitrate <= maxBitrate * 1000: return stream.direct(item_path) From 7530674275dc2c02fa1f963963e82959bdc15b13 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 23 Mar 2025 00:32:52 +0000 Subject: [PATCH 34/85] Removed unnecessary for loops and sorting calls --- beetsplug/beetstream/artists.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index fce318a..60e287f 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -37,13 +37,10 @@ def _artists(version: str): with flask.g.lib.transaction() as tx: rows = tx.query("SELECT DISTINCT albumartist FROM albums") - all_artists = [r[0] for r in rows if r[0]] - all_artists.sort(key=lambda name: strip_accents(name).upper()) - alphanum_dict = defaultdict(list) - for artist in all_artists: - ind = strip_accents(artist[0]).upper() - alphanum_dict[ind].append(artist) + for row in rows: + if row[0]: + alphanum_dict[strip_accents(row[0][0]).upper()].append(row[0]) payload = { version: { From 623292a219c428db8e146d6698d38e82f62dec7e Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 23 Mar 2025 00:33:59 +0000 Subject: [PATCH 35/85] Fixed small SQL syntax error --- beetsplug/beetstream/general.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/beetsplug/beetstream/general.py b/beetsplug/beetstream/general.py index c3b6f91..1e5d70a 100644 --- a/beetsplug/beetstream/general.py +++ b/beetsplug/beetstream/general.py @@ -5,6 +5,7 @@ from beetsplug.beetstream.songs import song_payload import flask + @app.route('/rest/getGenres', methods=["GET", "POST"]) @app.route('/rest/getGenres.view', methods=["GET", "POST"]) def genres(): @@ -13,9 +14,9 @@ def genres(): with flask.g.lib.transaction() as tx: mixed_genres = list(tx.query( """ - SELECT genre, COUNT(*) AS n_song, "" AS n_album FROM items GROUP BY genre + SELECT genre, COUNT(*) AS n_song, '' AS n_album FROM items GROUP BY genre UNION ALL - SELECT genre, "" AS n_song, COUNT(*) AS n_album FROM albums GROUP BY genre + SELECT genre, '' AS n_song, COUNT(*) AS n_album FROM albums GROUP BY genre """)) g_dict = {} From ec4b1dd6d4724485b796ccabbe493c01daed02d2 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 23 Mar 2025 00:34:36 +0000 Subject: [PATCH 36/85] Added missing import --- beetsplug/beetstream/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py index bc35085..c951685 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstream/__init__.py @@ -16,6 +16,8 @@ """Beetstream is a Beets.io plugin that exposes SubSonic API endpoints.""" from beets.plugins import BeetsPlugin +from beets.dbcore import types +from beets.library import DateType from beets import config from beets import ui import flask @@ -43,6 +45,7 @@ def home(): import beetsplug.beetstream.search import beetsplug.beetstream.songs import beetsplug.beetstream.users +import beetsplug.beetstream.general # Plugin hook class BeetstreamPlugin(BeetsPlugin): From fc9f0c347fe59c41b6fba66a0fc71c208256c991 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 23 Mar 2025 00:35:09 +0000 Subject: [PATCH 37/85] Adding missing fields to artist mapping --- beetsplug/beetstream/utils.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 6f0bb5b..7215b95 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -144,14 +144,16 @@ def map_song(song): } def map_artist(artist_name): + artist_id = artist_name_to_id(artist_name) return { - "id": artist_name_to_id(artist_name), - "name": artist_name, + 'id': artist_id, + 'name': artist_name, + # 'sortName': artist_name, # TODO # "starred": "2021-07-03T06:15:28.757Z", # nothing if not starred - "coverArt": "", - "albumCount": 1, - "artistImageUrl": "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg" + # 'coverArt': artist_id, + 'albumCount': 1, + 'artistImageUrl': "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg" } def map_playlist(playlist): From 1c3d073997c254067e7e7a645b6cb996aa14cfe0 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 23 Mar 2025 03:50:04 +0000 Subject: [PATCH 38/85] Added missing fields to artist mapping --- beetsplug/beetstream/artists.py | 7 +- beetsplug/beetstream/utils.py | 154 +++++++++++++++++++------------- 2 files changed, 94 insertions(+), 67 deletions(-) diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index 60e287f..2490f05 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -35,12 +35,11 @@ def _artists(version: str): r = flask.request.values with flask.g.lib.transaction() as tx: - rows = tx.query("SELECT DISTINCT albumartist FROM albums") + artists = [row[0] for row in tx.query("SELECT DISTINCT albumartist FROM albums WHERE albumartist is NOT NULL")] alphanum_dict = defaultdict(list) - for row in rows: - if row[0]: - alphanum_dict[strip_accents(row[0][0]).upper()].append(row[0]) + for artist in artists: + alphanum_dict[strip_accents(artist[0]).upper()].append(artist) payload = { version: { diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 7215b95..d023384 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -72,75 +72,103 @@ def song_subid_to_beetid(song_id: str): # === Mapping functions to translate Beets to Subsonic dict-like structures === -def map_album(album): +def map_album(album, songs=None): album = dict(album) - return { - "id": album_beetid_to_subid(str(album["id"])), - "name": album["album"], - "title": album["album"], - "album": album["album"], - "artist": album["albumartist"], - "artistId": artist_name_to_id(album["albumartist"]), - "parent": artist_name_to_id(album["albumartist"]), - "isDir": True, - "coverArt": album_beetid_to_subid(str(album["id"])) or "", - "songCount": 1, # TODO - "duration": 1, # TODO - "playCount": 1, # TODO - "created": timestamp_to_iso(album["added"]), - "year": album["year"], - "genre": album["genre"], - "starred": "1970-01-01T00:00:00.000Z", # TODO - "averageRating": 0 # TODO - } - -def map_album_list(album): - album = dict(album) - return { - "id": album_beetid_to_subid(str(album["id"])), - "parent": artist_name_to_id(album["albumartist"]), - "isDir": True, - "title": album["album"], - "album": album["album"], - "artist": album["albumartist"], - "year": album["year"], - "genre": album["genre"], - "coverArt": album_beetid_to_subid(str(album["id"])) or "", - "userRating": 5, # TODO - "averageRating": 5, # TODO - "playCount": 1, # TODO - "created": timestamp_to_iso(album["added"]), - "starred": "" + album_id = album_beetid_to_subid(str(album["id"])) + subsonic_album = { + 'id': album_id, + 'name': album.get('album', ''), + # 'version': 'Deluxe Edition', # TODO + 'artist': album.get('albumartist', ''), + 'year': album.get('year', None), + 'coverArt': album_id, + # 'starred': '1970-01-01T00:00:00.000Z', # TODO + # 'playCount': 1, # TODO + 'genre': album.get('genre', ''), + 'created': timestamp_to_iso(album["added"]), + 'artistId': artist_name_to_id(album["albumartist"]), + # 'played': '1970-01-01T00:00:00.000Z', # TODO + # 'userRating': '1970-01-01T00:00:00.000Z', # TODO + 'recordLabels': [{'name': album.get('label', '')}], + 'musicBrainzId': album.get("mb_albumid", ''), + 'genres': [{'name': g for g in stringlist_splitter(album.get('genre', ''))}], + 'displayArtist': album.get('albumartist', ''), + 'sortName': album["album"], + 'originalReleaseDate': { + 'year': album.get('original_year', 0), + 'month': album.get('original_month', 0), + 'day': album.get('original_day', 0) + }, + 'releaseDate': { + 'year': album.get('year', 0), + 'month': album.get('month', 0), + 'day': album.get('day', 0) + }, + 'isCompilation': bool(album.get('comp', False)), + # 'explicitStatus': 'explicit', # TODO + + # These are only needed when part of a directory response + 'isDir': True, + 'parent': artist_name_to_id(album["albumartist"]), + + # These are only needed when part of an albumList or albumList2 response + 'title': album.get('album', ''), + 'album': album.get('album', ''), } + # Add release types if possible + release_types = stringlist_splitter(album.get('albumtypes', '')) or stringlist_splitter(album.get('albumtype', '')) + print(album.get('album', ''), type(release_types)) + subsonic_album['releaseTypes'] = [r.strip().title() for r in release_types] + + # Add multi-disc info if needed + nb_discs = album.get("disctotal", 1) + if nb_discs > 1: + subsonic_album["discTitles"] = [ + { + 'disc': d, + 'title': ' - '.join(filter(None, [album.get('album', None), f'Disc {d + 1}'])) + } for d in range(nb_discs) + ] + + # Used when part of an AlbumID3WithSongs response OR directory ('song' key gets changed to 'child') + if songs: + subsonic_album['song'] = list(map(map_song, songs)) + subsonic_album['duration'] = int(sum(s.get('length', 0) for s in subsonic_album['song'])) + subsonic_album['songCount'] = len(subsonic_album['song']) + else: + subsonic_album['duration'] = 0 + subsonic_album['songCount'] = 0 + # TODO - These need to be set even when no songs are passed to the mapper... + return subsonic_album def map_song(song): song = dict(song) - path = song["path"].decode('utf-8') + path = song.get('path', b'').decode('utf-8') return { - "id": song_beetid_to_subid(str(song["id"])), - "parent": album_beetid_to_subid(str(song["album_id"])), - "isDir": False, - "title": song["title"], - "name": song["title"], - "album": song["album"], - "artist": song["albumartist"], - "track": song["track"], - "year": song["year"], - "genre": song["genre"], - "coverArt": _cover_art_id(song), - "size": os.path.getsize(path), - "contentType": path_to_mimetype(path), - "suffix": song["format"].lower(), - "duration": ceil(song["length"]), - "bitRate": ceil(song["bitrate"]/1000), - "path": path, - "playCount": 1, #TODO - "created": timestamp_to_iso(song["added"]), + 'id': song_beetid_to_subid(str(song["id"])), + 'parent': album_beetid_to_subid(str(song["album_id"])), + 'isDir': False, + 'title': song["title"], + 'name': song["title"], + 'album': song["album"], + 'artist': song["albumartist"], + 'track': song["track"], + 'year': song["year"], + 'genre': song["genre"], + 'coverArt': _cover_art_id(song), + 'size': os.path.getsize(path), + 'contentType': path_to_mimetype(path), + 'suffix': song["format"].lower(), + 'duration': ceil(song.get("length", 0)), + 'bitRate': ceil(song.get("bitrate", 0)/1000), + 'path': path, + 'playCount': 1, # TODO + 'created': timestamp_to_iso(song["added"]), # "starred": "2019-10-23T04:41:17.107Z", - "albumId": album_beetid_to_subid(str(song["album_id"])), - "artistId": artist_name_to_id(song["albumartist"]), - "type": "music", - "discNumber": song["disc"] + 'albumId': album_beetid_to_subid(str(song["album_id"])), + 'artistId': artist_name_to_id(song["albumartist"]), + 'type': "music", + 'discNumber': song["disc"] } def map_artist(artist_name): @@ -253,7 +281,7 @@ def path_to_mimetype(path): return DEFAULT_MIME_TYPE -def genres_splitter(genres_string): +def stringlist_splitter(genres_string): delimiters = re.compile('|'.join([';', ',', '/', '\\|'])) return [g.strip().title() .replace('Post ', 'Post-') From 856fc0a92d5a2ad9db8b32405483f5b701e48357 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 23 Mar 2025 03:50:29 +0000 Subject: [PATCH 39/85] Added missing fields to artist mapping --- beetsplug/beetstream/albums.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 8b61eef..a4cbc8a 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -11,8 +11,7 @@ def album_payload(album_id: str) -> dict: payload = { "album": { - **map_album(album), - **{"song": list(map(map_song, songs))} + **map_album(album, songs) } } return payload From d6bcfbbb832c3efea1e8521659568d516c533297 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 23 Mar 2025 03:51:04 +0000 Subject: [PATCH 40/85] Renamed a utility function --- beetsplug/beetstream/general.py | 2 +- beetsplug/beetstream/playlistprovider.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/beetsplug/beetstream/general.py b/beetsplug/beetstream/general.py index 1e5d70a..cbd5b17 100644 --- a/beetsplug/beetstream/general.py +++ b/beetsplug/beetstream/general.py @@ -22,7 +22,7 @@ def genres(): g_dict = {} for row in mixed_genres: genre_field, n_song, n_album = row - for key in genres_splitter(genre_field): + for key in stringlist_splitter(genre_field): if key not in g_dict: g_dict[key] = [0, 0] if n_song: # Update song count if present diff --git a/beetsplug/beetstream/playlistprovider.py b/beetsplug/beetstream/playlistprovider.py index 5cce3b9..c8839e7 100644 --- a/beetsplug/beetstream/playlistprovider.py +++ b/beetsplug/beetstream/playlistprovider.py @@ -1,4 +1,4 @@ -from beetsplug.beetstream.utils import PLY_ID_PREF, genres_splitter, creation_date +from beetsplug.beetstream.utils import PLY_ID_PREF, stringlist_splitter, creation_date from beetsplug.beetstream import app import flask from typing import Union, List @@ -48,7 +48,7 @@ def parse_m3u(filepath): continue elif line.startswith('#EXTGENRE:'): - curr_entry['genres'] = genres_splitter(line[10:]) + curr_entry['genres'] = stringlist_splitter(line[10:]) continue elif line.startswith('#EXTM3A'): From 702490a9b9b51f9f232b8b8402fcf4da7ccd6609 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 23 Mar 2025 03:52:14 +0000 Subject: [PATCH 41/85] Some more small performance improvements for search endpoint --- beetsplug/beetstream/search.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/beetsplug/beetstream/search.py b/beetsplug/beetstream/search.py index 407c4b2..a7b7036 100644 --- a/beetsplug/beetstream/search.py +++ b/beetsplug/beetstream/search.py @@ -12,7 +12,6 @@ def search2(): def search3(): return _search(ver=3) - def _search(ver=None): r = flask.request.values @@ -23,7 +22,10 @@ def _search(ver=None): artist_count = int(r.get('artistCount', 20)) artist_offset = int(r.get('artistOffset', 0)) - query = r.get('query') or '' + query = r.get('query', '') + # Remove surrounding quotes if present + if query.startswith('"') and query.endswith('"'): + query = query[1:-1] if not query: if ver == 2: @@ -45,12 +47,13 @@ def _search(ver=None): "SELECT * FROM albums WHERE lower(album) LIKE ? ORDER BY album LIMIT ? OFFSET ?", (pattern, album_count, album_offset) )) - artist_rows = list(tx.query( - "SELECT DISTINCT albumartist FROM albums WHERE lower(albumartist) LIKE ? ORDER BY albumartist LIMIT ? OFFSET ?", + artists = [row[0] for row in tx.query( + """SELECT DISTINCT albumartist FROM albums WHERE lower(albumartist) LIKE ? + and albumartist is NOT NULL LIMIT ? OFFSET ?""", (pattern, artist_count, artist_offset) - )) + )] - artists = [row[0] for row in artist_rows if row[0]] + # TODO - do the sort in the SQL query instead? artists.sort(key=lambda name: strip_accents(name).upper()) tag = f'searchResult{ver if ver else ""}' From 79ab9f672ff06b42e83288bab958b59aa7ad1a08 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 23 Mar 2025 03:56:56 +0000 Subject: [PATCH 42/85] Forgot a debug print, oops --- beetsplug/beetstream/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index d023384..fe2e2a9 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -117,7 +117,6 @@ def map_album(album, songs=None): } # Add release types if possible release_types = stringlist_splitter(album.get('albumtypes', '')) or stringlist_splitter(album.get('albumtype', '')) - print(album.get('album', ''), type(release_types)) subsonic_album['releaseTypes'] = [r.strip().title() for r in release_types] # Add multi-disc info if needed From 200c74d3ad9a53250f752b85b794243738a0fa97 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 23 Mar 2025 11:43:13 +0000 Subject: [PATCH 43/85] smol fix for album type processing --- beetsplug/beetstream/utils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index fe2e2a9..6faa8ae 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -116,8 +116,11 @@ def map_album(album, songs=None): 'album': album.get('album', ''), } # Add release types if possible - release_types = stringlist_splitter(album.get('albumtypes', '')) or stringlist_splitter(album.get('albumtype', '')) - subsonic_album['releaseTypes'] = [r.strip().title() for r in release_types] + release_types = album.get('albumtypes', '') or album.get('albumtype', '') + if isinstance(release_types, str): + subsonic_album['releaseTypes'] = [r.strip().title() for r in stringlist_splitter(release_types)] + else: + subsonic_album['releaseTypes'] = [r.strip().title() for r in release_types] # Add multi-disc info if needed nb_discs = album.get("disctotal", 1) From 7cce6134b0aa8093dede1ebc75fe0425fdef88b4 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 23 Mar 2025 21:59:44 +0000 Subject: [PATCH 44/85] Refactor of the mapping functions and inclusion of more fields, for albums and songs --- beetsplug/beetstream/__init__.py | 14 + beetsplug/beetstream/albums.py | 26 +- beetsplug/beetstream/artists.py | 26 +- beetsplug/beetstream/coverart.py | 6 +- beetsplug/beetstream/dummy.py | 27 -- beetsplug/beetstream/general.py | 65 ++++- beetsplug/beetstream/playlistprovider.py | 4 +- beetsplug/beetstream/search.py | 5 +- beetsplug/beetstream/songs.py | 14 +- beetsplug/beetstream/stream.py | 2 +- beetsplug/beetstream/utils.py | 340 ++++++++++++++--------- 11 files changed, 329 insertions(+), 200 deletions(-) diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py index c951685..9e1e51d 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstream/__init__.py @@ -62,6 +62,19 @@ def __init__(self): 'playlist_dir': '', }) + item_types = { + # We use the same fields as the MPDStats plugin for interoperability + 'play_count': types.INTEGER, + 'last_played': DateType(), + 'last_liked': DateType(), + 'stars_rating': types.INTEGER # ... except this one, it's a different rating system from MPDStats' "rating" + } + + # album_types = { + # 'last_liked_album': DateType(), + # 'stars_rating_album': types.INTEGER + # } + def commands(self): cmd = ui.Subcommand('beetstream', help='run Beetstream server, exposing SubSonic API') cmd.parser.add_option('-d', '--debug', action='store_true', default=False, help='debug mode') @@ -73,6 +86,7 @@ def func(lib, opts, args): if args: self.config['port'] = int(args.pop(0)) + app.config['root_directory'] = Path(config['directory'].get()) app.config['lib'] = lib app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False app.config['INCLUDE_PATHS'] = self.config['include_paths'] diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index a4cbc8a..a7202fd 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -2,16 +2,17 @@ from beetsplug.beetstream import app import flask import urllib.parse +from functools import partial -def album_payload(album_id: str) -> dict: - album_id = int(album_subid_to_beetid(album_id)) - album = flask.g.lib.get_album(album_id) - songs = sorted(album.items(), key=lambda s: s.track) +def album_payload(subsonic_album_id: str, with_songs=True) -> dict: + beets_album_id = stb_album(subsonic_album_id) + + album = flask.g.lib.get_album(beets_album_id) payload = { "album": { - **map_album(album, songs) + **map_album(album, with_songs=with_songs) } } return payload @@ -21,9 +22,8 @@ def album_payload(album_id: str) -> dict: @app.route('/rest/getAlbum.view', methods=["GET", "POST"]) def get_album(): r = flask.request.values - album_id = r.get('id') - payload = album_payload(album_id) + payload = album_payload(album_id, with_songs=True) return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getAlbumInfo', methods=["GET", "POST"]) @@ -40,7 +40,7 @@ def _album_info(ver=None): r = flask.request.values req_id = r.get('id') - album_id = int(album_subid_to_beetid(req_id)) + album_id = int(stb_album(req_id)) album = flask.g.lib.get_album(album_id) artist_quot = urllib.parse.quote(album.get('albumartist', '')) @@ -92,7 +92,7 @@ def get_album_list(ver=None): if sort_by == 'byGenre' and genre_filter: conditions.append("lower(genre) LIKE ?") - params.append(f"%{genre_filter.lower().strip()}%") + params.append(f"%{genre_filter.strip().lower()}%") if conditions: query += " WHERE " + " AND ".join(conditions) @@ -113,18 +113,20 @@ def get_album_list(ver=None): elif sort_by == 'random': query += " ORDER BY RANDOM()" + # TODO - sort_by: highest, frequent + # Add LIMIT and OFFSET for pagination query += " LIMIT ? OFFSET ?" params.extend([size, offset]) # Execute the query within a transaction with flask.g.lib.transaction() as tx: - albums = list(tx.query(query, params)) + albums = tx.query(query, params) tag = f"albumList{ver if ver else ''}" payload = { - tag: { - "album": list(map(map_album, albums)) + tag: { # albumList response does not include songs + "album": list(map(partial(map_album, with_songs=False), albums)) } } return subsonic_response(payload, r.get('f', 'xml')) \ No newline at end of file diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index 2490f05..5b66c67 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -2,23 +2,27 @@ from beetsplug.beetstream import app import time from collections import defaultdict +from functools import partial import flask -def artist_payload(artist_id: str) -> dict: +def artist_payload(subsonic_artist_id: str, with_albums=True) -> dict: - artist_name = artist_id_to_name(artist_id).replace("'", "\\'") - - albums = flask.g.lib.albums(artist_name) - albums = filter(lambda a: a.albumartist == artist_name, albums) + artist_name = stb_artist(subsonic_artist_id) payload = { - "artist": { - "id": artist_id, - "name": artist_name, - "child": list(map(map_album, albums)) + 'artist': { + 'id': subsonic_artist_id, + 'name': artist_name, } } + + # When part of a directory response or a ArtistWithAlbumsID3 response + if with_albums: + albums = flask.g.lib.albums(f'albumartist:{artist_name}') + # I don't think there is any endpoint that returns an artist with albums AND songs? + payload['artist']['album'] = list(map(partial(map_album, with_songs=False), albums)) + return payload @app.route('/rest/getArtists', methods=["GET", "POST"]) @@ -59,7 +63,7 @@ def get_artist(): r = flask.request.values artist_id = r.get('id') - payload = artist_payload(artist_id) + payload = artist_payload(artist_id, with_albums=True) # getArtist endpoint needs to include albums return subsonic_response(payload, r.get('f', 'xml')) @@ -70,7 +74,7 @@ def artistInfo2(): r = flask.request.values - artist_name = artist_id_to_name(r.get('id')) + artist_name = stb_artist(r.get('id')) payload = { "artistInfo2": { diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index e0f0a88..a259796 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -58,7 +58,7 @@ def send_album_art(album_id, size=None): if size: cover = resize_image(art_path, size) return flask.send_file(cover, mimetype='image/jpeg') - return flask.send_file(art_path, mimetype=path_to_mimetype(art_path)) + return flask.send_file(art_path, mimetype=get_mimetype(art_path)) mbid = album.get('mb_albumid') if mbid: @@ -85,14 +85,14 @@ def get_cover_art(): # album requests if req_id.startswith(ALB_ID_PREF): - album_id = int(album_subid_to_beetid(req_id)) + album_id = int(stb_album(req_id)) response = send_album_art(album_id, size) if response is not None: return response # song requests elif req_id.startswith(SNG_ID_PREF): - item_id = int(song_subid_to_beetid(req_id)) + item_id = int(stb_song(req_id)) item = flask.g.lib.get_item(item_id) album_id = item.get('album_id') if album_id: diff --git a/beetsplug/beetstream/dummy.py b/beetsplug/beetstream/dummy.py index 0486e63..41f2d3f 100644 --- a/beetsplug/beetstream/dummy.py +++ b/beetsplug/beetstream/dummy.py @@ -13,30 +13,3 @@ def ping(): r = flask.request.values return subsonic_response({}, r.get('f', 'xml')) - -@app.route('/rest/getLicense', methods=["GET", "POST"]) -@app.route('/rest/getLicense.view', methods=["GET", "POST"]) -def get_license(): - r = flask.request.values - - payload = { - 'license': { - "valid": True - } - } - return subsonic_response(payload, r.get('f', 'xml')) - -@app.route('/rest/getMusicFolders', methods=["GET", "POST"]) -@app.route('/rest/getMusicFolders.view', methods=["GET", "POST"]) -def music_folder(): - r = flask.request.values - - payload = { - 'musicFolders': { - "musicFolder": [{ - "id": 0, - "name": "Music" # TODO - This needs to be the real name of beets's config 'directory' key - }] - } - } - return subsonic_response(payload, r.get('f', 'xml')) diff --git a/beetsplug/beetstream/general.py b/beetsplug/beetstream/general.py index cbd5b17..2c20b26 100644 --- a/beetsplug/beetstream/general.py +++ b/beetsplug/beetstream/general.py @@ -6,9 +6,30 @@ import flask +def musicdirectory_payload(subsonic_musicdirectory_id: str, with_artists=True) -> dict: + + # Only one possible root directory in beets (?), so just return its name + directory_name = app.config['root_directory'].name + + payload = { + 'musicFolders': { + 'musicFolder': [{ + 'id': subsonic_musicdirectory_id, + 'name': directory_name + }] + } + } + + if with_artists: + # TODO + pass + + return payload + + @app.route('/rest/getGenres', methods=["GET", "POST"]) @app.route('/rest/getGenres.view', methods=["GET", "POST"]) -def genres(): +def get_genres(): r = flask.request.values with flask.g.lib.transaction() as tx: @@ -22,7 +43,7 @@ def genres(): g_dict = {} for row in mixed_genres: genre_field, n_song, n_album = row - for key in stringlist_splitter(genre_field): + for key in genres_formatter(genre_field): if key not in g_dict: g_dict[key] = [0, 0] if n_song: # Update song count if present @@ -42,9 +63,31 @@ def genres(): return subsonic_response(payload, r.get('f', 'xml')) +@app.route('/rest/getLicense', methods=["GET", "POST"]) +@app.route('/rest/getLicense.view', methods=["GET", "POST"]) +def get_license(): + r = flask.request.values + + payload = { + 'license': { + "valid": True + } + } + return subsonic_response(payload, r.get('f', 'xml')) + +@app.route('/rest/getMusicFolders', methods=["GET", "POST"]) +@app.route('/rest/getMusicFolders.view', methods=["GET", "POST"]) +def get_music_folders(): + r = flask.request.values + + payload = musicdirectory_payload(subsonic_musicdirectory_id='m-0', with_artists=False) + + return subsonic_response(payload, r.get('f', 'xml')) + + @app.route('/rest/getMusicDirectory', methods=["GET", "POST"]) @app.route('/rest/getMusicDirectory.view', methods=["GET", "POST"]) -def musicDirectory(): +def get_music_directory(): # Works pretty much like a file system # Usually Artist first, then Album, then Songs r = flask.request.values @@ -52,11 +95,12 @@ def musicDirectory(): req_id = r.get('id') if req_id.startswith(ART_ID_PREF): - payload = artist_payload(req_id) + payload = artist_payload(req_id, with_albums=True) # make sure to include albums payload['directory'] = payload.pop('artist') + payload['directory']['child'] = payload['directory'].pop('album') elif req_id.startswith(ALB_ID_PREF): - payload = album_payload(req_id) + payload = album_payload(req_id, with_songs=True) # make sure to include songs payload['directory'] = payload.pop('album') payload['directory']['child'] = payload['directory'].pop('song') @@ -64,7 +108,16 @@ def musicDirectory(): payload = song_payload(req_id) payload['directory'] = payload.pop('song') + elif req_id == 'm-0': + payload = musicdirectory_payload('m-0', with_artists=True) + + # TODO - Add missing fields to artist mapper so we can return a directory with artist children + # payload['directory'] = payload.pop('artist') + # payload['directory']['child'] = payload['directory'].pop('album') + else: return flask.abort(404) - return subsonic_response(payload, r.get('f', 'xml')) \ No newline at end of file + return subsonic_response(payload, r.get('f', 'xml')) +## + diff --git a/beetsplug/beetstream/playlistprovider.py b/beetsplug/beetstream/playlistprovider.py index c8839e7..276d439 100644 --- a/beetsplug/beetstream/playlistprovider.py +++ b/beetsplug/beetstream/playlistprovider.py @@ -1,4 +1,4 @@ -from beetsplug.beetstream.utils import PLY_ID_PREF, stringlist_splitter, creation_date +from beetsplug.beetstream.utils import PLY_ID_PREF, genres_formatter, creation_date from beetsplug.beetstream import app import flask from typing import Union, List @@ -48,7 +48,7 @@ def parse_m3u(filepath): continue elif line.startswith('#EXTGENRE:'): - curr_entry['genres'] = stringlist_splitter(line[10:]) + curr_entry['genres'] = genres_formatter(line[10:]) continue elif line.startswith('#EXTM3A'): diff --git a/beetsplug/beetstream/search.py b/beetsplug/beetstream/search.py index a7b7036..2424e32 100644 --- a/beetsplug/beetstream/search.py +++ b/beetsplug/beetstream/search.py @@ -1,5 +1,6 @@ from beetsplug.beetstream.utils import * from beetsplug.beetstream import app +from functools import partial @app.route('/rest/search2', methods=["GET", "POST"]) @@ -59,8 +60,8 @@ def _search(ver=None): tag = f'searchResult{ver if ver else ""}' payload = { tag: { - 'artist': list(map(map_artist, artists)), - 'album': list(map(map_album, albums)), + 'artist': list(map(partial(map_artist, with_albums=False), artists)), # no need to include albums twice + 'album': list(map(partial(map_album, with_songs=False), albums)), # no need to include songs twice 'song': list(map(map_song, songs)) } } diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index 6375afe..cc49a3d 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -3,10 +3,9 @@ import flask -def song_payload(song_id: str) -> dict: - song_id = int(song_subid_to_beetid(song_id)) - song_item = flask.g.lib.get_item(song_id) - +def song_payload(subsonic_song_id: str) -> dict: + beets_song_id = stb_song(subsonic_song_id) + song_item = flask.g.lib.get_item(beets_song_id) payload = { 'song': map_song(song_item) } @@ -78,7 +77,7 @@ def stream_song(): maxBitrate = int(r.get('maxBitRate') or 0) format = r.get('format') - song_id = int(song_subid_to_beetid(r.get('id'))) + song_id = int(stb_song(r.get('id'))) item = flask.g.lib.get_item(song_id) item_path = item.get('path', b'').decode('utf-8') @@ -95,17 +94,16 @@ def stream_song(): def download_song(): r = flask.request.values - song_id = int(song_subid_to_beetid(r.get('id'))) + song_id = int(stb_song(r.get('id'))) item = flask.g.lib.get_item(song_id) return stream.direct(item.path.decode('utf-8')) -# TODO link with Last.fm or ListenBrainz @app.route('/rest/getTopSongs', methods=["GET", "POST"]) @app.route('/rest/getTopSongs.view', methods=["GET", "POST"]) def get_top_songs(): - # TODO + # TODO - Use the play_count, and/or link with Last.fm or ListenBrainz r = flask.request.values diff --git a/beetsplug/beetstream/stream.py b/beetsplug/beetstream/stream.py index 76c97d5..0556ed8 100644 --- a/beetsplug/beetstream/stream.py +++ b/beetsplug/beetstream/stream.py @@ -6,7 +6,7 @@ def direct(filePath): - return flask.send_file(filePath, mimetype=path_to_mimetype(filePath)) + return flask.send_file(filePath, mimetype=get_mimetype(filePath)) def transcode(filePath, maxBitrate): if FFMPEG_PYTHON: diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 6faa8ae..753d38b 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -1,14 +1,15 @@ import unicodedata from datetime import datetime import platform +from pathlib import Path +from typing import Union import flask import json import base64 import mimetypes import os import re -import posixpath -from math import ceil +from beets import library import xml.etree.cElementTree as ET from xml.dom import minidom import shutil @@ -25,17 +26,6 @@ PLY_ID_PREF = 'pl-' -DEFAULT_MIME_TYPE = 'application/octet-stream' -EXTENSION_TO_MIME_TYPE_FALLBACK = { - '.aac' : 'audio/aac', - '.flac' : 'audio/flac', - '.mp3' : 'audio/mpeg', - '.mp4' : 'audio/mp4', - '.m4a' : 'audio/mp4', - '.ogg' : 'audio/ogg', - '.opus' : 'audio/opus', -} - FFMPEG_BIN = shutil.which("ffmpeg") is not None FFMPEG_PYTHON = importlib.util.find_spec("ffmpeg") is not None @@ -49,132 +39,210 @@ # These IDs are sent to the client once (when it accesses endpoints such as getArtists or getAlbumList # and the client will then use these to access a specific item via endpoints that need an ID -def artist_name_to_id(artist_name: str): - base64_name = base64.urlsafe_b64encode(artist_name.encode('utf-8')).decode('utf-8') - return f"{ART_ID_PREF}{base64_name}" +def bts_artist(beet_artist_name): + base64_name = base64.urlsafe_b64encode(str(beet_artist_name).encode('utf-8')) + return f"{ART_ID_PREF}{base64_name.rstrip(b"=").decode('utf-8')}" -def artist_id_to_name(artist_id: str): - base64_id = artist_id[len(ART_ID_PREF):] - return base64.urlsafe_b64decode(base64_id.encode('utf-8')).decode('utf-8') +def stb_artist(subsonic_artist_id): + subsonic_artist_id = str(subsonic_artist_id)[len(ART_ID_PREF):] + padding = 4 - (len(subsonic_artist_id) % 4) + return base64.urlsafe_b64decode(subsonic_artist_id + ("=" * padding)).decode('utf-8') -def album_beetid_to_subid(album_id: str): - return ALB_ID_PREF + album_id +def bts_album(beet_album_id): + return f'{ALB_ID_PREF}{beet_album_id}' -def album_subid_to_beetid(album_id: str): - return album_id[len(ALB_ID_PREF):] +def stb_album(subsonic_album_id): + return int(str(subsonic_album_id)[len(ALB_ID_PREF):]) -def song_beetid_to_subid(song_id: str): - return SNG_ID_PREF + song_id +def bts_song(beet_song_id): + return f'{SNG_ID_PREF}{beet_song_id}' -def song_subid_to_beetid(song_id: str): - return song_id[len(SNG_ID_PREF):] +def stb_song(subsonic_song_id): + return int(str(subsonic_song_id)[len(SNG_ID_PREF):]) # === Mapping functions to translate Beets to Subsonic dict-like structures === -def map_album(album, songs=None): - album = dict(album) - album_id = album_beetid_to_subid(str(album["id"])) - subsonic_album = { - 'id': album_id, - 'name': album.get('album', ''), - # 'version': 'Deluxe Edition', # TODO - 'artist': album.get('albumartist', ''), - 'year': album.get('year', None), - 'coverArt': album_id, - # 'starred': '1970-01-01T00:00:00.000Z', # TODO - # 'playCount': 1, # TODO - 'genre': album.get('genre', ''), - 'created': timestamp_to_iso(album["added"]), - 'artistId': artist_name_to_id(album["albumartist"]), - # 'played': '1970-01-01T00:00:00.000Z', # TODO - # 'userRating': '1970-01-01T00:00:00.000Z', # TODO - 'recordLabels': [{'name': album.get('label', '')}], - 'musicBrainzId': album.get("mb_albumid", ''), - 'genres': [{'name': g for g in stringlist_splitter(album.get('genre', ''))}], - 'displayArtist': album.get('albumartist', ''), - 'sortName': album["album"], +# TODO - Support multiartists lists!!! See https://opensubsonic.netlify.app/docs/responses/child/ + +def map_media(beets_object: Union[dict, library.LibModel]): + beets_object = dict(beets_object) + + artist_name = beets_object.get('albumartist', '') + + # Common fields to albums and songs + subsonic_media = { + 'artist': artist_name, + 'artistId': bts_artist(artist_name), + 'displayArtist': artist_name, + 'displayAlbumArtist': artist_name, + 'album': beets_object.get('album', ''), + 'year': beets_object.get('year', 0), + 'genre': beets_object.get('genre', ''), + 'genres': [{'name': g} for g in genres_formatter(beets_object.get('genre', ''))], + 'created': timestamp_to_iso(beets_object.get('added')) or datetime.now().isoformat(), # default to now? 'originalReleaseDate': { - 'year': album.get('original_year', 0), - 'month': album.get('original_month', 0), - 'day': album.get('original_day', 0) + 'year': beets_object.get('original_year', 0), + 'month': beets_object.get('original_month', 0), + 'day': beets_object.get('original_day', 0) }, 'releaseDate': { - 'year': album.get('year', 0), - 'month': album.get('month', 0), - 'day': album.get('day', 0) + 'year': beets_object.get('year', 0), + 'month': beets_object.get('month', 0), + 'day': beets_object.get('day', 0) }, + 'replayGain': { + 'albumGain': (beets_object.get('rg_album_gain') or 0) or ((beets_object.get('r128_album_gain') or 107) - 107), + 'albumPeak': beets_object.get('rg_album_peak') or 0 + } + } + return subsonic_media + +def map_album(album_object: Union[dict, library.Album], with_songs=True) -> dict: + album = dict(album_object) + + subsonic_album = map_media(album) + + beets_album_id = album.get('id', 0) + subsonic_album_id = bts_album(beets_album_id) + album_name = album.get('album', '') + + album_specific = { + 'id': subsonic_album_id, + 'musicBrainzId': album.get('mb_albumid', ''), + 'name': album_name, + 'sortName': album_name, + # 'version': 'Deluxe Edition', # TODO - Use the 'media' field maybe? + 'coverArt': subsonic_album_id, + + # 'starred': timestamp_to_iso(album.get('last_liked_album', 0)), + # 'userRating': album.get('stars_rating_album', 0), + + 'recordLabels': [{'name': l for l in stringlist_splitter(album.get('label', ''))}], 'isCompilation': bool(album.get('comp', False)), - # 'explicitStatus': 'explicit', # TODO # These are only needed when part of a directory response 'isDir': True, - 'parent': artist_name_to_id(album["albumartist"]), + 'parent': subsonic_album['artistId'], - # These are only needed when part of an albumList or albumList2 response - 'title': album.get('album', ''), - 'album': album.get('album', ''), + # Title field is required for Child responses (also used in albumList or albumList2 responses) + 'title': album.get('album'), + + # This is only needed when part of a Child response + 'mediaType': 'album' } + subsonic_album.update(album_specific) + # Add release types if possible release_types = album.get('albumtypes', '') or album.get('albumtype', '') if isinstance(release_types, str): - subsonic_album['releaseTypes'] = [r.strip().title() for r in stringlist_splitter(release_types)] + subsonic_album['releaseTypes'] = stringlist_splitter(release_types) else: subsonic_album['releaseTypes'] = [r.strip().title() for r in release_types] # Add multi-disc info if needed - nb_discs = album.get("disctotal", 1) + nb_discs = album.get('disctotal', 1) if nb_discs > 1: subsonic_album["discTitles"] = [ - { - 'disc': d, - 'title': ' - '.join(filter(None, [album.get('album', None), f'Disc {d + 1}'])) - } for d in range(nb_discs) + {'disc': d, 'title': ' - '.join(filter(None, [album.get('album', None), f'Disc {d + 1}']))} + for d in range(nb_discs) ] - # Used when part of an AlbumID3WithSongs response OR directory ('song' key gets changed to 'child') - if songs: - subsonic_album['song'] = list(map(map_song, songs)) - subsonic_album['duration'] = int(sum(s.get('length', 0) for s in subsonic_album['song'])) - subsonic_album['songCount'] = len(subsonic_album['song']) + # Songs should be included when part of either: + # - an AlbumID3WithSongs response + # - a directory response (in which case the 'song' key needs to be renamed to 'child') + + # Even if not including songs in the response, we still need to have their count and duration... + if not isinstance(album_object, library.Album): + # ...so in case album_object comes from a direct SQL transaction, we need to query once more + songs = list(flask.g.lib.items(f'album_id:{beets_album_id}')) else: - subsonic_album['duration'] = 0 - subsonic_album['songCount'] = 0 - # TODO - These need to be set even when no songs are passed to the mapper... + # ...if it is a beets.library.Album object, we already have them + songs = list(album_object.items()) + + if with_songs: + songs.sort(key=lambda s: s.track) # Is it really necessary to sort them? + subsonic_album['song'] = list(map(map_song, songs)) + + # Add remaining required fields + subsonic_album['duration'] = round(sum(s.get('length', 0) for s in songs)) + subsonic_album['songCount'] = len(songs) + + # Optional field + songs_ratings = [s.get('stars_rating', 0) for s in subsonic_album.get('song', []) if s.get('stars_rating', 0)] + subsonic_album['averageRating'] = sum(songs_ratings) / len(songs_ratings) if songs_ratings else 0 + return subsonic_album -def map_song(song): - song = dict(song) - path = song.get('path', b'').decode('utf-8') - return { - 'id': song_beetid_to_subid(str(song["id"])), - 'parent': album_beetid_to_subid(str(song["album_id"])), +def map_song(song_object): + song = dict(song_object) + + subsonic_song = map_media(song) + + song_id = bts_song(song.get('id', 0)) + song_name = song.get('title', '') + song_filepath = song.get('path', b'').decode('utf-8') + + album_id = bts_album(song.get('album_id', 0)) + + song_specific = { + 'id': song_id, + 'musicBrainzId': song.get('mb_albumid', ''), + 'name': song_name, + 'sortName': song_name, + 'albumId': album_id, + 'coverArt': album_id or song_id, + + 'track': song.get('track', 1), + 'path': song_filepath if os.path.isfile(song_filepath) else '', + + 'played': timestamp_to_iso(song.get('last_played', 0)), + 'starred': timestamp_to_iso(song.get('last_liked', 0)), + 'playCount': song.get('play_count', 0), + 'userRating': song.get('stars_rating', 0), + + 'duration': round(song.get('length', 0)), + 'bpm': song.get('bpm', 0), + 'bitRate': round(song.get('bitrate', 0) / 1000) or 0, + 'bitDepth': song.get('bitdepth', 0), + 'samplingRate': song.get('samplerate', 0), + 'channelCount': song.get('channels', 2), + 'discNumber': song.get('disc', 0), + 'comment': song.get('comment', ''), + + # These are only needed when part of a directory response 'isDir': False, - 'title': song["title"], - 'name': song["title"], - 'album': song["album"], - 'artist': song["albumartist"], - 'track': song["track"], - 'year': song["year"], - 'genre': song["genre"], - 'coverArt': _cover_art_id(song), - 'size': os.path.getsize(path), - 'contentType': path_to_mimetype(path), - 'suffix': song["format"].lower(), - 'duration': ceil(song.get("length", 0)), - 'bitRate': ceil(song.get("bitrate", 0)/1000), - 'path': path, - 'playCount': 1, # TODO - 'created': timestamp_to_iso(song["added"]), - # "starred": "2019-10-23T04:41:17.107Z", - 'albumId': album_beetid_to_subid(str(song["album_id"])), - 'artistId': artist_name_to_id(song["albumartist"]), - 'type': "music", - 'discNumber': song["disc"] + 'parent': album_id or subsonic_song['artistId'], + + # TODO - is there really no chance to have videos in beets' database? + 'isVideo': False, + 'type': 'music', + + # Title field is required for Child responses + 'title': song_name, + + # This is only needed when part of a Child response + 'mediaType': 'song' } + subsonic_song.update(song_specific) + + subsonic_song['replayGain'].update( + { + 'trackGain': (song.get('rg_track_gain') or 0) or ((song.get('r128_track_gain') or 107) - 107), + 'trackPeak': song.get('rg_track_peak', 0) + } + ) + + # Add remaining filetype-related elements with fallbacks + subsonic_song['suffix'] = song.get('format').lower() or subsonic_song['path'].rsplit('.', 1)[-1].lower() + subsonic_song['size'] = os.path.getsize(subsonic_song['path']) or round(song.get('bitrate', 0) * song.get('length', 0) / 8) + subsonic_song['contentType'] = get_mimetype(subsonic_song.get('path', None) or subsonic_song.get('suffix', None)) + + return subsonic_song + def map_artist(artist_name): - artist_id = artist_name_to_id(artist_name) + artist_id = bts_artist(artist_name) return { 'id': artist_id, 'name': artist_name, @@ -183,7 +251,10 @@ def map_artist(artist_name): # "starred": "2021-07-03T06:15:28.757Z", # nothing if not starred # 'coverArt': artist_id, 'albumCount': 1, - 'artistImageUrl': "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg" + 'artistImageUrl': "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg", + + # This is only needed when part of a Child response + 'mediaType': 'artist' } def map_playlist(playlist): @@ -266,30 +337,48 @@ def strip_accents(s): return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn') def timestamp_to_iso(timestamp): - return datetime.fromtimestamp(int(timestamp)).isoformat() - -def path_to_mimetype(path): - result = mimetypes.guess_type(path)[0] - if result: - return result - - # our mimetype database didn't have information about this file extension. - base, ext = posixpath.splitext(path) - result = EXTENSION_TO_MIME_TYPE_FALLBACK.get(ext) - if result: - return result - - flask.current_app.logger.warning(f"No mime type mapped for {ext} extension: {path}") - - return DEFAULT_MIME_TYPE + return datetime.fromtimestamp(timestamp).isoformat() if timestamp else None + +def get_mimetype(path): + + if isinstance(path, (bytes, bytearray)): + path = path.decode('utf-8') + elif isinstance(path, Path): + path = path.as_posix() + if not '.' in path: # Assume the passed arg is just an extension + path = f'file.{path}' + + mimetype_fallback = { + '.aac': 'audio/aac', + '.flac': 'audio/flac', + '.mp3': 'audio/mpeg', + '.mp4': 'audio/mp4', + '.m4a': 'audio/mp4', + '.ogg': 'audio/ogg', + '.opus': 'audio/opus', + None: 'application/octet-stream' + } + return mimetypes.guess_type(path)[0] or mimetype_fallback.get(path.rsplit('.', 1)[-1], 'application/octet-stream') -def stringlist_splitter(genres_string): +def stringlist_splitter(delimiter_separated_string: str): delimiters = re.compile('|'.join([';', ',', '/', '\\|'])) + return re.split(delimiters, delimiter_separated_string) + +def genres_formatter(genres): + """ Additional cleaning for common genres formatting issues """ + if isinstance(genres, str): + genres = stringlist_splitter(genres) return [g.strip().title() .replace('Post ', 'Post-') - .replace('Prog ', 'Prog-') + .replace('Prog ', 'Progressive ') + .replace('Rnb', 'R&B') + .replace("R'N'B", 'R&B') + .replace("R 'N' B", 'R&B') + .replace('Rock & ', 'Rock and ') + .replace("Rock'N'", 'Rock and') + .replace("Rock 'N'", 'Rock and') .replace('.', ' ') - for g in re.split(delimiters, genres_string)] + for g in genres] def creation_date(filepath): """ Get a file's creation date @@ -317,9 +406,4 @@ def creation_date(filepath): return float(f'{timestamp}.{millis}') except: # If that did not work, settle for last modification time - return stat.st_mtime - -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'])) + return stat.st_mtime \ No newline at end of file From 84e755afb251c3febb7331d122064bd96fe1a3dd Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Mon, 24 Mar 2025 01:28:08 +0000 Subject: [PATCH 45/85] Various small modifications --- beetsplug/beetstream/__init__.py | 7 ++++++- beetsplug/beetstream/albums.py | 8 ++++---- beetsplug/beetstream/artists.py | 33 ++++++++++++++++++++++++-------- beetsplug/beetstream/general.py | 12 ++---------- beetsplug/beetstream/songs.py | 9 +++++---- beetsplug/beetstream/utils.py | 30 +++++++++++++---------------- 6 files changed, 55 insertions(+), 44 deletions(-) diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py index 9e1e51d..57ac8f6 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstream/__init__.py @@ -23,7 +23,6 @@ import flask from flask import g from flask_cors import CORS -from pathlib import Path # Flask setup app = flask.Flask(__name__) @@ -87,6 +86,12 @@ def func(lib, opts, args): self.config['port'] = int(args.pop(0)) app.config['root_directory'] = Path(config['directory'].get()) + + # Total number of items in the Beets database (only used to detect deletions in getIndexes endpoint) + # We initialise to +inf at Beetstream start, so the real count is set the first time a client queries + # the getIndexes endpoint + app.config['nb_items'] = float('inf') + app.config['lib'] = lib app.config['JSONIFY_PRETTYPRINT_REGULAR'] = False app.config['INCLUDE_PATHS'] = self.config['include_paths'] diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index a7202fd..faa0dfc 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -6,13 +6,13 @@ def album_payload(subsonic_album_id: str, with_songs=True) -> dict: - beets_album_id = stb_album(subsonic_album_id) - album = flask.g.lib.get_album(beets_album_id) + beets_album_id = stb_album(subsonic_album_id) + album_object = flask.g.lib.get_album(beets_album_id) payload = { "album": { - **map_album(album, with_songs=with_songs) + **map_album(album_object, with_songs=with_songs) } } return payload @@ -40,7 +40,7 @@ def _album_info(ver=None): r = flask.request.values req_id = r.get('id') - album_id = int(stb_album(req_id)) + album_id = stb_album(req_id) album = flask.g.lib.get_album(album_id) artist_quot = urllib.parse.quote(album.get('albumartist', '')) diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index 5b66c67..98300fc 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -12,8 +12,7 @@ def artist_payload(subsonic_artist_id: str, with_albums=True) -> dict: payload = { 'artist': { - 'id': subsonic_artist_id, - 'name': artist_name, + **map_artist(artist_name, with_albums=with_albums) } } @@ -25,19 +24,22 @@ def artist_payload(subsonic_artist_id: str, with_albums=True) -> dict: return payload + @app.route('/rest/getArtists', methods=["GET", "POST"]) @app.route('/rest/getArtists.view', methods=["GET", "POST"]) def get_artists(): - return _artists("artists") + return _artists('artists') @app.route('/rest/getIndexes', methods=["GET", "POST"]) @app.route('/rest/getIndexes.view', methods=["GET", "POST"]) def get_indexes(): - return _artists("indexes") + return _artists('indexes') def _artists(version: str): r = flask.request.values + modified_since = r.get('ifModifiedSince', '') + with flask.g.lib.transaction() as tx: artists = [row[0] for row in tx.query("SELECT DISTINCT albumartist FROM albums WHERE albumartist is NOT NULL")] @@ -47,14 +49,29 @@ def _artists(version: str): payload = { version: { - "ignoredArticles": "", - "lastModified": int(time.time() * 1000), - "index": [ - {"name": char, "artist": list(map(map_artist, artists))} + 'ignoredArticles': '', # TODO - include config from 'the' plugin?? + 'index': [ + {'name': char, 'artist': list(map(map_artist, artists))} for char, artists in sorted(alphanum_dict.items()) ] } } + + if version == 'indexes': + + with flask.g.lib.transaction() as tx: + latest = int(tx.query('SELECT added FROM items ORDER BY added DESC LIMIT 1')[0][0]) + # TODO - 'mtime' field? + nb_items = tx.query('SELECT COUNT(*) FROM items')[0][0] + + if nb_items < app.config['nb_items']: + app.logger.warning('Media deletion detected (or very first time getIndexes is queried)') + # Deletion of items (or very first check since Beetstream started) + latest = int(time.time() * 1000) + app.config['nb_items'] = nb_items + + payload[version]['lastModified'] = latest + return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getArtist', methods=["GET", "POST"]) diff --git a/beetsplug/beetstream/general.py b/beetsplug/beetstream/general.py index 2c20b26..8fc8eb8 100644 --- a/beetsplug/beetstream/general.py +++ b/beetsplug/beetstream/general.py @@ -19,11 +19,6 @@ def musicdirectory_payload(subsonic_musicdirectory_id: str, with_artists=True) - }] } } - - if with_artists: - # TODO - pass - return payload @@ -108,16 +103,13 @@ def get_music_directory(): payload = song_payload(req_id) payload['directory'] = payload.pop('song') - elif req_id == 'm-0': + else: payload = musicdirectory_payload('m-0', with_artists=True) # TODO - Add missing fields to artist mapper so we can return a directory with artist children # payload['directory'] = payload.pop('artist') # payload['directory']['child'] = payload['directory'].pop('album') - else: - return flask.abort(404) - return subsonic_response(payload, r.get('f', 'xml')) -## + diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index cc49a3d..8198800 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -6,6 +6,7 @@ def song_payload(subsonic_song_id: str) -> dict: beets_song_id = stb_song(subsonic_song_id) song_item = flask.g.lib.get_item(beets_song_id) + payload = { 'song': map_song(song_item) } @@ -17,8 +18,8 @@ def song_payload(subsonic_song_id: str) -> dict: def get_song(): r = flask.request.values song_id = r.get('id') - payload = song_payload(song_id) + payload = song_payload(song_id) return subsonic_response(payload, r.get('f', 'xml')) @app.route('/rest/getSongsByGenre', methods=["GET", "POST"]) @@ -77,10 +78,10 @@ def stream_song(): maxBitrate = int(r.get('maxBitRate') or 0) format = r.get('format') - song_id = int(stb_song(r.get('id'))) + song_id = stb_song(r.get('id')) item = flask.g.lib.get_item(song_id) - item_path = item.get('path', b'').decode('utf-8') + item_path = item.get('path', b'').decode('utf-8') if item else '' if not item_path: flask.abort(404) @@ -94,7 +95,7 @@ def stream_song(): def download_song(): r = flask.request.values - song_id = int(stb_song(r.get('id'))) + song_id = stb_song(r.get('id')) item = flask.g.lib.get_item(song_id) return stream.direct(item.path.decode('utf-8')) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 753d38b..b3989d0 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -91,10 +91,6 @@ def map_media(beets_object: Union[dict, library.LibModel]): 'month': beets_object.get('month', 0), 'day': beets_object.get('day', 0) }, - 'replayGain': { - 'albumGain': (beets_object.get('rg_album_gain') or 0) or ((beets_object.get('r128_album_gain') or 107) - 107), - 'albumPeak': beets_object.get('rg_album_peak') or 0 - } } return subsonic_media @@ -126,7 +122,7 @@ def map_album(album_object: Union[dict, library.Album], with_songs=True) -> dict 'parent': subsonic_album['artistId'], # Title field is required for Child responses (also used in albumList or albumList2 responses) - 'title': album.get('album'), + 'title': album_name, # This is only needed when part of a Child response 'mediaType': 'album' @@ -226,12 +222,12 @@ def map_song(song_object): } subsonic_song.update(song_specific) - subsonic_song['replayGain'].update( - { - 'trackGain': (song.get('rg_track_gain') or 0) or ((song.get('r128_track_gain') or 107) - 107), - 'trackPeak': song.get('rg_track_peak', 0) - } - ) + # subsonic_song['replayGain'] = { + # 'trackGain': (song.get('rg_track_gain') or 0) or ((song.get('r128_track_gain') or 107) - 107), + # 'albumGain': (song.get('rg_album_gain') or 0) or ((song.get('r128_album_gain') or 107) - 107), + # 'trackPeak': song.get('rg_track_peak', 0), + # 'albumPeak': song.get('rg_album_peak', 0) + # } # Add remaining filetype-related elements with fallbacks subsonic_song['suffix'] = song.get('format').lower() or subsonic_song['path'].rsplit('.', 1)[-1].lower() @@ -241,13 +237,13 @@ def map_song(song_object): return subsonic_song -def map_artist(artist_name): - artist_id = bts_artist(artist_name) +def map_artist(artist_name, with_albums=True): + subsonid_artist_id = bts_artist(artist_name) return { - 'id': artist_id, + 'id': subsonid_artist_id, 'name': artist_name, - # 'sortName': artist_name, - # TODO + 'sortName': artist_name, + 'title': artist_name, # "starred": "2021-07-03T06:15:28.757Z", # nothing if not starred # 'coverArt': artist_id, 'albumCount': 1, @@ -337,7 +333,7 @@ def strip_accents(s): return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn') def timestamp_to_iso(timestamp): - return datetime.fromtimestamp(timestamp).isoformat() if timestamp else None + return datetime.fromtimestamp(timestamp).isoformat() if timestamp else '' def get_mimetype(path): From 87e96031e773392e91c1c1ddd2d405f576b7eed9 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Tue, 25 Mar 2025 00:16:40 +0000 Subject: [PATCH 46/85] Proper XML formatting --- beetsplug/beetstream/albums.py | 4 +- beetsplug/beetstream/coverart.py | 4 +- beetsplug/beetstream/songs.py | 8 +-- beetsplug/beetstream/utils.py | 101 ++++++++++++++++----------- pyproject.toml => pyproject.toml.old | 0 5 files changed, 68 insertions(+), 49 deletions(-) rename pyproject.toml => pyproject.toml.old (100%) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index faa0dfc..fef4dd3 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -17,7 +17,7 @@ def album_payload(subsonic_album_id: str, with_songs=True) -> dict: } return payload - +'/rest/search3.view&query=""&songCount=500&songOffset=0&artistCount=0&albumCount=0' @app.route('/rest/getAlbum', methods=["GET", "POST"]) @app.route('/rest/getAlbum.view', methods=["GET", "POST"]) def get_album(): @@ -72,7 +72,7 @@ def album_list_2(): def get_album_list(ver=None): r = flask.request.values - + '/rest/getAlbumList2.view?v=1.13.0&c=subtracks&u=ttj&s=.ZQ8&t=7b041defbd997bfa4a8b71724cfc5f6a&type=frequent&size=500&offset=0' sort_by = r.get('type', 'alphabeticalByName') size = int(r.get('size', 10)) offset = int(r.get('offset', 0)) diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index a259796..39c11dc 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -85,14 +85,14 @@ def get_cover_art(): # album requests if req_id.startswith(ALB_ID_PREF): - album_id = int(stb_album(req_id)) + album_id = stb_album(req_id) response = send_album_art(album_id, size) if response is not None: return response # song requests elif req_id.startswith(SNG_ID_PREF): - item_id = int(stb_song(req_id)) + item_id = stb_song(req_id) item = flask.g.lib.get_item(item_id) album_id = item.get('album_id') if album_id: diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index 8198800..86cda40 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -75,8 +75,8 @@ def get_random_songs(): def stream_song(): r = flask.request.values - maxBitrate = int(r.get('maxBitRate') or 0) - format = r.get('format') + max_bitrate = int(r.get('maxBitRate') or 0) + req_format = r.get('format') song_id = stb_song(r.get('id')) item = flask.g.lib.get_item(song_id) @@ -85,10 +85,10 @@ def stream_song(): if not item_path: flask.abort(404) - if app.config['never_transcode'] or format == 'raw' or maxBitrate <= 0 or item.bitrate <= maxBitrate * 1000: + if app.config['never_transcode'] or req_format == 'raw' or max_bitrate <= 0 or item.bitrate <= max_bitrate * 1000: return stream.direct(item_path) else: - return stream.try_transcode(item_path, maxBitrate) + return stream.try_transcode(item_path, max_bitrate) @app.route('/rest/download', methods=["GET", "POST"]) @app.route('/rest/download.view', methods=["GET", "POST"]) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index b3989d0..901e65e 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -52,6 +52,7 @@ def bts_album(beet_album_id): return f'{ALB_ID_PREF}{beet_album_id}' def stb_album(subsonic_album_id): + print('hgnuygjugy', subsonic_album_id) return int(str(subsonic_album_id)[len(ALB_ID_PREF):]) def bts_song(beet_song_id): @@ -79,18 +80,18 @@ def map_media(beets_object: Union[dict, library.LibModel]): 'album': beets_object.get('album', ''), 'year': beets_object.get('year', 0), 'genre': beets_object.get('genre', ''), - 'genres': [{'name': g} for g in genres_formatter(beets_object.get('genre', ''))], + # 'genres': [{'name': g} for g in genres_formatter(beets_object.get('genre', ''))], 'created': timestamp_to_iso(beets_object.get('added')) or datetime.now().isoformat(), # default to now? - 'originalReleaseDate': { - 'year': beets_object.get('original_year', 0), - 'month': beets_object.get('original_month', 0), - 'day': beets_object.get('original_day', 0) - }, - 'releaseDate': { - 'year': beets_object.get('year', 0), - 'month': beets_object.get('month', 0), - 'day': beets_object.get('day', 0) - }, + # 'originalReleaseDate': { + # 'year': beets_object.get('original_year', 0), + # 'month': beets_object.get('original_month', 0), + # 'day': beets_object.get('original_day', 0) + # }, + # 'releaseDate': { + # 'year': beets_object.get('year', 0), + # 'month': beets_object.get('month', 0), + # 'day': beets_object.get('day', 0) + # }, } return subsonic_media @@ -114,7 +115,7 @@ def map_album(album_object: Union[dict, library.Album], with_songs=True) -> dict # 'starred': timestamp_to_iso(album.get('last_liked_album', 0)), # 'userRating': album.get('stars_rating_album', 0), - 'recordLabels': [{'name': l for l in stringlist_splitter(album.get('label', ''))}], + # 'recordLabels': [{'name': l for l in stringlist_splitter(album.get('label', ''))}], 'isCompilation': bool(album.get('comp', False)), # These are only needed when part of a directory response @@ -128,21 +129,21 @@ def map_album(album_object: Union[dict, library.Album], with_songs=True) -> dict 'mediaType': 'album' } subsonic_album.update(album_specific) - - # Add release types if possible - release_types = album.get('albumtypes', '') or album.get('albumtype', '') - if isinstance(release_types, str): - subsonic_album['releaseTypes'] = stringlist_splitter(release_types) - else: - subsonic_album['releaseTypes'] = [r.strip().title() for r in release_types] - - # Add multi-disc info if needed - nb_discs = album.get('disctotal', 1) - if nb_discs > 1: - subsonic_album["discTitles"] = [ - {'disc': d, 'title': ' - '.join(filter(None, [album.get('album', None), f'Disc {d + 1}']))} - for d in range(nb_discs) - ] + # + # # Add release types if possible + # release_types = album.get('albumtypes', '') or album.get('albumtype', '') + # if isinstance(release_types, str): + # subsonic_album['releaseTypes'] = stringlist_splitter(release_types) + # else: + # subsonic_album['releaseTypes'] = [r.strip().title() for r in release_types] + + # # Add multi-disc info if needed + # nb_discs = album.get('disctotal', 1) + # if nb_discs > 1: + # subsonic_album["discTitles"] = [ + # {'disc': d, 'title': ' - '.join(filter(None, [album.get('album', None), f'Disc {d + 1}']))} + # for d in range(nb_discs) + # ] # Songs should be included when part of either: # - an AlbumID3WithSongs response @@ -268,26 +269,42 @@ def map_playlist(playlist): # === Core response-formatting functions === -def dict_to_xml(tag: str, data): - """ Recursively converts a json-like dict to an XML tree """ +def dict_to_xml(tag: str, data, list_item=False): + """ + Converts a json-like dict to an XML tree + + When a dict is part of a list (list_item=True), simple key/value pairs are + set as attributes. More complex values (dicts or lists) become child elements. + """ elem = ET.Element(tag) if isinstance(data, dict): for key, val in data.items(): - if isinstance(val, (dict, list)): - child = dict_to_xml(key, val) - elem.append(child) - else: - child = ET.Element(key) - child.text = str(val) + if not isinstance(val, (dict, list)): + if list_item: + # Set simple key/value pairs as attributes + elem.set(key, str(val)) + else: + child = ET.Element(key) + child.text = str(val) + elem.append(child) + elif isinstance(val, list): + for item in val: + # Each item in the list is processed with list_item=True + child = dict_to_xml(key, item, list_item=True) + elem.append(child) + elif isinstance(val, dict): + # For nested dicts, we typically want to preserve the structure. + child = dict_to_xml(key, val, list_item=False) elem.append(child) elif isinstance(data, list): for item in data: - child = dict_to_xml(tag, item) + child = dict_to_xml(tag, item, list_item=True) elem.append(child) else: elem.text = str(data) return elem + def jsonpify(format: str, data: dict): if format == 'jsonp': callback = flask.request.values.get("callback") @@ -295,11 +312,12 @@ def jsonpify(format: str, data: dict): else: return flask.jsonify(data) -def subsonic_response(data: dict = {}, format: str = 'xml', failed=False): + +def subsonic_response(data: dict = {}, resp_fmt: str = 'xml', failed=False): """ Wrap any json-like dict with the subsonic response elements and output the appropriate 'format' (json or xml) """ - if format.startswith('json'): + if resp_fmt.startswith('json'): wrapped = { 'subsonic-response': { 'status': 'failed' if failed else 'ok', @@ -310,7 +328,7 @@ def subsonic_response(data: dict = {}, format: str = 'xml', failed=False): **data } } - return jsonpify(format, wrapped) + return jsonpify(resp_fmt, wrapped) else: root = dict_to_xml("subsonic-response", data) @@ -321,8 +339,9 @@ def subsonic_response(data: dict = {}, format: str = 'xml', failed=False): root.set("serverVersion", BEETSTREAM_VERSION) root.set("openSubsonic", 'true') - xml_str = minidom.parseString(ET.tostring(root, encoding='unicode', - method='xml', xml_declaration=True)).toprettyxml() + xml_bytes = ET.tostring(root, encoding='UTF-8', method='xml', xml_declaration=True) + pretty_xml = minidom.parseString(xml_bytes).toprettyxml(encoding='UTF-8') + xml_str = pretty_xml.decode('UTF-8') return flask.Response(xml_str, mimetype="text/xml") diff --git a/pyproject.toml b/pyproject.toml.old similarity index 100% rename from pyproject.toml rename to pyproject.toml.old From 44c413938115dabee8c47538e438663d90d2218f Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Tue, 25 Mar 2025 00:46:00 +0000 Subject: [PATCH 47/85] Testing artist images --- beetsplug/beetstream/coverart.py | 9 +++++++-- beetsplug/beetstream/utils.py | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index 39c11dc..4a21138 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -110,8 +110,13 @@ def get_cover_art(): # artist requests elif req_id.startswith(ART_ID_PREF): - # TODO - pass + img = Image.open('./artist.png') + if size: + img.thumbnail((size, size)) + buf = BytesIO() + img.save(buf, format='JPEG') + buf.seek(0) + return flask.send_file(buf, mimetype='image/jpeg') # Fallback: return empty XML document on error return subsonic_response({}, 'xml', failed=True) \ No newline at end of file diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 901e65e..df8d92f 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -14,6 +14,7 @@ from xml.dom import minidom import shutil import importlib +from functools import partial API_VERSION = '1.16.1' @@ -240,20 +241,29 @@ def map_song(song_object): def map_artist(artist_name, with_albums=True): subsonid_artist_id = bts_artist(artist_name) - return { + + subsonic_artist = { 'id': subsonid_artist_id, 'name': artist_name, 'sortName': artist_name, 'title': artist_name, # "starred": "2021-07-03T06:15:28.757Z", # nothing if not starred - # 'coverArt': artist_id, + 'coverArt': subsonid_artist_id, 'albumCount': 1, - 'artistImageUrl': "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg", + # 'artistImageUrl': "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg", # This is only needed when part of a Child response 'mediaType': 'artist' } + albums = list(flask.g.lib.albums(f'albumartist:{artist_name}')) + subsonic_artist['albumCount'] = len(albums) + + if with_albums: + subsonic_artist['song'] = list(map(partial(map_album, with_songs=False), albums)) + + return subsonic_artist + def map_playlist(playlist): return { 'id': playlist.id, From 2f45499d3116e5933d083468071af2bc8217d2eb Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Tue, 25 Mar 2025 01:41:21 +0000 Subject: [PATCH 48/85] Musicbrainz query (maybe for 'version' album tag) --- beetsplug/beetstream/albums.py | 2 +- beetsplug/beetstream/utils.py | 86 +++++++++++++++++++++------------- 2 files changed, 54 insertions(+), 34 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index fef4dd3..1d52f62 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -72,7 +72,7 @@ def album_list_2(): def get_album_list(ver=None): r = flask.request.values - '/rest/getAlbumList2.view?v=1.13.0&c=subtracks&u=ttj&s=.ZQ8&t=7b041defbd997bfa4a8b71724cfc5f6a&type=frequent&size=500&offset=0' + sort_by = r.get('type', 'alphabeticalByName') size = int(r.get('size', 10)) offset = int(r.get('offset', 0)) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index df8d92f..f328efe 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -15,6 +15,7 @@ import shutil import importlib from functools import partial +import requests API_VERSION = '1.16.1' @@ -53,7 +54,6 @@ def bts_album(beet_album_id): return f'{ALB_ID_PREF}{beet_album_id}' def stb_album(subsonic_album_id): - print('hgnuygjugy', subsonic_album_id) return int(str(subsonic_album_id)[len(ALB_ID_PREF):]) def bts_song(beet_song_id): @@ -81,18 +81,18 @@ def map_media(beets_object: Union[dict, library.LibModel]): 'album': beets_object.get('album', ''), 'year': beets_object.get('year', 0), 'genre': beets_object.get('genre', ''), - # 'genres': [{'name': g} for g in genres_formatter(beets_object.get('genre', ''))], + 'genres': [{'name': g} for g in genres_formatter(beets_object.get('genre', ''))], 'created': timestamp_to_iso(beets_object.get('added')) or datetime.now().isoformat(), # default to now? - # 'originalReleaseDate': { - # 'year': beets_object.get('original_year', 0), - # 'month': beets_object.get('original_month', 0), - # 'day': beets_object.get('original_day', 0) - # }, - # 'releaseDate': { - # 'year': beets_object.get('year', 0), - # 'month': beets_object.get('month', 0), - # 'day': beets_object.get('day', 0) - # }, + 'originalReleaseDate': { + 'year': beets_object.get('original_year', 0), + 'month': beets_object.get('original_month', 0), + 'day': beets_object.get('original_day', 0) + }, + 'releaseDate': { + 'year': beets_object.get('year', 0), + 'month': beets_object.get('month', 0), + 'day': beets_object.get('day', 0) + }, } return subsonic_media @@ -114,9 +114,9 @@ def map_album(album_object: Union[dict, library.Album], with_songs=True) -> dict 'coverArt': subsonic_album_id, # 'starred': timestamp_to_iso(album.get('last_liked_album', 0)), - # 'userRating': album.get('stars_rating_album', 0), + 'userRating': album.get('stars_rating_album', 0), - # 'recordLabels': [{'name': l for l in stringlist_splitter(album.get('label', ''))}], + 'recordLabels': [{'name': l for l in stringlist_splitter(album.get('label', ''))}], 'isCompilation': bool(album.get('comp', False)), # These are only needed when part of a directory response @@ -130,21 +130,21 @@ def map_album(album_object: Union[dict, library.Album], with_songs=True) -> dict 'mediaType': 'album' } subsonic_album.update(album_specific) - # - # # Add release types if possible - # release_types = album.get('albumtypes', '') or album.get('albumtype', '') - # if isinstance(release_types, str): - # subsonic_album['releaseTypes'] = stringlist_splitter(release_types) - # else: - # subsonic_album['releaseTypes'] = [r.strip().title() for r in release_types] - - # # Add multi-disc info if needed - # nb_discs = album.get('disctotal', 1) - # if nb_discs > 1: - # subsonic_album["discTitles"] = [ - # {'disc': d, 'title': ' - '.join(filter(None, [album.get('album', None), f'Disc {d + 1}']))} - # for d in range(nb_discs) - # ] + + # Add release types if possible + release_types = album.get('albumtypes', '') or album.get('albumtype', '') + if isinstance(release_types, str): + subsonic_album['releaseTypes'] = stringlist_splitter(release_types) + else: + subsonic_album['releaseTypes'] = [r.strip().title() for r in release_types] + + # Add multi-disc info if needed + nb_discs = album.get('disctotal', 1) + if nb_discs > 1: + subsonic_album["discTitles"] = [ + {'disc': d, 'title': ' - '.join(filter(None, [album.get('album', None), f'Disc {d + 1}']))} + for d in range(nb_discs) + ] # Songs should be included when part of either: # - an AlbumID3WithSongs response @@ -249,15 +249,23 @@ def map_artist(artist_name, with_albums=True): 'title': artist_name, # "starred": "2021-07-03T06:15:28.757Z", # nothing if not starred 'coverArt': subsonid_artist_id, - 'albumCount': 1, # 'artistImageUrl': "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg", + "userRating": 0, + + # "roles": [ + # "artist", + # "albumartist", + # "composer" + # ], # This is only needed when part of a Child response 'mediaType': 'artist' } - albums = list(flask.g.lib.albums(f'albumartist:{artist_name}')) + albums = list(g.lib.albums(f'albumartist:{artist_name}')) subsonic_artist['albumCount'] = len(albums) + if albums: + subsonic_artist['musicBrainzId'] = albums[0].get('mb_albumartistid', '') if with_albums: subsonic_artist['song'] = list(map(partial(map_album, with_songs=False), albums)) @@ -362,7 +370,7 @@ def strip_accents(s): return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn') def timestamp_to_iso(timestamp): - return datetime.fromtimestamp(timestamp).isoformat() if timestamp else '' + return datetime.fromtimestamp(timestamp if timestamp else 0).isoformat() def get_mimetype(path): @@ -431,4 +439,16 @@ def creation_date(filepath): return float(f'{timestamp}.{millis}') except: # If that did not work, settle for last modification time - return stat.st_mtime \ No newline at end of file + return stat.st_mtime + + +def query_musicbrainz(mbid:str, type: str): + + types_mb = {'track': 'recording', 'album': 'release', 'artist': 'artist'} + endpoint = f'https://musicbrainz.org/ws/2/{types_mb[type]}/{mbid}' + + headers = {'User-Agent': 'Beetstream/1.4.5 ( https://github.com/FlorentLM/Beetstream )'} + params = {'fmt': 'json'} + + response = requests.get(endpoint, headers=headers, params=params) + return response.json() \ No newline at end of file From f6300beb152f08b624845189e51b7431ead71727 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Tue, 25 Mar 2025 20:14:38 +0000 Subject: [PATCH 49/85] Fixed botched XML --- beetsplug/beetstream/utils.py | 80 ++++++++++++++++++++++------------- 1 file changed, 51 insertions(+), 29 deletions(-) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index f328efe..ead39b1 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -116,7 +116,7 @@ def map_album(album_object: Union[dict, library.Album], with_songs=True) -> dict # 'starred': timestamp_to_iso(album.get('last_liked_album', 0)), 'userRating': album.get('stars_rating_album', 0), - 'recordLabels': [{'name': l for l in stringlist_splitter(album.get('label', ''))}], + # 'recordLabels': [{'name': l for l in stringlist_splitter(album.get('label', ''))}], 'isCompilation': bool(album.get('comp', False)), # These are only needed when part of a directory response @@ -224,12 +224,12 @@ def map_song(song_object): } subsonic_song.update(song_specific) - # subsonic_song['replayGain'] = { - # 'trackGain': (song.get('rg_track_gain') or 0) or ((song.get('r128_track_gain') or 107) - 107), - # 'albumGain': (song.get('rg_album_gain') or 0) or ((song.get('r128_album_gain') or 107) - 107), - # 'trackPeak': song.get('rg_track_peak', 0), - # 'albumPeak': song.get('rg_album_peak', 0) - # } + subsonic_song['replayGain'] = { + 'trackGain': (song.get('rg_track_gain') or 0) or ((song.get('r128_track_gain') or 107) - 107), + 'albumGain': (song.get('rg_album_gain') or 0) or ((song.get('r128_album_gain') or 107) - 107), + 'trackPeak': song.get('rg_track_peak', 0), + 'albumPeak': song.get('rg_album_peak', 0) + } # Add remaining filetype-related elements with fallbacks subsonic_song['suffix'] = song.get('format').lower() or subsonic_song['path'].rsplit('.', 1)[-1].lower() @@ -252,17 +252,17 @@ def map_artist(artist_name, with_albums=True): # 'artistImageUrl': "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg", "userRating": 0, - # "roles": [ - # "artist", - # "albumartist", - # "composer" - # ], + "roles": [ + "artist", + "albumartist", + "composer" + ], # This is only needed when part of a Child response 'mediaType': 'artist' } - albums = list(g.lib.albums(f'albumartist:{artist_name}')) + albums = list(flask.g.lib.albums(f'albumartist:{artist_name}')) subsonic_artist['albumCount'] = len(albums) if albums: subsonic_artist['musicBrainzId'] = albums[0].get('mb_albumartistid', '') @@ -287,39 +287,61 @@ def map_playlist(playlist): # === Core response-formatting functions === -def dict_to_xml(tag: str, data, list_item=False): - """ - Converts a json-like dict to an XML tree +import xml.etree.ElementTree as ET - When a dict is part of a list (list_item=True), simple key/value pairs are - set as attributes. More complex values (dicts or lists) become child elements. + +def dict_to_xml(tag: str, data): + """ + Converts a json-like dict to an XML tree where every key/value pair + with a simple value is mapped as an attribute. If adding the attribute + would create a duplicate (i.e. the key is already used), a new element + with that tag is created instead. """ elem = ET.Element(tag) + if isinstance(data, dict): for key, val in data.items(): if not isinstance(val, (dict, list)): - if list_item: - # Set simple key/value pairs as attributes - elem.set(key, str(val)) - else: + # If the attribute already exists, create a child element. + if key in elem.attrib: child = ET.Element(key) child.text = str(val) elem.append(child) + else: + elem.set(key, str(val)) elif isinstance(val, list): for item in val: - # Each item in the list is processed with list_item=True - child = dict_to_xml(key, item, list_item=True) - elem.append(child) + # For each item in the list, process depending on type. + if not isinstance(item, (dict, list)): + if key in elem.attrib: + child = ET.Element(key) + child.text = str(item) + elem.append(child) + else: + elem.set(key, str(item)) + else: + child = dict_to_xml(key, item) + elem.append(child) elif isinstance(val, dict): - # For nested dicts, we typically want to preserve the structure. - child = dict_to_xml(key, val, list_item=False) + child = dict_to_xml(key, val) elem.append(child) + elif isinstance(data, list): + # When the data is a list, each item becomes a new child. for item in data: - child = dict_to_xml(tag, item, list_item=True) - elem.append(child) + if not isinstance(item, (dict, list)): + if tag in elem.attrib: + child = ET.Element(tag) + child.text = str(item) + elem.append(child) + else: + elem.set(tag, str(item)) + else: + child = dict_to_xml(tag, item) + elem.append(child) else: elem.text = str(data) + return elem From f860d6f1467016cf4b6e2f0a25a7e9bdd807aea7 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Tue, 25 Mar 2025 20:20:19 +0000 Subject: [PATCH 50/85] Fixed botched XML --- beetsplug/beetstream/utils.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index ead39b1..01823e9 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -287,22 +287,19 @@ def map_playlist(playlist): # === Core response-formatting functions === -import xml.etree.ElementTree as ET - def dict_to_xml(tag: str, data): """ Converts a json-like dict to an XML tree where every key/value pair - with a simple value is mapped as an attribute. If adding the attribute - would create a duplicate (i.e. the key is already used), a new element - with that tag is created instead. + with a simple value is mapped as an attribute.... unless if adding the attribute + would create a duplicate, in which case a new element with that tag is created instead """ elem = ET.Element(tag) if isinstance(data, dict): for key, val in data.items(): if not isinstance(val, (dict, list)): - # If the attribute already exists, create a child element. + # If the attribute already exists, create a child element if key in elem.attrib: child = ET.Element(key) child.text = str(val) @@ -311,7 +308,7 @@ def dict_to_xml(tag: str, data): elem.set(key, str(val)) elif isinstance(val, list): for item in val: - # For each item in the list, process depending on type. + # For each item in the list, process depending on type if not isinstance(item, (dict, list)): if key in elem.attrib: child = ET.Element(key) @@ -327,7 +324,7 @@ def dict_to_xml(tag: str, data): elem.append(child) elif isinstance(data, list): - # When the data is a list, each item becomes a new child. + # when data is a list, each item becomes a new child for item in data: if not isinstance(item, (dict, list)): if tag in elem.attrib: From cfbcfa5d7b219347a3a9cc66e5a14ffdfc062b08 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Tue, 25 Mar 2025 20:24:45 +0000 Subject: [PATCH 51/85] Fixed broken XML response --- beetsplug/beetstream/utils.py | 50 +++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 6faa8ae..380915f 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -202,25 +202,59 @@ def map_playlist(playlist): # === Core response-formatting functions === def dict_to_xml(tag: str, data): - """ Recursively converts a json-like dict to an XML tree """ + """ + Converts a json-like dict to an XML tree where every key/value pair + with a simple value is mapped as an attribute.... unless if adding the attribute + would create a duplicate, in which case a new element with that tag is created instead + """ elem = ET.Element(tag) + if isinstance(data, dict): for key, val in data.items(): - if isinstance(val, (dict, list)): + if not isinstance(val, (dict, list)): + # If the attribute already exists, create a child element + if key in elem.attrib: + child = ET.Element(key) + child.text = str(val) + elem.append(child) + else: + elem.set(key, str(val)) + elif isinstance(val, list): + for item in val: + # For each item in the list, process depending on type + if not isinstance(item, (dict, list)): + if key in elem.attrib: + child = ET.Element(key) + child.text = str(item) + elem.append(child) + else: + elem.set(key, str(item)) + else: + child = dict_to_xml(key, item) + elem.append(child) + elif isinstance(val, dict): child = dict_to_xml(key, val) elem.append(child) - else: - child = ET.Element(key) - child.text = str(val) - elem.append(child) + elif isinstance(data, list): + # when data is a list, each item becomes a new child for item in data: - child = dict_to_xml(tag, item) - elem.append(child) + if not isinstance(item, (dict, list)): + if tag in elem.attrib: + child = ET.Element(tag) + child.text = str(item) + elem.append(child) + else: + elem.set(tag, str(item)) + else: + child = dict_to_xml(tag, item) + elem.append(child) else: elem.text = str(data) + return elem + def jsonpify(format: str, data: dict): if format == 'jsonp': callback = flask.request.values.get("callback") From 16b51bfb83a12146a2b63de9e642cdc61a1b3089 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Tue, 25 Mar 2025 20:38:13 +0000 Subject: [PATCH 52/85] oopsy --- beetsplug/beetstream/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 01823e9..11d85db 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -268,7 +268,7 @@ def map_artist(artist_name, with_albums=True): subsonic_artist['musicBrainzId'] = albums[0].get('mb_albumartistid', '') if with_albums: - subsonic_artist['song'] = list(map(partial(map_album, with_songs=False), albums)) + subsonic_artist['album'] = list(map(partial(map_album, with_songs=False), albums)) return subsonic_artist From 917412eba749c1673f8e6387c53980862fb6ea2a Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Tue, 25 Mar 2025 20:40:11 +0000 Subject: [PATCH 53/85] had wrong name for arist's album list attribute... --- beetsplug/beetstream/artists.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index 2490f05..b70584f 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -16,7 +16,7 @@ def artist_payload(artist_id: str) -> dict: "artist": { "id": artist_id, "name": artist_name, - "child": list(map(map_album, albums)) + "album": list(map(map_album, albums)) } } return payload From 7d8b53d1db75df97a8f8626074f913d07bd63eed Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Tue, 25 Mar 2025 20:43:24 +0000 Subject: [PATCH 54/85] clearer function names --- beetsplug/beetstream/albums.py | 4 ++-- beetsplug/beetstream/artists.py | 4 ++-- beetsplug/beetstream/coverart.py | 4 ++-- beetsplug/beetstream/songs.py | 6 +++--- beetsplug/beetstream/utils.py | 22 +++++++++++----------- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 1d52f62..0a9b1c6 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -7,7 +7,7 @@ def album_payload(subsonic_album_id: str, with_songs=True) -> dict: - beets_album_id = stb_album(subsonic_album_id) + beets_album_id = sub_to_beets_album(subsonic_album_id) album_object = flask.g.lib.get_album(beets_album_id) payload = { @@ -40,7 +40,7 @@ def _album_info(ver=None): r = flask.request.values req_id = r.get('id') - album_id = stb_album(req_id) + album_id = sub_to_beets_album(req_id) album = flask.g.lib.get_album(album_id) artist_quot = urllib.parse.quote(album.get('albumartist', '')) diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index 98300fc..fc53cb7 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -8,7 +8,7 @@ def artist_payload(subsonic_artist_id: str, with_albums=True) -> dict: - artist_name = stb_artist(subsonic_artist_id) + artist_name = sub_to_beets_artist(subsonic_artist_id) payload = { 'artist': { @@ -91,7 +91,7 @@ def artistInfo2(): r = flask.request.values - artist_name = stb_artist(r.get('id')) + artist_name = sub_to_beets_artist(r.get('id')) payload = { "artistInfo2": { diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index 4a21138..d20e46d 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -85,14 +85,14 @@ def get_cover_art(): # album requests if req_id.startswith(ALB_ID_PREF): - album_id = stb_album(req_id) + album_id = sub_to_beets_album(req_id) response = send_album_art(album_id, size) if response is not None: return response # song requests elif req_id.startswith(SNG_ID_PREF): - item_id = stb_song(req_id) + item_id = sub_to_beets_song(req_id) item = flask.g.lib.get_item(item_id) album_id = item.get('album_id') if album_id: diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index 86cda40..c2adb50 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -4,7 +4,7 @@ def song_payload(subsonic_song_id: str) -> dict: - beets_song_id = stb_song(subsonic_song_id) + beets_song_id = sub_to_beets_song(subsonic_song_id) song_item = flask.g.lib.get_item(beets_song_id) payload = { @@ -78,7 +78,7 @@ def stream_song(): max_bitrate = int(r.get('maxBitRate') or 0) req_format = r.get('format') - song_id = stb_song(r.get('id')) + song_id = sub_to_beets_song(r.get('id')) item = flask.g.lib.get_item(song_id) item_path = item.get('path', b'').decode('utf-8') if item else '' @@ -95,7 +95,7 @@ def stream_song(): def download_song(): r = flask.request.values - song_id = stb_song(r.get('id')) + song_id = sub_to_beets_song(r.get('id')) item = flask.g.lib.get_item(song_id) return stream.direct(item.path.decode('utf-8')) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 11d85db..f46991d 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -41,25 +41,25 @@ # These IDs are sent to the client once (when it accesses endpoints such as getArtists or getAlbumList # and the client will then use these to access a specific item via endpoints that need an ID -def bts_artist(beet_artist_name): +def beets_to_sub_artist(beet_artist_name): base64_name = base64.urlsafe_b64encode(str(beet_artist_name).encode('utf-8')) return f"{ART_ID_PREF}{base64_name.rstrip(b"=").decode('utf-8')}" -def stb_artist(subsonic_artist_id): +def sub_to_beets_artist(subsonic_artist_id): subsonic_artist_id = str(subsonic_artist_id)[len(ART_ID_PREF):] padding = 4 - (len(subsonic_artist_id) % 4) return base64.urlsafe_b64decode(subsonic_artist_id + ("=" * padding)).decode('utf-8') -def bts_album(beet_album_id): +def beets_to_sub_album(beet_album_id): return f'{ALB_ID_PREF}{beet_album_id}' -def stb_album(subsonic_album_id): +def sub_to_beets_album(subsonic_album_id): return int(str(subsonic_album_id)[len(ALB_ID_PREF):]) -def bts_song(beet_song_id): +def beets_to_sub_song(beet_song_id): return f'{SNG_ID_PREF}{beet_song_id}' -def stb_song(subsonic_song_id): +def sub_to_beets_song(subsonic_song_id): return int(str(subsonic_song_id)[len(SNG_ID_PREF):]) @@ -75,7 +75,7 @@ def map_media(beets_object: Union[dict, library.LibModel]): # Common fields to albums and songs subsonic_media = { 'artist': artist_name, - 'artistId': bts_artist(artist_name), + 'artistId': beets_to_sub_artist(artist_name), 'displayArtist': artist_name, 'displayAlbumArtist': artist_name, 'album': beets_object.get('album', ''), @@ -102,7 +102,7 @@ def map_album(album_object: Union[dict, library.Album], with_songs=True) -> dict subsonic_album = map_media(album) beets_album_id = album.get('id', 0) - subsonic_album_id = bts_album(beets_album_id) + subsonic_album_id = beets_to_sub_album(beets_album_id) album_name = album.get('album', '') album_specific = { @@ -177,11 +177,11 @@ def map_song(song_object): subsonic_song = map_media(song) - song_id = bts_song(song.get('id', 0)) + song_id = beets_to_sub_song(song.get('id', 0)) song_name = song.get('title', '') song_filepath = song.get('path', b'').decode('utf-8') - album_id = bts_album(song.get('album_id', 0)) + album_id = beets_to_sub_album(song.get('album_id', 0)) song_specific = { 'id': song_id, @@ -240,7 +240,7 @@ def map_song(song_object): def map_artist(artist_name, with_albums=True): - subsonid_artist_id = bts_artist(artist_name) + subsonid_artist_id = beets_to_sub_artist(artist_name) subsonic_artist = { 'id': subsonid_artist_id, From 26ead8f53f2862d9eabd88c947aa799d7999f106 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Tue, 25 Mar 2025 21:00:58 +0000 Subject: [PATCH 55/85] removed unused line --- beetsplug/beetstream/albums.py | 1 - 1 file changed, 1 deletion(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 0a9b1c6..267113e 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -17,7 +17,6 @@ def album_payload(subsonic_album_id: str, with_songs=True) -> dict: } return payload -'/rest/search3.view&query=""&songCount=500&songOffset=0&artistCount=0&albumCount=0' @app.route('/rest/getAlbum', methods=["GET", "POST"]) @app.route('/rest/getAlbum.view', methods=["GET", "POST"]) def get_album(): From a9f054fd4df200d1e089d1176820b454b43e53f2 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Tue, 25 Mar 2025 23:25:39 +0000 Subject: [PATCH 56/85] Added support for artist images via Deezer's API --- beetsplug/beetstream/coverart.py | 45 +++++++++++++++++++++++++------- beetsplug/beetstream/utils.py | 31 +++++++++++++++++----- 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index d20e46d..e1aaa00 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -63,11 +63,16 @@ def send_album_art(album_id, size=None): mbid = album.get('mb_albumid') if mbid: art_url = f'https://coverartarchive.org/release/{mbid}/front' + available_sizes = [250, 500, 1200] if size: # If requested size is one of coverarchive's available sizes, query it directly - if size in (250, 500, 1200): + if size in available_sizes: return flask.redirect(f'{art_url}-{size}') - response = requests.get(art_url) + # Otherwise, get the smallest available size that is greater than the requested size + next_size = next((s for s in sorted(available_sizes) if s > size), None) + if next_size is None: + next_size = max(available_sizes) + response = requests.get(f'{art_url}-{next_size}') cover = resize_image(BytesIO(response.content), size) return flask.send_file(cover, mimetype='image/jpeg') return flask.redirect(art_url) @@ -75,6 +80,32 @@ def send_album_art(album_id, size=None): return None +def send_artist_image(artist_id, size=None): + + # TODO - Load from disk if available + # TODO - Maybe make a separate plugin to save the deezer data permanently to disk?? + + artist_name = sub_to_beets_artist(artist_id) + dz_data = query_deezer(artist_name, 'artist') + + if dz_data: + artist_image_url = dz_data.get('picture_small', '') + available_sizes = [56, 250, 500, 1000] + if artist_image_url: + if size: + # If requested size is one of coverarchive's available sizes, query it directly + if size in available_sizes: + return flask.redirect(artist_image_url.replace('56x56', f'{size}x{size}')) + # Otherwise, get the smallest available size that is greater than the requested size + next_size = next((s for s in sorted(available_sizes) if s >= size), None) + if next_size is None: + next_size = max(available_sizes) + response = requests.get(artist_image_url.replace('56x56', f'{next_size}x{next_size}')) + cover = resize_image(BytesIO(response.content), size) + return flask.send_file(cover, mimetype='image/jpeg') + return flask.redirect(artist_image_url) + + @app.route('/rest/getCoverArt', methods=["GET", "POST"]) @app.route('/rest/getCoverArt.view', methods=["GET", "POST"]) def get_cover_art(): @@ -110,13 +141,9 @@ def get_cover_art(): # artist requests elif req_id.startswith(ART_ID_PREF): - img = Image.open('./artist.png') - if size: - img.thumbnail((size, size)) - buf = BytesIO() - img.save(buf, format='JPEG') - buf.seek(0) - return flask.send_file(buf, mimetype='image/jpeg') + response = send_artist_image(req_id, size=None) + if response is not None: + return response # Fallback: return empty XML document on error return subsonic_response({}, 'xml', failed=True) \ No newline at end of file diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index f46991d..3356536 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -16,6 +16,7 @@ import importlib from functools import partial import requests +import urllib.parse API_VERSION = '1.16.1' @@ -240,16 +241,15 @@ def map_song(song_object): def map_artist(artist_name, with_albums=True): - subsonid_artist_id = beets_to_sub_artist(artist_name) + subsonic_artist_id = beets_to_sub_artist(artist_name) subsonic_artist = { - 'id': subsonid_artist_id, + 'id': subsonic_artist_id, 'name': artist_name, 'sortName': artist_name, 'title': artist_name, # "starred": "2021-07-03T06:15:28.757Z", # nothing if not starred - 'coverArt': subsonid_artist_id, - # 'artistImageUrl': "https://t4.ftcdn.net/jpg/00/64/67/63/360_F_64676383_LdbmhiNM6Ypzb3FM4PPuFP9rHe7ri8Ju.jpg", + 'coverArt': subsonic_artist_id, "userRating": 0, "roles": [ @@ -262,6 +262,10 @@ def map_artist(artist_name, with_albums=True): 'mediaType': 'artist' } + dz_data = query_deezer(artist_name, 'artist') + if dz_data: + subsonic_artist['artistImageUrl'] = dz_data.get('picture_big', '') + albums = list(flask.g.lib.albums(f'albumartist:{artist_name}')) subsonic_artist['albumCount'] = len(albums) if albums: @@ -466,8 +470,23 @@ def query_musicbrainz(mbid:str, type: str): types_mb = {'track': 'recording', 'album': 'release', 'artist': 'artist'} endpoint = f'https://musicbrainz.org/ws/2/{types_mb[type]}/{mbid}' - headers = {'User-Agent': 'Beetstream/1.4.5 ( https://github.com/FlorentLM/Beetstream )'} + headers = {'User-Agent': f'Beetstream/{BEETSTREAM_VERSION} ( https://github.com/FlorentLM/Beetstream )'} params = {'fmt': 'json'} + if types_mb[type] == 'artist': + params['inc'] = 'annotation' + response = requests.get(endpoint, headers=headers, params=params) - return response.json() \ No newline at end of file + return response.json() + + +def query_deezer(query:str, type: str): + + query_urlsafe = urllib.parse.quote_plus(query.replace(' ', '-')) + endpoint = f'https://api.deezer.com/{type}/{query_urlsafe}' + + headers = {'User-Agent': f'Beetstream/{BEETSTREAM_VERSION} ( https://github.com/FlorentLM/Beetstream )'} + + response = requests.get(endpoint, headers=headers) + + return response.json() if response.ok else {} From 43e2ec1737c171ad72a56ab96caac454214185a6 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Wed, 26 Mar 2025 00:08:32 +0000 Subject: [PATCH 57/85] Added new config options to control artist photos fetching --- beetsplug/beetstream/__init__.py | 4 ++++ beetsplug/beetstream/coverart.py | 27 ++++++++++++++++++++++++--- beetsplug/beetstream/utils.py | 6 +++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py index 57ac8f6..7e105b0 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstream/__init__.py @@ -58,6 +58,8 @@ def __init__(self): 'reverse_proxy': False, 'include_paths': False, 'never_transcode': False, + 'fetch_artists_images': False, + 'save_artists_images': False, 'playlist_dir': '', }) @@ -85,6 +87,8 @@ def func(lib, opts, args): if args: self.config['port'] = int(args.pop(0)) + app.config['fetch_artists_images'] = self.config['fetch_artists_images'].get(False) + app.config['save_artists_images'] = self.config['save_artists_images'].get(False) app.config['root_directory'] = Path(config['directory'].get()) # Total number of items in the Beets database (only used to detect deletions in getIndexes endpoint) diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index e1aaa00..182cc53 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -86,8 +86,29 @@ def send_artist_image(artist_id, size=None): # TODO - Maybe make a separate plugin to save the deezer data permanently to disk?? artist_name = sub_to_beets_artist(artist_id) - dz_data = query_deezer(artist_name, 'artist') + local_folder = app.config['root_directory'] / artist_name + local_image_path = local_folder / f'{artist_name}.jpg' + + # First, check if we have the image already downloaded + if app.config['save_artists_images'] and not local_image_path.is_file(): + dz_data = query_deezer(artist_name, 'artist') + if dz_data: + artist_image_url = dz_data.get('picture_xl', '') + response = requests.get(artist_image_url) + if response.ok: + img = Image.open(BytesIO(response.content)) + img.save(local_image_path) + + # If we have the image locally, serve it + if os.path.isfile(local_image_path): + if size: + cover = resize_image(local_image_path, size) + return flask.send_file(cover, mimetype='image/jpeg') + return flask.send_file(local_image_path, mimetype=get_mimetype(local_image_path)) + + # No local image, and no need to cache - Query deezer + dz_data = query_deezer(artist_name, 'artist') if dz_data: artist_image_url = dz_data.get('picture_small', '') available_sizes = [56, 250, 500, 1000] @@ -140,8 +161,8 @@ def get_cover_art(): return flask.send_file(cover, mimetype='image/jpeg') # artist requests - elif req_id.startswith(ART_ID_PREF): - response = send_artist_image(req_id, size=None) + elif req_id.startswith(ART_ID_PREF) and app.config['fetch_artists_images']: + response = send_artist_image(req_id, size=size) if response is not None: return response diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 3356536..251a994 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -262,9 +262,9 @@ def map_artist(artist_name, with_albums=True): 'mediaType': 'artist' } - dz_data = query_deezer(artist_name, 'artist') - if dz_data: - subsonic_artist['artistImageUrl'] = dz_data.get('picture_big', '') + # dz_data = query_deezer(artist_name, 'artist') + # if dz_data: + # subsonic_artist['artistImageUrl'] = dz_data.get('picture_big', '') albums = list(flask.g.lib.albums(f'albumartist:{artist_name}')) subsonic_artist['albumCount'] = len(albums) From 49f74d89e125d3bcf2f3baf1bc8ff286a0cf68fa Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Wed, 26 Mar 2025 00:08:48 +0000 Subject: [PATCH 58/85] Updated README.md --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b3d70e1..e44e9c2 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,17 @@ beetstream: never_transcode: False ``` -5) Run with: +5) Other configuration parameters: + +If `fetch_artists_images` is enabled, Beetstream will fetch the artists photos to display in your preferred client. If you enable this, it is recommended to also enable `save_artists_images`. +Beetstream supports playlists from Beets' **playlist** and **smartplaylist** plugins. But you can also define a Beetstream-specific playlist folder with the `playlist_dir` option . +```yaml + fetch_artists_images: False # Whether Beetstream should fetch artists photos for clients that support this + save_artists_images: False # Save artists photos to their respective folders in your music library + playlist_dir: './path/to/playlists' # A directory with Beetstream-specific playlists +``` + +6) Run with: ``` $ beet beetstream ``` From 11bdfd4b19f7b3aab92eac7364d32e6621118d9c Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Wed, 26 Mar 2025 01:31:22 +0000 Subject: [PATCH 59/85] Fixed an issue with playlists and XML format --- beetsplug/beetstream/playlistprovider.py | 4 ++-- beetsplug/beetstream/playlists.py | 9 +-------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/beetsplug/beetstream/playlistprovider.py b/beetsplug/beetstream/playlistprovider.py index 276d439..1f45385 100644 --- a/beetsplug/beetstream/playlistprovider.py +++ b/beetsplug/beetstream/playlistprovider.py @@ -1,4 +1,4 @@ -from beetsplug.beetstream.utils import PLY_ID_PREF, genres_formatter, creation_date +from beetsplug.beetstream.utils import PLY_ID_PREF, genres_formatter, creation_date, map_song from beetsplug.beetstream import app import flask from typing import Union, List @@ -97,7 +97,7 @@ def __init__(self, path): song = tx.query("SELECT * FROM items WHERE (path) LIKE (?) LIMIT 1", (entry_path.as_posix(),)) if song: - self.songs.append(dict(song[0])) + self.songs.append(map_song(song[0])) self.duration += int(song[0]['length'] or 0) diff --git a/beetsplug/beetstream/playlists.py b/beetsplug/beetstream/playlists.py index d96cd58..f92c439 100644 --- a/beetsplug/beetstream/playlists.py +++ b/beetsplug/beetstream/playlists.py @@ -40,14 +40,7 @@ def get_playlist(): if playlist is not None: payload = { - 'playlist': { - 'entry': [ - map_song( - flask.g.lib.get_item(int(song['id'])) - ) - for song in playlist.songs - ] - } + 'playlist': map_playlist(playlist) } return subsonic_response(payload, r.get('f', 'xml')) flask.abort(404) \ No newline at end of file From 8d0400aa61f23147f803bba6ebba1a747dadf0ae Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Wed, 26 Mar 2025 01:31:53 +0000 Subject: [PATCH 60/85] Small things --- README.md | 20 ++++++------ beetsplug/beetstream/coverart.py | 52 +++++++++++++++++--------------- beetsplug/beetstream/utils.py | 42 ++++++++++++++------------ 3 files changed, 59 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index e44e9c2..906c940 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Beetstream +Beetstream logo (a beetroot with soundwaves-like leaves) Beetstream is a [Beets.io](https://beets.io) plugin that exposes [SubSonic API endpoints](http://www.subsonic.org/pages/api.jsp), allowing you to stream your music everywhere. @@ -37,12 +38,14 @@ beetstream: 5) Other configuration parameters: -If `fetch_artists_images` is enabled, Beetstream will fetch the artists photos to display in your preferred client. If you enable this, it is recommended to also enable `save_artists_images`. -Beetstream supports playlists from Beets' **playlist** and **smartplaylist** plugins. But you can also define a Beetstream-specific playlist folder with the `playlist_dir` option . +If `fetch_artists_images` is enabled, Beetstream will fetch the artists photos to display in your client player (if you enable this, it is recommended to also enable `save_artists_images`). + +Beetstream supports playlists from Beets' [playlist](https://beets.readthedocs.io/en/stable/plugins/playlist.html) and [smartplaylist](https://beets.readthedocs.io/en/stable/plugins/smartplaylist.html) plugins. You can also define a Beetstream-specific playlist folder with the `playlist_dir` option: ```yaml - fetch_artists_images: False # Whether Beetstream should fetch artists photos for clients that support this - save_artists_images: False # Save artists photos to their respective folders in your music library - playlist_dir: './path/to/playlists' # A directory with Beetstream-specific playlists +beetstream: + fetch_artists_images: False # Whether Beetstream should fetch artists photos when clients request them + save_artists_images: False # Save artists photos to their respective folders in your music library + playlist_dir: './path/to/playlists' # A directory with Beetstream-specific playlists ``` 6) Run with: @@ -58,12 +61,7 @@ There is currently no security whatsoever. You can put whatever user and passwor ### Server and Port -Currently runs on port `8080`. i.e: `https://192.168.1.10:8080`. You can configure it in `~/.config/beets/config.yaml`. Defaults are: -```yaml -beetstream: - host: 0.0.0.0 - port: 8080 -``` +Currently runs on port `8080` (i.e.: `https://192.168.1.10:8080`) ## Supported Clients diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index 182cc53..a20a463 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -91,14 +91,15 @@ def send_artist_image(artist_id, size=None): local_image_path = local_folder / f'{artist_name}.jpg' # First, check if we have the image already downloaded - if app.config['save_artists_images'] and not local_image_path.is_file(): + if app.config['fetch_artists_images'] and app.config['save_artists_images'] and not local_image_path.is_file(): dz_data = query_deezer(artist_name, 'artist') if dz_data: - artist_image_url = dz_data.get('picture_xl', '') - response = requests.get(artist_image_url) - if response.ok: - img = Image.open(BytesIO(response.content)) - img.save(local_image_path) + artist_image_url = dz_data.get('picture_xl', '') or dz_data.get('picture_big', '') + if artist_image_url: + response = requests.get(artist_image_url) + if response.ok: + img = Image.open(BytesIO(response.content)) + img.save(local_image_path) # If we have the image locally, serve it if os.path.isfile(local_image_path): @@ -107,24 +108,25 @@ def send_artist_image(artist_id, size=None): return flask.send_file(cover, mimetype='image/jpeg') return flask.send_file(local_image_path, mimetype=get_mimetype(local_image_path)) - # No local image, and no need to cache - Query deezer - dz_data = query_deezer(artist_name, 'artist') - if dz_data: - artist_image_url = dz_data.get('picture_small', '') - available_sizes = [56, 250, 500, 1000] - if artist_image_url: - if size: - # If requested size is one of coverarchive's available sizes, query it directly - if size in available_sizes: - return flask.redirect(artist_image_url.replace('56x56', f'{size}x{size}')) - # Otherwise, get the smallest available size that is greater than the requested size - next_size = next((s for s in sorted(available_sizes) if s >= size), None) - if next_size is None: - next_size = max(available_sizes) - response = requests.get(artist_image_url.replace('56x56', f'{next_size}x{next_size}')) - cover = resize_image(BytesIO(response.content), size) - return flask.send_file(cover, mimetype='image/jpeg') - return flask.redirect(artist_image_url) + if app.config['fetch_artists_images']: + # No local image, and no need to cache - Query deezer + dz_data = query_deezer(artist_name, 'artist') + if dz_data: + artist_image_url = dz_data.get('picture_small', '') + available_sizes = [56, 250, 500, 1000] + if artist_image_url: + if size: + # If requested size is one of coverarchive's available sizes, query it directly + if size in available_sizes: + return flask.redirect(artist_image_url.replace('56x56', f'{size}x{size}')) + # Otherwise, get the smallest available size that is greater than the requested size + next_size = next((s for s in sorted(available_sizes) if s >= size), None) + if next_size is None: + next_size = max(available_sizes) + response = requests.get(artist_image_url.replace('56x56', f'{next_size}x{next_size}')) + cover = resize_image(BytesIO(response.content), size) + return flask.send_file(cover, mimetype='image/jpeg') + return flask.redirect(artist_image_url) @app.route('/rest/getCoverArt', methods=["GET", "POST"]) @@ -161,7 +163,7 @@ def get_cover_art(): return flask.send_file(cover, mimetype='image/jpeg') # artist requests - elif req_id.startswith(ART_ID_PREF) and app.config['fetch_artists_images']: + elif req_id.startswith(ART_ID_PREF): response = send_artist_image(req_id, size=size) if response is not None: return response diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 251a994..952b395 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -225,12 +225,12 @@ def map_song(song_object): } subsonic_song.update(song_specific) - subsonic_song['replayGain'] = { - 'trackGain': (song.get('rg_track_gain') or 0) or ((song.get('r128_track_gain') or 107) - 107), - 'albumGain': (song.get('rg_album_gain') or 0) or ((song.get('r128_album_gain') or 107) - 107), - 'trackPeak': song.get('rg_track_peak', 0), - 'albumPeak': song.get('rg_album_peak', 0) - } + # subsonic_song['replayGain'] = { + # 'trackGain': (song.get('rg_track_gain') or 0) or ((song.get('r128_track_gain') or 107) - 107), + # 'albumGain': (song.get('rg_album_gain') or 0) or ((song.get('r128_album_gain') or 107) - 107), + # 'trackPeak': song.get('rg_track_peak', 0), + # 'albumPeak': song.get('rg_album_peak', 0) + # } # Add remaining filetype-related elements with fallbacks subsonic_song['suffix'] = song.get('format').lower() or subsonic_song['path'].rsplit('.', 1)[-1].lower() @@ -252,11 +252,11 @@ def map_artist(artist_name, with_albums=True): 'coverArt': subsonic_artist_id, "userRating": 0, - "roles": [ - "artist", - "albumartist", - "composer" - ], + # "roles": [ + # "artist", + # "albumartist", + # "composer" + # ], # This is only needed when part of a Child response 'mediaType': 'artist' @@ -276,17 +276,21 @@ def map_artist(artist_name, with_albums=True): return subsonic_artist + def map_playlist(playlist): - return { + subsonic_playlist = { 'id': playlist.id, 'name': playlist.name, 'songCount': len(playlist.songs), 'duration': playlist.duration, 'created': timestamp_to_iso(playlist.ctime), 'changed': timestamp_to_iso(playlist.mtime), + 'entry': playlist.songs + # 'owner': 'userA', # TODO # 'public': True, } + return subsonic_playlist # === Core response-formatting functions === @@ -306,20 +310,20 @@ def dict_to_xml(tag: str, data): # If the attribute already exists, create a child element if key in elem.attrib: child = ET.Element(key) - child.text = str(val) + child.text = str(val).lower() if isinstance(val, bool) else str(val) elem.append(child) else: - elem.set(key, str(val)) + elem.set(key, str(val).lower() if isinstance(val, bool) else str(val)) elif isinstance(val, list): for item in val: # For each item in the list, process depending on type if not isinstance(item, (dict, list)): if key in elem.attrib: child = ET.Element(key) - child.text = str(item) + child.text = str(item).lower() if isinstance(item, bool) else str(item) elem.append(child) else: - elem.set(key, str(item)) + elem.set(key, str(item).lower() if isinstance(item, bool) else str(item)) else: child = dict_to_xml(key, item) elem.append(child) @@ -333,15 +337,15 @@ def dict_to_xml(tag: str, data): if not isinstance(item, (dict, list)): if tag in elem.attrib: child = ET.Element(tag) - child.text = str(item) + child.text = str(item).lower() if isinstance(item, bool) else str(item) elem.append(child) else: - elem.set(tag, str(item)) + elem.set(tag, str(item).lower() if isinstance(item, bool) else str(item)) else: child = dict_to_xml(tag, item) elem.append(child) else: - elem.text = str(data) + elem.text = str(data).lower() if isinstance(data, bool) else str(data) return elem From cd38ce1bcab7d7ad9330460b586251d948a7e766 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Wed, 26 Mar 2025 02:37:42 +0000 Subject: [PATCH 61/85] Added getArtistInfo2 endpoint --- beetsplug/beetstream/__init__.py | 4 +++ beetsplug/beetstream/artists.py | 31 +++++++++++++++------ beetsplug/beetstream/utils.py | 46 +++++++++++++++++++++++++++++--- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py index 7e105b0..b2a0c26 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstream/__init__.py @@ -60,6 +60,7 @@ def __init__(self): 'never_transcode': False, 'fetch_artists_images': False, 'save_artists_images': False, + 'lastfm_api_key': '', 'playlist_dir': '', }) @@ -87,8 +88,11 @@ def func(lib, opts, args): if args: self.config['port'] = int(args.pop(0)) + app.config['lastfm_api_key'] = self.config['lastfm_api_key'].get(None) + app.config['fetch_artists_images'] = self.config['fetch_artists_images'].get(False) app.config['save_artists_images'] = self.config['save_artists_images'].get(False) + app.config['root_directory'] = Path(config['directory'].get()) # Total number of items in the Beets database (only used to detect deletions in getIndexes endpoint) diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index fc53cb7..764d1f5 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -1,6 +1,7 @@ from beetsplug.beetstream.utils import * from beetsplug.beetstream import app import time +import urllib.parse from collections import defaultdict from functools import partial import flask @@ -87,21 +88,35 @@ def get_artist(): @app.route('/rest/getArtistInfo2', methods=["GET", "POST"]) @app.route('/rest/getArtistInfo2.view', methods=["GET", "POST"]) def artistInfo2(): - # TODO r = flask.request.values artist_name = sub_to_beets_artist(r.get('id')) + first_item = flask.g.lib.items(f'albumartist:{artist_name}')[0] + artist_mbid = first_item.get('mb_albumartistid', '') + + if app.config['lastfm_api_key']: + data_lastfm = query_lastfm(artist_mbid, 'artist') + bio = data_lastfm.get('artist', {}).get('bio', {}).get('content', '') + short_bio = trim_bio(bio, char_limit=300) + else: + short_bio = f'wow. much artist. very {artist_name}' payload = { - "artistInfo2": { - "biography": f"wow. much artist. very {artist_name}", - "musicBrainzId": "", - "lastFmUrl": "", - "smallImageUrl": "", - "mediumImageUrl": "", - "largeImageUrl": "" + 'artistInfo2': { + 'biography': short_bio, + 'musicBrainzId': artist_mbid, + 'lastFmUrl': f'https://www.last.fm/music/{urllib.parse.quote_plus(artist_name.replace(' ', '+'))}', } } + if app.config['fetch_artists_images']: + # TODO - this is not fetching the actual images, maybe we keep it as always on? + dz_query = urllib.parse.quote_plus(artist_name.replace(' ', '-')) + dz_data = query_deezer(dz_query, 'artist') + if dz_data: + payload['artistInfo2']['smallImageUrl'] = dz_data.get('picture_medium', ''), + payload['artistInfo2']['mediumImageUrl'] = dz_data.get('picture_big', ''), + payload['artistInfo2']['largeImageUrl'] = dz_data.get('picture_xl', '') + return subsonic_response(payload, r.get('f', 'xml')) \ No newline at end of file diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 952b395..0de4637 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -17,6 +17,8 @@ from functools import partial import requests import urllib.parse +from beetsplug.beetstream import app + API_VERSION = '1.16.1' @@ -469,7 +471,7 @@ def creation_date(filepath): return stat.st_mtime -def query_musicbrainz(mbid:str, type: str): +def query_musicbrainz(mbid: str, type: str): types_mb = {'track': 'recording', 'album': 'release', 'artist': 'artist'} endpoint = f'https://musicbrainz.org/ws/2/{types_mb[type]}/{mbid}' @@ -481,10 +483,10 @@ def query_musicbrainz(mbid:str, type: str): params['inc'] = 'annotation' response = requests.get(endpoint, headers=headers, params=params) - return response.json() + return response.json() if response.ok else {} -def query_deezer(query:str, type: str): +def query_deezer(query: str, type: str): query_urlsafe = urllib.parse.quote_plus(query.replace(' ', '-')) endpoint = f'https://api.deezer.com/{type}/{query_urlsafe}' @@ -494,3 +496,41 @@ def query_deezer(query:str, type: str): response = requests.get(endpoint, headers=headers) return response.json() if response.ok else {} + + +def query_lastfm(query: str, type: str, mbid=True): + if not app.config['lastfm_api_key']: + return {} + + query_lastfm = query.replace(' ', '+') + endpoint = 'http://ws.audioscrobbler.com/2.0/' + + params = { + 'format': 'json', + 'method': f'{type}.getInfo', + 'api_key': app.config['lastfm_api_key'], + } + + if mbid: + params['mbid'] = query + elif query_lastfm and type != 'user': + params[type] = query_lastfm + + + headers = {'User-Agent': f'Beetstream/{BEETSTREAM_VERSION} ( https://github.com/FlorentLM/Beetstream )'} + response = requests.get(endpoint, headers=headers, params=params) + + return response.json() if response.ok else {} + + +def trim_bio(text, char_limit=300): + if len(text) <= char_limit: + return text + + snippet = text[:char_limit] + period_index = text.find(".", char_limit) + + if period_index != -1: + snippet = text[:period_index + 1] + + return snippet \ No newline at end of file From cda3dfa020ea664b98839ec60c4eb3cbcdbaa459 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Wed, 26 Mar 2025 03:11:47 +0000 Subject: [PATCH 62/85] unified the functions with versions 1, 2, 3 --- beetsplug/beetstream/albums.py | 16 +++------------- beetsplug/beetstream/artists.py | 21 ++++++++++----------- beetsplug/beetstream/search.py | 8 ++------ beetsplug/beetstream/songs.py | 10 ++-------- beetsplug/beetstream/utils.py | 4 ++++ missing-endpoints.md | 2 -- 6 files changed, 21 insertions(+), 40 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 0a9b1c6..4bbc970 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -28,15 +28,10 @@ def get_album(): @app.route('/rest/getAlbumInfo', methods=["GET", "POST"]) @app.route('/rest/getAlbumInfo.view', methods=["GET", "POST"]) -def get_album_info(): - return _album_info() @app.route('/rest/getAlbumInfo2', methods=["GET", "POST"]) @app.route('/rest/getAlbumInfo2.view', methods=["GET", "POST"]) -def get_album_info_2(): - return _album_info(ver=2) - -def _album_info(ver=None): +def get_album_info(ver=None): r = flask.request.values req_id = r.get('id') @@ -47,7 +42,7 @@ def _album_info(ver=None): album_quot = urllib.parse.quote(album.get('album', '')) lastfm_url = f'https://www.last.fm/music/{artist_quot}/{album_quot}' if artist_quot and album_quot else '' - tag = f"albumInfo{ver if ver else ''}" + tag = endpoint_to_tag(flask.request.path) payload = { tag: { 'musicBrainzId': album.get('mb_albumid', ''), @@ -61,14 +56,9 @@ def _album_info(ver=None): @app.route('/rest/getAlbumList', methods=["GET", "POST"]) @app.route('/rest/getAlbumList.view', methods=["GET", "POST"]) -def album_list(): - return get_album_list() @app.route('/rest/getAlbumList2', methods=["GET", "POST"]) @app.route('/rest/getAlbumList2.view', methods=["GET", "POST"]) -def album_list_2(): - return get_album_list(ver=2) - def get_album_list(ver=None): r = flask.request.values @@ -123,7 +113,7 @@ def get_album_list(ver=None): with flask.g.lib.transaction() as tx: albums = tx.query(query, params) - tag = f"albumList{ver if ver else ''}" + tag = endpoint_to_tag(flask.request.path) payload = { tag: { # albumList response does not include songs "album": list(map(partial(map_album, with_songs=False), albums)) diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index 764d1f5..85de68e 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -28,15 +28,10 @@ def artist_payload(subsonic_artist_id: str, with_albums=True) -> dict: @app.route('/rest/getArtists', methods=["GET", "POST"]) @app.route('/rest/getArtists.view', methods=["GET", "POST"]) -def get_artists(): - return _artists('artists') @app.route('/rest/getIndexes', methods=["GET", "POST"]) @app.route('/rest/getIndexes.view', methods=["GET", "POST"]) -def get_indexes(): - return _artists('indexes') - -def _artists(version: str): +def get_artists_or_indexes(): r = flask.request.values modified_since = r.get('ifModifiedSince', '') @@ -48,8 +43,9 @@ def _artists(version: str): for artist in artists: alphanum_dict[strip_accents(artist[0]).upper()].append(artist) + tag = endpoint_to_tag(flask.request.path) payload = { - version: { + tag: { 'ignoredArticles': '', # TODO - include config from 'the' plugin?? 'index': [ {'name': char, 'artist': list(map(map_artist, artists))} @@ -58,8 +54,7 @@ def _artists(version: str): } } - if version == 'indexes': - + if tag == 'indexes': with flask.g.lib.transaction() as tx: latest = int(tx.query('SELECT added FROM items ORDER BY added DESC LIMIT 1')[0][0]) # TODO - 'mtime' field? @@ -71,7 +66,7 @@ def _artists(version: str): latest = int(time.time() * 1000) app.config['nb_items'] = nb_items - payload[version]['lastModified'] = latest + payload[tag]['lastModified'] = latest return subsonic_response(payload, r.get('f', 'xml')) @@ -85,6 +80,9 @@ def get_artist(): return subsonic_response(payload, r.get('f', 'xml')) +@app.route('/rest/getArtistInfo', methods=["GET", "POST"]) +@app.route('/rest/getArtistInfo.view', methods=["GET", "POST"]) + @app.route('/rest/getArtistInfo2', methods=["GET", "POST"]) @app.route('/rest/getArtistInfo2.view', methods=["GET", "POST"]) def artistInfo2(): @@ -102,8 +100,9 @@ def artistInfo2(): else: short_bio = f'wow. much artist. very {artist_name}' + tag = endpoint_to_tag(flask.request.path) payload = { - 'artistInfo2': { + tag: { 'biography': short_bio, 'musicBrainzId': artist_mbid, 'lastFmUrl': f'https://www.last.fm/music/{urllib.parse.quote_plus(artist_name.replace(' ', '+'))}', diff --git a/beetsplug/beetstream/search.py b/beetsplug/beetstream/search.py index 2424e32..531da05 100644 --- a/beetsplug/beetstream/search.py +++ b/beetsplug/beetstream/search.py @@ -5,15 +5,11 @@ @app.route('/rest/search2', methods=["GET", "POST"]) @app.route('/rest/search2.view', methods=["GET", "POST"]) -def search2(): - return _search(ver=2) @app.route('/rest/search3', methods=["GET", "POST"]) @app.route('/rest/search3.view', methods=["GET", "POST"]) -def search3(): - return _search(ver=3) -def _search(ver=None): +def search(ver=None): r = flask.request.values song_count = int(r.get('songCount', 20)) @@ -57,7 +53,7 @@ def _search(ver=None): # TODO - do the sort in the SQL query instead? artists.sort(key=lambda name: strip_accents(name).upper()) - tag = f'searchResult{ver if ver else ""}' + tag = endpoint_to_tag(flask.request.path) payload = { tag: { 'artist': list(map(partial(map_artist, with_albums=False), artists)), # no need to include albums twice diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index c2adb50..a7720d6 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -116,21 +116,15 @@ def get_top_songs(): @app.route('/rest/getStarred', methods=["GET", "POST"]) @app.route('/rest/getStarred.view', methods=["GET", "POST"]) -def get_starred_songs(): - return _starred_songs() @app.route('/rest/getStarred2', methods=["GET", "POST"]) @app.route('/rest/getStarred2.view', methods=["GET", "POST"]) -def get_starred2_songs(): - return _starred_songs(ver=2) - - -def _starred_songs(ver=None): +def get_starred_songs(ver=None): # TODO r = flask.request.values - tag = f'starred{ver if ver else ''}' + tag = endpoint_to_tag(flask.request.path) payload = { tag: { 'song': [] diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 0de4637..ef02ca6 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -395,6 +395,10 @@ def subsonic_response(data: dict = {}, resp_fmt: str = 'xml', failed=False): # === Various other utility functions === +def endpoint_to_tag(endpoint): + tag = endpoint[9:].rsplit('.', 1)[0] + return tag[0].lower() + tag[1:] + def strip_accents(s): return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn') diff --git a/missing-endpoints.md b/missing-endpoints.md index 4b53d50..7e1ef6a 100644 --- a/missing-endpoints.md +++ b/missing-endpoints.md @@ -1,10 +1,8 @@ # Missing Endpoints To be implemented: -- `getArtistInfo` - `getSimilarSongs` - `getSimilarSongs2` -- `search` Could be fun to implement: - `createPlaylist` From f852f12f532b9e9212730324de3f155f6f5d9822 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 27 Mar 2025 00:41:45 +0000 Subject: [PATCH 63/85] Added getSimilarSongs 1 and 2 --- beetsplug/beetstream/artists.py | 8 +-- beetsplug/beetstream/coverart.py | 3 +- beetsplug/beetstream/songs.py | 92 ++++++++++++++++++++++++++++++++ beetsplug/beetstream/utils.py | 12 ++--- missing-endpoints.md | 4 -- 5 files changed, 103 insertions(+), 16 deletions(-) diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index 85de68e..f892775 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -96,7 +96,7 @@ def artistInfo2(): if app.config['lastfm_api_key']: data_lastfm = query_lastfm(artist_mbid, 'artist') bio = data_lastfm.get('artist', {}).get('bio', {}).get('content', '') - short_bio = trim_bio(bio, char_limit=300) + short_bio = trim_text(bio, char_limit=300) else: short_bio = f'wow. much artist. very {artist_name}' @@ -114,8 +114,8 @@ def artistInfo2(): dz_query = urllib.parse.quote_plus(artist_name.replace(' ', '-')) dz_data = query_deezer(dz_query, 'artist') if dz_data: - payload['artistInfo2']['smallImageUrl'] = dz_data.get('picture_medium', ''), - payload['artistInfo2']['mediumImageUrl'] = dz_data.get('picture_big', ''), - payload['artistInfo2']['largeImageUrl'] = dz_data.get('picture_xl', '') + payload[tag]['smallImageUrl'] = dz_data.get('picture_medium', ''), + payload[tag]['mediumImageUrl'] = dz_data.get('picture_big', ''), + payload[tag]['largeImageUrl'] = dz_data.get('picture_xl', '') return subsonic_response(payload, r.get('f', 'xml')) \ No newline at end of file diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index a20a463..85ac3c2 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -82,8 +82,7 @@ def send_album_art(album_id, size=None): def send_artist_image(artist_id, size=None): - # TODO - Load from disk if available - # TODO - Maybe make a separate plugin to save the deezer data permanently to disk?? + # TODO - Maybe make a separate plugin to save deezer data permanently to disk / beets db? artist_name = sub_to_beets_artist(artist_id) diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index a7720d6..1186eaf 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -1,6 +1,10 @@ from beetsplug.beetstream.utils import * from beetsplug.beetstream import app, stream import flask +import re + + +artists_separators = re.compile(r', | & ') def song_payload(subsonic_song_id: str) -> dict: @@ -131,3 +135,91 @@ def get_starred_songs(ver=None): } } return subsonic_response(payload, r.get('f', 'xml')) + + +@app.route('/rest/getSimilarSongs', methods=["GET", "POST"]) +@app.route('/rest/getSimilarSongs.view', methods=["GET", "POST"]) + +@app.route('/rest/getSimilarSongs2', methods=["GET", "POST"]) +@app.route('/rest/getSimilarSongs2.view', methods=["GET", "POST"]) +def get_similar_songs(): + + r = flask.request.values + + req_id = r.get('id') + limit = r.get('count', 50) + + if req_id.startswith(ART_ID_PREF): + artist_name = sub_to_beets_artist(req_id) + # grab the artist's mbid + with flask.g.lib.transaction() as tx: + mbid_artist = tx.query(f""" SELECT mb_artistid FROM items WHERE albumartist LIKE '{artist_name}' LIMIT 1 """) + else: + flask.abort(404) # just for now + + similar_artists = {} + + # If we can ask lastfm + if app.config['lastfm_api_key']: + # Query last.fm for similar artists and parse the response + if mbid_artist: + lastfm_resp = query_lastfm(query=mbid_artist[0][0], type='artist', method='similar', mbid=True) + else: + lastfm_resp = query_lastfm(query=artist_name, type='artist', method='similar', mbid=False) + + if lastfm_resp: + + similar_artists = { + artist.get('name'): artist.get('mbid', '') + for artist in lastfm_resp.get('similarartists', {}).get('artist', []) + } + + # Add the requested artist (will be the only fallback if no lastfm key available) + similar_artists[artist_name] = mbid_artist[0][0] if mbid_artist else '' + + # Build up a humongous SQL query to get everything with related artists + mbid_fields = ['mb_artistid', 'mb_artistids'] + name_fields = ['artist', 'artists', 'composer', 'lyricist'] + conditions = [] + params = [] + for name, mbid in similar_artists.items(): + if mbid: + # When we have an mbid, match against all relevant mbid fields + sub_conditions = [] + # Check each mbid field for an exact match + for field in mbid_fields: + sub_conditions.append(f"{field} = ?") + params.append(mbid) + # Also check each name field with a LIKE condition + for field in name_fields: + sub_conditions.append(f"{field} LIKE ?") + params.append(f"%{name}%") + conditions.append("(" + " OR ".join(sub_conditions) + ")") + else: + # no mbid: typically with lastfm responses that's bc the entry is several artists in a collab + parts = re.split(artists_separators, name) + sub_conditions_outer = [] + for part in parts: + sub_conditions_inner = [] + for field in name_fields: + sub_conditions_inner.append(f"{field} LIKE ?") + params.append(f"%{part}%") + sub_conditions_outer.append("(" + " OR ".join(sub_conditions_inner) + ")") + conditions.append("(" + " OR ".join(sub_conditions_outer) + ")") + + # we also let SQL remove duplicate rows using DISTINCT, and apply the limit there directly + query = "SELECT DISTINCT * FROM items WHERE " + " OR ".join(conditions) + " LIMIT ?" + params.append(limit) + + # Run the single big SQL query + with flask.g.lib.transaction() as tx: + beets_results = list(tx.query(query, params)) + + # and finally reply to the client + tag = endpoint_to_tag(flask.request.path) + payload = { + tag: { + 'song': list(map(map_song, beets_results)) + } + } + return subsonic_response(payload, r.get('f', 'xml')) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index ef02ca6..a2993b1 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -198,7 +198,7 @@ def map_song(song_object): 'path': song_filepath if os.path.isfile(song_filepath) else '', 'played': timestamp_to_iso(song.get('last_played', 0)), - 'starred': timestamp_to_iso(song.get('last_liked', 0)), + # 'starred': timestamp_to_iso(song.get('last_liked', 0)), 'playCount': song.get('play_count', 0), 'userRating': song.get('stars_rating', 0), @@ -273,8 +273,8 @@ def map_artist(artist_name, with_albums=True): if albums: subsonic_artist['musicBrainzId'] = albums[0].get('mb_albumartistid', '') - if with_albums: - subsonic_artist['album'] = list(map(partial(map_album, with_songs=False), albums)) + if with_albums: + subsonic_artist['album'] = list(map(partial(map_album, with_songs=False), albums)) return subsonic_artist @@ -502,7 +502,7 @@ def query_deezer(query: str, type: str): return response.json() if response.ok else {} -def query_lastfm(query: str, type: str, mbid=True): +def query_lastfm(query: str, type: str, method: str = 'info', mbid=True): if not app.config['lastfm_api_key']: return {} @@ -511,7 +511,7 @@ def query_lastfm(query: str, type: str, mbid=True): params = { 'format': 'json', - 'method': f'{type}.getInfo', + 'method': f'{type}.get{method.title()}', 'api_key': app.config['lastfm_api_key'], } @@ -527,7 +527,7 @@ def query_lastfm(query: str, type: str, mbid=True): return response.json() if response.ok else {} -def trim_bio(text, char_limit=300): +def trim_text(text, char_limit=300): if len(text) <= char_limit: return text diff --git a/missing-endpoints.md b/missing-endpoints.md index 7e1ef6a..ea2f230 100644 --- a/missing-endpoints.md +++ b/missing-endpoints.md @@ -1,9 +1,5 @@ # Missing Endpoints -To be implemented: -- `getSimilarSongs` -- `getSimilarSongs2` - Could be fun to implement: - `createPlaylist` - `updatePlaylist` From 0c205e9d4679da23ade3ae3837b1894bd9360e07 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 27 Mar 2025 00:49:27 +0000 Subject: [PATCH 64/85] Added getSimilarSongs 1 and 2 --- beetsplug/beetstream/songs.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index 1186eaf..aec2a01 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -154,6 +154,21 @@ def get_similar_songs(): # grab the artist's mbid with flask.g.lib.transaction() as tx: mbid_artist = tx.query(f""" SELECT mb_artistid FROM items WHERE albumartist LIKE '{artist_name}' LIMIT 1 """) + elif req_id.startswith(SNG_ID_PREF): + # TODO - Maybe query the track.getSimilar endpoint on lastfm instead of using the artist? + beets_song_id = sub_to_beets_song(req_id) + song_item = flask.g.lib.get_item(beets_song_id) + if not song_item: + flask.abort(404) + artist_name = song_item.get('albumartist', '') + mbid_artist = [[song_item.get('mb_artistid', '')]] + elif req_id.startswith(ALB_ID_PREF): + beets_album_id = sub_to_beets_album(req_id) + album_object = flask.g.lib.get_album(beets_album_id) + if not album_object: + flask.abort(404) + artist_name = album_object.get('albumartist', '') + mbid_artist = [[album_object.get('mb_artistid', '')]] else: flask.abort(404) # just for now From 52fc157635fc5e3dea4b8372d91455f01b01d226 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 27 Mar 2025 01:41:55 +0000 Subject: [PATCH 65/85] Removed endpoint_to_tag function --- beetsplug/beetstream/albums.py | 4 ++-- beetsplug/beetstream/artists.py | 4 ++-- beetsplug/beetstream/search.py | 2 +- beetsplug/beetstream/songs.py | 39 ++++++++++++++++++++++++++++----- 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 4bbc970..5c1fcbc 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -42,7 +42,7 @@ def get_album_info(ver=None): album_quot = urllib.parse.quote(album.get('album', '')) lastfm_url = f'https://www.last.fm/music/{artist_quot}/{album_quot}' if artist_quot and album_quot else '' - tag = endpoint_to_tag(flask.request.path) + tag = 'albumInfo2' if flask.request.path.rsplit('.', 1)[0].endswith('2') else 'albumInfo' payload = { tag: { 'musicBrainzId': album.get('mb_albumid', ''), @@ -113,7 +113,7 @@ def get_album_list(ver=None): with flask.g.lib.transaction() as tx: albums = tx.query(query, params) - tag = endpoint_to_tag(flask.request.path) + tag = 'albumList2' if flask.request.path.rsplit('.', 1)[0].endswith('2') else 'albumList' payload = { tag: { # albumList response does not include songs "album": list(map(partial(map_album, with_songs=False), albums)) diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index f892775..37e7561 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -43,7 +43,7 @@ def get_artists_or_indexes(): for artist in artists: alphanum_dict[strip_accents(artist[0]).upper()].append(artist) - tag = endpoint_to_tag(flask.request.path) + tag = 'indexes' if flask.request.path.rsplit('.', 1)[0].endswith('Indexes') else 'artists' payload = { tag: { 'ignoredArticles': '', # TODO - include config from 'the' plugin?? @@ -100,7 +100,7 @@ def artistInfo2(): else: short_bio = f'wow. much artist. very {artist_name}' - tag = endpoint_to_tag(flask.request.path) + tag = 'artistInfo2' if flask.request.path.rsplit('.', 1)[0].endswith('2') else 'artistInfo' payload = { tag: { 'biography': short_bio, diff --git a/beetsplug/beetstream/search.py b/beetsplug/beetstream/search.py index 531da05..b573b85 100644 --- a/beetsplug/beetstream/search.py +++ b/beetsplug/beetstream/search.py @@ -53,7 +53,7 @@ def search(ver=None): # TODO - do the sort in the SQL query instead? artists.sort(key=lambda name: strip_accents(name).upper()) - tag = endpoint_to_tag(flask.request.path) + tag = flask.request.path.rsplit('.', 1)[0][6:] payload = { tag: { 'artist': list(map(partial(map_artist, with_albums=False), artists)), # no need to include albums twice diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index aec2a01..fd88659 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -108,13 +108,40 @@ def download_song(): @app.route('/rest/getTopSongs', methods=["GET", "POST"]) @app.route('/rest/getTopSongs.view', methods=["GET", "POST"]) def get_top_songs(): - # TODO - Use the play_count, and/or link with Last.fm or ListenBrainz r = flask.request.values - payload = { - 'topSongs': {} - } + req_id = r.get('id', '') + + payload = {'topSongs': {'song': []}} + + if req_id.startswith(ART_ID_PREF): + artist_name = sub_to_beets_artist(req_id) + # grab the artist's mbid + with flask.g.lib.transaction() as tx: + mbid_artist = tx.query(f""" SELECT mb_artistid FROM items WHERE albumartist LIKE '{artist_name}' LIMIT 1 """) + + if app.config['lastfm_api_key']: + # Query last.fm for top tracks for this artist and parse the response + if mbid_artist: + lastfm_resp = query_lastfm(query=mbid_artist[0][0], type='artist', method='TopTracks', mbid=True) + else: + lastfm_resp = query_lastfm(query=artist_name, type='artist', method='TopTracks', mbid=False) + + if lastfm_resp: + beets_results = [flask.g.lib.items(f'title:{t.get('name', '').replace("'", "")}') + for t in lastfm_resp.get('toptracks', {}).get('track', [])] + top_tracks_available = [track[0] for track in beets_results if track] + + payload = { + 'topSongs': { + 'song': list(map(map_song, top_tracks_available)) + } + } + else: + # TODO - Use the local play_count in this case + pass + return subsonic_response(payload, r.get('f', 'xml')) @@ -128,7 +155,7 @@ def get_starred_songs(ver=None): r = flask.request.values - tag = endpoint_to_tag(flask.request.path) + tag = 'starred2' if flask.request.path.rsplit('.', 1)[0].endswith('2') else 'starred' payload = { tag: { 'song': [] @@ -231,7 +258,7 @@ def get_similar_songs(): beets_results = list(tx.query(query, params)) # and finally reply to the client - tag = endpoint_to_tag(flask.request.path) + tag = 'similarSongs2' if flask.request.path.rsplit('.', 1)[0].endswith('2') else 'similarSongs' payload = { tag: { 'song': list(map(map_song, beets_results)) From 17b2b4221752c334659559dddaac9f9b5c4c7d03 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 27 Mar 2025 01:42:13 +0000 Subject: [PATCH 66/85] Removed endpoint_to_tag function --- beetsplug/beetstream/utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index a2993b1..15ed138 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -395,9 +395,6 @@ def subsonic_response(data: dict = {}, resp_fmt: str = 'xml', failed=False): # === Various other utility functions === -def endpoint_to_tag(endpoint): - tag = endpoint[9:].rsplit('.', 1)[0] - return tag[0].lower() + tag[1:] def strip_accents(s): return ''.join(c for c in unicodedata.normalize('NFD', s) if unicodedata.category(c) != 'Mn') From 936c967a8d94242e11ecec9eeafc2ba94ad3d5da Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 27 Mar 2025 01:45:48 +0000 Subject: [PATCH 67/85] Removed endpoint_to_tag function --- beetsplug/beetstream/search.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/beetsplug/beetstream/search.py b/beetsplug/beetstream/search.py index b573b85..13fc1b2 100644 --- a/beetsplug/beetstream/search.py +++ b/beetsplug/beetstream/search.py @@ -53,7 +53,12 @@ def search(ver=None): # TODO - do the sort in the SQL query instead? artists.sort(key=lambda name: strip_accents(name).upper()) - tag = flask.request.path.rsplit('.', 1)[0][6:] + if flask.request.path.rsplit('.', 1)[0][6:] == 'search2': + tag = 'searchResult2' + elif flask.request.path.rsplit('.', 1)[0][6:] == 'search3': + tag = 'searchResult3' + else: + tag = 'searchResult' payload = { tag: { 'artist': list(map(partial(map_artist, with_albums=False), artists)), # no need to include albums twice From 3d3e1b92f49aeb5a2838078345eeb8aa87334898 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 27 Mar 2025 23:46:39 +0000 Subject: [PATCH 68/85] Added getOpenSubsonicExtensions endpoint (empty) --- beetsplug/beetstream/general.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/beetsplug/beetstream/general.py b/beetsplug/beetstream/general.py index 8fc8eb8..05ab923 100644 --- a/beetsplug/beetstream/general.py +++ b/beetsplug/beetstream/general.py @@ -6,6 +6,22 @@ import flask +subsonic_errors = { + 0: 'A generic error.', + 10: 'Required parameter is missing.', + 20: 'Incompatible Subsonic REST protocol version. Client must upgrade.', + 30: 'Incompatible Subsonic REST protocol version. Server must upgrade.', + 40: 'Wrong username or password.', + 41: 'Token authentication not supported for LDAP users.', + 42: 'Provided authentication mechanism not supported.', + 43: 'Multiple conflicting authentication mechanisms provided.', + 44: 'Invalid API key.', + 50: 'User is not authorized for the given operation.', + # 60: 'The trial period for the Subsonic server is over.', + 70: 'The requested data was not found.' +} + + def musicdirectory_payload(subsonic_musicdirectory_id: str, with_artists=True) -> dict: # Only one possible root directory in beets (?), so just return its name @@ -22,6 +38,17 @@ def musicdirectory_payload(subsonic_musicdirectory_id: str, with_artists=True) - return payload +@app.route('/rest/getOpenSubsonicExtensions', methods=["GET", "POST"]) +@app.route('/rest/getOpenSubsonicExtensions.view', methods=["GET", "POST"]) +def get_open_subsonic_extensions(): + r = flask.request.values + + payload = { + 'openSubsonicExtensions': [] + } + return subsonic_response(payload, r.get('f', 'xml')) + + @app.route('/rest/getGenres', methods=["GET", "POST"]) @app.route('/rest/getGenres.view', methods=["GET", "POST"]) def get_genres(): From 5e085b520ee8a6c3b74f15a65f1293e66277bdb9 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Thu, 27 Mar 2025 23:47:40 +0000 Subject: [PATCH 69/85] groundwork for supporting user authentication --- beetsplug/beetstream/__init__.py | 33 +++++++- beetsplug/beetstream/albums.py | 2 + beetsplug/beetstream/authentication.py | 100 +++++++++++++++++++++++++ beetsplug/beetstream/dummy.py | 3 + 4 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 beetsplug/beetstream/authentication.py diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py index b2a0c26..2b87063 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstream/__init__.py @@ -45,6 +45,8 @@ def home(): import beetsplug.beetstream.songs import beetsplug.beetstream.users import beetsplug.beetstream.general +import beetsplug.beetstream.authentication + # Plugin hook class BeetstreamPlugin(BeetsPlugin): @@ -62,7 +64,9 @@ def __init__(self): 'save_artists_images': False, 'lastfm_api_key': '', 'playlist_dir': '', + 'users_storage': Path(config['library'].get()).parent / 'beetstream_users.bin', }) + self.config['lastfm_api_key'].redact = True item_types = { # We use the same fields as the MPDStats plugin for interoperability @@ -78,10 +82,34 @@ def __init__(self): # } def commands(self): - cmd = ui.Subcommand('beetstream', help='run Beetstream server, exposing SubSonic API') - cmd.parser.add_option('-d', '--debug', action='store_true', default=False, help='debug mode') + cmd = ui.Subcommand('beetstream', help='run Beetstream server, exposing OpenSubsonic API') + cmd.parser.add_option('-d', '--debug', action='store_true', default=False, help='Debug mode') + cmd.parser.add_option('-k', '--key', action='store_true', default=False, help='Generate a key to store passwords') def func(lib, opts, args): + if opts.key: + users_storage = Path(self.config['users_storage'].get()) + + if not users_storage.is_file(): + key = authentication.generate_key() + print(f'Here is your new key (store it safely): {key}') + yn_input = input('No existing users, create one? [y/n]: ') + if 'y' in yn_input.lower(): + username = input('Username: ') + password = input('Password: ') + success = authentication.update_user(users_storage, key, {username: password}) + if success: + print('User created.') + else: + yn_input = input('Users storage file exists, update key? [y/n]: ') + if 'y' in yn_input.lower(): + current_key = input('Current key: ').encode() + new_key = authentication.generate_key() + success = authentication.update_key(users_storage, current_key, new_key) + if success: + print(f'Key updated (store it safely): {new_key.decode()}') + return + args = ui.decargs(args) if args: self.config['host'] = args.pop(0) @@ -94,6 +122,7 @@ def func(lib, opts, args): app.config['save_artists_images'] = self.config['save_artists_images'].get(False) app.config['root_directory'] = Path(config['directory'].get()) + app.config['users_storage'] = Path(self.config['users_storage'].get()) # Total number of items in the Beets database (only used to detect deletions in getIndexes endpoint) # We initialise to +inf at Beetstream start, so the real count is set the first time a client queries diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstream/albums.py index 5c1fcbc..9366bda 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstream/albums.py @@ -1,4 +1,5 @@ from beetsplug.beetstream.utils import * +from beetsplug.beetstream import authentication from beetsplug.beetstream import app import flask import urllib.parse @@ -62,6 +63,7 @@ def get_album_info(ver=None): def get_album_list(ver=None): r = flask.request.values + authentication.authenticate(r) sort_by = r.get('type', 'alphabeticalByName') size = int(r.get('size', 10)) diff --git a/beetsplug/beetstream/authentication.py b/beetsplug/beetstream/authentication.py new file mode 100644 index 0000000..8bb397d --- /dev/null +++ b/beetsplug/beetstream/authentication.py @@ -0,0 +1,100 @@ +from beetsplug.beetstream import app +import hashlib +import os +from cryptography.fernet import Fernet +import json +from urllib.parse import unquote + + + +def generate_key(): + return Fernet.generate_key() + + +def update_key(path, old_key, new_key): + if not os.path.exists(path): + return False + + data = load_credentials(path, old_key) + if data is None: + return False + + cipher = Fernet(new_key) + reencrypted = cipher.encrypt(json.dumps(data).encode("utf-8")) + + # Write the encrypted data back to the file + with open(path, "wb") as f: + f.write(reencrypted) + + return True + + +def update_user(path, key, new_data): + cipher = Fernet(key) + + if os.path.exists(path): + data = load_credentials(path, key) + if data is None: + return False + else: + data = {} + + data.update(new_data) + new_encrypted_users_data = cipher.encrypt(json.dumps(data).encode("utf-8")) + + # Write the encrypted data back to the file + with open(path, "wb") as f: + f.write(new_encrypted_users_data) + + return True + + +def load_credentials(path, key): + if not os.path.exists(path): + return None + with open(path, 'rb') as f: + encrypted_data = f.read() + try: + cipher = Fernet(key) + decrypted_data = cipher.decrypt(encrypted_data) + except: + print('Wrong key.') + return None + return json.loads(decrypted_data.decode('utf-8')) + + +def authenticate(req_values): + user = unquote(req_values.get('u', '')) + token = unquote(req_values.get('t', '')) + salt = unquote(req_values.get('s', '')) + clearpass = unquote(req_values.get('p', '')) + + if not user: + app.logger.warning('No username provided.') + return False + + if (not token or not salt) and not clearpass: + app.logger.warning('No authentication data provided.') + return False + + key = os.environ.get('BEETSTREAM_KEY', '') + if not key: + app.logger.warning('Decryption key not found.') + return False + + users_data = load_credentials(app.config['users_storage'], key) + if not users_data: + app.logger.warning("Can't load saved users.") + return False + + if user and token and salt: + pw_digest = hashlib.md5(f'{users_data.get(user, '')}{salt}'.encode('utf-8')).hexdigest().lower() + is_auth = token == pw_digest + elif clearpass: + pw = clearpass.lstrip('enc:') + is_auth = pw == users_data.get(user, '') + else: + is_auth = False + + print(f"User {user} {'is' if is_auth else 'isn\'t'} authenticated.") + return is_auth \ No newline at end of file diff --git a/beetsplug/beetstream/dummy.py b/beetsplug/beetstream/dummy.py index 41f2d3f..c38879f 100644 --- a/beetsplug/beetstream/dummy.py +++ b/beetsplug/beetstream/dummy.py @@ -1,5 +1,6 @@ from beetsplug.beetstream.utils import * from beetsplug.beetstream import app +from beetsplug.beetstream import authentication import flask @@ -12,4 +13,6 @@ @app.route('/rest/ping.view', methods=["GET", "POST"]) def ping(): r = flask.request.values + authentication.authenticate(r) + return subsonic_response({}, r.get('f', 'xml')) From f373268b756c6f403ae1da1b44f50888215785b0 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Fri, 28 Mar 2025 00:15:00 +0000 Subject: [PATCH 70/85] Started adding the proper errors --- beetsplug/beetstream/coverart.py | 2 +- beetsplug/beetstream/general.py | 16 --------- beetsplug/beetstream/search.py | 2 +- beetsplug/beetstream/utils.py | 60 ++++++++++++++++++++++++++++++-- 4 files changed, 59 insertions(+), 21 deletions(-) diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index 85ac3c2..9a1241c 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -168,4 +168,4 @@ def get_cover_art(): return response # Fallback: return empty XML document on error - return subsonic_response({}, 'xml', failed=True) \ No newline at end of file + return subsonic_error(70, message='Covert art not found.', resp_fmt='xml') \ No newline at end of file diff --git a/beetsplug/beetstream/general.py b/beetsplug/beetstream/general.py index 05ab923..c851b92 100644 --- a/beetsplug/beetstream/general.py +++ b/beetsplug/beetstream/general.py @@ -6,22 +6,6 @@ import flask -subsonic_errors = { - 0: 'A generic error.', - 10: 'Required parameter is missing.', - 20: 'Incompatible Subsonic REST protocol version. Client must upgrade.', - 30: 'Incompatible Subsonic REST protocol version. Server must upgrade.', - 40: 'Wrong username or password.', - 41: 'Token authentication not supported for LDAP users.', - 42: 'Provided authentication mechanism not supported.', - 43: 'Multiple conflicting authentication mechanisms provided.', - 44: 'Invalid API key.', - 50: 'User is not authorized for the given operation.', - # 60: 'The trial period for the Subsonic server is over.', - 70: 'The requested data was not found.' -} - - def musicdirectory_payload(subsonic_musicdirectory_id: str, with_artists=True) -> dict: # Only one possible root directory in beets (?), so just return its name diff --git a/beetsplug/beetstream/search.py b/beetsplug/beetstream/search.py index 13fc1b2..63711f5 100644 --- a/beetsplug/beetstream/search.py +++ b/beetsplug/beetstream/search.py @@ -27,7 +27,7 @@ def search(ver=None): if not query: if ver == 2: # search2 does not support empty queries: return an empty response - return subsonic_response({}, r.get('f', 'xml'), failed=True) + return subsonic_error(10, r.get('f', 'xml')) # search3 "must support an empty query and return all the data" # https://opensubsonic.netlify.app/docs/endpoints/search3/ diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 15ed138..0836ba8 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -360,14 +360,14 @@ def jsonpify(format: str, data: dict): return flask.jsonify(data) -def subsonic_response(data: dict = {}, resp_fmt: str = 'xml', failed=False): +def subsonic_response(data: dict = {}, resp_fmt: str = 'xml'): """ Wrap any json-like dict with the subsonic response elements and output the appropriate 'format' (json or xml) """ if resp_fmt.startswith('json'): wrapped = { 'subsonic-response': { - 'status': 'failed' if failed else 'ok', + 'status': 'ok', 'version': API_VERSION, 'type': 'Beetstream', 'serverVersion': BEETSTREAM_VERSION, @@ -380,7 +380,61 @@ def subsonic_response(data: dict = {}, resp_fmt: str = 'xml', failed=False): else: root = dict_to_xml("subsonic-response", data) root.set("xmlns", "http://subsonic.org/restapi") - root.set("status", 'failed' if failed else 'ok') + root.set("status", 'ok') + root.set("version", API_VERSION) + root.set("type", 'Beetstream') + root.set("serverVersion", BEETSTREAM_VERSION) + root.set("openSubsonic", 'true') + + xml_bytes = ET.tostring(root, encoding='UTF-8', method='xml', xml_declaration=True) + pretty_xml = minidom.parseString(xml_bytes).toprettyxml(encoding='UTF-8') + xml_str = pretty_xml.decode('UTF-8') + + return flask.Response(xml_str, mimetype="text/xml") + + +def subsonic_error(code: int = 0, message: str = '', resp_fmt: str = 'xml'): + + subsonic_errors = { + 0: 'A generic error.', + 10: 'Required parameter is missing.', + 20: 'Incompatible Subsonic REST protocol version. Client must upgrade.', + 30: 'Incompatible Subsonic REST protocol version. Server must upgrade.', + 40: 'Wrong username or password.', + 41: 'Token authentication not supported.', + 42: 'Provided authentication mechanism not supported.', + 43: 'Multiple conflicting authentication mechanisms provided.', + 44: 'Invalid API key.', + 50: 'User is not authorized for the given operation.', + # 60: 'The trial period for the Subsonic server is over.', + 70: 'The requested data was not found.' + } + + err_payload = { + 'error': { + 'code': code, + 'message': message if message else subsonic_errors[code], + # 'helpUrl': '' + } + } + + if resp_fmt.startswith('json'): + wrapped = { + 'subsonic-response': { + 'status': 'failed', + 'version': API_VERSION, + 'type': 'Beetstream', + 'serverVersion': BEETSTREAM_VERSION, + 'openSubsonic': True, + **err_payload + } + } + return jsonpify(resp_fmt, wrapped) + + else: + root = dict_to_xml("subsonic-response", err_payload) + root.set("xmlns", "http://subsonic.org/restapi") + root.set("status", 'failed') root.set("version", API_VERSION) root.set("type", 'Beetstream') root.set("serverVersion", BEETSTREAM_VERSION) From 10cf463f9d55bf9b2278502feb4309d2f7a58641 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Fri, 28 Mar 2025 00:49:50 +0000 Subject: [PATCH 71/85] Serving Beetstream icon for root folder getCoverArt requests --- beetsplug/beetstream/coverart.py | 56 ++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstream/coverart.py index 9a1241c..0293112 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstream/coverart.py @@ -53,30 +53,30 @@ def send_album_art(album_id, size=None): Uses the local file first, then falls back to coverartarchive.org """ album = flask.g.lib.get_album(album_id) - art_path = album.get('artpath', b'').decode('utf-8') - if os.path.isfile(art_path): - if size: - cover = resize_image(art_path, size) - return flask.send_file(cover, mimetype='image/jpeg') - return flask.send_file(art_path, mimetype=get_mimetype(art_path)) - - mbid = album.get('mb_albumid') - if mbid: - art_url = f'https://coverartarchive.org/release/{mbid}/front' - available_sizes = [250, 500, 1200] - if size: - # If requested size is one of coverarchive's available sizes, query it directly - if size in available_sizes: - return flask.redirect(f'{art_url}-{size}') - # Otherwise, get the smallest available size that is greater than the requested size - next_size = next((s for s in sorted(available_sizes) if s > size), None) - if next_size is None: - next_size = max(available_sizes) - response = requests.get(f'{art_url}-{next_size}') - cover = resize_image(BytesIO(response.content), size) - return flask.send_file(cover, mimetype='image/jpeg') - return flask.redirect(art_url) - + if album: + art_path = album.get('artpath', b'').decode('utf-8') + if os.path.isfile(art_path): + if size: + cover = resize_image(art_path, size) + return flask.send_file(cover, mimetype='image/jpeg') + return flask.send_file(art_path, mimetype=get_mimetype(art_path)) + + mbid = album.get('mb_albumid') + if mbid: + art_url = f'https://coverartarchive.org/release/{mbid}/front' + available_sizes = [250, 500, 1200] + if size: + # If requested size is one of coverarchive's available sizes, query it directly + if size in available_sizes: + return flask.redirect(f'{art_url}-{size}') + # Otherwise, get the smallest available size that is greater than the requested size + next_size = next((s for s in sorted(available_sizes) if s > size), None) + if next_size is None: + next_size = max(available_sizes) + response = requests.get(f'{art_url}-{next_size}') + cover = resize_image(BytesIO(response.content), size) + return flask.send_file(cover, mimetype='image/jpeg') + return flask.redirect(art_url) return None @@ -167,5 +167,13 @@ def get_cover_art(): if response is not None: return response + # root folder ID or name: serve Beetstream's logo + elif req_id == app.config['root_directory'].name or req_id == 'm-0': + module_dir = os.path.dirname(os.path.abspath(__file__)) + beetstream_icon = os.path.join(module_dir, '../../beetstream.png') + return flask.send_file(beetstream_icon, mimetype=get_mimetype(beetstream_icon)) + + # TODO - We mighe want to serve artists images when a client requests an artist folder by name (for instance Tempo does this) + # Fallback: return empty XML document on error return subsonic_error(70, message='Covert art not found.', resp_fmt='xml') \ No newline at end of file From 06df827c5334396d5aa07be3a11ce5a4021e6331 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Fri, 28 Mar 2025 01:15:17 +0000 Subject: [PATCH 72/85] Fixed quotes issue for Python <3.12 --- beetsplug/beetstream/artists.py | 2 +- beetsplug/beetstream/authentication.py | 4 ++-- beetsplug/beetstream/playlistprovider.py | 2 +- beetsplug/beetstream/songs.py | 2 +- beetsplug/beetstream/utils.py | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstream/artists.py index 37e7561..eda8cbb 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstream/artists.py @@ -105,7 +105,7 @@ def artistInfo2(): tag: { 'biography': short_bio, 'musicBrainzId': artist_mbid, - 'lastFmUrl': f'https://www.last.fm/music/{urllib.parse.quote_plus(artist_name.replace(' ', '+'))}', + 'lastFmUrl': f"https://www.last.fm/music/{urllib.parse.quote_plus(artist_name.replace(' ', '+'))}", } } diff --git a/beetsplug/beetstream/authentication.py b/beetsplug/beetstream/authentication.py index 8bb397d..b6393ac 100644 --- a/beetsplug/beetstream/authentication.py +++ b/beetsplug/beetstream/authentication.py @@ -88,7 +88,7 @@ def authenticate(req_values): return False if user and token and salt: - pw_digest = hashlib.md5(f'{users_data.get(user, '')}{salt}'.encode('utf-8')).hexdigest().lower() + pw_digest = hashlib.md5(f"{users_data.get(user, '')}{salt}".encode('utf-8')).hexdigest().lower() is_auth = token == pw_digest elif clearpass: pw = clearpass.lstrip('enc:') @@ -96,5 +96,5 @@ def authenticate(req_values): else: is_auth = False - print(f"User {user} {'is' if is_auth else 'isn\'t'} authenticated.") + print(f"User {user} {'is' if is_auth else 'isn not'} authenticated.") return is_auth \ No newline at end of file diff --git a/beetsplug/beetstream/playlistprovider.py b/beetsplug/beetstream/playlistprovider.py index 1f45385..cc616c8 100644 --- a/beetsplug/beetstream/playlistprovider.py +++ b/beetsplug/beetstream/playlistprovider.py @@ -123,7 +123,7 @@ def _load_playlist(self, filepath): """ Load playlist data from a file, or from cache if it exists """ file_mtime = filepath.stat().st_mtime - playlist_id = f'{PLY_ID_PREF}{'-'.join(filepath.parts[-2:]).lower()}' + playlist_id = f"{PLY_ID_PREF}{'-'.join(filepath.parts[-2:]).lower()}" # Get potential cached version playlist = self._playlists.get(playlist_id) diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index fd88659..8ec048b 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -129,7 +129,7 @@ def get_top_songs(): lastfm_resp = query_lastfm(query=artist_name, type='artist', method='TopTracks', mbid=False) if lastfm_resp: - beets_results = [flask.g.lib.items(f'title:{t.get('name', '').replace("'", "")}') + beets_results = [flask.g.lib.items(f"""title:{t.get('name', '').replace("'", "")}""") for t in lastfm_resp.get('toptracks', {}).get('track', [])] top_tracks_available = [track[0] for track in beets_results if track] diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 0836ba8..864edb0 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -46,12 +46,12 @@ def beets_to_sub_artist(beet_artist_name): base64_name = base64.urlsafe_b64encode(str(beet_artist_name).encode('utf-8')) - return f"{ART_ID_PREF}{base64_name.rstrip(b"=").decode('utf-8')}" + return f"{ART_ID_PREF}{base64_name.rstrip(b'=').decode('utf-8')}" def sub_to_beets_artist(subsonic_artist_id): subsonic_artist_id = str(subsonic_artist_id)[len(ART_ID_PREF):] padding = 4 - (len(subsonic_artist_id) % 4) - return base64.urlsafe_b64decode(subsonic_artist_id + ("=" * padding)).decode('utf-8') + return base64.urlsafe_b64decode(subsonic_artist_id + ('=' * padding)).decode('utf-8') def beets_to_sub_album(beet_album_id): return f'{ALB_ID_PREF}{beet_album_id}' From e79cca9e9c6d40aa31d4709374976e17803ee140 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sat, 29 Mar 2025 20:21:16 +0000 Subject: [PATCH 73/85] Cleaner playlists IDs in preparation for adding endpoints --- beetsplug/beetstream/__init__.py | 2 +- beetsplug/beetstream/playlistprovider.py | 41 +++++++++++++++--------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py index 2b87063..02faa39 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstream/__init__.py @@ -138,7 +138,7 @@ def func(lib, opts, args): config['playlist']['playlist_dir'].get(None), # Playlists plugin config['smartplaylist']['playlist_dir'].get(None)] # Smartplaylists plugin - app.config['playlist_dirs'] = set(Path(d) for d in playlist_directories if d and os.path.isdir(d)) + app.config['playlist_dirs'] = {i: Path(d) for i, d in enumerate(playlist_directories) if d and os.path.isdir(d)} # Enable CORS if required if self.config['cors']: diff --git a/beetsplug/beetstream/playlistprovider.py b/beetsplug/beetstream/playlistprovider.py index cc616c8..7e2e0be 100644 --- a/beetsplug/beetstream/playlistprovider.py +++ b/beetsplug/beetstream/playlistprovider.py @@ -77,8 +77,8 @@ def parse_m3u(filepath): class Playlist: - def __init__(self, path): - self.id = f'{PLY_ID_PREF}{path.parent.stem.lower()}-{path.name}' + def __init__(self, dir_id, path): + self.id = f'{PLY_ID_PREF}{dir_id}-{path.name}' self.name = path.stem self.ctime = creation_date(path) self.mtime = path.stat().st_mtime @@ -107,23 +107,24 @@ def __init__(self): self.playlist_dirs = app.config.get('playlist_dirs', set()) self._playlists = {} - if len(self.playlist_dirs) == 0: + if not self.playlist_dirs: app.logger.warning('No playlist directories could be found.') - else: - for path in chain.from_iterable(Path(d).glob('*.m3u*') for d in self.playlist_dirs): - try: - self._load_playlist(path) - except Exception as e: - app.logger.error(f"Failed to load playlist {path.name}: {e}") + for dir_id, dir_path in self.playlist_dirs.items(): + dir_path = Path(dir_path) + for path in dir_path.glob('*.m3u*'): + try: + self._load_playlist(dir_id, path) + except Exception as e: + app.logger.error(f"Failed to load playlist {path.name}: {e}") app.logger.debug(f"Loaded {len(self._playlists)} playlists.") - def _load_playlist(self, filepath): + def _load_playlist(self, dir_id, filepath): """ Load playlist data from a file, or from cache if it exists """ file_mtime = filepath.stat().st_mtime - playlist_id = f"{PLY_ID_PREF}{'-'.join(filepath.parts[-2:]).lower()}" + playlist_id = f"{PLY_ID_PREF}{dir_id}-{filepath.name.lower()}" # Get potential cached version playlist = self._playlists.get(playlist_id) @@ -131,7 +132,7 @@ def _load_playlist(self, filepath): # If the playlist is not found in cache, or if the cached version is outdated if not playlist or playlist.mtime < file_mtime: # Load new data from file - playlist = Playlist(filepath) + playlist = Playlist(dir_id, filepath) # And cache it self._playlists[playlist_id] = playlist @@ -139,11 +140,21 @@ def _load_playlist(self, filepath): def get(self, playlist_id: str) -> Union[Playlist, None]: """ Get a playlist by its id """ - folder, file = playlist_id.rsplit('-')[1:] - filepath = next(dir_path / file for dir_path in self.playlist_dirs if dir_path.stem.lower() == folder) + + if not playlist_id.startswith(PLY_ID_PREF): + return None + + dir_key, file_name = playlist_id.lstrip(PLY_ID_PREF).split('-', 1) + dir_id = int(dir_key) + + dir_path = self.playlist_dirs.get(dir_id) + if not dir_path: + return None + + filepath = Path(dir_path) / file_name if filepath.is_file(): - return self._load_playlist(filepath) + return self._load_playlist(dir_id, filepath) else: return None From 1601ceeea3c860de0a3429cd495ce70d36a6f7b6 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 6 Apr 2025 18:06:24 +0100 Subject: [PATCH 74/85] Added createPlaylist endpoint --- beetsplug/beetstream/__init__.py | 22 ++- beetsplug/beetstream/playlistprovider.py | 217 ++++++++++++++--------- beetsplug/beetstream/playlists.py | 40 ++++- 3 files changed, 189 insertions(+), 90 deletions(-) diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py index 02faa39..868c435 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstream/__init__.py @@ -61,7 +61,7 @@ def __init__(self): 'include_paths': False, 'never_transcode': False, 'fetch_artists_images': False, - 'save_artists_images': False, + 'save_artists_images': True, 'lastfm_api_key': '', 'playlist_dir': '', 'users_storage': Path(config['library'].get()).parent / 'beetstream_users.bin', @@ -134,11 +134,21 @@ def func(lib, opts, args): app.config['INCLUDE_PATHS'] = self.config['include_paths'] app.config['never_transcode'] = self.config['never_transcode'].get(False) - playlist_directories = [self.config['playlist_dir'].get(None), # Beetstream's own - config['playlist']['playlist_dir'].get(None), # Playlists plugin - config['smartplaylist']['playlist_dir'].get(None)] # Smartplaylists plugin - - app.config['playlist_dirs'] = {i: Path(d) for i, d in enumerate(playlist_directories) if d and os.path.isdir(d)} + possible_paths = [ + (0, self.config['playlist_dir'].get(None)), # Beetstream's own + (1, config['playlist']['playlist_dir'].get(None)), # Playlist plugin + (2, config['smartplaylist']['playlist_dir'].get(None)) # Smartplaylist plugin + ] + + playlist_dirs = {} + used_paths = set() + for k, path in possible_paths: + if path not in used_paths: + playlist_dirs[k] = path + used_paths.add(path) + else: + playlist_dirs[k] = None + app.config['playlist_dirs'] = playlist_dirs # Enable CORS if required if self.config['cors']: diff --git a/beetsplug/beetstream/playlistprovider.py b/beetsplug/beetstream/playlistprovider.py index 7e2e0be..3a835db 100644 --- a/beetsplug/beetstream/playlistprovider.py +++ b/beetsplug/beetstream/playlistprovider.py @@ -3,77 +3,7 @@ import flask from typing import Union, List from pathlib import Path -from itertools import chain - - -def parse_m3u(filepath): - """ Parses a playlist (m3u, m3u8 or m3a) and yields its entries """ - - with open(filepath, 'r', encoding='UTF-8') as f: - curr_entry = {} - - for line in f: - line = line.strip() - - if not line: - continue - - if line.startswith('#EXTM3U'): - continue - - if line.startswith('#EXTINF:'): - left_part, info = line[8:].split(",", 1) - duration_and_props = left_part.split() - curr_entry['info'] = info.strip() - curr_entry['runtime'] = int(duration_and_props[0].strip()) - curr_entry['props'] = {k.strip(): v.strip('"').strip() - for k, v in (p.split('=', 1) for p in duration_and_props[1:])} - continue - - # Add content from any additional m3u directives - elif line.startswith('#PLAYLIST:'): - curr_entry['name'] = line[10:].strip() - continue - - elif line.startswith('#EXTGRP:'): - curr_entry['group'] = line[8:].strip() - continue - - elif line.startswith('#EXTALB:'): - curr_entry['album'] = line[8:].strip() - continue - - elif line.startswith('#EXTART:'): - curr_entry['artist'] = line[8:].strip() - continue - - elif line.startswith('#EXTGENRE:'): - curr_entry['genres'] = genres_formatter(line[10:]) - continue - - elif line.startswith('#EXTM3A'): - curr_entry['m3a'] = True - continue - - elif line.startswith('#EXTBYT:'): - curr_entry['size'] = int(line[8:].strip()) - continue - - elif line.startswith('#EXTBIN:'): - # Skip the binary mp3 content - continue - - elif line.startswith('#EXTALBUMARTURL:'): - curr_entry['artpath'] = line[16:].strip() - continue - - elif line.startswith('#EXT-X-'): - # We ignore HLS M3U fields - continue - - curr_entry['uri'] = line - yield curr_entry - curr_entry = {} +import time class Playlist: @@ -85,7 +15,7 @@ def __init__(self, dir_id, path): self.path = path self.songs = [] self.duration = 0 - for entry in parse_m3u(path): + for entry in self.from_m3u(path): entry_path = (path.parent / Path(entry['uri'])).resolve() entry_id = entry.get('props', {}).get('id', None) @@ -100,23 +30,143 @@ def __init__(self, dir_id, path): self.songs.append(map_song(song[0])) self.duration += int(song[0]['length'] or 0) + @classmethod + def from_songs(cls, name, songs): + instance = cls.__new__(cls) + + instance.name = name.rsplit(".", 1)[0] + instance.id = f'{PLY_ID_PREF}0-{instance.name}.m3u' + instance.path = Path(flask.g.playlist_provider.playlist_dirs[0]) / f'{instance.name}.m3u' + if instance.path.is_file(): + err = f"Playlist {instance.name}.m3u already exists in Beetstream's folder!" + app.logger.warning(err) + raise FileExistsError(err) + instance.songs = [dict(song) for song in songs] + instance.duration = sum([int(song['length'] or 0) for song in songs]) + + # Save the new playlist + instance.to_m3u() + + # And read it back from the file so we can use the real __init__ + return cls(0, instance.path) + + @classmethod + def from_m3u(cls, filepath): + """ Parses a playlist (m3u, m3u8 or m3a) and yields its entries """ + + with open(filepath, 'r', encoding='UTF-8') as f: + curr_entry = {} + + for line in f: + line = line.strip() + + if not line: + continue + + if line.startswith('#EXTM3U'): + continue + + if line.startswith('#EXTINF:'): + left_part, info = line[8:].split(",", 1) + duration_and_props = left_part.split() + curr_entry['info'] = info.strip() + curr_entry['runtime'] = int(duration_and_props[0].strip()) + curr_entry['props'] = {k.strip(): v.strip('"').strip() + for k, v in (p.split('=', 1) for p in duration_and_props[1:])} + continue + + # Add content from any additional m3u directives + elif line.startswith('#PLAYLIST:'): + curr_entry['name'] = line[10:].strip() + continue + + elif line.startswith('#EXTGRP:'): + curr_entry['group'] = line[8:].strip() + continue + + elif line.startswith('#EXTALB:'): + curr_entry['album'] = line[8:].strip() + continue + + elif line.startswith('#EXTART:'): + curr_entry['artist'] = line[8:].strip() + continue + + elif line.startswith('#EXTGENRE:'): + curr_entry['genres'] = genres_formatter(line[10:]) + continue + + elif line.startswith('#EXTM3A'): + curr_entry['m3a'] = True + continue + + elif line.startswith('#EXTBYT:'): + curr_entry['size'] = int(line[8:].strip()) + continue + + elif line.startswith('#EXTBIN:'): + # Skip the binary mp3 content + continue + + elif line.startswith('#EXTALBUMARTURL:'): + curr_entry['artpath'] = line[16:].strip() + continue + + elif line.startswith('#EXT-X-'): + # We ignore HLS M3U fields + continue + + curr_entry['uri'] = line + yield curr_entry + curr_entry = {} + + def to_m3u(self): + + content = ['#EXTM3U'] + + for song in self.songs: + path = song.get('path').decode('utf-8') + if not path: + continue + info = f"#EXTINF:{round(song.get('length', 0))} id={song.get('id')}" + artist = song.get('artist') + title = song.get('title') + album = song.get('album') + year = song.get('year') + if artist and title: + info += f',{artist} - {title}' + elif artist: + info += f',{artist}' + elif title: + info += f',{title}' + content.append(info) + if album: + albuminfo = f'#EXTALB:{album}' + albuminfo += f' ({year})' if year else '' + content.append(albuminfo) + content.append(Path(path).relative_to(app.config['root_directory']).as_posix()) + + with open(self.path.with_suffix('.m3u'), 'w', encoding='UTF-8') as f: + f.write('\n'.join(content)) + class PlaylistProvider: def __init__(self): - self.playlist_dirs = app.config.get('playlist_dirs', set()) + self.playlist_dirs = app.config.get('playlist_dirs', {}) self._playlists = {} - if not self.playlist_dirs: + if not self.playlist_dirs or all(v is None for v in self.playlist_dirs.values()): app.logger.warning('No playlist directories could be found.') else: for dir_id, dir_path in self.playlist_dirs.items(): - dir_path = Path(dir_path) - for path in dir_path.glob('*.m3u*'): - try: - self._load_playlist(dir_id, path) - except Exception as e: - app.logger.error(f"Failed to load playlist {path.name}: {e}") + if dir_path is not None: + dir_path = Path(dir_path) + for path in dir_path.glob('*.m3u*'): + try: + self._load_playlist(dir_id, path) + except Exception as e: + app.logger.error(f"Failed to load playlist {path.name}: {e}") app.logger.debug(f"Loaded {len(self._playlists)} playlists.") @@ -134,7 +184,7 @@ def _load_playlist(self, dir_id, filepath): # Load new data from file playlist = Playlist(dir_id, filepath) # And cache it - self._playlists[playlist_id] = playlist + self.register(playlist) return playlist @@ -160,4 +210,7 @@ def get(self, playlist_id: str) -> Union[Playlist, None]: def getall(self) -> List[Playlist]: """ Get all playlists """ - return list(self._playlists.values()) \ No newline at end of file + return list(self._playlists.values()) + + def register(self, playlist: Playlist) -> None: + self._playlists[playlist.id] = playlist \ No newline at end of file diff --git a/beetsplug/beetstream/playlists.py b/beetsplug/beetstream/playlists.py index f92c439..0a806d3 100644 --- a/beetsplug/beetstream/playlists.py +++ b/beetsplug/beetstream/playlists.py @@ -1,7 +1,7 @@ from beetsplug.beetstream.utils import * import flask from beetsplug.beetstream import app -from .playlistprovider import PlaylistProvider +from .playlistprovider import PlaylistProvider, Playlist @app.route('/rest/getPlaylists', methods=['GET', 'POST']) @@ -43,4 +43,40 @@ def get_playlist(): 'playlist': map_playlist(playlist) } return subsonic_response(payload, r.get('f', 'xml')) - flask.abort(404) \ No newline at end of file + + return subsonic_error(70, r.get('f', 'xml')) + + +@app.route('/rest/createPlaylist', methods=['GET', 'POST']) +@app.route('/rest/createPlaylist.view', methods=['GET', 'POST']) +def create_playlist(): + + r = flask.request.values + + playlist_id = r.get('playlistId') + name = r.get('name') + songs_ids = r.getlist('songId') + + # Lazily initialize the playlist provider the first time it's needed + if not hasattr(flask.g, 'playlist_provider'): + flask.g.playlist_provider = PlaylistProvider() + + if playlist_id: + # Update mode: API documentation is unclear so we just return an error; probably better to use updatePlaylist + return subsonic_error(0, r.get('f', 'xml')) + elif name and songs_ids: + # Create mode + songs = list(flask.g.lib.items('id:' + ' , id:'.join(songs_ids))) + try: + playlist = Playlist.from_songs(name, songs) + except FileExistsError as e: + return subsonic_error(10, message=str(e), resp_fmt=r.get('f', 'xml')) + + flask.g.playlist_provider.register(playlist) + + payload = { + 'playlist': map_playlist(playlist) + } + return subsonic_response(payload, r.get('f', 'xml')) + + return subsonic_error(10, r.get('f', 'xml')) \ No newline at end of file From 63a6a5de1cfeb5b79a24fabbbc476bf4c996c784 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 6 Apr 2025 18:38:34 +0100 Subject: [PATCH 75/85] Added deletePlaylist endpoint --- beetsplug/beetstream/playlistprovider.py | 16 +++++++++++++--- beetsplug/beetstream/playlists.py | 24 +++++++++++++++++++++++- missing-endpoints.md | 1 - 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/beetsplug/beetstream/playlistprovider.py b/beetsplug/beetstream/playlistprovider.py index 3a835db..d001832 100644 --- a/beetsplug/beetstream/playlistprovider.py +++ b/beetsplug/beetstream/playlistprovider.py @@ -3,7 +3,7 @@ import flask from typing import Union, List from pathlib import Path -import time +import os class Playlist: @@ -16,7 +16,6 @@ def __init__(self, dir_id, path): self.songs = [] self.duration = 0 for entry in self.from_m3u(path): - entry_path = (path.parent / Path(entry['uri'])).resolve() entry_id = entry.get('props', {}).get('id', None) @@ -213,4 +212,15 @@ def getall(self) -> List[Playlist]: return list(self._playlists.values()) def register(self, playlist: Playlist) -> None: - self._playlists[playlist.id] = playlist \ No newline at end of file + self._playlists[playlist.id] = playlist + + def delete(self, playlist_id: str) -> None: + playlist = self._playlists.get(playlist_id) + if playlist: + path = Path(playlist.path) + try: + os.remove(path) + except FileNotFoundError: + err = f"Playlist {path.name} does not exist in {path.parent}." + app.logger.warning(err) + raise FileExistsError(err) \ No newline at end of file diff --git a/beetsplug/beetstream/playlists.py b/beetsplug/beetstream/playlists.py index 0a806d3..f7b297f 100644 --- a/beetsplug/beetstream/playlists.py +++ b/beetsplug/beetstream/playlists.py @@ -1,4 +1,5 @@ from beetsplug.beetstream.utils import * +import os import flask from beetsplug.beetstream import app from .playlistprovider import PlaylistProvider, Playlist @@ -79,4 +80,25 @@ def create_playlist(): } return subsonic_response(payload, r.get('f', 'xml')) - return subsonic_error(10, r.get('f', 'xml')) \ No newline at end of file + return subsonic_error(10, r.get('f', 'xml')) + + +@app.route('/rest/deletePlaylist', methods=['GET', 'POST']) +@app.route('/rest/deletePlaylist.view', methods=['GET', 'POST']) +def delete_playlist(): + + r = flask.request.values + + playlist_id = r.get('id') + + if playlist_id: + # Lazily initialize the playlist provider the first time it's needed + if not hasattr(flask.g, 'playlist_provider'): + flask.g.playlist_provider = PlaylistProvider() + try: + flask.g.playlist_provider.delete(playlist_id) + return subsonic_response({}, r.get('f', 'xml')) + except FileExistsError as e: + subsonic_error(70, message=str(e), resp_fmt=r.get('f', 'xml')) + + subsonic_error(70, resp_fmt=r.get('f', 'xml')) diff --git a/missing-endpoints.md b/missing-endpoints.md index ea2f230..2e5e8ca 100644 --- a/missing-endpoints.md +++ b/missing-endpoints.md @@ -1,7 +1,6 @@ # Missing Endpoints Could be fun to implement: -- `createPlaylist` - `updatePlaylist` - `deletePlaylist` - `getLyrics` From ae3739cbfa449a6b236bb859ab9ddfce920f9369 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 6 Apr 2025 18:39:14 +0100 Subject: [PATCH 76/85] Added deletePlaylist endpoint --- missing-endpoints.md | 1 - 1 file changed, 1 deletion(-) diff --git a/missing-endpoints.md b/missing-endpoints.md index 2e5e8ca..31bb127 100644 --- a/missing-endpoints.md +++ b/missing-endpoints.md @@ -2,7 +2,6 @@ Could be fun to implement: - `updatePlaylist` -- `deletePlaylist` - `getLyrics` - `getAvatar` - `star` From 554122d6ae79726afc66bcf66d25ec4884282af4 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 6 Apr 2025 19:18:01 +0100 Subject: [PATCH 77/85] Added transcode offset extension --- beetsplug/beetstream/general.py | 7 ++++++- beetsplug/beetstream/songs.py | 27 ++++++++++++++++++--------- beetsplug/beetstream/stream.py | 30 ++++++++++++++++++------------ 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/beetsplug/beetstream/general.py b/beetsplug/beetstream/general.py index c851b92..129ecec 100644 --- a/beetsplug/beetstream/general.py +++ b/beetsplug/beetstream/general.py @@ -28,7 +28,12 @@ def get_open_subsonic_extensions(): r = flask.request.values payload = { - 'openSubsonicExtensions': [] + 'openSubsonicExtensions': [ + { + 'name': 'transcodeOffset', + 'versions': [1] + }, + ] } return subsonic_response(payload, r.get('f', 'xml')) diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index 8ec048b..4beb514 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -79,20 +79,29 @@ def get_random_songs(): def stream_song(): r = flask.request.values - max_bitrate = int(r.get('maxBitRate') or 0) + max_bitrate = int(r.get('maxBitRate', 0)) req_format = r.get('format') + estimate_content_length = bool(r.get('estimateContentLength', False)) + time_offset = float(r.get('timeOffset', 0.0)) + song_id = sub_to_beets_song(r.get('id')) - item = flask.g.lib.get_item(song_id) + song = flask.g.lib.get_item(song_id) + song_path = song.get('path', b'').decode('utf-8') if song else '' - item_path = item.get('path', b'').decode('utf-8') if item else '' - if not item_path: - flask.abort(404) + if song_path: + if app.config['never_transcode'] or req_format == 'raw' or max_bitrate <= 0 or song.bitrate <= max_bitrate * 1000: + response = stream.direct(song_path) + est_size = os.path.getsize(song_path) or round(song.get('bitrate', 0) * song.get('length', 0) / 8) + else: + response = stream.try_transcode(song_path, start_at=time_offset, max_bitrate=max_bitrate) + est_size = int(((max_bitrate * 1000) / 8) * song.get('length', 0)) - if app.config['never_transcode'] or req_format == 'raw' or max_bitrate <= 0 or item.bitrate <= max_bitrate * 1000: - return stream.direct(item_path) - else: - return stream.try_transcode(item_path, max_bitrate) + if response is not None: + if estimate_content_length and est_size: + response.headers['Content-Length'] = est_size + + subsonic_error(70, message="Song not found.", resp_fmt=r.get('f', 'xml')) @app.route('/rest/download', methods=["GET", "POST"]) @app.route('/rest/download.view', methods=["GET", "POST"]) diff --git a/beetsplug/beetstream/stream.py b/beetsplug/beetstream/stream.py index 0556ed8..3fabc1e 100644 --- a/beetsplug/beetstream/stream.py +++ b/beetsplug/beetstream/stream.py @@ -5,34 +5,40 @@ have_ffmpeg = FFMPEG_PYTHON or FFMPEG_BIN -def direct(filePath): - return flask.send_file(filePath, mimetype=get_mimetype(filePath)) +def direct(file_path): + if os.path.isfile(file_path): + return flask.send_file(file_path, mimetype=get_mimetype(file_path)) + else: + return None -def transcode(filePath, maxBitrate): +def transcode(file_path, start_at: float = 0.0, max_bitrate: int = 128): if FFMPEG_PYTHON: + input_stream = ffmpeg.input(file_path, ss=start_at) if start_at else ffmpeg.input(file_path) + output_stream = ( - ffmpeg - .input(filePath) + input_stream .audio - .output('pipe:', format="mp3", audio_bitrate=maxBitrate * 1000) + .output('pipe:', format="mp3", audio_bitrate=max_bitrate * 1000) .run_async(pipe_stdout=True, quiet=True) ) elif FFMPEG_BIN: command = [ "ffmpeg", - "-i", filePath, + f"-ss {start_at:.2f}" if start_at else "", + "-i", file_path, "-f", "mp3", - "-b:a", f"{maxBitrate}k", + "-b:a", f"{max_bitrate}k", "pipe:1" ] output_stream = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) else: - raise RuntimeError("Can't transcode, ffmpeg is not available.") + return None return flask.Response(output_stream.stdout, mimetype='audio/mpeg') -def try_transcode(filePath, maxBitrate): + +def try_transcode(file_path, start_at: float = 0.0, max_bitrate: int = 128): if have_ffmpeg: - return transcode(filePath, maxBitrate) + return transcode(file_path, start_at, max_bitrate) else: - return direct(filePath) + return direct(file_path) \ No newline at end of file From 41979e869ed20c0849a661115cd679745a6b13f3 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 6 Apr 2025 19:19:07 +0100 Subject: [PATCH 78/85] oopsy --- beetsplug/beetstream/songs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index 4beb514..c2d26cf 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -100,6 +100,7 @@ def stream_song(): if response is not None: if estimate_content_length and est_size: response.headers['Content-Length'] = est_size + return response subsonic_error(70, message="Song not found.", resp_fmt=r.get('f', 'xml')) From 44c24b755955f635a12b31908050a988c3b73cce Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 6 Apr 2025 19:23:20 +0100 Subject: [PATCH 79/85] cleaner check for request boolean --- beetsplug/beetstream/songs.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstream/songs.py index c2d26cf..1843d5f 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstream/songs.py @@ -81,9 +81,8 @@ def stream_song(): max_bitrate = int(r.get('maxBitRate', 0)) req_format = r.get('format') - estimate_content_length = bool(r.get('estimateContentLength', False)) time_offset = float(r.get('timeOffset', 0.0)) - + estimate_content_length = r.get('estimateContentLength', 'false').lower() == 'true' song_id = sub_to_beets_song(r.get('id')) song = flask.g.lib.get_item(song_id) From 829a50fea78ec15f9ba138ad0859bf304be6cf6d Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Sun, 6 Apr 2025 23:35:29 +0100 Subject: [PATCH 80/85] Preparing for SQLite storage of beetstream's own data (users, etc) --- beetsplug/beetstream/__init__.py | 1 - beetsplug/beetstream/db.py | 149 +++++++++++++++++++++++++++++++ beetsplug/beetstream/dummy.py | 2 +- missing-endpoints.md | 20 ++--- 4 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 beetsplug/beetstream/db.py diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstream/__init__.py index 868c435..eeba868 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstream/__init__.py @@ -73,7 +73,6 @@ def __init__(self): 'play_count': types.INTEGER, 'last_played': DateType(), 'last_liked': DateType(), - 'stars_rating': types.INTEGER # ... except this one, it's a different rating system from MPDStats' "rating" } # album_types = { diff --git a/beetsplug/beetstream/db.py b/beetsplug/beetstream/db.py new file mode 100644 index 0000000..a69dd2a --- /dev/null +++ b/beetsplug/beetstream/db.py @@ -0,0 +1,149 @@ +import sqlite3 +import os +from typing import Union +from cryptography.fernet import Fernet + +# TODO - handle these correctly in the init and in a flask.g attribute +GLOBAL_ENCRYPTION_KEY = os.environ.get('BEETSTREAM_KEY') +cipher = Fernet(GLOBAL_ENCRYPTION_KEY) +DB_PATH = './beetstream-dev.db' + + +def initialise_db(): + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + + cur.execute("PRAGMA foreign_keys = ON;") + + cur.execute(""" + CREATE TABLE IF NOT EXISTS users ( + username TEXT PRIMARY KEY, + password BLOB NOT NULL, + email TEXT, + avatar BLOB, + avatarLastChanged REAL, + scrobblingEnabled INTEGER DEFAULT 0, + adminRole INTEGER DEFAULT 0, + settingsRole INTEGER DEFAULT 1, + streamRole INTEGER DEFAULT 0, + jukeboxRole INTEGER DEFAULT 0, + downloadRole INTEGER DEFAULT 0, + uploadRole INTEGER DEFAULT 0, + coverArtRole INTEGER DEFAULT 0, + playlistRole INTEGER DEFAULT 1, + commentRole INTEGER DEFAULT 1, + podcastRole INTEGER DEFAULT 0, + shareRole INTEGER DEFAULT 0, + videoConversionRole INTEGER DEFAULT 0, + folder INTEGER DEFAULT 0, + maxBitRate INTEGER DEFAULT 0 -- 0 (no limit), 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320 + ) + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS likes ( + username TEXT NOT NULL, + item_type TEXT NOT NULL, + item_id INTEGER NOT NULL, + PRIMARY KEY (username, item_type, item_id), + FOREIGN KEY (username) REFERENCES users (username) + ) + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + song_id INTEGER NOT NULL, + position REAL NOT NULL, + comments TEXT, + FOREIGN KEY (username) REFERENCES users (username) + ); + """) + + conn.commit() + conn.close() + + +def store_userdata(user_dict): + + username = user_dict.pop("username", None) + if not username: + raise ValueError('User dict must have the "username" key!') + + columns = ['username'] + placeholders = ['?'] + updates = [] + values = [username] + + for key, val in user_dict.items(): + if key == 'password': + val = cipher.encrypt(val.encode("utf-8")) + columns.append(key) + placeholders.append('?') + updates.append(f"{key} = excluded.{key}") + values.append(1 if val else 0) + + columns_str = ', '.join(columns) + placeholders_str = ', '.join(['?'] * len(columns)) + updates_str = ', '.join(updates) + + sql = f""" + INSERT INTO users ({columns_str}) + VALUES ({placeholders_str}) + ON CONFLICT (username) + DO UPDATE SET + {updates_str} + """ + + conn = sqlite3.connect(DB_PATH) + conn.execute(sql, values) + conn.commit() + conn.close() + + +def load_userdata(username: str, fields: Union[list[str], tuple[str], set[str], str, None] = None) -> Union[dict, None]: + + if fields is None: + return None + + elif isinstance(fields, str): + fields = {fields} + else: + fields = set(fields) + + # We don't really want SQL injection :) + safe_fields = list(set(fields).intersection( + {'password', 'email', 'avatar', 'avatarLastChanged', 'scrobblingEnabled', 'adminRole', 'settingsRole', + 'streamRole', 'jukeboxRole', 'downloadRole', 'uploadRole', 'coverArtRole', 'playlistRole', 'commentRole', + 'podcastRole', 'shareRole', 'videoConversionRole', 'folder', 'maxBitRate'} + )) + + if not safe_fields: + return None + + columns_str = "username, " + ", ".join(safe_fields) + + conn = sqlite3.connect(DB_PATH) + row = conn.execute(f""" + SELECT {columns_str} + FROM users + WHERE username = ? + """, (username,)).fetchone() + conn.close() + + if not row: + return None + + user_dict = {k: v for k, v in zip(columns_str, row)} + + if 'password' in user_dict.keys(): + password = user_dict.pop('password') + + try: + user_dict['password'] = cipher.decrypt(password).decode("utf-8") + except Exception: + # TODO - need to deal with exceptions correctly here + pass + + return user_dict \ No newline at end of file diff --git a/beetsplug/beetstream/dummy.py b/beetsplug/beetstream/dummy.py index c38879f..0e77ad4 100644 --- a/beetsplug/beetstream/dummy.py +++ b/beetsplug/beetstream/dummy.py @@ -13,6 +13,6 @@ @app.route('/rest/ping.view', methods=["GET", "POST"]) def ping(): r = flask.request.values - authentication.authenticate(r) + # authentication.authenticate(r) return subsonic_response({}, r.get('f', 'xml')) diff --git a/missing-endpoints.md b/missing-endpoints.md index 31bb127..6e52c8d 100644 --- a/missing-endpoints.md +++ b/missing-endpoints.md @@ -1,8 +1,12 @@ # Missing Endpoints -Could be fun to implement: +These need the beetstream internal database first: +- `getUsers` +- `createUser` +- `updateUser` +- `deleteUser` +- `changePassword` - `updatePlaylist` -- `getLyrics` - `getAvatar` - `star` - `unstar` @@ -10,12 +14,15 @@ Could be fun to implement: - `getBookmarks` - `createBookmark` - `deleteBookmark` + +Could be fun to implement: +- `getLyrics` - `getPlayQueue` - `savePlayQueue` - `getScanStatus` - `startScan` -Video/Radio/Podcast stuff. Not related to this project +Video/Radio/Podcast stuff: - `getVideos` - `getVideoInfo` - `hls` @@ -41,10 +48,3 @@ Social stuff. Some could be fun to implement but I'm still not sure: - `jukeboxControl` - `getChatMessages` - `addChatMessage` - -Handling users is annoying but may be useful: -- `getUsers` -- `createUser` -- `updateUser` -- `deleteUser` -- `changePassword` From 7426fa765f2d720b4b93337773071699434598f8 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Mon, 7 Apr 2025 23:46:09 +0100 Subject: [PATCH 81/85] Added more stuff to prepare for SQLite storage of beetstream's data --- beetsplug/beetstream/db.py | 95 ++++++++++++++++++++++++++++++++--- beetsplug/beetstream/utils.py | 6 +-- 2 files changed, 90 insertions(+), 11 deletions(-) diff --git a/beetsplug/beetstream/db.py b/beetsplug/beetstream/db.py index a69dd2a..d025761 100644 --- a/beetsplug/beetstream/db.py +++ b/beetsplug/beetstream/db.py @@ -1,20 +1,96 @@ import sqlite3 import os +import base64 +import hashlib +from pathlib import Path from typing import Union from cryptography.fernet import Fernet # TODO - handle these correctly in the init and in a flask.g attribute -GLOBAL_ENCRYPTION_KEY = os.environ.get('BEETSTREAM_KEY') -cipher = Fernet(GLOBAL_ENCRYPTION_KEY) + DB_PATH = './beetstream-dev.db' +def load_env_file(filepath: Union[Path, str] = ".env") -> None: + env_file = Path(filepath) + + if not env_file.is_file(): + return + + for line in env_file.read_text().splitlines(): + line = line.strip() + + if not line or line.startswith("#") or '=' not in line: + continue + + var, value = line.split('=', 1) + os.environ[var.strip()] = value.strip().strip('"').strip("'") + + +def get_cipher() -> Union[Fernet, None]: + load_env_file() + key = os.environ.get('BEETSTREAM_KEY') + try: + cipher = Fernet(key) + except ValueError: + cipher = None + return cipher + + +def get_key_hash() -> Union[str, None]: + load_env_file() + key = os.environ.get('BEETSTREAM_KEY') + if not key: + return None + decoded_key = base64.urlsafe_b64decode(key) + return hashlib.sha256(decoded_key).hexdigest() + + +def verify_key(): + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + result = cur.execute("SELECT value FROM encryption WHERE key = 'key_hash'").fetchone() + conn.close() + + stored_hash = result[0] if result else None + current_hash = get_key_hash() + + return current_hash == stored_hash + + def initialise_db(): conn = sqlite3.connect(DB_PATH) cur = conn.cursor() cur.execute("PRAGMA foreign_keys = ON;") + cur.execute(""" + CREATE TABLE IF NOT EXISTS encryption ( + key TEXT PRIMARY KEY, + value TEXT) + """) + + cipher = get_cipher() + + if cipher is not None: + key_hash = get_key_hash() + + cur.execute(f""" + INSERT INTO encryption (key, value) VALUES ('enabled', 'true'); + """) + + cur.execute(""" + INSERT OR REPLACE INTO encryption (key, value) VALUES (?, ?) + """, ('key_hash', key_hash)) + else: + cur.execute(f""" + INSERT INTO encryption (key, value) VALUES ('enabled', 'false'); + """) + + cur.execute(""" + INSERT OR REPLACE INTO encryption (key, value) VALUES (?, ?) + """, ('key_hash', None)) + cur.execute(""" CREATE TABLE IF NOT EXISTS users ( username TEXT PRIMARY KEY, @@ -58,7 +134,7 @@ def initialise_db(): position REAL NOT NULL, comments TEXT, FOREIGN KEY (username) REFERENCES users (username) - ); + ) """) conn.commit() @@ -76,8 +152,10 @@ def store_userdata(user_dict): updates = [] values = [username] + cipher = get_cipher() + for key, val in user_dict.items(): - if key == 'password': + if cipher: val = cipher.encrypt(val.encode("utf-8")) columns.append(key) placeholders.append('?') @@ -137,13 +215,14 @@ def load_userdata(username: str, fields: Union[list[str], tuple[str], set[str], user_dict = {k: v for k, v in zip(columns_str, row)} + cipher = get_cipher() + if 'password' in user_dict.keys(): password = user_dict.pop('password') - try: + if cipher: user_dict['password'] = cipher.decrypt(password).decode("utf-8") - except Exception: - # TODO - need to deal with exceptions correctly here - pass + else: + user_dict['password'] = password return user_dict \ No newline at end of file diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstream/utils.py index 864edb0..924a184 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstream/utils.py @@ -87,9 +87,9 @@ def map_media(beets_object: Union[dict, library.LibModel]): 'genres': [{'name': g} for g in genres_formatter(beets_object.get('genre', ''))], 'created': timestamp_to_iso(beets_object.get('added')) or datetime.now().isoformat(), # default to now? 'originalReleaseDate': { - 'year': beets_object.get('original_year', 0), - 'month': beets_object.get('original_month', 0), - 'day': beets_object.get('original_day', 0) + 'year': beets_object.get('original_year', 0) or beets_object.get('year', 0), + 'month': beets_object.get('original_month', 0) or beets_object.get('month', 0), + 'day': beets_object.get('original_day', 0) or beets_object.get('day', 0) }, 'releaseDate': { 'year': beets_object.get('year', 0), From d38b0716c06a8e824f375451bddf7b405e1b3300 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Wed, 20 Aug 2025 10:15:43 +0100 Subject: [PATCH 82/85] Renamed project --- .idea/BeetstreamNext.iml | 17 ++++ LICENSE | 4 +- NOTICE.md | 29 ++++++ README.md | 86 ++++++++++-------- .../__init__.py | 38 ++++---- .../{beetstream => beetstreamnext}/albums.py | 6 +- .../{beetstream => beetstreamnext}/artists.py | 6 +- .../authentication.py | 4 +- .../coverart.py | 10 +- .../{beetstream => beetstreamnext}/db.py | 6 +- .../{beetstream => beetstreamnext}/dummy.py | 6 +- .../{beetstream => beetstreamnext}/general.py | 10 +- .../playlistprovider.py | 6 +- .../playlists.py | 4 +- .../{beetstream => beetstreamnext}/search.py | 4 +- .../{beetstream => beetstreamnext}/songs.py | 4 +- .../{beetstream => beetstreamnext}/stream.py | 2 +- .../{beetstream => beetstreamnext}/users.py | 4 +- .../{beetstream => beetstreamnext}/utils.py | 28 +++--- beetstream.png => beetstreamnext.png | Bin beetstream.svg => beetstreamnext.svg | 0 missing-endpoints.md | 2 +- pyproject.toml | 16 ++++ pyproject.toml.old | 3 - requirements.txt | 1 - setup.cfg | 2 +- 26 files changed, 185 insertions(+), 113 deletions(-) create mode 100644 .idea/BeetstreamNext.iml create mode 100644 NOTICE.md rename beetsplug/{beetstream => beetstreamnext}/__init__.py (88%) rename beetsplug/{beetstream => beetstreamnext}/albums.py (96%) rename beetsplug/{beetstream => beetstreamnext}/artists.py (97%) rename beetsplug/{beetstream => beetstreamnext}/authentication.py (96%) rename beetsplug/{beetstream => beetstreamnext}/coverart.py (95%) rename beetsplug/{beetstream => beetstreamnext}/db.py (97%) rename beetsplug/{beetstream => beetstreamnext}/dummy.py (74%) rename beetsplug/{beetstream => beetstreamnext}/general.py (94%) rename beetsplug/{beetstream => beetstreamnext}/playlistprovider.py (97%) rename beetsplug/{beetstream => beetstreamnext}/playlists.py (97%) rename beetsplug/{beetstream => beetstreamnext}/search.py (96%) rename beetsplug/{beetstream => beetstreamnext}/songs.py (99%) rename beetsplug/{beetstream => beetstreamnext}/stream.py (96%) rename beetsplug/{beetstream => beetstreamnext}/users.py (91%) rename beetsplug/{beetstream => beetstreamnext}/utils.py (95%) rename beetstream.png => beetstreamnext.png (100%) rename beetstream.svg => beetstreamnext.svg (100%) create mode 100644 pyproject.toml delete mode 100644 pyproject.toml.old delete mode 100644 requirements.txt diff --git a/.idea/BeetstreamNext.iml b/.idea/BeetstreamNext.iml new file mode 100644 index 0000000..30c8e22 --- /dev/null +++ b/.idea/BeetstreamNext.iml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE index 1d9e5ff..a7f04db 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Sacha Bron +Copyright (c) 2025 Florent Le Moël Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..7eac4e7 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,29 @@ +# Dependency Licenses + +This fork includes code from the parent project. The original license and copyright notices are preserved below. + +--- + +## beetsream + +MIT License + +Copyright (c) 2020 Sacha Bron + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 906c940..87c7be8 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,45 @@ -# Beetstream -Beetstream logo (a beetroot with soundwaves-like leaves) +
-Beetstream is a [Beets.io](https://beets.io) plugin that exposes [SubSonic API endpoints](http://www.subsonic.org/pages/api.jsp), allowing you to stream your music everywhere. +
+ + + Logo + + +

BeetstreamNext

+

+ BeetstreamNext exposes your Beets.io database with the OpenSubsonic API +
+ +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +

+
+ + +BeetstreamNext is a fork of Beetstream, a [Beets.io](https://beets.io) plugin that exposes [OpenSubsonic API endpoints](https://opensubsonic.netlify.app/docs/opensubsonic-api/), allowing you to stream your Beets library. ## Motivation -I personally use Beets to manage my music library on a Raspberry Pi but when I was looking for a way to stream it to my phone I couldn't find any comfortable, suitable and free options. -I tried [AirSonic](https://airsonic.github.io) and [SubSonic](http://www.subsonic.org), [Plex](https://www.plex.tv) and some other tools but a lot of these solutions want to manage the library as they need (but I prefer Beets) and AirSonic/SubSonic were quite slow and CPU intensive and seemed to have a lot of overhead just to browse albums and send music files. Thus said, SubSonic APIs are good and implemented by a lot of different [clients](#supported-clients), so I decided to re-implement the server side but based on Beets database (and some piece of code). +I started implementing new features to Beetstream but ended up rewriting a significant part of it, so I figured it'd make more sense to keep it as a distinct project. +The goal is to cover all of the modern OpenSubsonic API, with some additions. I'm getting there :) + +Personally, I use Beets to manage my music library but I don't like to write metadata to the files. So with this, I can have the best of both worlds. ## Install & Run Requires Python 3.8 or newer. 1) First of all, you need to [install Beets](https://beets.readthedocs.io/en/stable/guides/main.html): - 2) Install the dependancies with: ``` -$ pip install beetstream +$ pip install beetstreamnext ``` 3) Enable the plugin for Beets in your config file `~/.config/beets/config.yaml`: ```yaml -plugins: beetstream +plugins: beetstreamnext ``` 4) **Optional** You can change the host and port in your config file `~/.config/beets/config.yaml`. @@ -30,7 +47,7 @@ You can also chose to never re-encode files even if the clients asks for it with Here are the default values: ```yaml -beetstream: +beetstreamnext: host: 0.0.0.0 port: 8080 never_transcode: False @@ -38,26 +55,27 @@ beetstream: 5) Other configuration parameters: -If `fetch_artists_images` is enabled, Beetstream will fetch the artists photos to display in your client player (if you enable this, it is recommended to also enable `save_artists_images`). +If `fetch_artists_images` is enabled, BeetstreamNext will fetch the artists photos to display in your client player (if you enable this, it is recommended to also enable `save_artists_images`). -Beetstream supports playlists from Beets' [playlist](https://beets.readthedocs.io/en/stable/plugins/playlist.html) and [smartplaylist](https://beets.readthedocs.io/en/stable/plugins/smartplaylist.html) plugins. You can also define a Beetstream-specific playlist folder with the `playlist_dir` option: +BeetstreamNext supports playlists from Beets' [playlist](https://beets.readthedocs.io/en/stable/plugins/playlist.html) and [smartplaylist](https://beets.readthedocs.io/en/stable/plugins/smartplaylist.html) plugins. You can also define a BeetstreamNext-specific playlist folder with the `playlist_dir` option: ```yaml -beetstream: - fetch_artists_images: False # Whether Beetstream should fetch artists photos when clients request them +beetstreamnext: + fetch_artists_images: False # Whether BeetstreamNext should fetch artists photos when clients request them save_artists_images: False # Save artists photos to their respective folders in your music library - playlist_dir: './path/to/playlists' # A directory with Beetstream-specific playlists + playlist_dir: './path/to/playlists' # A directory with BeetstreamNext-specific playlists ``` 6) Run with: ``` -$ beet beetstream +$ beet beetstreamnext ``` ## Clients Configuration ### Authentication -There is currently no security whatsoever. You can put whatever user and password you want in your favorite app. +There is currently no security. You can put whatever user and password you want in your favorite app. +But this is going to change soon. ### Server and Port @@ -65,31 +83,27 @@ Currently runs on port `8080` (i.e.: `https://192.168.1.10:8080`) ## Supported Clients -All clients below are working with this server. By "working", it means one can use most of the features, browse library and most importantly play music! +All clients below have been tested and are working with this server. But in theory any Subsonic-compatible player should work. ### Android -- [Subsonic](https://play.google.com/store/apps/details?id=net.sourceforge.subsonic.androidapp) (official app) -- [DSub](https://play.google.com/store/apps/details?id=github.daneren2005.dsub) -- [Audinaut](https://play.google.com/store/apps/details?id=net.nullsum.audinaut) -- [Ultrasonic](https://play.google.com/store/apps/details?id=org.moire.ultrasonic) -- [GoSONIC](https://play.google.com/store/apps/details?id=com.readysteadygosoftware.gosonic) -- [Subtracks](https://play.google.com/store/apps/details?id=com.subtracks) -- [Music Stash](https://play.google.com/store/apps/details?id=com.ghenry22.mymusicstash) -- [substreamer](https://play.google.com/store/apps/details?id=com.ghenry22.substream2) +- [Synfonium](https://symfonium.app/) +- [Tempo](https://github.com/CappielloAntonio/tempo) +- [SubTune](https://github.com/TaylorKunZhang/SubTune) +- [Subtracks](https://github.com/austinried/subtracks) +- [K-19 Player](https://github.com/ulysg/k19-player) +- [substreamer](https://substreamerapp.com/) +- [GoSONIC](https://play.google.com/store/apps/details?id=com.readysteadygosoftware.gosonic&hl=en_GB) +- [Ultrasonic](https://gitlab.com/ultrasonic/ultrasonic) ### Desktop -- [Clementine](https://www.clementine-player.org) - -### Web - -- [Jamstash](http://jamstash.com) ([Chrome App](https://chrome.google.com/webstore/detail/jamstash/jccdpflnecheidefpofmlblgebobbloc)) -- [SubFire](http://subfireplayer.net) - -_Currently supports a subset of API v1.16.1, avaiable as Json, Jsonp and XML._ +- [Supersonic](https://github.com/dweymouth/supersonic) -## Contributing +## Roadmap -There is still some [missing endpoints](missing-endpoints.md) and `TODO` in the code. -Feel free to create some PR! +- [ ] Finalise BeetstreamNext's database storage (for multiple users etc) +- [ ] Finalise authentication (needs database to be fully operational) +- [ ] Implement missing endpoints +- [ ] Create a Docker image +- [ ] Cleanup the README and update the installation instructions \ No newline at end of file diff --git a/beetsplug/beetstream/__init__.py b/beetsplug/beetstreamnext/__init__.py similarity index 88% rename from beetsplug/beetstream/__init__.py rename to beetsplug/beetstreamnext/__init__.py index eeba868..e1888c1 100644 --- a/beetsplug/beetstream/__init__.py +++ b/beetsplug/beetstreamnext/__init__.py @@ -13,7 +13,7 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -"""Beetstream is a Beets.io plugin that exposes SubSonic API endpoints.""" +"""BeetstreamNext is a Beets.io plugin that exposes SubSonic API endpoints.""" from beets.plugins import BeetsPlugin from beets.dbcore import types @@ -33,25 +33,25 @@ def before_request(): @app.route('/') def home(): - return "Beetstream server running" + return "BeetstreamNext server running" -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 -import beetsplug.beetstream.songs -import beetsplug.beetstream.users -import beetsplug.beetstream.general -import beetsplug.beetstream.authentication +from beetsplug.beetstreamnext.utils import * +import beetsplug.beetstreamnext.albums +import beetsplug.beetstreamnext.artists +import beetsplug.beetstreamnext.coverart +import beetsplug.beetstreamnext.dummy +import beetsplug.beetstreamnext.playlists +import beetsplug.beetstreamnext.search +import beetsplug.beetstreamnext.songs +import beetsplug.beetstreamnext.users +import beetsplug.beetstreamnext.general +import beetsplug.beetstreamnext.authentication # Plugin hook -class BeetstreamPlugin(BeetsPlugin): +class BeetstreamNextPlugin(BeetsPlugin): def __init__(self): - super(BeetstreamPlugin, self).__init__() + super(BeetstreamNextPlugin, self).__init__() self.config.add({ 'host': '0.0.0.0', 'port': 8080, @@ -64,7 +64,7 @@ def __init__(self): 'save_artists_images': True, 'lastfm_api_key': '', 'playlist_dir': '', - 'users_storage': Path(config['library'].get()).parent / 'beetstream_users.bin', + 'users_storage': Path(config['library'].get()).parent / 'beetstreamnext_users.bin', }) self.config['lastfm_api_key'].redact = True @@ -81,7 +81,7 @@ def __init__(self): # } def commands(self): - cmd = ui.Subcommand('beetstream', help='run Beetstream server, exposing OpenSubsonic API') + cmd = ui.Subcommand('beetstreamnext', help='run BeetstreamNext server, exposing OpenSubsonic API') cmd.parser.add_option('-d', '--debug', action='store_true', default=False, help='Debug mode') cmd.parser.add_option('-k', '--key', action='store_true', default=False, help='Generate a key to store passwords') @@ -124,7 +124,7 @@ def func(lib, opts, args): app.config['users_storage'] = Path(self.config['users_storage'].get()) # Total number of items in the Beets database (only used to detect deletions in getIndexes endpoint) - # We initialise to +inf at Beetstream start, so the real count is set the first time a client queries + # We initialise to +inf at BeetstreamNext start, so the real count is set the first time a client queries # the getIndexes endpoint app.config['nb_items'] = float('inf') @@ -134,7 +134,7 @@ def func(lib, opts, args): app.config['never_transcode'] = self.config['never_transcode'].get(False) possible_paths = [ - (0, self.config['playlist_dir'].get(None)), # Beetstream's own + (0, self.config['playlist_dir'].get(None)), # BeetstreamNext's own (1, config['playlist']['playlist_dir'].get(None)), # Playlist plugin (2, config['smartplaylist']['playlist_dir'].get(None)) # Smartplaylist plugin ] diff --git a/beetsplug/beetstream/albums.py b/beetsplug/beetstreamnext/albums.py similarity index 96% rename from beetsplug/beetstream/albums.py rename to beetsplug/beetstreamnext/albums.py index 9366bda..18e7a24 100644 --- a/beetsplug/beetstream/albums.py +++ b/beetsplug/beetstreamnext/albums.py @@ -1,6 +1,6 @@ -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import authentication -from beetsplug.beetstream import app +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import authentication +from beetsplug.beetstreamnext import app import flask import urllib.parse from functools import partial diff --git a/beetsplug/beetstream/artists.py b/beetsplug/beetstreamnext/artists.py similarity index 97% rename from beetsplug/beetstream/artists.py rename to beetsplug/beetstreamnext/artists.py index eda8cbb..8acd2e2 100644 --- a/beetsplug/beetstream/artists.py +++ b/beetsplug/beetstreamnext/artists.py @@ -1,5 +1,5 @@ -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import app import time import urllib.parse from collections import defaultdict @@ -62,7 +62,7 @@ def get_artists_or_indexes(): if nb_items < app.config['nb_items']: app.logger.warning('Media deletion detected (or very first time getIndexes is queried)') - # Deletion of items (or very first check since Beetstream started) + # Deletion of items (or very first check since BeetstreamNext started) latest = int(time.time() * 1000) app.config['nb_items'] = nb_items diff --git a/beetsplug/beetstream/authentication.py b/beetsplug/beetstreamnext/authentication.py similarity index 96% rename from beetsplug/beetstream/authentication.py rename to beetsplug/beetstreamnext/authentication.py index b6393ac..1748f06 100644 --- a/beetsplug/beetstream/authentication.py +++ b/beetsplug/beetstreamnext/authentication.py @@ -1,4 +1,4 @@ -from beetsplug.beetstream import app +from beetsplug.beetstreamnext import app import hashlib import os from cryptography.fernet import Fernet @@ -77,7 +77,7 @@ def authenticate(req_values): app.logger.warning('No authentication data provided.') return False - key = os.environ.get('BEETSTREAM_KEY', '') + key = os.environ.get('BEETSTREAMNEXT_KEY', '') if not key: app.logger.warning('Decryption key not found.') return False diff --git a/beetsplug/beetstream/coverart.py b/beetsplug/beetstreamnext/coverart.py similarity index 95% rename from beetsplug/beetstream/coverart.py rename to beetsplug/beetstreamnext/coverart.py index 0293112..411069a 100644 --- a/beetsplug/beetstream/coverart.py +++ b/beetsplug/beetstreamnext/coverart.py @@ -1,5 +1,5 @@ -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import app import os from typing import Union import requests @@ -167,11 +167,11 @@ def get_cover_art(): if response is not None: return response - # root folder ID or name: serve Beetstream's logo + # root folder ID or name: serve BeetstreamNext's logo elif req_id == app.config['root_directory'].name or req_id == 'm-0': module_dir = os.path.dirname(os.path.abspath(__file__)) - beetstream_icon = os.path.join(module_dir, '../../beetstream.png') - return flask.send_file(beetstream_icon, mimetype=get_mimetype(beetstream_icon)) + beetstreamnext_icon = os.path.join(module_dir, '../../beetstreamnext.png') + return flask.send_file(beetstreamnext_icon, mimetype=get_mimetype(beetstreamnext_icon)) # TODO - We mighe want to serve artists images when a client requests an artist folder by name (for instance Tempo does this) diff --git a/beetsplug/beetstream/db.py b/beetsplug/beetstreamnext/db.py similarity index 97% rename from beetsplug/beetstream/db.py rename to beetsplug/beetstreamnext/db.py index d025761..2797c09 100644 --- a/beetsplug/beetstream/db.py +++ b/beetsplug/beetstreamnext/db.py @@ -8,7 +8,7 @@ # TODO - handle these correctly in the init and in a flask.g attribute -DB_PATH = './beetstream-dev.db' +DB_PATH = './beetstreamnext-dev.db' def load_env_file(filepath: Union[Path, str] = ".env") -> None: @@ -29,7 +29,7 @@ def load_env_file(filepath: Union[Path, str] = ".env") -> None: def get_cipher() -> Union[Fernet, None]: load_env_file() - key = os.environ.get('BEETSTREAM_KEY') + key = os.environ.get('BEETSTREAMNEXT_KEY') try: cipher = Fernet(key) except ValueError: @@ -39,7 +39,7 @@ def get_cipher() -> Union[Fernet, None]: def get_key_hash() -> Union[str, None]: load_env_file() - key = os.environ.get('BEETSTREAM_KEY') + key = os.environ.get('BEETSTREAMNEXT_KEY') if not key: return None decoded_key = base64.urlsafe_b64decode(key) diff --git a/beetsplug/beetstream/dummy.py b/beetsplug/beetstreamnext/dummy.py similarity index 74% rename from beetsplug/beetstream/dummy.py rename to beetsplug/beetstreamnext/dummy.py index 0e77ad4..fb78301 100644 --- a/beetsplug/beetstream/dummy.py +++ b/beetsplug/beetstreamnext/dummy.py @@ -1,6 +1,6 @@ -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app -from beetsplug.beetstream import authentication +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import app +from beetsplug.beetstreamnext import authentication import flask diff --git a/beetsplug/beetstream/general.py b/beetsplug/beetstreamnext/general.py similarity index 94% rename from beetsplug/beetstream/general.py rename to beetsplug/beetstreamnext/general.py index 129ecec..00a4abd 100644 --- a/beetsplug/beetstream/general.py +++ b/beetsplug/beetstreamnext/general.py @@ -1,8 +1,8 @@ -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app -from beetsplug.beetstream.artists import artist_payload -from beetsplug.beetstream.albums import album_payload -from beetsplug.beetstream.songs import song_payload +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import app +from beetsplug.beetstreamnext.artists import artist_payload +from beetsplug.beetstreamnext.albums import album_payload +from beetsplug.beetstreamnext.songs import song_payload import flask diff --git a/beetsplug/beetstream/playlistprovider.py b/beetsplug/beetstreamnext/playlistprovider.py similarity index 97% rename from beetsplug/beetstream/playlistprovider.py rename to beetsplug/beetstreamnext/playlistprovider.py index d001832..fbfbc6d 100644 --- a/beetsplug/beetstream/playlistprovider.py +++ b/beetsplug/beetstreamnext/playlistprovider.py @@ -1,5 +1,5 @@ -from beetsplug.beetstream.utils import PLY_ID_PREF, genres_formatter, creation_date, map_song -from beetsplug.beetstream import app +from beetsplug.beetstreamnext.utils import PLY_ID_PREF, genres_formatter, creation_date, map_song +from beetsplug.beetstreamnext import app import flask from typing import Union, List from pathlib import Path @@ -37,7 +37,7 @@ def from_songs(cls, name, songs): instance.id = f'{PLY_ID_PREF}0-{instance.name}.m3u' instance.path = Path(flask.g.playlist_provider.playlist_dirs[0]) / f'{instance.name}.m3u' if instance.path.is_file(): - err = f"Playlist {instance.name}.m3u already exists in Beetstream's folder!" + err = f"Playlist {instance.name}.m3u already exists in BeetstreamNext's folder!" app.logger.warning(err) raise FileExistsError(err) instance.songs = [dict(song) for song in songs] diff --git a/beetsplug/beetstream/playlists.py b/beetsplug/beetstreamnext/playlists.py similarity index 97% rename from beetsplug/beetstream/playlists.py rename to beetsplug/beetstreamnext/playlists.py index f7b297f..4cb686b 100644 --- a/beetsplug/beetstream/playlists.py +++ b/beetsplug/beetstreamnext/playlists.py @@ -1,7 +1,7 @@ -from beetsplug.beetstream.utils import * +from beetsplug.beetstreamnext.utils import * import os import flask -from beetsplug.beetstream import app +from beetsplug.beetstreamnext import app from .playlistprovider import PlaylistProvider, Playlist diff --git a/beetsplug/beetstream/search.py b/beetsplug/beetstreamnext/search.py similarity index 96% rename from beetsplug/beetstream/search.py rename to beetsplug/beetstreamnext/search.py index 63711f5..a5052a3 100644 --- a/beetsplug/beetstream/search.py +++ b/beetsplug/beetstreamnext/search.py @@ -1,5 +1,5 @@ -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import app from functools import partial diff --git a/beetsplug/beetstream/songs.py b/beetsplug/beetstreamnext/songs.py similarity index 99% rename from beetsplug/beetstream/songs.py rename to beetsplug/beetstreamnext/songs.py index 1843d5f..ca78d7f 100644 --- a/beetsplug/beetstream/songs.py +++ b/beetsplug/beetstreamnext/songs.py @@ -1,5 +1,5 @@ -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app, stream +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import app, stream import flask import re diff --git a/beetsplug/beetstream/stream.py b/beetsplug/beetstreamnext/stream.py similarity index 96% rename from beetsplug/beetstream/stream.py rename to beetsplug/beetstreamnext/stream.py index 3fabc1e..40a1929 100644 --- a/beetsplug/beetstream/stream.py +++ b/beetsplug/beetstreamnext/stream.py @@ -1,4 +1,4 @@ -from beetsplug.beetstream.utils import * +from beetsplug.beetstreamnext.utils import * import subprocess import flask diff --git a/beetsplug/beetstream/users.py b/beetsplug/beetstreamnext/users.py similarity index 91% rename from beetsplug/beetstream/users.py rename to beetsplug/beetstreamnext/users.py index ba9dd7a..81679eb 100644 --- a/beetsplug/beetstream/users.py +++ b/beetsplug/beetstreamnext/users.py @@ -1,5 +1,5 @@ -from beetsplug.beetstream.utils import * -from beetsplug.beetstream import app +from beetsplug.beetstreamnext.utils import * +from beetsplug.beetstreamnext import app import flask diff --git a/beetsplug/beetstream/utils.py b/beetsplug/beetstreamnext/utils.py similarity index 95% rename from beetsplug/beetstream/utils.py rename to beetsplug/beetstreamnext/utils.py index 924a184..32f6f93 100644 --- a/beetsplug/beetstream/utils.py +++ b/beetsplug/beetstreamnext/utils.py @@ -17,14 +17,14 @@ from functools import partial import requests import urllib.parse -from beetsplug.beetstream import app +from beetsplug.beetstreamnext import app API_VERSION = '1.16.1' -BEETSTREAM_VERSION = '1.4.5' +BEETSTREAMNEXT_VERSION = '1.4.5' -# Prefixes for Beetstream's internal IDs +# Prefixes for BeetstreamNext's internal IDs ART_ID_PREF = 'ar-' ALB_ID_PREF = 'al-' SNG_ID_PREF = 'sg-' @@ -40,7 +40,7 @@ import subprocess -# === Beetstream internal IDs makers/readers === +# === BeetstreamNext internal IDs makers/readers === # These IDs are sent to the client once (when it accesses endpoints such as getArtists or getAlbumList # and the client will then use these to access a specific item via endpoints that need an ID @@ -369,8 +369,8 @@ def subsonic_response(data: dict = {}, resp_fmt: str = 'xml'): 'subsonic-response': { 'status': 'ok', 'version': API_VERSION, - 'type': 'Beetstream', - 'serverVersion': BEETSTREAM_VERSION, + 'type': 'BeetstreamNext', + 'serverVersion': BEETSTREAMNEXT_VERSION, 'openSubsonic': True, **data } @@ -382,7 +382,7 @@ def subsonic_response(data: dict = {}, resp_fmt: str = 'xml'): root.set("xmlns", "http://subsonic.org/restapi") root.set("status", 'ok') root.set("version", API_VERSION) - root.set("type", 'Beetstream') + root.set("type", 'BeetstreamNext') root.set("serverVersion", BEETSTREAM_VERSION) root.set("openSubsonic", 'true') @@ -423,8 +423,8 @@ def subsonic_error(code: int = 0, message: str = '', resp_fmt: str = 'xml'): 'subsonic-response': { 'status': 'failed', 'version': API_VERSION, - 'type': 'Beetstream', - 'serverVersion': BEETSTREAM_VERSION, + 'type': 'BeetstreamNext', + 'serverVersion': BEETSTREAMNEXT_VERSION, 'openSubsonic': True, **err_payload } @@ -436,8 +436,8 @@ def subsonic_error(code: int = 0, message: str = '', resp_fmt: str = 'xml'): root.set("xmlns", "http://subsonic.org/restapi") root.set("status", 'failed') root.set("version", API_VERSION) - root.set("type", 'Beetstream') - root.set("serverVersion", BEETSTREAM_VERSION) + root.set("type", 'BeetstreamNext') + root.set("serverVersion", BEETSTREAMNEXT_VERSION) root.set("openSubsonic", 'true') xml_bytes = ET.tostring(root, encoding='UTF-8', method='xml', xml_declaration=True) @@ -531,7 +531,7 @@ def query_musicbrainz(mbid: str, type: str): types_mb = {'track': 'recording', 'album': 'release', 'artist': 'artist'} endpoint = f'https://musicbrainz.org/ws/2/{types_mb[type]}/{mbid}' - headers = {'User-Agent': f'Beetstream/{BEETSTREAM_VERSION} ( https://github.com/FlorentLM/Beetstream )'} + headers = {'User-Agent': f'BeetstreamNext/{BEETSTREAMNEXT_VERSION} ( https://github.com/FlorentLM/BeetstreamNext )'} params = {'fmt': 'json'} if types_mb[type] == 'artist': @@ -546,7 +546,7 @@ def query_deezer(query: str, type: str): query_urlsafe = urllib.parse.quote_plus(query.replace(' ', '-')) endpoint = f'https://api.deezer.com/{type}/{query_urlsafe}' - headers = {'User-Agent': f'Beetstream/{BEETSTREAM_VERSION} ( https://github.com/FlorentLM/Beetstream )'} + headers = {'User-Agent': f'BeetstreamNext/{BEETSTREAMNEXT_VERSION} ( https://github.com/FlorentLM/BeetstreamNext )'} response = requests.get(endpoint, headers=headers) @@ -572,7 +572,7 @@ def query_lastfm(query: str, type: str, method: str = 'info', mbid=True): params[type] = query_lastfm - headers = {'User-Agent': f'Beetstream/{BEETSTREAM_VERSION} ( https://github.com/FlorentLM/Beetstream )'} + headers = {'User-Agent': f'BeetstreamNext/{BEETSTREAMNEXT_VERSION} ( https://github.com/FlorentLM/BeetstreamNext )'} response = requests.get(endpoint, headers=headers, params=params) return response.json() if response.ok else {} diff --git a/beetstream.png b/beetstreamnext.png similarity index 100% rename from beetstream.png rename to beetstreamnext.png diff --git a/beetstream.svg b/beetstreamnext.svg similarity index 100% rename from beetstream.svg rename to beetstreamnext.svg diff --git a/missing-endpoints.md b/missing-endpoints.md index 6e52c8d..557c685 100644 --- a/missing-endpoints.md +++ b/missing-endpoints.md @@ -1,6 +1,6 @@ # Missing Endpoints -These need the beetstream internal database first: +These need the BeetstreamNext internal database first: - `getUsers` - `createUser` - `updateUser` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..31dafc5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "BeetstreamNext" +version = "2.0" +description = "BeetstreamNext exposes your Beets.io database with the OpenSubsonic API." +readme = "README.md" +requires-python = "==3.8.*" +dependencies = [ + "beets>=2.2.0", + "cryptography>=44.0.2", + "ffmpeg-python>=0.2.0", + "flask-cors>=5.0.1", + "flask-jwt-extended>=4.7.1", + "pillow>=11.1.0", + "pylast>=5.5.0", + "requests>=2.32.3", +] diff --git a/pyproject.toml.old b/pyproject.toml.old deleted file mode 100644 index fa7093a..0000000 --- a/pyproject.toml.old +++ /dev/null @@ -1,3 +0,0 @@ -[build-system] -requires = ["setuptools>=42"] -build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 9c558e3..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -. diff --git a/setup.cfg b/setup.cfg index 01d5b2d..f558e09 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -name = Beetstream +name = BeetstreamNext version = 1.4.0 author = Binary Brain author_email = me@sachabron.ch From 8c0e7e5c38cee30c465133b81a5f40bbc8f92f64 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Wed, 20 Aug 2025 11:02:40 +0100 Subject: [PATCH 83/85] Updated install instructions to follow Beets.io's preferred method --- README.md | 217 +++++++++++++++++++++++++++++++++++++------------ pyproject.toml | 62 ++++++++++---- setup.cfg | 32 -------- 3 files changed, 215 insertions(+), 96 deletions(-) delete mode 100644 setup.cfg diff --git a/README.md b/README.md index 87c7be8..6ff18da 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@
- + Logo

BeetstreamNext

- BeetstreamNext exposes your Beets.io database with the OpenSubsonic API + A modern, feature-rich OpenSubsonic API server for your Beets.io music library.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -19,91 +19,208 @@ BeetstreamNext is a fork of Beetstream, a [Beets.io](https://beets.io) plugin that exposes [OpenSubsonic API endpoints](https://opensubsonic.netlify.app/docs/opensubsonic-api/), allowing you to stream your Beets library. -## Motivation - I started implementing new features to Beetstream but ended up rewriting a significant part of it, so I figured it'd make more sense to keep it as a distinct project. The goal is to cover all of the modern OpenSubsonic API, with some additions. I'm getting there :) Personally, I use Beets to manage my music library but I don't like to write metadata to the files. So with this, I can have the best of both worlds. -## Install & Run +## Key Features -Requires Python 3.8 or newer. +- **Extensive API coverage**: Implements a wide range of OpenSubsonic endpoints, soon to cover everything. +- **On-the-fly transcoding**: As in the original project, this uses **FFmpeg** to transcode audio in real-time to your desired bitrate. Direct streaming is also supported. +- **Full cover art support**: Serves artwork from multiple sources: + - Local album art path from your Beets library + - Fallback to the [Cover Art Archive](https://coverartarchive.org/) using MusicBrainz IDs + - Fetches and caches artist images from the [Deezer API](https://developers.deezer.com/api) + - Extracts embedded artwork directly from media files +- **Dynamic Playlist management**: + - Reads `.m3u` playlists from specified directories + - Supports creating and deleting playlists directly through the API + - Integrates with Beets' native `playlist` and `smartplaylist` plugins +- **Rich metadata integration**: Fetches optional artist info like biographies, top tracks, and similar artists from the [Last.fm API](https://www.last.fm/api) -1) First of all, you need to [install Beets](https://beets.readthedocs.io/en/stable/guides/main.html): -2) Install the dependancies with: +- **COMING SOON: Multi-user support**: Dedicated SQLite database to manage users, ratings, bookmarks, and starred content (full multi-user endpoints are in development) +- **COMING SOON: Actual authentication**: User credentials, encryption at rest -``` -$ pip install beetstreamnext -``` +## Installation -3) Enable the plugin for Beets in your config file `~/.config/beets/config.yaml`: -```yaml -plugins: beetstreamnext -``` +Requires Python 3.9.1 or newer. + +> [!NOTE] +> BeetstreamNext is not yet available on PyPI. Installation currently requires cloning the source code from GitHub. + +[//]: # (### For Users) + +1. **Install Beets**: If you haven't already, [install and configure Beets](https://beets.readthedocs.io/en/stable/guides/main.html). You will also need `git` installed on your system. + +2. **Clone the BeetstreamNext Repository**: + ```bash + git clone https://github.com/FlorentLM/BeetstreamNext.git + cd BeetstreamNext + ``` + +3. **Install the Plugin**: + From inside the `BeetstreamNext` directory, run the installation command. You can use `pip` or any modern installer like `uv`. + ```bash + pip install . + ``` + For transcoding, you will also need to have **FFmpeg** installed and available in your system's PATH. + +4. **Enable the Plugin**: Add `beetstreamnext` to the `plugins` section of your Beets config file (`~/.config/beets/config.yaml`): + ```yaml + plugins: beetstreamnext + ``` + +5. **Run the Server**: + ```bash + beet beetstreamnext + ``` + +[//]: # (### For Developers) + +[//]: # () +[//]: # (If you want to contribute to BeetstreamNext, the setup process uses [Poetry](https://python-poetry.org/) for dependency management.) + +[//]: # () +[//]: # (1. **Clone the repository**:) + +[//]: # ( ```bash) + +[//]: # ( git clone https://github.com/FlorentLM/BeetstreamNext.git) + +[//]: # ( cd BeetstreamNext) + +[//]: # ( ```) + +[//]: # () +[//]: # (2. **Install dependencies**:) + +[//]: # ( This will create a virtual environment and install all required packages for development and testing.) + +[//]: # ( ```bash) + +[//]: # ( poetry install) -4) **Optional** You can change the host and port in your config file `~/.config/beets/config.yaml`. -You can also chose to never re-encode files even if the clients asks for it with the option `never_transcode: True`. This can be useful if you have a weak CPU or a lot of clients. +[//]: # ( ```) + +[//]: # () +[//]: # (3. **Activate the environment**:) + +[//]: # ( ```bash) + +[//]: # ( poetry shell) + +[//]: # ( ```) + +[//]: # () +[//]: # (4. **Run the server**:) + +[//]: # ( From inside the activated shell, you can run the plugin directly.) + +[//]: # ( ```bash) + +[//]: # ( beet beetstreamnext) + +[//]: # ( ```) + +[//]: # (See the [**CONTRIBUTING.md**](./CONTRIBUTING.md) file for more details on running tests and submitting changes.) + +## Configuration + +You can configure BeetstreamNext in your Beets `config.yaml` file. Here are the available options with their default values: -Here are the default values: ```yaml beetstreamnext: host: 0.0.0.0 port: 8080 - never_transcode: False + never_transcode: False # Never re-encode files, even if a client requests it. + + # Artist Image Handling + fetch_artists_images: True # Fetch artist photos from Deezer when a client requests them. + save_artists_images: True # Save fetched artist photos to their respective folders. + + # Playlist Configuration + playlist_dirs: # A list of directories to scan for .m3u playlists. + - '/path/to/my/playlists' + - '/another/path/for/playlists' ``` -5) Other configuration parameters: +### Environment Variables -If `fetch_artists_images` is enabled, BeetstreamNext will fetch the artists photos to display in your client player (if you enable this, it is recommended to also enable `save_artists_images`). +Some features require API keys or secrets, which should be configured as environment variables for security. You can place these in a `.env` file in the directory where you run the `beet` command. -BeetstreamNext supports playlists from Beets' [playlist](https://beets.readthedocs.io/en/stable/plugins/playlist.html) and [smartplaylist](https://beets.readthedocs.io/en/stable/plugins/smartplaylist.html) plugins. You can also define a BeetstreamNext-specific playlist folder with the `playlist_dir` option: -```yaml -beetstreamnext: - fetch_artists_images: False # Whether BeetstreamNext should fetch artists photos when clients request them - save_artists_images: False # Save artists photos to their respective folders in your music library - playlist_dir: './path/to/playlists' # A directory with BeetstreamNext-specific playlists -``` +[//]: # (- **`BEETSTREAMNEXT_KEY` (Required for User features)**: A unique encryption key for storing user data. You can generate one by running this Python command:) -6) Run with: -``` -$ beet beetstreamnext -``` +[//]: # ( ```bash) -## Clients Configuration +[//]: # ( python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") + +[//]: # ( ```) + +- **`LASTFM_API_KEY` (Optional)**: Your API key from Last.fm to enable fetching artist bios, top tracks, and similar songs. + ``` + LASTFM_API_KEY="your_lastfm_api_key_here" + ``` + +## Client Configuration ### Authentication -There is currently no security. You can put whatever user and password you want in your favorite app. -But this is going to change soon. +The backend for a full, multi-user authentication system is under active development. For now, **you can use any username and password** in your client to connect to the server. The server will respond as a default "admin" user. ### Server and Port -Currently runs on port `8080` (i.e.: `https://192.168.1.10:8080`) +Connect your client to the server's IP address and the configured port (default is `8080`). For example: `http://192.168.1.10:8080`. ## Supported Clients -All clients below have been tested and are working with this server. But in theory any Subsonic-compatible player should work. +BeetstreamNext aims for broad compatibility with any Subsonic-compliant player. It has been successfully tested with the following clients: -### Android - -- [Synfonium](https://symfonium.app/) +#### Android +- [Symfonium](https://symfonium.app/) - [Tempo](https://github.com/CappielloAntonio/tempo) - [SubTune](https://github.com/TaylorKunZhang/SubTune) -- [Subtracks](https://github.com/austinried/subtracks) -- [K-19 Player](https://github.com/ulysg/k19-player) - [substreamer](https://substreamerapp.com/) -- [GoSONIC](https://play.google.com/store/apps/details?id=com.readysteadygosoftware.gosonic&hl=en_GB) - [Ultrasonic](https://gitlab.com/ultrasonic/ultrasonic) -### Desktop - +#### Desktop - [Supersonic](https://github.com/dweymouth/supersonic) ## Roadmap -- [ ] Finalise BeetstreamNext's database storage (for multiple users etc) -- [ ] Finalise authentication (needs database to be fully operational) -- [ ] Implement missing endpoints -- [ ] Create a Docker image -- [ ] Cleanup the README and update the installation instructions \ No newline at end of file +This project is under active development. Here is a high-level overview of planned features and missing endpoints. + +#### Core Features (In Progress) +- [ ] Finalize BeetstreamNext's database storage for multi-user data +- [ ] Implement a complete, secure authentication and user management system +- [ ] Create a Docker image + +#### Missing API Endpoints +These endpoints require the database and user management systems to be fully operational: +- `getUsers`, `createUser`, `updateUser`, `deleteUser` +- `changePassword` +- `updatePlaylist` +- `getAvatar` +- `star`, `unstar`, `setRating` +- `getBookmarks`, `createBookmark`, `deleteBookmark` + +#### Future API Endpoints +- `getLyrics` +- `getPlayQueue`, `savePlayQueue` +- `getScanStatus`, `startScan` + +#### Video, Radio & Podcast Endpoints +These are lower priority but may be considered in the future. +- `getVideos`, `getVideoInfo`, `hls`, `getCaptions` +- `getPodcasts`, `getNewestPodcasts`, etc. +- `getInternetRadioStations` and related endpoints. + +#### Social Endpoints +- `getNowPlaying` +- `getShares`, `createShare`, `updateShare`, `deleteShare` +- `jukeboxControl` +- `getChatMessages`, `addChatMessage` + +## License + +This project is licensed under the MIT License. See the `LICENSE` file for details. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 31dafc5..935501a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,50 @@ -[project] -name = "BeetstreamNext" -version = "2.0" -description = "BeetstreamNext exposes your Beets.io database with the OpenSubsonic API." +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "beetstreamnext" +version = "1.9.0" +description = "A modern, feature-rich OpenSubsonic API server for your Beets.io music library." +authors = ["Florent Le Moël <25004801+FlorentLM@users.noreply.github.com>"] +license = "MIT" readme = "README.md" -requires-python = "==3.8.*" -dependencies = [ - "beets>=2.2.0", - "cryptography>=44.0.2", - "ffmpeg-python>=0.2.0", - "flask-cors>=5.0.1", - "flask-jwt-extended>=4.7.1", - "pillow>=11.1.0", - "pylast>=5.5.0", - "requests>=2.32.3", +repository = "https://github.com/FlorentLM/BeetstreamNext" +packages = [{include = "beetsplug"}] + +[tool.poetry.dependencies] +python = ">3.9.1,<4.0" +beets = ">=2.0.0" +flask-cors = ">=6.0.1" +cryptography = ">=45.0.6" +pillow = ">=11.3.0" +requests = ">=2.32.4" +ffmpeg-python = {version = ">=0.2.0", optional = true} + +[tool.poetry.extras] +transcode = ["ffmpeg-python"] + +[tool.poetry.group.dev.dependencies] +pytest = ">=7.4.0" +ruff = ">=0.1.0" +poethepoet = ">=0.24.0" +pre-commit = ">=3.5.0" + +[tool.ruff] +# same line length as beets for consistency +line-length = 79 +select = ["E", "F", "I", "W", "C90", "N"] +ignore = ["E501"] # handled by formatter + +[tool.ruff.format] +quote-style = "single" + +[tool.poe.tasks] +# dev tasks +lint = "ruff check ." +format = "ruff format ." +check = [ + {cmd = "ruff format . --check"}, + {cmd = "ruff check ."}, ] +test = "pytest" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index f558e09..0000000 --- a/setup.cfg +++ /dev/null @@ -1,32 +0,0 @@ -[metadata] -name = BeetstreamNext -version = 1.4.0 -author = Binary Brain -author_email = me@sachabron.ch -description = Beets.io plugin that expose SubSonic API endpoints, allowing you to stream your music everywhere. -long_description = file: README.md -long_description_content_type = text/markdown -url = https://github.com/BinaryBrain/Beetstream -project_urls = - Bug Tracker = https://github.com/BinaryBrain/Beetstream/issues -classifiers = - Environment :: No Input/Output (Daemon) - Intended Audience :: End Users/Desktop - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Operating System :: OS Independent - Topic :: Multimedia :: Sound/Audio :: Players - License :: OSI Approved :: MIT License - Framework :: Flask - -[options] -packages = find: -python_requires = >=3.8 -install_requires = - flask >= 1.1.2 - flask_cors >= 3.0.10 - Pillow >= 8.4.0 - ffmpeg-python >= 0.2.0 - -[options.packages.find] From a2a575166829718e03ab3ccdc3b5523393da2d49 Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Wed, 20 Aug 2025 11:04:16 +0100 Subject: [PATCH 84/85] Updated gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b6e4761..cc6e0c5 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +Jetbrains stuff +.idea/ \ No newline at end of file From ca036be8f78e0a3c794d7e47f091c737a1afd61c Mon Sep 17 00:00:00 2001 From: FlorentLM <25004801+FlorentLM@users.noreply.github.com> Date: Wed, 20 Aug 2025 11:07:55 +0100 Subject: [PATCH 85/85] Removed .idea folder from github --- .idea/BeetstreamNext.iml | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 .idea/BeetstreamNext.iml diff --git a/.idea/BeetstreamNext.iml b/.idea/BeetstreamNext.iml deleted file mode 100644 index 30c8e22..0000000 --- a/.idea/BeetstreamNext.iml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file