refactor(playlistdb): Enforce constant entry index length

To simplify the playlist handling, enforce that the indices are always
formatted to the same length (5, which allows for 100000 entries, that
should be enough).

Then make the position stored in the Playlist object be a simple integer
instead of a database key. This simplifies the code, and will make
implementing shuffle much easier.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
This commit is contained in:
2025-09-07 17:45:27 +02:00
parent 7e532ec641
commit 7327549eea
4 changed files with 91 additions and 95 deletions

View File

@@ -18,10 +18,8 @@ pip install -U micropython-rp2-pico_w-stubs --target typings
The playlist data is stored in the btree database in a hierarchical schema. The hierarchy levels are The playlist data is stored in the btree database in a hierarchical schema. The hierarchy levels are
separated by the '/' character. Currently, the schema is as follows: The top level for a playlist separated by the '/' character. Currently, the schema is as follows: The top level for a playlist
is the 'tag' id encoded as a hexadecimal string. Beneath this, the 'playlist' key contains the is the 'tag' id encoded as a hexadecimal string. Beneath this, the 'playlist' key contains the
elements in the playlist. The keys used for the playlist entries should be decimal integers, elements in the playlist. The keys used for the playlist entries must be decimal integers,
prefixed with sufficient zeros such that they are in the correct order when sorted left-padded with zeros so their length is 5 (e.g. format `{:05}`).
lexicographically. All keys must have the same width. When writing a playlist using the playlistdb
module, the keys are 00000, 00001, etc. by default.
#### Playlist modes #### Playlist modes

View File

