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:
parent
300ee18569
commit
6131640e25
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue