db: Add a base class for Playlist Tables
This is an implementation of an emmental.db.table.Table that adds support for creating Playlists, updating playlist properties, sorting based on playlist name, and displaying playlists with their child playlists in a tree structure. Additionally, I create a playlist_properties table in the database to store properties that all playlists have. Implements: #17 ("Save currently selected playlist") Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
parent
d22b3c0ce2
commit
8a9c90a7ff
|
@ -3,6 +3,7 @@
|
|||
import pathlib
|
||||
from gi.repository import GObject
|
||||
from . import connection
|
||||
from . import playlist
|
||||
from . import settings
|
||||
from . import table
|
||||
|
||||
|
@ -13,6 +14,8 @@ SQL_SCRIPT = pathlib.Path(__file__).parent / "emmental.sql"
|
|||
class Connection(connection.Connection):
|
||||
"""Connect to the database."""
|
||||
|
||||
active_playlist = GObject.Property(type=playlist.Playlist)
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize a sqlite connection."""
|
||||
super().__init__()
|
||||
|
@ -34,6 +37,16 @@ class Connection(connection.Connection):
|
|||
"""Load the database tables."""
|
||||
self.settings.load()
|
||||
|
||||
def set_active_playlist(self, plist: playlist.Playlist) -> None:
|
||||
"""Set the currently active playlist."""
|
||||
if self.active_playlist is not None:
|
||||
self.active_playlist.active = False
|
||||
|
||||
self.active_playlist = plist
|
||||
|
||||
if plist is not None:
|
||||
plist.active = True
|
||||
|
||||
@GObject.Signal(arg_types=(table.Table,))
|
||||
def table_loaded(self, tbl: table.Table) -> None:
|
||||
"""Signal that a table has been loaded."""
|
||||
|
|
|
@ -15,3 +15,23 @@ CREATE TABLE settings (
|
|||
value TEXT NOT NULL,
|
||||
CHECK (type IN ("gint", "gdouble", "gboolean", "gchararray"))
|
||||
);
|
||||
|
||||
|
||||
/*************************************
|
||||
* *
|
||||
* Playlist Properties *
|
||||
* *
|
||||
*************************************/
|
||||
|
||||
CREATE TABLE playlist_properties (
|
||||
propertyid INTEGER PRIMARY KEY,
|
||||
active BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE TRIGGER playlists_active_trigger
|
||||
AFTER UPDATE OF active ON playlist_properties
|
||||
FOR EACH ROW BEGIN
|
||||
UPDATE playlist_properties
|
||||
SET active = FALSE
|
||||
WHERE propertyid != NEW.propertyid AND active == TRUE;
|
||||
END;
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
from gi.repository import GObject
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gtk
|
||||
from .. import format
|
||||
from . import table
|
||||
|
||||
|
||||
|
@ -19,10 +20,10 @@ class Playlist(table.Row):
|
|||
children = GObject.Property(type=Gtk.FilterListModel)
|
||||
|
||||
def __init__(self, table: Gio.ListModel, propertyid: int,
|
||||
name: str = "", active: bool = False, **kwargs):
|
||||
name: str, **kwargs):
|
||||
"""Initialize a Playlist object."""
|
||||
super().__init__(table=table, propertyid=propertyid,
|
||||
name=name, active=active, **kwargs)
|
||||
name=name, **kwargs)
|
||||
|
||||
def add_children(self, child_table: table.Table,
|
||||
child_filter: Gtk.Filter) -> None:
|
||||
|
@ -41,3 +42,62 @@ class Playlist(table.Row):
|
|||
def parent(self) -> table.Row | None:
|
||||
"""Get this playlist's parent playlist."""
|
||||
return None
|
||||
|
||||
|
||||
class Table(table.Table):
|
||||
"""A table.Table with extra functionality for Playlists."""
|
||||
|
||||
active_playlist = GObject.Property(type=Playlist)
|
||||
treemodel = GObject.Property(type=Gtk.TreeListModel)
|
||||
|
||||
def __init__(self, sql: GObject.TYPE_PYOBJECT, **kwargs):
|
||||
"""Initialize a Playlist Table."""
|
||||
super().__init__(sql=sql, **kwargs)
|
||||
self.treemodel = Gtk.TreeListModel.new(root=self,
|
||||
passthrough=False,
|
||||
autoexpand=False,
|
||||
create_func=self.__create_tree)
|
||||
|
||||
def __create_tree(self, plist: Playlist) -> Gtk.FilterListModel | None:
|
||||
return plist.children
|
||||
|
||||
def do_get_sort_key(self, playlist: Playlist) -> tuple[str]:
|
||||
"""Get a sort key for the requested Playlist."""
|
||||
return format.sort_key(playlist.name)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the Table."""
|
||||
self.active_playlist = None
|
||||
super().clear()
|
||||
|
||||
def construct(self, propertyid: int, name: str, **kwargs) -> Playlist:
|
||||
"""Construct a new Playlist object."""
|
||||
res = super().construct(propertyid=propertyid, name=name, **kwargs)
|
||||
if res.active:
|
||||
self.sql.set_active_playlist(res)
|
||||
return res
|
||||
|
||||
def delete(self, playlist: Playlist) -> bool:
|
||||
"""Delete a playlist from the database."""
|
||||
if playlist.active:
|
||||
self.sql.set_active_playlist(None)
|
||||
return super().delete(playlist)
|
||||
|
||||
def update(self, playlist: Playlist, column: str, newval) -> bool:
|
||||
"""Update a Playlist in the Database."""
|
||||
match column:
|
||||
case "active":
|
||||
return self.update_playlist_property(playlist, column, newval)
|
||||
case _:
|
||||
return super().update(playlist, column, newval)
|
||||
|
||||
def update_playlist_property(self, playlist: Playlist,
|
||||
column: str, newval) -> bool:
|
||||
"""Update the playlists_common table."""
|
||||
match column:
|
||||
case "active":
|
||||
self.active_playlist = playlist if playlist.active else None
|
||||
|
||||
return self.sql(f"""UPDATE playlist_properties
|
||||
SET {column}=? WHERE propertyid=?""",
|
||||
newval, playlist.propertyid) is not None
|
||||
|
|
|
@ -48,3 +48,23 @@ class TestConnection(tests.util.TestCase):
|
|||
calls = [unittest.mock.call(self.sql, tbl)
|
||||
for tbl in [self.sql.settings]]
|
||||
table_loaded.assert_has_calls(calls)
|
||||
|
||||
def test_set_active_playlist(self):
|
||||
"""Check setting the active playlist."""
|
||||
table = tests.util.playlist.MockTable(self.sql)
|
||||
plist1 = table.create(name="Playlist 1")
|
||||
plist2 = table.create(name="Playlist 2")
|
||||
self.assertIsNone(self.sql.active_playlist)
|
||||
|
||||
self.sql.set_active_playlist(plist1)
|
||||
self.assertEqual(self.sql.active_playlist, plist1)
|
||||
self.assertTrue(plist1.active)
|
||||
|
||||
self.sql.set_active_playlist(plist2)
|
||||
self.assertEqual(self.sql.active_playlist, plist2)
|
||||
self.assertFalse(plist1.active)
|
||||
self.assertTrue(plist2.active)
|
||||
|
||||
self.sql.set_active_playlist(None)
|
||||
self.assertIsNone(self.sql.active_playlist)
|
||||
self.assertFalse(plist2.active)
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import unittest
|
||||
import unittest.mock
|
||||
import emmental.db.playlist
|
||||
import tests.util.playlist
|
||||
from gi.repository import Gio
|
||||
from gi.repository import Gtk
|
||||
|
||||
|
@ -15,14 +16,15 @@ class TestPlaylistRow(unittest.TestCase):
|
|||
self.table = Gio.ListStore()
|
||||
self.table.update = unittest.mock.Mock(return_value=True)
|
||||
self.playlist = emmental.db.playlist.Playlist(table=self.table,
|
||||
propertyid=0)
|
||||
propertyid=0,
|
||||
name="Test Playlist")
|
||||
|
||||
def test_init(self):
|
||||
"""Test that the Playlist object is configured correctly."""
|
||||
self.assertIsInstance(self.playlist, emmental.db.table.Row)
|
||||
self.assertEqual(self.playlist.table, self.table)
|
||||
self.assertEqual(self.playlist.propertyid, 0)
|
||||
self.assertEqual(self.playlist.name, "")
|
||||
self.assertEqual(self.playlist.name, "Test Playlist")
|
||||
self.assertEqual(self.playlist.n_tracks, 0)
|
||||
self.assertFalse(self.playlist.active)
|
||||
|
||||
|
@ -59,3 +61,82 @@ class TestPlaylistRow(unittest.TestCase):
|
|||
|
||||
self.playlist.active = True
|
||||
self.table.update.assert_called_with(self.playlist, "active", True)
|
||||
|
||||
|
||||
class TestPlaylistTable(tests.util.TestCase):
|
||||
"""Tests our Playlist Table."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up common variables."""
|
||||
super().setUp()
|
||||
self.table = tests.util.playlist.MockTable(self.sql)
|
||||
self.sql("DELETE FROM playlist_properties")
|
||||
|
||||
def test_treemodel(self):
|
||||
"""Check that the table's treemodel was set up properly."""
|
||||
self.assertIsInstance(self.table.treemodel, Gtk.TreeListModel)
|
||||
self.assertEqual(self.table.treemodel.get_model(), self.table)
|
||||
self.assertFalse(self.table.treemodel.get_passthrough())
|
||||
self.assertFalse(self.table.treemodel.get_autoexpand())
|
||||
|
||||
root = self.table.create("Root")
|
||||
self.assertIsNone(self.table._Table__create_tree(root))
|
||||
root.children = Gtk.FilterListModel()
|
||||
self.assertEqual(self.table._Table__create_tree(root), root.children)
|
||||
|
||||
def test_construct(self):
|
||||
"""Test constructing a new playlist."""
|
||||
self.assertIsNone(self.table.active_playlist)
|
||||
|
||||
plist1 = self.table.construct(propertyid=1, name="Test")
|
||||
self.assertIsInstance(plist1, emmental.db.playlist.Playlist)
|
||||
self.assertEqual(plist1.table, self.table)
|
||||
self.assertEqual(plist1.propertyid, 1)
|
||||
self.assertEqual(plist1.name, "Test")
|
||||
self.assertFalse(plist1.active)
|
||||
|
||||
plist2 = self.table.construct(propertyid=2, name="Test 2", active=True)
|
||||
self.assertEqual(self.table.active_playlist, plist2)
|
||||
self.assertEqual(self.sql.active_playlist, plist2)
|
||||
self.assertTrue(plist2.active)
|
||||
|
||||
def test_get_sort_key(self):
|
||||
"""Test getting a sort key for a playlist."""
|
||||
plist = self.table.create("Playlist 1")
|
||||
self.assertTupleEqual(self.table.get_sort_key(plist),
|
||||
("playlist", "1"))
|
||||
|
||||
def test_clear(self):
|
||||
"""Test clearing the active_playlist property."""
|
||||
plist = self.table.create("Playlist 1")
|
||||
self.table.active_playlist = plist
|
||||
self.table.clear()
|
||||
self.assertIsNone(self.table.active_playlist)
|
||||
self.assertEqual(len(self.table), 0)
|
||||
|
||||
def test_delete(self):
|
||||
"""Test deleting the active playlist."""
|
||||
plist = self.table.create("Test Playlist")
|
||||
self.sql.set_active_playlist(plist)
|
||||
|
||||
self.assertTrue(self.table.delete(plist))
|
||||
self.assertIsNone(self.table.active_playlist)
|
||||
self.assertIsNone(self.sql.active_playlist)
|
||||
self.assertNotIn(plist, self.table)
|
||||
|
||||
def test_update(self):
|
||||
"""Test updating playlist properties."""
|
||||
plist1 = self.table.create("Test Playlist 1")
|
||||
plist2 = self.table.create("Test Playlist 2")
|
||||
plist1.active = True
|
||||
|
||||
self.assertEqual(self.table.active_playlist, plist1)
|
||||
row = self.sql("""SELECT active FROM playlist_properties
|
||||
WHERE propertyid=?""", plist1.propertyid).fetchone()
|
||||
self.assertEqual(row["active"], True)
|
||||
|
||||
plist2.active = True
|
||||
self.assertEqual(self.table.active_playlist, plist2)
|
||||
row = self.sql("SELECT active FROM playlist_properties WHERE rowid=?",
|
||||
plist1.propertyid).fetchone()
|
||||
self.assertEqual(row["active"], False)
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
# Copyright 2023 (c) Anna Schumaker.
|
||||
"""Mock Playlist and Table objects for testing."""
|
||||
import emmental.db.playlist
|
||||
import sqlite3
|
||||
|
||||
|
||||
class MockPlaylist(emmental.db.playlist.Playlist):
|
||||
"""A fake Playlist for testing."""
|
||||
|
||||
@property
|
||||
def primary_key(self) -> int:
|
||||
"""Get the primary_key of this playlist."""
|
||||
return self.propertyid
|
||||
|
||||
|
||||
class MockTable(emmental.db.playlist.Table):
|
||||
"""A fake Playlist Table for testing."""
|
||||
|
||||
def do_construct(self, **kwargs) -> MockPlaylist:
|
||||
"""Construct a new Playlist object."""
|
||||
return MockPlaylist(**kwargs)
|
||||
|
||||
def do_sql_delete(self, playlist: MockPlaylist) -> sqlite3.Cursor:
|
||||
"""Extra work for deleting a Playlist."""
|
||||
return self.sql("DELETE FROM playlist_properties WHERE propertyid=?",
|
||||
playlist.propertyid)
|
||||
|
||||
def do_sql_insert(self, name: str) -> sqlite3.Cursor:
|
||||
"""Extra work for adding a new Playlist."""
|
||||
return self.sql("""INSERT INTO playlist_properties DEFAULT VALUES
|
||||
RETURNING ? as name, *""", name)
|
Loading…
Reference in New Issue