diff --git a/emmental/__init__.py b/emmental/__init__.py index ca1e73c..ae87671 100644 --- a/emmental/__init__.py +++ b/emmental/__init__.py @@ -1,5 +1,6 @@ # Copyright 2022 (c) Anna Schumaker. """Set up our Application.""" +import musicbrainzngs import pathlib from . import gsetup from . import audio @@ -147,6 +148,8 @@ class Application(Adw.Application): self.player = audio.Player() gsetup.add_style() + musicbrainzngs.set_useragent(f"emmental{gsetup.DEBUG_STR}", + f"{MAJOR_VERSION}.{MINOR_VERSION}") self.db.load() self.win = self.build_window() diff --git a/emmental/db/tagger.py b/emmental/db/tagger.py index 5a3e912..81212f5 100644 --- a/emmental/db/tagger.py +++ b/emmental/db/tagger.py @@ -1,9 +1,14 @@ # Copyright 2022 (c) Anna Schumaker """A wrapper around Mutagen to help us read tags.""" +import emmental.audio.tagger +import musicbrainzngs +import pathlib +import threading from gi.repository import GObject from .. import audio from . import albums from . import artists +from . import connection from . import decades from . import media from . import genres @@ -97,3 +102,84 @@ class Tags: if raw_year: year = self.db.years.lookup(raw_year) return year if year else self.db.years.create(raw_year) + + +class Thread(threading.Thread): + """A thread for tagging files without blocking the UI.""" + + def __init__(self): + """Initialize the Tagger Thread.""" + super().__init__() + self.ready = threading.Event() + + self._connection = None + self._condition = threading.Condition() + self._file = None + self._tags = None + self.start() + + def __close_connection(self) -> None: + if self._connection: + self._connection.close() + self._connection = None + + def __get_connection(self) -> connection.Connection: + if not self._connection: + self._connection = connection.Connection() + return self._connection + + def __check_artist(self, artist: audio.tagger._Artist) -> None: + if artist.name is None and len(artist.mbid) > 0: + sql = self.__get_connection() + cur = sql("SELECT name FROM artists WHERE mbid=?", artist.mbid) + if row := cur.fetchone(): + artist.name = row["name"] + else: + mb_res = musicbrainzngs.get_artist_by_id(artist.mbid) + artist.name = mb_res["artist"]["name"] + + def get_result(self, db: GObject.TYPE_PYOBJECT) \ + -> tuple[pathlib.Path | None, Tags | None]: + """Return the resulting Tags structure.""" + with self._condition: + if not self.ready.is_set(): + return (None, None) + + tags = Tags(db, self._tags) if self._tags else None + res = (self._file, tags) + self._file = None + self._tags = None + return res + + def run(self) -> None: + """Sleep until we have work to do.""" + with self._condition: + self.ready.set() + + while self._condition.wait(): + if self._file is None: + break + + if tags := emmental.audio.tagger.tag_file(self._file): + for artist in tags.artists: + self.__check_artist(artist) + + self._tags = tags + self.ready.set() + + self.__close_connection() + + def stop(self) -> None: + """Stop the thread.""" + with self._condition: + self._file = None + self._condition.notify() + self.join() + + def tag_file(self, file: pathlib.Path) -> None: + """Tag a file.""" + with self._condition: + self.ready.clear() + self._file = file + self._tags = None + self._condition.notify() diff --git a/tests/db/test_tagger.py b/tests/db/test_tagger.py index 034a1e6..dd5e68d 100644 --- a/tests/db/test_tagger.py +++ b/tests/db/test_tagger.py @@ -1,6 +1,7 @@ # Copyright 2022 (c) Anna Schumaker """Tests our Mutagen wrapper.""" import pathlib +import threading import unittest.mock import emmental.db.tagger import tests.util @@ -144,3 +145,139 @@ class TestTags(tests.util.TestCase): self.assertEqual(tags.year.year, 1988) self.assertEqual(self.make_tags(raw_tags).year, tags.year) + + +@unittest.mock.patch("emmental.audio.tagger.tag_file") +class TestTaggerThread(tests.util.TestCase): + """Test the tagger thread behavior.""" + + def setUp(self): + """Set up common variables.""" + super().setUp() + self.tagger = emmental.db.tagger.Thread() + self.tags = dict() + + def tearDown(self): + """Clean up.""" + super().tearDown() + self.tagger.stop() + + def make_tags(self, tags: dict) -> emmental.db.tagger.Tags: + """Set up and return our Tags object.""" + return emmental.audio.tagger._Tags(pathlib.Path("/a/b/c.ogg"), tags) + + def test_init(self, mock_file: unittest.mock.Mock): + """Test that the tagger thread was initialized properly.""" + self.assertIsInstance(self.tagger, threading.Thread) + self.assertIsInstance(self.tagger._condition, threading.Condition) + self.assertTrue(self.tagger.is_alive()) + + def test_stop(self, mock_file: unittest.mock.Mock): + """Test the stop function.""" + mock_connection = unittest.mock.Mock() + mock_connection.close = unittest.mock.Mock() + + self.tagger._file = "abcde" + self.tagger._connection = mock_connection + + with unittest.mock.patch.object(self.tagger._condition, "notify", + wraps=self.tagger._condition.notify) \ + as mock_notify: + self.tagger.stop() + self.assertIsNone(self.tagger._file) + mock_notify.assert_called() + + self.assertFalse(self.tagger.is_alive()) + self.assertIsNone(self.tagger._connection) + mock_connection.close.assert_called() + + def test_tag_file(self, mock_file: unittest.mock.Mock): + """Test asking the thread to tag a file.""" + self.assertIsInstance(self.tagger.ready, threading.Event) + self.assertIsNone(self.tagger._file) + self.assertIsNone(self.tagger._tags) + self.assertTrue(self.tagger.ready.is_set()) + + mock_file.return_value = None + self.tagger.ready.set() + self.tagger._tags = 12345 + self.tagger.tag_file(pathlib.Path("/a/b/c.ogg")) + self.assertFalse(self.tagger.ready.is_set()) + self.assertEqual(self.tagger._file, pathlib.Path("/a/b/c.ogg")) + self.assertIsNone(self.tagger._tags) + + self.tagger.ready.wait() + self.assertIsNone(self.tagger._tags) + mock_file.assert_called_with(pathlib.Path("/a/b/c.ogg")) + + mock_file.return_value = self.tags + self.tagger.tag_file(pathlib.Path("/a/b/c.ogg")) + self.tagger.ready.wait() + self.assertIsNotNone(self.tagger._tags) + + def test_get_result(self, mock_file: unittest.mock.Mock): + """Test creating a Tags structure after tagging.""" + mock_file.return_value = None + self.tagger.tag_file(pathlib.Path("/a/b/c.ogg")) + self.assertTupleEqual(self.tagger.get_result(self.sql), (None, None)) + + self.tagger.ready.wait() + self.assertTupleEqual(self.tagger.get_result(self.sql), + (pathlib.Path("/a/b/c.ogg"), None)) + self.assertIsNone(self.tagger._file) + + mock_file.return_value = self.make_tags(dict()) + self.tagger.tag_file(pathlib.Path("/a/b/c.ogg")) + self.tagger.ready.wait() + (file, tags) = self.tagger.get_result(self.sql) + self.assertEqual(file, pathlib.Path("/a/b/c.ogg")) + self.assertIsInstance(tags, emmental.db.tagger.Tags) + self.assertIsNone(self.tagger._file) + self.assertIsNone(self.tagger._tags) + + @unittest.mock.patch("emmental.db.connection.Connection.__call__") + @unittest.mock.patch("musicbrainzngs.get_artist_by_id") + def test_tag_file_lookup_mbid(self, mock_get_artist: unittest.mock.Mock, + mock_connection: unittest.mock.Mock, + mock_file: unittest.mock.Mock): + """Test looking up artists with an MBID but no name after tagging.""" + audio_tags = self.make_tags({"albumartist": ["No Artist"], + "musicbrainz_albumartistid": + ["ab-cd-ef", "gh-ij-kl"]}) + mock_file.return_value = audio_tags + mock_get_artist.return_value = {"artist": {"name": "Some Artist"}} + + mock_cursor = unittest.mock.Mock() + mock_cursor.fetchone = unittest.mock.Mock(return_value=None) + mock_connection.return_value = mock_cursor + + self.tagger.tag_file(pathlib.Path("/a/b/c.ogg")) + self.tagger.ready.wait() + self.assertEqual(audio_tags.artists[0].name, "Some Artist") + self.assertEqual(audio_tags.artists[1].name, "Some Artist") + + @unittest.mock.patch("emmental.db.connection.Connection.__call__") + @unittest.mock.patch("musicbrainzngs.get_artist_by_id") + def test_tag_file_lookup_sql(self, mock_get_artist: unittest.mock.Mock, + mock_connection: unittest.mock.Mock, + mock_file: unittest.mock.Mock): + """Test looking up unnamed artists in the database.""" + audio_tags = self.make_tags({"albumartist": ["No Artist"], + "musicbrainz_albumartistid": + ["ab-cd-ef", "gh-ij-kl"]}) + mock_file.return_value = audio_tags + mock_get_artist.return_value = {"artist": {"name": None}} + + mock_row = {"name": "Some Artist"} + mock_cursor = unittest.mock.Mock() + mock_cursor.fetchone = unittest.mock.Mock(return_value=mock_row) + mock_connection.return_value = mock_cursor + + self.assertIsNone(self.tagger._connection) + + self.tagger.tag_file(pathlib.Path("/a/b/c.ogg")) + self.tagger.ready.wait() + self.assertIsInstance(self.tagger._connection, + emmental.db.connection.Connection) + self.assertEqual(audio_tags.artists[0].name, "Some Artist") + self.assertEqual(audio_tags.artists[1].name, "Some Artist") diff --git a/tests/test_emmental.py b/tests/test_emmental.py index 4fd0c28..4291f3f 100644 --- a/tests/test_emmental.py +++ b/tests/test_emmental.py @@ -32,6 +32,7 @@ class TestEmmental(unittest.TestCase): self.assertEqual(self.application.get_property("resource-base-path"), "/com/nowheycreamery/emmental") + @unittest.mock.patch("musicbrainzngs.set_useragent") @unittest.mock.patch("sys.stdout") @unittest.mock.patch("gi.repository.Adw.Application.add_window") @unittest.mock.patch("emmental.db.Connection.load") @@ -39,7 +40,8 @@ class TestEmmental(unittest.TestCase): def test_startup(self, mock_startup: unittest.mock.Mock, mock_load: unittest.mock.Mock, mock_add_window: unittest.mock.Mock, - mock_stdout: unittest.mock.Mock): + mock_stdout: unittest.mock.Mock, + mock_set_useragent: unittest.mock.Mock): """Test that the startup signal works as expected.""" self.assertIsNone(self.application.db) self.assertIsNone(self.application.mpris) @@ -56,6 +58,7 @@ class TestEmmental(unittest.TestCase): mock_startup.assert_called() mock_load.assert_called() mock_add_window.assert_called_with(self.application.win) + mock_set_useragent.assert_called_with("emmental-debug", "3.0") @unittest.mock.patch("sys.stdout") @unittest.mock.patch("gi.repository.Adw.Application.add_window")