db: Give Libraries an idle Tagger

This uses a combination of an Idle Queue, ReaddirThread, and tagger
Thread to scan a directory path and tag the audio files found within. I
do this by adding a scan() function to each Library object to begin
scanning (if not already running).

Libraries also have a stop() function to cancel any pending idle tasks
and stop any running threads. The Library table makes sure to stop each
Library object during shutdown so we don't leave any hanging threads.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2023-03-10 17:03:19 -05:00
parent 6131640e25
commit 5545cb106d
2 changed files with 184 additions and 1 deletions

View File

@ -3,8 +3,10 @@
import pathlib import pathlib
import sqlite3 import sqlite3
from gi.repository import GObject from gi.repository import GObject
from .. import path
from . import idle from . import idle
from . import playlist from . import playlist
from . import tagger
class Library(playlist.Playlist): class Library(playlist.Playlist):
@ -16,19 +18,55 @@ class Library(playlist.Playlist):
deleting = GObject.Property(type=bool, default=False) deleting = GObject.Property(type=bool, default=False)
queue = GObject.Property(type=idle.Queue) queue = GObject.Property(type=idle.Queue)
readdir = GObject.Property(type=GObject.TYPE_PYOBJECT)
tagger = GObject.Property(type=GObject.TYPE_PYOBJECT)
online = GObject.Property(type=bool, default=False) online = GObject.Property(type=bool, default=False)
def __init__(self, **kwargs): def __init__(self, **kwargs):
"""Initialize our Library object.""" """Initialize our Library object."""
super().__init__(queue=idle.Queue(), **kwargs) super().__init__(queue=idle.Queue(), **kwargs)
self.scan()
def __queue_delete(self) -> bool: def __queue_delete(self) -> bool:
self.table.delete(self) self.table.delete(self)
return True return True
def __queue_tracks(self) -> bool:
if (files := self.readdir.poll_result()) is None:
self.__stop_thread("readdir")
self.queue.push(self.__stop_thread, "tagger")
return True
self.queue.push_many(self.__tag_track, [(f,) for f in files])
return False
def __tag_track(self, path: pathlib.Path) -> bool:
if self.tagger.ready.is_set():
(file, tags) = self.tagger.get_result(self.table.sql)
if file is None:
self.tagger.tag_file(path)
else:
return True
return False
def __scan_library(self) -> bool:
self.readdir = path.readdir_async(self.path)
if self.readdir is not None:
self.online = True
self.queue.push(self.__queue_tracks)
self.tagger = tagger.Thread()
return True
def __stop_thread(self, thread_name: str) -> bool:
if (thread := self.get_property(thread_name)) is not None:
thread.stop()
self.set_property(thread_name, None)
return True
def do_update(self, column: str) -> bool: def do_update(self, column: str) -> bool:
"""Update a Library playlist.""" """Update a Library playlist."""
match column: match column:
case "readdir" | "tagger": pass
case "online": self.table.notify_online(self) case "online": self.table.notify_online(self)
case _: return super().do_update(column) case _: return super().do_update(column)
return True return True
@ -36,11 +74,23 @@ class Library(playlist.Playlist):
def delete(self) -> bool: def delete(self) -> bool:
"""Delete this Library.""" """Delete this Library."""
if self.deleting is False: if self.deleting is False:
self.stop()
self.deleting = True self.deleting = True
self.queue.push(self.__queue_delete) self.queue.push(self.__queue_delete)
return True return True
return False return False
def scan(self) -> None:
"""Scan the Library."""
if not self.queue.running:
self.queue.push(self.__scan_library)
def stop(self) -> None:
"""Stop this Library's background work."""
self.__stop_thread("readdir")
self.__stop_thread("tagger")
self.queue.cancel()
@property @property
def primary_key(self) -> int: def primary_key(self) -> int:
"""Get this library's primary key.""" """Get this library's primary key."""
@ -88,6 +138,12 @@ class Table(playlist.Table):
if not library.online or self.loaded: if not library.online or self.loaded:
self.emit("library-online", library) self.emit("library-online", library)
def stop(self) -> None:
"""Stop any background work."""
for library in self.store:
library.stop()
super().stop()
@GObject.Signal(arg_types=(Library,)) @GObject.Signal(arg_types=(Library,))
def library_online(self, library: Library) -> None: def library_online(self, library: Library) -> None:
"""Signal that a library online status has changed.""" """Signal that a library online status has changed."""

