From 51cb2c3a68d80fdb89c8a0c3554ffb0da0a27e9c Mon Sep 17 00:00:00 2001 From: Stefan Kratochwil Date: Fri, 19 Dec 2025 18:33:42 +0100 Subject: [PATCH 1/6] feat: api endpoint for reading all available playlists --- software/src/app.py | 3 +++ software/src/utils/playlistdb.py | 10 ++++++++++ software/src/webserver.py | 10 ++++++++-- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/software/src/app.py b/software/src/app.py index c83c236..ee565b5 100644 --- a/software/src/app.py +++ b/software/src/app.py @@ -193,3 +193,6 @@ class PlayerApp: def get_nfc(self): return self.nfc + + def get_playlist_db(self): + return self.playlist_db diff --git a/software/src/utils/playlistdb.py b/software/src/utils/playlistdb.py index a8c866c..649be86 100644 --- a/software/src/utils/playlistdb.py +++ b/software/src/utils/playlistdb.py @@ -251,6 +251,16 @@ class BTreeDB(IPlaylistDB): if flush: self._flush() + def getPlaylistTags(self): + """ + Get a keys-only dict of all defined playlists. Playlists currently do not have names, but are identified by + their tag. + """ + playlist_tags = set() + for item in self.db: + playlist_tags.add(item.split(b'/')[0]) + return playlist_tags + def getPlaylistForTag(self, tag: bytes): """ Lookup the playlist for 'tag' and return the Playlist object. Return None if no playlist exists for the given diff --git a/software/src/webserver.py b/software/src/webserver.py index ca4d658..6e3b4cf 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -12,14 +12,15 @@ server = None config = None app = None nfc = None - +playlist_db = None def start_webserver(config_, app_): - global server, config, app, nfc + global server, config, app, nfc, playlist_db server = asyncio.create_task(webapp.start_server(port=80)) config = config_ app = app_ nfc = app.get_nfc() + playlist_db = app.get_playlist_db() @webapp.before_request @@ -72,3 +73,8 @@ async def config_put(request): async def last_tag_uid_get(request): tag, _ = nfc.get_last_uid() return {'tag': tag} + + +@webapp.route('/api/v1/playlists', methods=['GET']) +async def playlists_get(request): + return sorted(playlist_db.getPlaylistTags()) From 28846c9274a98ed5fa168828ef108b95f7ba0376 Mon Sep 17 00:00:00 2001 From: Stefan Kratochwil Date: Fri, 19 Dec 2025 18:56:51 +0100 Subject: [PATCH 2/6] wip: api endpoint to list all available audio files Iterative approach. Currently only lists mp3 files. --- software/src/webserver.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/software/src/webserver.py b/software/src/webserver.py index 6e3b4cf..68e140e 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -4,6 +4,7 @@ Copyright (c) 2024-2025 Stefan Kratochwil ''' import asyncio +import os from microdot import Microdot @@ -78,3 +79,24 @@ async def last_tag_uid_get(request): @webapp.route('/api/v1/playlists', methods=['GET']) async def playlists_get(request): return sorted(playlist_db.getPlaylistTags()) + + +@webapp.route('/api/v1/audiofiles', methods=['GET']) +async def audiofiles_get(request): + root = b'/sd' + audiofiles = set() + dirstack = [root] + + while dirstack: + current_dir = dirstack.pop() + for entry in os.ilistdir(current_dir): + name = entry[0] + type_ = entry[1] + current_path = current_dir + '/' + name + if type_ == 0x4000: + dirstack.append(current_path) + elif type_ == 0x8000: + if name.lower().endswith('.mp3'): + audiofiles.add(current_path[len(root):]) + + return sorted(audiofiles) From 070cf887abe26361089f150c9f294e0021a39a63 Mon Sep 17 00:00:00 2001 From: Stefan Kratochwil Date: Sat, 20 Dec 2025 15:40:52 +0100 Subject: [PATCH 3/6] feat(wip): api endpoint to get playlist properties --- software/src/webserver.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/software/src/webserver.py b/software/src/webserver.py index 68e140e..222a11e 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -81,6 +81,27 @@ async def playlists_get(request): return sorted(playlist_db.getPlaylistTags()) +def is_hex(s): + hex_chars = '0123456789abcdef' + return all(c in hex_chars for c in s) + + +@webapp.route('/api/v1/playlist/', methods=['GET']) +async def playlist_get(request, tag): + if not is_hex(tag): + return 'invalid tag', 400 + + playlist = playlist_db.getPlaylistForTag(tag.encode()) + paths = [] + + if playlist is not None: + for path in playlist.getPaths(): + paths.append({'path': path}) + + # empty paths are okay, tag might be know, but playlist may be empty + return paths + + @webapp.route('/api/v1/audiofiles', methods=['GET']) async def audiofiles_get(request): root = b'/sd' From e2ca9e51393750e93df93eb50356e10fd3186903 Mon Sep 17 00:00:00 2001 From: Stefan Kratochwil Date: Sat, 20 Dec 2025 16:52:37 +0100 Subject: [PATCH 4/6] feat: api endpoint to get playlist properties --- software/src/webserver.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/software/src/webserver.py b/software/src/webserver.py index 222a11e..68fe185 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -4,6 +4,7 @@ Copyright (c) 2024-2025 Stefan Kratochwil ''' import asyncio +import json import os from microdot import Microdot @@ -86,28 +87,28 @@ def is_hex(s): return all(c in hex_chars for c in s) +fsroot = b'/sd' + + @webapp.route('/api/v1/playlist/', methods=['GET']) async def playlist_get(request, tag): if not is_hex(tag): return 'invalid tag', 400 playlist = playlist_db.getPlaylistForTag(tag.encode()) - paths = [] - - if playlist is not None: - for path in playlist.getPaths(): - paths.append({'path': path}) - - # empty paths are okay, tag might be know, but playlist may be empty - return paths + return { + 'shuffle': playlist.__dict__.get('shuffle'), + 'persist': playlist.__dict__.get('persist'), + 'paths': [(p[len(fsroot):] if p.startswith(fsroot) else p).decode() + for p in playlist.getPaths()], + } @webapp.route('/api/v1/audiofiles', methods=['GET']) async def audiofiles_get(request): - root = b'/sd' audiofiles = set() - dirstack = [root] - + dirstack = [fsroot] + while dirstack: current_dir = dirstack.pop() for entry in os.ilistdir(current_dir): @@ -118,6 +119,6 @@ async def audiofiles_get(request): dirstack.append(current_path) elif type_ == 0x8000: if name.lower().endswith('.mp3'): - audiofiles.add(current_path[len(root):]) + audiofiles.add(current_path[len(fsroot):]) return sorted(audiofiles) From 3213ec8f66ee58f4cce796907c029ef222d60ef5 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sat, 20 Dec 2025 19:23:39 +0100 Subject: [PATCH 5/6] feat: webserver: set and delete playlists Signed-off-by: Matthias Blankertz --- software/src/utils/playlistdb.py | 3 +++ software/src/webserver.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/software/src/utils/playlistdb.py b/software/src/utils/playlistdb.py index 649be86..b7de2ca 100644 --- a/software/src/utils/playlistdb.py +++ b/software/src/utils/playlistdb.py @@ -297,6 +297,9 @@ class BTreeDB(IPlaylistDB): key = key.encode() return self.db.get(b'settings/' + key, self.DEFAULT_SETTINGS[key]).decode() + def deletePlaylistForTag(self, tag: bytes): + self._deletePlaylist(tag) + def validate(self, dump=False): """ Validate the structure of the playlist database. diff --git a/software/src/webserver.py b/software/src/webserver.py index 68fe185..5fd3a9b 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -16,6 +16,7 @@ app = None nfc = None playlist_db = None + def start_webserver(config_, app_): global server, config, app, nfc, playlist_db server = asyncio.create_task(webapp.start_server(port=80)) @@ -96,6 +97,8 @@ async def playlist_get(request, tag): return 'invalid tag', 400 playlist = playlist_db.getPlaylistForTag(tag.encode()) + if playlist is None: + return None, 404 return { 'shuffle': playlist.__dict__.get('shuffle'), @@ -104,6 +107,35 @@ async def playlist_get(request, tag): for p in playlist.getPaths()], } + +@webapp.route('/api/v1/playlist/', methods=['PUT']) +async def playlist_put(request, tag): + if not is_hex(tag): + return 'invalid tag', 400 + + playlist = request.json + if 'persist' in playlist and \ + playlist['persist'] not in ['no', 'track', 'offset']: + return "Invalid 'persist' setting", 400 + if 'shuffle' in playlist and \ + playlist['shuffle'] not in ['no', 'yes']: + return "Invalid 'shuffle' setting", 400 + + playlist_db.createPlaylistForTag(tag.encode(), + (fsroot + path.encode() for path in playlist.get('paths', [])), + playlist.get('persist', 'track').encode(), + playlist.get('shuffle', 'no').encode()) + return '', 204 + + +@webapp.route('/api/v1/playlist/', methods=['DELETE']) +async def playlist_delete(request, tag): + if not is_hex(tag): + return 'invalid tag', 400 + playlist_db.deletePlaylistForTag(tag.encode()) + return '', 204 + + @webapp.route('/api/v1/audiofiles', methods=['GET']) async def audiofiles_get(request): audiofiles = set() From 059b705a38aac1c477632392549d54a44b4c5c7d Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sat, 20 Dec 2025 19:24:14 +0100 Subject: [PATCH 6/6] fix: webserver: Use streaming response for filesystem listing Signed-off-by: Matthias Blankertz --- software/src/webserver.py | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/software/src/webserver.py b/software/src/webserver.py index 5fd3a9b..81db0a6 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -138,19 +138,26 @@ async def playlist_delete(request, tag): @webapp.route('/api/v1/audiofiles', methods=['GET']) async def audiofiles_get(request): - audiofiles = set() - dirstack = [fsroot] + def directory_iterator(): + yield '[' + first = True + dirstack = [fsroot] + while dirstack: + current_dir = dirstack.pop() + for entry in os.ilistdir(current_dir): + name = entry[0] + type_ = entry[1] + current_path = current_dir + b'/' + name + if type_ == 0x4000: + dirstack.append(current_path) + elif type_ == 0x8000: + if name.lower().endswith('.mp3'): + jsonpath = json.dumps(current_path[len(fsroot):]) + if not first: + yield ','+jsonpath + else: + yield jsonpath + first = False + yield ']' - while dirstack: - current_dir = dirstack.pop() - for entry in os.ilistdir(current_dir): - name = entry[0] - type_ = entry[1] - current_path = current_dir + '/' + name - if type_ == 0x4000: - dirstack.append(current_path) - elif type_ == 0x8000: - if name.lower().endswith('.mp3'): - audiofiles.add(current_path[len(fsroot):]) - - return sorted(audiofiles) + return directory_iterator(), {'Content-Type': 'application/json; charset=UTF-8'}