@@ -31,12 +31,13 @@ class BTreeDB(IPlaylistDB):
PERSIST_OFFSET = b'offset' PERSIST_OFFSET = b'offset'
class Playlist(IPlaylist): class Playlist(IPlaylist):
def __init__(self, parent, tag, pos, persist, shuffle): def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle):
self.parent = parent self.parent = parent
self.tag = tag self.tag = tag
self.pos = pos self.pos = pos
self.persist = persist self.persist = persist
self.shuffle = shuffle self.shuffle = shuffle
self.length = self.parent._getPlaylistLength(self.tag)
def getPaths(self): def getPaths(self):
""" """
@@ -54,15 +55,17 @@ class BTreeDB(IPlaylistDB):
""" """
Select next track and return path. Select next track and return path.
""" """
try: if self.pos + 1 >= self.length:
self.pos = self.parent._getNextTrack(self.tag, self.pos) self.pos = 0
except StopIteration:
self.pos = self.parent._getFirstTrack(self.tag)
return None
finally:
if self.persist != BTreeDB.PERSIST_NO: if self.persist != BTreeDB.PERSIST_NO:
self.parent._setPlaylistPos(self.tag, self.pos) self.parent._setPlaylistPos(self.tag, self.pos)
self.setPlaybackOffset(0) self.setPlaybackOffset(0)
return None
self.pos += 1
if self.persist != BTreeDB.PERSIST_NO:
self.parent._setPlaylistPos(self.tag, self.pos)
self.setPlaybackOffset(0)
return self.getCurrentPath() return self.getCurrentPath()
def setPlaybackOffset(self, offset): def setPlaybackOffset(self, offset):
@@ -122,27 +125,42 @@ class BTreeDB(IPlaylistDB):
if self.flush_func is not None: if self.flush_func is not None:
self.flush_func() self.flush_func()
def _getPlaylistValueIterator(self, tag): def _getPlaylistValueIterator(self, tag: bytes):
start, end = self._keyPlaylistStartEnd(tag) start, end = self._keyPlaylistStartEnd(tag)
return self.db.values(start, end) return self.db.values(start, end)
def _getPlaylistEntry(self, _, pos): def _getPlaylistEntry(self, tag: bytes, pos: int) -> bytes:
return self.db[pos] return self.db[self._keyPlaylistEntry(tag, pos)]
def _setPlaylistPos(self, tag, pos, flush=True): def _setPlaylistPos(self, tag: bytes, pos: int, flush=True):
assert pos.startswith(self._keyPlaylistStart(tag)) self.db[self._keyPlaylistPos(tag)] = str(pos).encode()
self.db[self._keyPlaylistPos(tag)] = pos[len(self._keyPlaylistStart(tag)):]
if flush: if flush:
self._flush() self._flush()
def _setPlaylistPosOffset(self, tag, offset, flush=True): def _setPlaylistPosOffset(self, tag: bytes, offset: int, flush=True):
self.db[self._keyPlaylistPosOffset(tag)] = str(offset).encode() self.db[self._keyPlaylistPosOffset(tag)] = str(offset).encode()
if flush: if flush:
self._flush() self._flush()
def _getPlaylistPosOffset(self, tag): def _getPlaylistPosOffset(self, tag: bytes) -> int:
return int(self.db.get(self._keyPlaylistPosOffset(tag), b'0')) return int(self.db.get(self._keyPlaylistPosOffset(tag), b'0'))
def _getPlaylistLength(self, tag: bytes) -> int:
start, end = self._keyPlaylistStartEnd(tag)
for k in self.db.keys(end, start, btree.DESC):
# There is a bug in btreedb that causes an additional key after 'end' to be returned when iterating in
# descending order
# Check for this and skip it if needed
elements = k.split(b'/')
if len(elements) >= 2 and elements[1] == b'playlist':
last = k
break
print(last)
elements = last.split(b'/')
if len(elements) != 3:
raise RuntimeError("Malformed playlist key")
return int(elements[2])+1
def _savePlaylist(self, tag, entries, persist, shuffle, flush=True): def _savePlaylist(self, tag, entries, persist, shuffle, flush=True):
self._deletePlaylist(tag, False) self._deletePlaylist(tag, False)
for idx, entry in enumerate(entries): for idx, entry in enumerate(entries):
@@ -159,41 +177,32 @@ class BTreeDB(IPlaylistDB):
del self.db[k] del self.db[k]
except KeyError: except KeyError:
pass pass
try: for k in (self._keyPlaylistPos(tag), self._keyPlaylistPosOffset(tag),
del self.db[self._keyPlaylistPos(tag)] self._keyPlaylistPersist(tag), self._keyPlaylistShuffle(tag)):
except KeyError: try:
pass del self.db[k]
except KeyError:
pass
if flush: if flush:
self._flush() self._flush()
def _getFirstTrack(self, tag: bytes):
start_key, end_key = self._keyPlaylistStartEnd(tag)
return next(self.db.keys(start_key, end_key))
def _getNextTrack(self, tag, pos):
_, end_key = self._keyPlaylistStartEnd(tag)
it = self.db.keys(pos, end_key)
next(it)
return next(it)
def getPlaylistForTag(self, tag: bytes): def getPlaylistForTag(self, tag: bytes):
""" """
Lookup the playlist for 'tag' and return the Playlist object. Return None if no playlist exists for the given Lookup the playlist for 'tag' and return the Playlist object. Return None if no playlist exists for the given
tag. tag.
""" """
persist = self.db.get(self._keyPlaylistPersist(tag), self.PERSIST_TRACK) persist = self.db.get(self._keyPlaylistPersist(tag), self.PERSIST_TRACK)
if persist != self.PERSIST_NO: pos = 0
pos = self.db.get(self._keyPlaylistPos(tag)) if persist != self.PERSIST_NO and self._keyPlaylistPos(tag) in self.db:
else:
pos = None
if pos is None:
try: try:
pos = self._getFirstTrack(tag) pos = int(self.db[self._keyPlaylistPos(tag)])
except StopIteration: except ValueError:
# playist does not exist pass
return None if self._keyPlaylistEntry(tag, 0) not in self.db:
else: # Empty playlist
pos = self._keyPlaylistStart(tag) + pos return None
if self._keyPlaylistEntry(tag, pos) not in self.db:
pos = 0
shuffle = self.db.get(self._keyPlaylistShuffle(tag), self.SHUFFLE_NO) shuffle = self.db.get(self._keyPlaylistShuffle(tag), self.SHUFFLE_NO)
return self.Playlist(self, tag, pos, persist, shuffle) return self.Playlist(self, tag, pos, persist, shuffle)
@@ -221,7 +230,6 @@ class BTreeDB(IPlaylistDB):
last_tag = None last_tag = None
last_pos = None last_pos = None
index_width = None
for k in self.db.keys(): for k in self.db.keys():
fields = k.split(b'/') fields = k.split(b'/')
if len(fields) <= 1: if len(fields) <= 1:
@@ -240,13 +248,12 @@ class BTreeDB(IPlaylistDB):
except ValueError: except ValueError:
fail(f'Malformed playlist entry: {k!r}') fail(f'Malformed playlist entry: {k!r}')
continue continue
if index_width is not None and len(fields[2]) != index_width: if len(fields[2]) != 5:
fail(f'Inconsistent index width for {last_tag} at {idx}') fail(f'Bad index width for {last_tag} at {idx}')
if (last_pos is not None and last_pos + 1 != idx) or \ if (last_pos is not None and last_pos + 1 != idx) or \
(last_pos is None and idx != 0): (last_pos is None and idx != 0):
fail(f'Bad playlist entry sequence for {last_tag} at {idx}') fail(f'Bad playlist entry sequence for {last_tag} at {idx}')
last_pos = idx last_pos = idx
index_width = len(fields[2])
if dump: if dump:
print(f'\tTrack {idx}: {self.db[k]!r}') print(f'\tTrack {idx}: {self.db[k]!r}')
elif fields[1] == b'playlistpos': elif fields[1] == b'playlistpos':
@@ -276,8 +283,11 @@ class BTreeDB(IPlaylistDB):
# Format TBD # Format TBD
pass pass
elif fields[1] == b'playlistposoffset': elif fields[1] == b'playlistposoffset':
# Format TBD val = self.db[k]
pass try:
_ = int(val)
except ValueError:
fail(f' Bad playlistposoffset value for {last_tag}: {val!r}')
else: else:
fail(f'Unknown key {k!r}') fail(f'Unknown key {k!r}')
return result return result

