audio: Add Track support to the tagger

I extract the artist, length, mbid, mtime, tracknumber, and title from
the tags to use when constructing Tracks.

Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2023-04-02 18:55:45 -04:00
parent 2c629c887c
commit afb599dcf4
4 changed files with 80 additions and 14 deletions

View File

@ -39,10 +39,23 @@ class _Medium:
type: str type: str
@dataclasses.dataclass
class _Track:
"""Class for holding Track-related tags."""
artist: str
length: int
mbid: int
mtime: float
number: int
title: str
class _Tags: class _Tags:
"""Extract tags found in the Mutagen tag dictionary.""" """Extract tags found in the Mutagen tag dictionary."""
def __init__(self, file: pathlib.Path, tags: dict): def __init__(self, file: pathlib.Path, tags: dict,
length: int = 0, mtime: float = 0.0):
"""Initialize the Tagger.""" """Initialize the Tagger."""
self.file = file self.file = file
self.tags = tags self.tags = tags
@ -60,6 +73,13 @@ class _Tags:
tags.get("discsubtitle", [""])[0], tags.get("discsubtitle", [""])[0],
tags.get("media", [""])[0]) tags.get("media", [""])[0])
self.track = _Track(tags.get("artist", [""])[0],
length,
tags.get("musicbrainz_releasetrackid", [""])[0],
mtime,
int(tags.get("tracknumber", [0])[0]),
tags.get("title", [""])[0])
self.genres = sorted(self.list_genres()) self.genres = sorted(self.list_genres())
self.year = self.get_year() self.year = self.get_year()
@ -139,8 +159,10 @@ class _Tags:
return int(re.match(r"\d+", self.album.release).group(0)) return int(re.match(r"\d+", self.album.release).group(0))
def tag_file(file: pathlib.Path) -> _Tags | None: def tag_file(file: pathlib.Path, mtime: float | None) -> _Tags | None:
"""Tag the requested file.""" """Tag the requested file."""
if file.is_file(): if file.is_file():
if (tags := mutagen.File(file)) is not None: file_mtime = file.stat().st_mtime
return _Tags(file, tags) if mtime is None or file_mtime > mtime:
if (tags := mutagen.File(file)) is not None:
return _Tags(file, tags, tags.info.length, file_mtime)

View File

@ -160,7 +160,7 @@ class Thread(threading.Thread):
if self._file is None: if self._file is None:
break break
if tags := emmental.audio.tagger.tag_file(self._file): if tags := emmental.audio.tagger.tag_file(self._file, None):
for artist in tags.artists: for artist in tags.artists:
self.__check_artist(artist) self.__check_artist(artist)

View File

