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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -19,3 +19,7 @@ class BTree:
|
|||||||
|
|
||||||
def open(dbfile) -> BTree:
|
def open(dbfile) -> BTree:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
DESC = 1
|
||||||
|
INCL = 2
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user