View File

@@ -19,3 +19,7 @@ class BTree:
def open(dbfile) -> BTree: def open(dbfile) -> BTree:
pass pass
DESC = 1
INCL = 2

View File

@@ -1,6 +1,7 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org> # Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import btree
import pytest import pytest
from utils import BTreeDB from utils import BTreeDB
@@ -24,12 +25,18 @@ class FakeDB:
res.append(self.contents[key]) res.append(self.contents[key])
def keys(self, start_key=None, end_key=None, flags=None): def keys(self, start_key=None, end_key=None, flags=None):
keys = []
if flags is not None and flags & btree.DESC != 0:
start_key, end_key = end_key, start_key
for key in sorted(self.contents): for key in sorted(self.contents):
if start_key is not None and start_key > key: if start_key is not None and start_key > key:
continue continue
if end_key is not None and end_key <= key: if end_key is not None and end_key <= key:
break break
yield key keys.append(key)
if flags is not None and flags & btree.DESC != 0:
keys.reverse()
return iter(keys)
def get(self, key, default=None): def get(self, key, default=None):
return self.contents.get(key, default) return self.contents.get(key, default)
@@ -43,11 +50,14 @@ class FakeDB:
def __delitem__(self, key): def __delitem__(self, key):
del self.contents[key] del self.contents[key]
def __contains__(self, key):
return key in self.contents
def test_playlist_load(): def test_playlist_load():
contents = {b'foo/part': b'no', contents = {b'foo/part': b'no',
b'foo/playlist/0': b'track1', b'foo/playlist/00000': b'track1',
b'foo/playlist/1': b'track2', b'foo/playlist/00001': b'track2',
b'foo/playlisttt': b'no' b'foo/playlisttt': b'no'
} }
uut = BTreeDB(FakeDB(contents)) uut = BTreeDB(FakeDB(contents))
@@ -58,8 +68,8 @@ def test_playlist_load():
def test_playlist_nextpath(): def test_playlist_nextpath():
contents = FakeDB({b'foo/part': b'no', contents = FakeDB({b'foo/part': b'no',
b'foo/playlist/0': b'track1', b'foo/playlist/00000': b'track1',
b'foo/playlist/1': b'track2', b'foo/playlist/00001': b'track2',
b'foo/playlisttt': b'no' b'foo/playlisttt': b'no'
}) })
uut = BTreeDB(contents) uut = BTreeDB(contents)
@@ -69,8 +79,8 @@ def test_playlist_nextpath():
def test_playlist_nextpath_last(): def test_playlist_nextpath_last():
contents = FakeDB({b'foo/playlist/0': b'track1', contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/1': b'track2', b'foo/playlist/00001': b'track2',
b'foo/playlistpos': b'1' b'foo/playlistpos': b'1'
}) })
uut = BTreeDB(contents) uut = BTreeDB(contents)
@@ -80,8 +90,8 @@ def test_playlist_nextpath_last():
def test_playlist_create(): def test_playlist_create():
contents = FakeDB({b'foo/playlist/0': b'track1', contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/1': b'track2', b'foo/playlist/00001': b'track2',
b'foo/playlistpos': b'1' b'foo/playlistpos': b'1'
}) })
newplaylist = [b'never gonna give you up.mp3', b'durch den monsun.mp3'] newplaylist = [b'never gonna give you up.mp3', b'durch den monsun.mp3']
@@ -93,43 +103,17 @@ def test_playlist_create():
def test_playlist_load_notexist(): def test_playlist_load_notexist():
contents = FakeDB({b'foo/playlist/0': b'track1', contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/1': b'track2', b'foo/playlist/00001': b'track2',
b'foo/playlistpos': b'1' b'foo/playlistpos': b'1'
}) })
uut = BTreeDB(contents) uut = BTreeDB(contents)
assert uut.getPlaylistForTag(b'notfound') is None assert uut.getPlaylistForTag(b'notfound') is None
def test_playlist_remains_lexicographically_ordered_by_key():
contents = FakeDB({b'foo/playlist/3': b'track3',
b'foo/playlist/2': b'track2',
b'foo/playlist/1': b'track1',
b'foo/playlistpos': b'1'
})
uut = BTreeDB(contents)
pl = uut.getPlaylistForTag(b'foo')
assert pl.getCurrentPath() == b'track1'
assert pl.getNextPath() == b'track2'
assert pl.getNextPath() == b'track3'
def test_playlist_remains_lexicographically_ordered_with_non_numeric_keys():
contents = FakeDB({b'foo/playlist/k': b'trackk',
b'foo/playlist/l': b'trackl',
b'foo/playlist/i': b'tracki',
b'foo/playlistpos': b'k'
})
uut = BTreeDB(contents)
pl = uut.getPlaylistForTag(b'foo')
assert pl.getCurrentPath() == b'trackk'
assert pl.getNextPath() == b'trackl'
assert pl.getNextPath() is None
def test_playlist_starts_at_beginning_in_persist_no_mode(): def test_playlist_starts_at_beginning_in_persist_no_mode():
contents = FakeDB({b'foo/playlist/0': b'track1', contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/1': b'track2', b'foo/playlist/00001': b'track2',
b'foo/playlistpersist': b'no', b'foo/playlistpersist': b'no',
}) })
uut = BTreeDB(contents) uut = BTreeDB(contents)
@@ -143,8 +127,8 @@ def test_playlist_starts_at_beginning_in_persist_no_mode():
@pytest.mark.parametrize("mode", [b'no', b'track']) @pytest.mark.parametrize("mode", [b'no', b'track'])
def test_playlist_ignores_offset_in_other_modes(mode): def test_playlist_ignores_offset_in_other_modes(mode):
contents = FakeDB({b'foo/playlist/0': b'track1', contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/1': b'track2', b'foo/playlist/00001': b'track2',
b'foo/playlistpersist': mode, b'foo/playlistpersist': mode,
}) })
uut = BTreeDB(contents) uut = BTreeDB(contents)
@@ -156,8 +140,8 @@ def test_playlist_ignores_offset_in_other_modes(mode):
def test_playlist_stores_offset_in_offset_mode(): def test_playlist_stores_offset_in_offset_mode():
contents = FakeDB({b'foo/playlist/0': b'track1', contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/1': b'track2', b'foo/playlist/00001': b'track2',
b'foo/playlistpersist': b'offset', b'foo/playlistpersist': b'offset',
}) })
uut = BTreeDB(contents) uut = BTreeDB(contents)
@@ -169,8 +153,8 @@ def test_playlist_stores_offset_in_offset_mode():
def test_playlist_resets_offset_on_next_track(): def test_playlist_resets_offset_on_next_track():
contents = FakeDB({b'foo/playlist/0': b'track1', contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/1': b'track2', b'foo/playlist/00001': b'track2',
b'foo/playlistpersist': b'offset', b'foo/playlistpersist': b'offset',
}) })
uut = BTreeDB(contents) uut = BTreeDB(contents)