db: Give Playlists extra properties

* loop is based on the mpris loop property, and can be set to "None",
      "Track", or "Playlist".
* shuffle is a boolean True / False value.
* sort-order saves a user-configured sort order for each playlist.
* current-trackid is an integer referring to the trackid of the currently
      playing track.
* user-tracks: is a boolean representing if the user should be allowed
      to manually add and remove tracks.

I also use the sort-order property to implement a get_track_order()
function to get the sort keys for tracks in a Playlist.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2022-10-03 14:40:32 -04:00
parent 6eec4dbfc3
commit 85c42216ab
3 changed files with 130 additions and 13 deletions

View File

@ -25,7 +25,14 @@ CREATE TABLE settings (
CREATE TABLE playlist_properties ( CREATE TABLE playlist_properties (
propertyid INTEGER PRIMARY KEY, propertyid INTEGER PRIMARY KEY,
active BOOLEAN NOT NULL DEFAULT FALSE active BOOLEAN NOT NULL DEFAULT FALSE,
loop STRING NOT NULL DEFAULT "None",
shuffle BOOLEAN NOT NULL DEFAULT FALSE,
sort_order STRING NOT NULL DEFAULT "",
current_trackid INTEGER DEFAULT NULL REFERENCES tracks (trackid)
ON DELETE SET NULL
ON UPDATE CASCADE,
CHECK (loop IN ("None", "Track", "Playlist"))
); );
CREATE TRIGGER playlists_active_trigger CREATE TRIGGER playlists_active_trigger

View File

@ -17,17 +17,25 @@ class Playlist(table.Row):
name = GObject.Property(type=str) name = GObject.Property(type=str)
active = GObject.Property(type=bool, default=False) active = GObject.Property(type=bool, default=False)
loop = GObject.Property(type=str, default="None")
shuffle = GObject.Property(type=bool, default=False)
sort_order = GObject.Property(type=str)
tracks = GObject.Property(type=TrackidSet) tracks = GObject.Property(type=TrackidSet)
n_tracks = GObject.Property(type=int) n_tracks = GObject.Property(type=int)
user_tracks = GObject.Property(type=bool, default=False)
tracks_loaded = GObject.Property(type=bool, default=False) tracks_loaded = GObject.Property(type=bool, default=False)
tracks_movable = GObject.Property(type=bool, default=False) tracks_movable = GObject.Property(type=bool, default=False)
current_trackid = GObject.Property(type=int)
children = GObject.Property(type=Gtk.FilterListModel) children = GObject.Property(type=Gtk.FilterListModel)
def __init__(self, table: Gio.ListModel, propertyid: int, def __init__(self, table: Gio.ListModel, propertyid: int,
name: str, **kwargs): name: str, current_trackid: int | None = 0, **kwargs):
"""Initialize a Playlist object.""" """Initialize a Playlist object."""
current_trackid = 0 if current_trackid is None else current_trackid
super().__init__(table=table, propertyid=propertyid, name=name, super().__init__(table=table, propertyid=propertyid, name=name,
current_trackid=current_trackid,
tracks=TrackidSet(), **kwargs) tracks=TrackidSet(), **kwargs)
self.tracks.bind_property("n-trackids", self, "n-tracks") self.tracks.bind_property("n-trackids", self, "n-tracks")
@ -50,7 +58,7 @@ class Playlist(table.Row):
"""Update a Playlist object.""" """Update a Playlist object."""
match column: match column:
case "propertyid" | "name" | "n-tracks" | "children" | \ case "propertyid" | "name" | "n-tracks" | "children" | \
"tracks-loaded" | "tracks-movable": pass "user-tracks" | "tracks-loaded" | "tracks-movable": pass
case _: return super().do_update(column) case _: return super().do_update(column)
return True return True
@ -59,6 +67,10 @@ class Playlist(table.Row):
if self.table.add_track(self, track): if self.table.add_track(self, track):
self.table.queue.push(self.__add_track, track, now=not idle) self.table.queue.push(self.__add_track, track, now=not idle)
def get_track_order(self) -> dict[int, int]:
"""Get a dictionary mapping for trackid -> sorted position."""
return self.table.get_track_order(self)
def has_track(self, track: Track) -> bool: def has_track(self, track: Track) -> bool:
"""Check if a Track is on this Playlist.""" """Check if a Track is on this Playlist."""
return track in self.tracks return track in self.tracks
@ -134,6 +146,10 @@ class Table(table.Table):
"""Get a sort key for the requested Playlist.""" """Get a sort key for the requested Playlist."""
return format.sort_key(playlist.name) return format.sort_key(playlist.name)
def do_get_user_track_order(self, playlist: Playlist) -> dict[int, int]:
"""Get a mapping of sort keys for the tracks in this Playlist."""
raise NotImplementedError
def do_move_track_down(self, playlist: Playlist, track: Track) -> bool: def do_move_track_down(self, playlist: Playlist, track: Track) -> bool:
"""Move a track down in the sort order.""" """Move a track down in the sort order."""
raise NotImplementedError raise NotImplementedError
@ -199,17 +215,29 @@ class Table(table.Table):
self.__autodelete(playlist) self.__autodelete(playlist)
return res return res
def get_track_order(self, playlist: Playlist) -> dict[int, int]:
"""Get the track sort order for a playlist."""
if playlist.tracks_movable and playlist.sort_order == "user":
return self.do_get_user_track_order(playlist)
return self.sql.tracks.map_sort_order(playlist.sort_order)
def move_track_down(self, playlist: Playlist, track: Track) -> bool: def move_track_down(self, playlist: Playlist, track: Track) -> bool:
"""Move a track down in the playlist.""" """Move a track down in the playlist."""
if not playlist.tracks_movable: if not playlist.tracks_movable:
return False return False
return self.do_move_track_down(playlist, track) if res := self.do_move_track_down(playlist, track):
if playlist.sort_order != "user":
playlist.sort_order = "user"
return res
def move_track_up(self, playlist: Playlist, track: Track) -> bool: def move_track_up(self, playlist: Playlist, track: Track) -> bool:
"""Move a track up in the playlist.""" """Move a track up in the playlist."""
if not playlist.tracks_movable: if not playlist.tracks_movable:
return False return False
return self.do_move_track_up(playlist, track) if res := self.do_move_track_up(playlist, track):
if playlist.sort_order != "user":
playlist.sort_order = "user"
return res
def remove_system_track(self, playlist: Playlist, track: Track) -> bool: def remove_system_track(self, playlist: Playlist, track: Track) -> bool:
"""Remove a Track from a system Playlist.""" """Remove a Track from a system Playlist."""
@ -230,7 +258,8 @@ class Table(table.Table):
def update(self, playlist: Playlist, column: str, newval) -> bool: def update(self, playlist: Playlist, column: str, newval) -> bool:
"""Update a Playlist in the Database.""" """Update a Playlist in the Database."""
match column: match column:
case "active": case "active" | "loop" | "shuffle" | \
"sort-order" | "current-trackid":
return self.update_playlist_property(playlist, column, newval) return self.update_playlist_property(playlist, column, newval)
case _: case _:
return super().update(playlist, column, newval) return super().update(playlist, column, newval)
@ -241,6 +270,11 @@ class Table(table.Table):
match column: match column:
case "active": case "active":
self.active_playlist = playlist if playlist.active else None self.active_playlist = playlist if playlist.active else None
case "current-trackid":
column = "current_trackid"
newval = None if newval == 0 else newval
case "sort-order":
column = "sort_order"
return self.sql(f"""UPDATE playlist_properties return self.sql(f"""UPDATE playlist_properties
SET {column}=? WHERE propertyid=?""", SET {column}=? WHERE propertyid=?""",

View File

@ -20,6 +20,7 @@ class TestPlaylistRow(unittest.TestCase):
self.table.move_track_down = unittest.mock.Mock(return_value=True) self.table.move_track_down = unittest.mock.Mock(return_value=True)
self.table.move_track_up = unittest.mock.Mock(return_value=True) self.table.move_track_up = unittest.mock.Mock(return_value=True)
self.table.get_trackids = unittest.mock.Mock(return_value={1, 2, 3}) self.table.get_trackids = unittest.mock.Mock(return_value={1, 2, 3})
self.table.get_track_order = unittest.mock.Mock()
self.table.queue = emmental.db.idle.Queue() self.table.queue = emmental.db.idle.Queue()
self.table.update = unittest.mock.Mock(return_value=True) self.table.update = unittest.mock.Mock(return_value=True)
@ -39,14 +40,27 @@ class TestPlaylistRow(unittest.TestCase):
self.assertEqual(self.playlist.name, "Test Playlist") self.assertEqual(self.playlist.name, "Test Playlist")
self.assertFalse(self.playlist.active) self.assertFalse(self.playlist.active)
self.assertEqual(self.playlist.loop, "None")
self.assertFalse(self.playlist.shuffle)
self.assertEqual(self.playlist.current_trackid, 0)
self.assertEqual(self.playlist.sort_order, "")
self.assertEqual(self.playlist.n_tracks, 0) self.assertEqual(self.playlist.n_tracks, 0)
self.assertFalse(self.playlist.user_tracks)
self.assertFalse(self.playlist.tracks_loaded) self.assertFalse(self.playlist.tracks_loaded)
playlist2 = emmental.db.playlist.Playlist(table=self.table, playlist2 = emmental.db.playlist.Playlist(table=self.table,
propertyid=1, propertyid=1,
name="Test Name", active=1) name="Test Name", active=1,
shuffle=True, loop="Track",
current_trackid=42,
sort_order="Track Number")
self.assertEqual(playlist2.propertyid, 1) self.assertEqual(playlist2.propertyid, 1)
self.assertEqual(playlist2.name, "Test Name") self.assertEqual(playlist2.name, "Test Name")
self.assertEqual(playlist2.current_trackid, 42)
self.assertEqual(playlist2.sort_order, "Track Number")
self.assertEqual(playlist2.loop, "Track")
self.assertTrue(playlist2.shuffle)
self.assertTrue(playlist2.active) self.assertTrue(playlist2.active)
def test_children(self): def test_children(self):
@ -68,14 +82,20 @@ class TestPlaylistRow(unittest.TestCase):
"""Test the do_update() function.""" """Test the do_update() function."""
for (prop, value) in [("name", "New Name"), ("propertyid", 12345), for (prop, value) in [("name", "New Name"), ("propertyid", 12345),
("children", Gtk.FilterListModel()), ("children", Gtk.FilterListModel()),
("n-tracks", 42), ("tracks-loaded", True), ("n-tracks", 42), ("user-tracks", True),
("tracks-loaded", True),
("tracks-movable", True)]: ("tracks-movable", True)]:
with self.subTest(property=prop): with self.subTest(property=prop):
self.playlist.set_property(prop, value) self.playlist.set_property(prop, value)
self.table.update.assert_not_called() self.table.update.assert_not_called()
self.playlist.active = True for (prop, value) in [("active", True), ("loop", "Track"),
self.table.update.assert_called_with(self.playlist, "active", True) ("shuffle", True), ("sort-order", "my order"),
("current-trackid", 42)]:
with self.subTest(property=prop):
self.playlist.set_property(prop, value)
self.table.update.assert_called_with(self.playlist,
prop, value)
def test_add_track(self): def test_add_track(self):
"""Test adding a track to the playlist.""" """Test adding a track to the playlist."""
@ -95,6 +115,13 @@ class TestPlaylistRow(unittest.TestCase):
self.playlist.add_track(self.track) self.playlist.add_track(self.track)
self.assertNotIn(self.track, self.playlist.tracks) self.assertNotIn(self.track, self.playlist.tracks)
def test_get_track_order(self):
"""Test the get_track_order() function."""
self.table.get_track_order.return_value = {1: 3, 2: 2, 3: 1}
self.assertDictEqual(self.playlist.get_track_order(),
{1: 3, 2: 2, 3: 1})
self.table.get_track_order.assert_called_with(self.playlist)
def test_has_track(self): def test_has_track(self):
"""Test the playlist has_track() function.""" """Test the playlist has_track() function."""
self.assertFalse(self.playlist.has_track(self.track)) self.assertFalse(self.playlist.has_track(self.track))
@ -277,6 +304,26 @@ class TestPlaylistTable(tests.util.TestCase):
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
self.table.get_trackids(plist) self.table.get_trackids(plist)
def test_get_track_order(self):
"""Test getting track sort keys for a playlist."""
plist = self.table.create("Test Playlist")
plist.sort_order = "my order"
self.sql.tracks.map_sort_order = unittest.mock.Mock()
self.sql.tracks.map_sort_order.return_value = {1: 3, 2: 2, 3: 1}
self.assertDictEqual(self.table.get_track_order(plist),
{1: 3, 2: 2, 3: 1})
self.sql.tracks.map_sort_order.assert_called_with("my order")
self.sql.tracks.map_sort_order.reset_mock()
plist.tracks_movable = True
self.assertDictEqual(self.table.get_track_order(plist),
{1: 3, 2: 2, 3: 1})
self.sql.tracks.map_sort_order.assert_called_with("my order")
plist.sort_order = "user"
with self.assertRaises(NotImplementedError):
self.table.get_track_order(plist)
def test_move_track_down(self): def test_move_track_down(self):
"""Test moving tracks down in the sort order.""" """Test moving tracks down in the sort order."""
plist = self.table.create("Test Playlist") plist = self.table.create("Test Playlist")
@ -286,6 +333,14 @@ class TestPlaylistTable(tests.util.TestCase):
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
self.table.move_track_down(plist, self.track) self.table.move_track_down(plist, self.track)
self.table.do_move_track_down = unittest.mock.Mock(return_value=False)
self.table.move_track_down(plist, self.track)
self.assertEqual(plist.sort_order, "")
self.table.do_move_track_down.return_value = True
self.table.move_track_down(plist, self.track)
self.assertEqual(plist.sort_order, "user")
def test_move_track_up(self): def test_move_track_up(self):
"""Test moving tracks up in the sort order.""" """Test moving tracks up in the sort order."""
plist = self.table.create("Test Playlist") plist = self.table.create("Test Playlist")
@ -295,6 +350,14 @@ class TestPlaylistTable(tests.util.TestCase):
with self.assertRaises(NotImplementedError): with self.assertRaises(NotImplementedError):
self.table.move_track_up(plist, self.track) self.table.move_track_up(plist, self.track)
self.table.do_move_track_up = unittest.mock.Mock(return_value=False)
self.table.move_track_up(plist, self.track)
self.assertEqual(plist.sort_order, "")
self.table.do_move_track_up.return_value = True
self.table.move_track_up(plist, self.track)
self.assertEqual(plist.sort_order, "user")
def test_remove_track(self): def test_remove_track(self):
"""Test adding a track to a playlist.""" """Test adding a track to a playlist."""
self.assertTrue(self.table.system_tracks) self.assertTrue(self.table.system_tracks)
@ -317,17 +380,30 @@ class TestPlaylistTable(tests.util.TestCase):
plist1 = self.table.create("Test Playlist 1") plist1 = self.table.create("Test Playlist 1")
plist2 = self.table.create("Test Playlist 2") plist2 = self.table.create("Test Playlist 2")
plist1.active = True plist1.active = True
plist1.loop = "Track"
plist1.shuffle = True
plist1.sort_order = "Track Number"
plist1.current_trackid = self.track.trackid
self.assertEqual(self.table.active_playlist, plist1) self.assertEqual(self.table.active_playlist, plist1)
row = self.sql("""SELECT active FROM playlist_properties row = self.sql("""SELECT active, loop, shuffle,
WHERE propertyid=?""", plist1.propertyid).fetchone() current_trackid, sort_order
FROM playlist_properties
WHERE propertyid=?""", plist1.propertyid).fetchone()
self.assertEqual(row["active"], True) self.assertEqual(row["active"], True)
self.assertEqual(row["current_trackid"], self.track.trackid)
self.assertEqual(row["sort_order"], "Track Number")
self.assertEqual(row["loop"], "Track")
self.assertTrue(row["shuffle"])
plist1.current_trackid = 0
plist2.active = True plist2.active = True
self.assertEqual(self.table.active_playlist, plist2) self.assertEqual(self.table.active_playlist, plist2)
row = self.sql("SELECT active FROM playlist_properties WHERE rowid=?", row = self.sql("""SELECT active, current_trackid
FROM playlist_properties WHERE propertyid=?""",
plist1.propertyid).fetchone() plist1.propertyid).fetchone()
self.assertEqual(row["active"], False) self.assertEqual(row["active"], False)
self.assertEqual(row["current_trackid"], None)
def test_autodelete(self): def test_autodelete(self):
"""Test automatically deleting playlists.""" """Test automatically deleting playlists."""