From 4c5d3c78c00834e5a48f8afdadf72798e1b42d79 Mon Sep 17 00:00:00 2001 From: Anna Schumaker Date: Tue, 13 Feb 2024 22:39:46 -0500 Subject: [PATCH] listenbrainz: Submit the currently playing track to ListenBrainz I do this by creating a new Listen class that is constructed from one of our db.track.Tracks to convert to something liblistenbrainz understands. From there, I watch for changes to the "now-playing" property and call out to the Thread to submit the track to ListenBrainz. Implements: #69 ("Add ListenBrainz support") Signed-off-by: Anna Schumaker --- emmental/listenbrainz/__init__.py | 18 ++++++ emmental/listenbrainz/listen.py | 20 +++++++ emmental/listenbrainz/task.py | 15 +++-- emmental/listenbrainz/thread.py | 15 +++++ tests/listenbrainz/test_listen.py | 46 ++++++++++++++++ tests/listenbrainz/test_listenbrainz.py | 73 ++++++++++++++++++++++++- tests/listenbrainz/test_task.py | 18 ++++++ tests/listenbrainz/test_thread.py | 34 ++++++++++++ 8 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 emmental/listenbrainz/listen.py create mode 100644 tests/listenbrainz/test_listen.py diff --git a/emmental/listenbrainz/__init__.py b/emmental/listenbrainz/__init__.py index bc137ab..7458a86 100644 --- a/emmental/listenbrainz/__init__.py +++ b/emmental/listenbrainz/__init__.py @@ -2,6 +2,8 @@ """Our ListenBrainz custom GObject.""" from gi.repository import GObject from gi.repository import GLib +from .. import db +from . import listen from . import thread from . import task @@ -11,6 +13,7 @@ class ListenBrainz(GObject.GObject): user_token = GObject.Property(type=str) valid_token = GObject.Property(type=bool, default=True) + now_playing = GObject.Property(type=db.tracks.Track) def __init__(self): """Initialize the ListenBrainz GObject.""" @@ -21,6 +24,10 @@ class ListenBrainz(GObject.GObject): self._idle_id = None self.connect("notify::user-token", self.__notify_user_token) + self.connect("notify::now-playing", self.__notify_now_playing) + + def __check_connected(self) -> bool: + return len(self.user_token) and self.valid_token def __check_result(self) -> None: if (res := self._thread.get_result()) is not None: @@ -30,6 +37,8 @@ class ListenBrainz(GObject.GObject): match op: case "clear-token": self._thread.clear_user_token() + case "now-playing": + self._thread.submit_now_playing(listen.Listen(*args)) case "set-token": self._thread.set_user_token(*args) @@ -57,6 +66,15 @@ class ListenBrainz(GObject.GObject): case _: self._queue.push("set-token", self.user_token) self.__idle_start() + def __notify_now_playing(self, listenbrainz: GObject.GObject, + param: GObject.ParamSpec) -> None: + if self.now_playing is not None: + self._queue.push("now-playing", self.now_playing) + if self.__check_connected(): + self.__idle_start() + else: + self._queue.clear("now-playing") + def __source_stop(self, srcid: str) -> None: if (id := getattr(self, srcid)) is not None: GLib.source_remove(id) diff --git a/emmental/listenbrainz/listen.py b/emmental/listenbrainz/listen.py new file mode 100644 index 0000000..cc50d40 --- /dev/null +++ b/emmental/listenbrainz/listen.py @@ -0,0 +1,20 @@ +# Copyright 2024 (c) Anna Schumaker. +"""Convert a db.track.Track to a liblistenbrainz.Listen.""" +import liblistenbrainz +from .. import db +from .. import gsetup + + +class Listen(liblistenbrainz.Listen): + """A single ListenBrainz Listen.""" + + def __init__(self, track: db.tracks.Track): + """Initialize our Listen class.""" + album = track.get_medium().get_album() + artists = [a.mbid for a in track.get_artists() if len(a.mbid) > 0] + album_mbid = album.mbid if len(album.mbid) > 0 else None + super().__init__(track.title, track.artist, release_name=album.name, + artist_mbids=artists, release_group_mbid=album_mbid, + tracknumber=track.number, + additional_info={"media_player": + f"emmental{gsetup.DEBUG_STR}"}) diff --git a/emmental/listenbrainz/task.py b/emmental/listenbrainz/task.py index 29f5c40..33469a4 100644 --- a/emmental/listenbrainz/task.py +++ b/emmental/listenbrainz/task.py @@ -8,17 +8,24 @@ class Queue: def __init__(self): """Initialize the task Queue.""" self._set_token = None + self._now_playing = None def clear(self, op: str) -> None: """Clear a pending operation.""" - self._set_token = None + match op: + case "clear-token" | "set-token": self._set_token = None + case "now-playing": self._now_playing = None def push(self, op: str, *args) -> None: """Push an operation onto the queue.""" - self._set_token = (op, *args) + match op: + case "clear-token" | "set-token": self._set_token = (op, *args) + case "now-playing": self._now_playing = (op, *args) def pop(self) -> tuple: """Pop an operation off the queue.""" - res = self._set_token - self._set_token = None + if (res := self._set_token) is not None: + self._set_token = None + elif (res := self._now_playing) is not None: + self._now_playing = None return res diff --git a/emmental/listenbrainz/thread.py b/emmental/listenbrainz/thread.py index ee44bf7..5e4bad7 100644 --- a/emmental/listenbrainz/thread.py +++ b/emmental/listenbrainz/thread.py @@ -22,12 +22,21 @@ class Thread(thread.Thread): except liblistenbrainz.errors.InvalidAuthTokenException: self.set_result("set-token", token=token, valid=False) + def __submit_now_playing(self, listen: liblistenbrainz.Listen) -> None: + try: + self._client.submit_playing_now(listen) + self.set_result("now-playing") + except liblistenbrainz.errors.ListenBrainzAPIException: + self.set_result("now-playing", valid=False) + def do_run_task(self, task: thread.Data) -> None: """Call a specific listenbrainz operation.""" match task.op: case "clear-token": self._client.set_auth_token(None, check_validity=False) self.set_result("clear-token") + case "now-playing": + self.__submit_now_playing(task.listen) case "set-token": self.__set_user_token(task.token) @@ -51,3 +60,9 @@ class Thread(thread.Thread): """Schedule setting the user token.""" self.__print("setting user token") self.set_task(op="set-token", token=token) + + def submit_now_playing(self, listen: liblistenbrainz.Listen) -> None: + """Schedule setting the now-playing track.""" + self.__print(f"now playing '{listen.track_name}' " + + f"by '{listen.artist_name}'") + self.set_task(op="now-playing", listen=listen) diff --git a/tests/listenbrainz/test_listen.py b/tests/listenbrainz/test_listen.py new file mode 100644 index 0000000..a24fe3d --- /dev/null +++ b/tests/listenbrainz/test_listen.py @@ -0,0 +1,46 @@ +# Copyright 2024 (c) Anna Schumaker. +"""Test creating a liblistenbrainz.Listen from a Track.""" +import emmental.listenbrainz.listen +import liblistenbrainz +import pathlib +import tests.util + + +class TestListen(tests.util.TestCase): + """ListenBrainz Listen test case.""" + + def setUp(self): + """Set up common variables.""" + super().setUp() + self.library = self.sql.libraries.create(pathlib.Path("/a/b")) + self.artists = [self.sql.artists.create("Artist 1", mbid="mbid-ar1"), + self.sql.artists.create("Artist 2"), + self.sql.artists.create("Artist 3", mbid="mbid-ar3")] + self.album = self.sql.albums.create("Test Album", "Test Artist", + release="1988-06", + mbid="mbid-release") + self.medium = self.sql.media.create(self.album, "", number=1) + self.year = self.sql.years.create(1988) + self.track = self.sql.tracks.create(self.library, + pathlib.Path("/a/b/c.ogg"), + self.medium, self.year, + title="Track 1", number=1, + artist="Track Artist") + + for artist in self.artists: + artist.add_track(self.track) + + self.listen = emmental.listenbrainz.listen.Listen(self.track) + + def test_init(self): + """Test initializing our Listen instance.""" + self.assertIsInstance(self.listen, liblistenbrainz.Listen) + self.assertEqual(self.listen.track_name, "Track 1") + self.assertEqual(self.listen.artist_name, "Track Artist") + self.assertEqual(self.listen.release_name, "Test Album") + self.assertEqual(self.listen.release_group_mbid, "mbid-release") + self.assertEqual(self.listen.tracknumber, 1) + self.assertDictEqual(self.listen.additional_info, + {"media_player": "emmental-debug"}) + self.assertListEqual(self.listen.artist_mbids, + ["mbid-ar1", "mbid-ar3"]) diff --git a/tests/listenbrainz/test_listenbrainz.py b/tests/listenbrainz/test_listenbrainz.py index 5d8367f..f5c97e6 100644 --- a/tests/listenbrainz/test_listenbrainz.py +++ b/tests/listenbrainz/test_listenbrainz.py @@ -2,6 +2,8 @@ """Tests our custom ListenBrainz GObject.""" import emmental.listenbrainz import io +import pathlib +import tests.util import unittest from gi.repository import GObject from gi.repository import GLib @@ -10,12 +12,24 @@ from gi.repository import GLib @unittest.mock.patch("sys.stdout", new_callable=io.StringIO) @unittest.mock.patch("gi.repository.GLib.source_remove") @unittest.mock.patch("gi.repository.GLib.idle_add", return_value=42) -class TestListenBrainz(unittest.TestCase): +class TestListenBrainz(tests.util.TestCase): """ListenBrainz GObject test case.""" def setUp(self): """Set up common variables.""" + super().setUp() self.listenbrainz = emmental.listenbrainz.ListenBrainz() + self.library = self.sql.libraries.create(pathlib.Path("/a/b")) + self.album = self.sql.albums.create("Test Album", "Test Artist", + release="1988-06", + mbid="mbid-release") + self.medium = self.sql.media.create(self.album, "", number=1) + self.year = self.sql.years.create(1988) + self.track = self.sql.tracks.create(self.library, + pathlib.Path("/a/b/c.ogg"), + self.medium, self.year, + title="Track 1", number=1, + artist="Track Artist") @unittest.mock.patch("gi.repository.GLib.source_remove") def tearDown(self, mock_source_remove: unittest.mock.Mock): @@ -108,3 +122,60 @@ class TestListenBrainz(unittest.TestCase): self.assertEqual(idle_work(), GLib.SOURCE_REMOVE) self.assertTrue(self.listenbrainz.valid_token) self.assertIsNone(self.listenbrainz._idle_id) + + def test_submit_now_playing(self, mock_idle_add: unittest.mock.Mock, + mock_source_remove: unittest.mock.Mock, + mock_stdout: io.StringIO): + """Test setting the now-playing property.""" + self.assertIsNone(self.listenbrainz.now_playing) + + self.listenbrainz.user_token = "abcde" + self.listenbrainz.valid_token = True + self.listenbrainz._queue.pop() + self.listenbrainz._ListenBrainz__source_stop("_idle_id") + + self.listenbrainz.now_playing = self.track + self.assertTupleEqual(self.listenbrainz._queue._now_playing, + ("now-playing", self.track)) + self.assertEqual(self.listenbrainz._idle_id, 42) + + idle_work = self.listenbrainz._ListenBrainz__idle_work + with unittest.mock.patch.object(self.listenbrainz._thread, + "submit_now_playing") as mock_playing: + self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE) + mock_playing.assert_called() + self.assertIsInstance(mock_playing.call_args.args[0], + emmental.listenbrainz.listen.Listen) + + for valid in [True, False]: + with self.subTest(valid=valid): + self.listenbrainz._thread.set_result(op="now-playing", + valid=valid) + self.assertEqual(idle_work(), GLib.SOURCE_REMOVE) + self.assertEqual(self.listenbrainz.valid_token, valid) + + def test_submit_now_playing_later(self, mock_idle_add: unittest.mock.Mock, + mock_source_remove: unittest.mock.Mock, + mock_stdout: io.StringIO): + """Test the now-playing property when ListenBrainz is disconnected.""" + self.assertIsNone(self.listenbrainz.now_playing) + + self.listenbrainz.now_playing = self.track + self.assertTupleEqual(self.listenbrainz._queue._now_playing, + ("now-playing", self.track)) + self.assertIsNone(self.listenbrainz._idle_id) + + self.listenbrainz.user_token = "abcde" + self.listenbrainz.valid_token = False + self.listenbrainz._queue.pop() + self.listenbrainz._ListenBrainz__source_stop("_idle_id") + self.listenbrainz.now_playing = self.track + self.assertTupleEqual(self.listenbrainz._queue._now_playing, + ("now-playing", self.track)) + self.assertIsNone(self.listenbrainz._idle_id) + + self.listenbrainz.valid_token = True + self.listenbrainz._queue._now_playing = "abcde" + self.listenbrainz.now_playing = None + self.assertIsNone(self.listenbrainz._queue._now_playing) + self.assertIsNone(self.listenbrainz._idle_id) diff --git a/tests/listenbrainz/test_task.py b/tests/listenbrainz/test_task.py index 6e07f7a..4d673a8 100644 --- a/tests/listenbrainz/test_task.py +++ b/tests/listenbrainz/test_task.py @@ -1,6 +1,7 @@ # Copyright 2024 (c) Anna Schumaker. """Tests our ListenBrainz priority queue.""" import emmental.listenbrainz.task +import liblistenbrainz import unittest @@ -41,3 +42,20 @@ class TestTaskQueue(unittest.TestCase): self.queue.push("clear-token") self.queue.clear("clear-token") self.assertIsNone(self.queue._set_token) + + def test_push_now_playing(self): + """Test the push_now_playing() function.""" + self.assertIsNone(self.queue._now_playing) + + listen = liblistenbrainz.Listen("Track Name", "Artist Name") + self.queue.push("now-playing", listen) + self.assertTupleEqual(self.queue._now_playing, ("now-playing", listen)) + + self.queue.push("set-token", "abcde") + self.assertTupleEqual(self.queue.pop(), ("set-token", "abcde")) + self.assertTupleEqual(self.queue.pop(), ("now-playing", listen)) + self.assertIsNone(self.queue._now_playing) + + self.queue.push("now-playing", listen) + self.queue.clear("now-playing") + self.assertIsNone(self.queue._now_playing) diff --git a/tests/listenbrainz/test_thread.py b/tests/listenbrainz/test_thread.py index 294d2cc..b8eaf19 100644 --- a/tests/listenbrainz/test_thread.py +++ b/tests/listenbrainz/test_thread.py @@ -70,3 +70,37 @@ class TestThread(unittest.TestCase): self.assertEqual(mock_stdout.getvalue(), "listenbrainz: setting user token\n" + "listenbrainz: user token is invalid\n") + + def test_submit_now_playing(self, mock_stdout: io.StringIO): + """Test submitting the now playing track.""" + listen = liblistenbrainz.Listen("Track Name", "Artist Name") + with unittest.mock.patch.object(self.thread._client, + "submit_playing_now") as mock_submit: + self.thread.submit_now_playing(listen) + self.assertFalse(self.thread.ready.is_set()) + self.assertEqual(self.thread._task, {"op": "now-playing", + "listen": listen}) + self.assertEqual(mock_stdout.getvalue(), + "listenbrainz: now playing 'Track Name' " + + "by 'Artist Name'\n") + + self.thread.ready.wait() + mock_submit.assert_called_with(listen) + self.assertEqual(self.thread.get_result(), + {"op": "now-playing", "valid": True}) + + def test_submit_now_playing_exceptions(self, mock_stdout: io.StringIO): + """Test exception handling when submitting the now playing track.""" + listen = liblistenbrainz.Listen("Track Name", "Artist Name") + with unittest.mock.patch.object(self.thread._client, + "submit_playing_now") as mock_submit: + mock_submit.side_effect = \ + liblistenbrainz.errors.ListenBrainzAPIException(401) + self.thread.submit_now_playing(listen) + self.thread.ready.wait() + self.assertEqual(self.thread.get_result(), + {"op": "now-playing", "valid": False}) + self.assertEqual(mock_stdout.getvalue(), + "listenbrainz: now playing 'Track Name' " + + "by 'Artist Name'\n" + + "listenbrainz: user token is invalid\n")