db: Create a tagger Thread

This Thread uses the audio.tagger.tag_file() function to find the tags
for a specific file without hanging the UI. There may be cases where we
have an Artist MBID but not the matching Artist name. When this happens,
I do my best to first check the database and then query the musicbrainz
server.

I take some care to only connect to the database once, and to close the
connection when the thread exits.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2022-09-15 12:49:06 -04:00
parent 300ee18569
commit 6131640e25
4 changed files with 230 additions and 1 deletions

View File

@ -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()

View File

@ -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()

View File

@ -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")

View File

@ -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")