diff --git a/emmental/db/emmental.sql b/emmental/db/emmental.sql index 58798e4..11e6684 100644 --- a/emmental/db/emmental.sql +++ b/emmental/db/emmental.sql @@ -25,7 +25,14 @@ CREATE TABLE settings ( CREATE TABLE playlist_properties ( 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 diff --git a/emmental/db/playlist.py b/emmental/db/playlist.py index c01dd3b..bc6e7e2 100644 --- a/emmental/db/playlist.py +++ b/emmental/db/playlist.py @@ -17,17 +17,25 @@ class Playlist(table.Row): name = GObject.Property(type=str) 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) n_tracks = GObject.Property(type=int) + user_tracks = GObject.Property(type=bool, default=False) tracks_loaded = 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) def __init__(self, table: Gio.ListModel, propertyid: int, - name: str, **kwargs): + name: str, current_trackid: int | None = 0, **kwargs): """Initialize a Playlist object.""" + current_trackid = 0 if current_trackid is None else current_trackid super().__init__(table=table, propertyid=propertyid, name=name, + current_trackid=current_trackid, tracks=TrackidSet(), **kwargs) self.tracks.bind_property("n-trackids", self, "n-tracks") @@ -50,7 +58,7 @@ class Playlist(table.Row): """Update a Playlist object.""" match column: case "propertyid" | "name" | "n-tracks" | "children" | \ - "tracks-loaded" | "tracks-movable": pass + "user-tracks" | "tracks-loaded" | "tracks-movable": pass case _: return super().do_update(column) return True @@ -59,6 +67,10 @@ class Playlist(table.Row): if self.table.add_track(self, track): 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: """Check if a Track is on this Playlist.""" return track in self.tracks @@ -134,6 +146,10 @@ class Table(table.Table): """Get a sort key for the requested Playlist.""" 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: """Move a track down in the sort order.""" raise NotImplementedError @@ -199,17 +215,29 @@ class Table(table.Table): self.__autodelete(playlist) 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: """Move a track down in the playlist.""" if not playlist.tracks_movable: 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: """Move a track up in the playlist.""" if not playlist.tracks_movable: 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: """Remove a Track from a system Playlist.""" @@ -230,7 +258,8 @@ class Table(table.Table): def update(self, playlist: Playlist, column: str, newval) -> bool: """Update a Playlist in the Database.""" match column: - case "active": + case "active" | "loop" | "shuffle" | \ + "sort-order" | "current-trackid": return self.update_playlist_property(playlist, column, newval) case _: return super().update(playlist, column, newval) @@ -241,6 +270,11 @@ class Table(table.Table): match column: case "active": 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 SET {column}=? WHERE propertyid=?""", diff --git a/tests/db/test_playlist.py b/tests/db/test_playlist.py index fe772c5..8c8b712 100644 --- a/tests/db/test_playlist.py +++ b/tests/db/test_playlist.py @@ -20,6 +20,7 @@ class TestPlaylistRow(unittest.TestCase): self.table.move_track_down = 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_track_order = unittest.mock.Mock() self.table.queue = emmental.db.idle.Queue() 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.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.assertFalse(self.playlist.user_tracks) self.assertFalse(self.playlist.tracks_loaded) playlist2 = emmental.db.playlist.Playlist(table=self.table, 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.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) def test_children(self): @@ -68,14 +82,20 @@ class TestPlaylistRow(unittest.TestCase): """Test the do_update() function.""" for (prop, value) in [("name", "New Name"), ("propertyid", 12345), ("children", Gtk.FilterListModel()), - ("n-tracks", 42), ("tracks-loaded", True), + ("n-tracks", 42), ("user-tracks", True), + ("tracks-loaded", True), ("tracks-movable", True)]: with self.subTest(property=prop): self.playlist.set_property(prop, value) self.table.update.assert_not_called() - self.playlist.active = True - self.table.update.assert_called_with(self.playlist, "active", True) + for (prop, value) in [("active", True), ("loop", "Track"), + ("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): """Test adding a track to the playlist.""" @@ -95,6 +115,13 @@ class TestPlaylistRow(unittest.TestCase): self.playlist.add_track(self.track) 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): """Test the playlist has_track() function.""" self.assertFalse(self.playlist.has_track(self.track)) @@ -277,6 +304,26 @@ class TestPlaylistTable(tests.util.TestCase): with self.assertRaises(NotImplementedError): 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): """Test moving tracks down in the sort order.""" plist = self.table.create("Test Playlist") @@ -286,6 +333,14 @@ class TestPlaylistTable(tests.util.TestCase): with self.assertRaises(NotImplementedError): 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): """Test moving tracks up in the sort order.""" plist = self.table.create("Test Playlist") @@ -295,6 +350,14 @@ class TestPlaylistTable(tests.util.TestCase): with self.assertRaises(NotImplementedError): 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): """Test adding a track to a playlist.""" self.assertTrue(self.table.system_tracks) @@ -317,17 +380,30 @@ class TestPlaylistTable(tests.util.TestCase): plist1 = self.table.create("Test Playlist 1") plist2 = self.table.create("Test Playlist 2") 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) - row = self.sql("""SELECT active FROM playlist_properties - WHERE propertyid=?""", plist1.propertyid).fetchone() + row = self.sql("""SELECT active, loop, shuffle, + current_trackid, sort_order + FROM playlist_properties + WHERE propertyid=?""", plist1.propertyid).fetchone() 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 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() self.assertEqual(row["active"], False) + self.assertEqual(row["current_trackid"], None) def test_autodelete(self): """Test automatically deleting playlists."""