View File

@ -20,10 +20,17 @@ class TestLibraryObject(tests.util.TestCase):
path=self.path, path=self.path,
name=str(self.path)) name=str(self.path))
def tearDown(self):
"""Clean up."""
super().tearDown()
self.library.stop()
def test_init(self): def test_init(self):
"""Test that the Library is set up properly.""" """Test that the Library is set up properly."""
self.assertIsInstance(self.library, emmental.db.playlist.Playlist) self.assertIsInstance(self.library, emmental.db.playlist.Playlist)
self.assertIsInstance(self.library.queue, emmental.db.idle.Queue) self.assertIsInstance(self.library.queue, emmental.db.idle.Queue)
self.assertIsNone(self.library.readdir)
self.assertIsNone(self.library.tagger)
self.assertEqual(self.library.table, self.table) self.assertEqual(self.library.table, self.table)
self.assertEqual(self.library.propertyid, 456) self.assertEqual(self.library.propertyid, 456)
@ -32,9 +39,12 @@ class TestLibraryObject(tests.util.TestCase):
self.assertEqual(self.library.path, self.path) self.assertEqual(self.library.path, self.path)
self.assertTrue(self.library.enabled) self.assertTrue(self.library.enabled)
self.assertFalse(self.library.deleting) self.assertFalse(self.library.deleting)
self.assertFalse(self.library.online)
self.assertIsNone(self.library.parent) self.assertIsNone(self.library.parent)
self.assertFalse(self.library.online)
self.assertEqual(self.library.queue[0],
(self.library._Library__scan_library,))
def test_delete(self): def test_delete(self):
"""Test deleting a Library path.""" """Test deleting a Library path."""
with unittest.mock.patch.object(self.table, "delete") as mock_delete: with unittest.mock.patch.object(self.table, "delete") as mock_delete:
@ -58,6 +68,114 @@ class TestLibraryObject(tests.util.TestCase):
self.library.online = True self.library.online = True
mock_notify.assert_called_with(self.library) mock_notify.assert_called_with(self.library)
@unittest.mock.patch.object(pathlib.Path, "iterdir", return_value=[])
@unittest.mock.patch.object(pathlib.Path, "is_dir")
def test_scan_online(self, mock_is_dir: unittest.mock.Mock,
mock_iterdir: unittest.mock.Mock):
"""Test scanning an online Library path."""
self.assertEqual(self.library.queue[0],
(self.library._Library__scan_library,))
with unittest.mock.patch.object(self.library.queue,
"push") as mock_push:
self.library.scan()
mock_push.assert_not_called()
mock_is_dir.return_value = True
self.assertTrue(self.library.queue.run_task())
mock_is_dir.assert_called()
self.assertTrue(self.library.online)
self.assertTrue(self.library.queue.running)
self.assertIsInstance(self.library.readdir,
emmental.path.ReaddirThread)
self.assertIsInstance(self.library.tagger,
emmental.db.tagger.Thread)
self.assertEqual(self.library.queue[0],
(self.library._Library__queue_tracks,))
def test_scan_offline(self):
"""Test scanning an offline Library path."""
self.assertEqual(self.library.queue[0],
(self.library._Library__scan_library,))
self.assertFalse(self.library.queue.run_task())
self.assertFalse(self.library.queue.running)
self.assertFalse(self.library.online)
self.assertIsNone(self.library.readdir)
@unittest.mock.patch.object(pathlib.Path, "iterdir", return_value=[])
@unittest.mock.patch.object(pathlib.Path, "is_file", return_value=True)
def test_scan_queue_tracks(self, mock_is_file: unittest.mock.Mock,
mock_iterdir: unittest.mock.Mock):
"""Test that tracks are queued for scanning."""
self.library.stop()
self.library.readdir = unittest.mock.Mock()
readdir = self.library.readdir
readdir.poll_result.return_value = []
self.assertFalse(self.library._Library__queue_tracks())
readdir.poll_result.assert_called()
readdir.stop.assert_not_called()
tag_func = self.library._Library__tag_track
readdir.poll_result.return_value = [pathlib.Path("/a/b/c/1.ogg"),
pathlib.Path("/a/b/c/2.ogg")]
self.assertFalse(self.library._Library__queue_tracks())
self.assertListEqual(self.library.queue._tasks,
[(tag_func, pathlib.Path("/a/b/c/1.ogg")),
(tag_func, pathlib.Path("/a/b/c/2.ogg"))])
readdir.poll_result.return_value = None
self.assertTrue(self.library._Library__queue_tracks())
self.assertListEqual(self.library.queue._tasks,
[(tag_func, pathlib.Path("/a/b/c/1.ogg")),
(tag_func, pathlib.Path("/a/b/c/2.ogg")),
(self.library._Library__stop_thread, "tagger")])
readdir.stop.assert_called()
def test_scan_tag_track(self):
"""Test that tracks are tagged during scanning."""
track = pathlib.Path("/a/b/c/1.ogg")
raw_tags = emmental.audio.tagger._Tags(track, {})
tags = emmental.db.tagger.Tags(self.sql, raw_tags)
tagger = unittest.mock.Mock()
self.library.tagger = tagger
tagger.ready.is_set.return_value = False
self.assertFalse(self.library._Library__tag_track(track))
tagger.get_result.assert_not_called()
tagger.tag_file.assert_not_called()
tagger.ready.is_set.return_value = True
tagger.get_result.return_value = (None, None)
self.assertFalse(self.library._Library__tag_track(track))
tagger.get_result.assert_called_with(self.sql)
tagger.tag_file.assert_called_with(track)
tagger.reset_mock()
tagger.ready.is_set.return_value = True
tagger.get_result.return_value = (track, tags)
self.assertTrue(self.library._Library__tag_track(track))
tagger.tag_file.assert_not_called()
tagger.get_result.assert_called_with(self.sql)
def test_stop(self):
"""Test stopping a Library's background work."""
readdir = unittest.mock.Mock()
tagger = unittest.mock.Mock()
self.library.readdir = readdir
self.library.tagger = tagger
self.library.queue.running = True
self.library.stop()
self.assertIsNone(self.library.readdir)
self.assertIsNone(self.library.tagger)
self.assertFalse(self.library.queue.running)
readdir.stop.assert_called()
tagger.stop.assert_called()
class TestLibraryTable(tests.util.TestCase): class TestLibraryTable(tests.util.TestCase):
"""Tests our library table.""" """Tests our library table."""
@ -154,6 +272,15 @@ class TestLibraryTable(tests.util.TestCase):
self.assertEqual(self.table.lookup(pathlib.Path("/a/b/c/")), library) self.assertEqual(self.table.lookup(pathlib.Path("/a/b/c/")), library)
self.assertIsNone(self.table.lookup(pathlib.Path("/no/library/path"))) self.assertIsNone(self.table.lookup(pathlib.Path("/no/library/path")))
def test_stop(self):
"""Test stopping the library table."""
library = self.table.create(pathlib.Path("/a/b/c"))
library.queue.running = True
self.table.stop()
self.assertFalse(self.table.queue.running)
self.assertFalse(library.queue.running)
def test_update(self): def test_update(self):
"""Test updating genre attributes.""" """Test updating genre attributes."""
library = self.table.create("/a/b/c") library = self.table.create("/a/b/c")