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:
parent
6131640e25
commit
5545cb106d
|
@ -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."""
|
||||||
|
|
|
@ -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")
|
||||||
|
|
Loading…
Reference in New Issue