@ -157,6 +157,28 @@ class TestAudioTagger(unittest.TestCase):
self.assertListEqual(tagger.genres, ["EP", "Genre 1", "Genre 2", self.assertListEqual(tagger.genres, ["EP", "Genre 1", "Genre 2",
"Genre 3", "Genre 4", "Single"]) "Genre 3", "Genre 4", "Single"])
def test_track(self):
"""Test that tracks can be tagged corretly."""
tagger = _Tags(self.file, {"artist": ["Test Artist"],
"title": ["Test Title"],
"tracknumber": ["2"],
"musicbrainz_releasetrackid": ["ab-cd-ef"]},
12345, 678.90)
self.assertEqual(tagger.track.artist, "Test Artist")
self.assertEqual(tagger.track.length, 12345)
self.assertEqual(tagger.track.mbid, "ab-cd-ef")
self.assertEqual(tagger.track.mtime, 678.90)
self.assertEqual(tagger.track.number, 2)
self.assertEqual(tagger.track.title, "Test Title")
tagger = _Tags(self.file, {})
self.assertEqual(tagger.track.artist, "")
self.assertEqual(tagger.track.length, 0)
self.assertEqual(tagger.track.mbid, "")
self.assertEqual(tagger.track.mtime, 0.0)
self.assertEqual(tagger.track.number, 0)
self.assertEqual(tagger.track.title, "")
def test_year(self): def test_year(self):
"""Test the year property.""" """Test the year property."""
tagger = _Tags(self.file, {"date": ["1988-06-17"]}) tagger = _Tags(self.file, {"date": ["1988-06-17"]})
@ -164,38 +186,60 @@ class TestAudioTagger(unittest.TestCase):
@unittest.mock.patch("pathlib.Path.is_file") @unittest.mock.patch("pathlib.Path.is_file")
@unittest.mock.patch("pathlib.Path.stat")
class TestTagFile(unittest.TestCase): class TestTagFile(unittest.TestCase):
"""Test case for the tag_file() function.""" """Test case for the tag_file() function."""
def test_not_file(self, mock_is_file: unittest.mock.Mock): def test_not_file(self, mock_stat: unittest.mock.Mock(),
mock_is_file: unittest.mock.Mock):
"""Test calling tag_file() on something other than a file.""" """Test calling tag_file() on something other than a file."""
path = pathlib.Path("/a/b/c") path = pathlib.Path("/a/b/c")
mock_is_file.return_value = False mock_is_file.return_value = False
self.assertIsNone(emmental.audio.tagger.tag_file(path)) self.assertIsNone(emmental.audio.tagger.tag_file(path, None))
mock_is_file.assert_called() mock_is_file.assert_called()
@unittest.mock.patch("mutagen.File") @unittest.mock.patch("mutagen.File")
def test_no_tags(self, mock_mutagen_file: unittest.mock.Mock, def test_no_tags(self, mock_mutagen_file: unittest.mock.Mock,
mock_stat: unittest.mock.Mock(),
mock_is_file: unittest.mock.Mock): mock_is_file: unittest.mock.Mock):
"""Test calling tag_file() on a file that doesn't have tags.""" """Test calling tag_file() on a file that doesn't have tags."""
path = pathlib.Path("/a/b/c/notags.txt") path = pathlib.Path("/a/b/c/notags.txt")
mock_is_file.return_value = True mock_is_file.return_value = True
mock_mutagen_file.return_value = None mock_mutagen_file.return_value = None
self.assertIsNone(emmental.audio.tagger.tag_file(path)) self.assertIsNone(emmental.audio.tagger.tag_file(path, None))
mock_is_file.assert_called() mock_is_file.assert_called()
mock_mutagen_file.assert_called_with(path) mock_mutagen_file.assert_called_with(path)
def test_not_updated(self, mock_stat: unittest.mock.Mock,
mock_is_file: unittest.mock.Mock):
"""Test calling tag_file() with an mtime <= mtime reported by stat."""
path = pathlib.Path("/a/b/c/track.ogg")
mock_is_file.return_value = True
mock_stat.return_value.st_mtime = 123.45
self.assertIsNone(emmental.audio.tagger.tag_file(path, 123.45))
self.assertIsNone(emmental.audio.tagger.tag_file(path, 246.8))
@unittest.mock.patch("mutagen.File") @unittest.mock.patch("mutagen.File")
def test_have_tags(self, mock_mutagen_file: unittest.mock.Mock, def test_have_tags(self, mock_mutagen_file: unittest.mock.Mock,
mock_stat: unittest.mock.Mock,
mock_is_file: unittest.mock.Mock): mock_is_file: unittest.mock.Mock):
"""Test calling tag_file() successfully.""" """Test calling tag_file() successfully."""
path = pathlib.Path("/a/b/c/track.ogg") path = pathlib.Path("/a/b/c/track.ogg")
mock_is_file.return_value = True mock_is_file.return_value = True
mock_mutagen_file.return_Value = dict() mock_stat.return_value.st_mtime = 67.890
mock_mutagen_file.return_value = unittest.mock.MagicMock()
mock_mutagen_file.return_value.info.length = 12345
self.assertIsInstance(emmental.audio.tagger.tag_file(path), for mtime in [None, 70.123]:
emmental.audio.tagger._Tags) with self.subTest(mtime=mtime):
mock_is_file.assert_called() tags = emmental.audio.tagger.tag_file(path, None)
mock_mutagen_file.assert_called_with(path) self.assertIsInstance(tags, emmental.audio.tagger._Tags)
self.assertEqual(tags.track.length, 12345)
self.assertEqual(tags.track.mtime, 67.890)
mock_is_file.assert_called()
mock_stat.assert_called()
mock_mutagen_file.assert_called_with(path)

View File

@ -208,7 +208,7 @@ class TestTaggerThread(tests.util.TestCase):
self.tagger.ready.wait() self.tagger.ready.wait()
self.assertIsNone(self.tagger._tags) self.assertIsNone(self.tagger._tags)
mock_file.assert_called_with(pathlib.Path("/a/b/c.ogg")) mock_file.assert_called_with(pathlib.Path("/a/b/c.ogg"), None)
mock_file.return_value = self.tags mock_file.return_value = self.tags
self.tagger.tag_file(pathlib.Path("/a/b/c.ogg")) self.tagger.tag_file(pathlib.Path("/a/b/c.ogg"))