diff --git a/emmental/listenbrainz/__init__.py b/emmental/listenbrainz/__init__.py index 7458a86..e0d61e0 100644 --- a/emmental/listenbrainz/__init__.py +++ b/emmental/listenbrainz/__init__.py @@ -11,13 +11,14 @@ from . import task class ListenBrainz(GObject.GObject): """Our main ListenBrainz GObject.""" + sql = GObject.Property(type=db.Connection) 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): + def __init__(self, sql: db.Connection): """Initialize the ListenBrainz GObject.""" - super().__init__() + super().__init__(sql=sql) self._queue = task.Queue() self._thread = thread.Thread() @@ -32,6 +33,9 @@ class ListenBrainz(GObject.GObject): def __check_result(self) -> None: if (res := self._thread.get_result()) is not None: self.valid_token = res.valid + if res.op == "submit-listens" and self.valid_token: + listens = [lsn.listenid for lsn in res.listens] + self.sql.tracks.delete_listens(listens) def __parse_task(self, op: str, *args) -> bool: match op: @@ -41,18 +45,20 @@ class ListenBrainz(GObject.GObject): self._thread.submit_now_playing(listen.Listen(*args)) case "set-token": self._thread.set_user_token(*args) - + case "submit-listens": + listens = self.sql.tracks.get_n_listens(50) + if len(listens) == 0: + self._idle_id = None + return GLib.SOURCE_REMOVE + self._thread.submit_listens([listen.Listen(trk, listenid=id, + listened_at=ts) + for (id, trk, ts) in listens]) return GLib.SOURCE_CONTINUE def __idle_work(self) -> bool: if self._thread.ready.is_set(): self.__check_result() - - if (task := self._queue.pop()) is None: - self._idle_id = None - return GLib.SOURCE_REMOVE - - return self.__parse_task(*task) + return self.__parse_task(*self._queue.pop()) return GLib.SOURCE_CONTINUE def __idle_start(self) -> None: @@ -84,3 +90,8 @@ class ListenBrainz(GObject.GObject): """Stop the ListenBrainz thread.""" self.__source_stop("_idle_id") self._thread.stop() + + def submit_listens(self, *args) -> None: + """Submit recent listens to ListenBrainz.""" + if self.__check_connected(): + self.__idle_start() diff --git a/emmental/listenbrainz/listen.py b/emmental/listenbrainz/listen.py index cc50d40..f516e93 100644 --- a/emmental/listenbrainz/listen.py +++ b/emmental/listenbrainz/listen.py @@ -1,5 +1,7 @@ # Copyright 2024 (c) Anna Schumaker. """Convert a db.track.Track to a liblistenbrainz.Listen.""" +import datetime +import dateutil.tz import liblistenbrainz from .. import db from .. import gsetup @@ -8,7 +10,8 @@ from .. import gsetup class Listen(liblistenbrainz.Listen): """A single ListenBrainz Listen.""" - def __init__(self, track: db.tracks.Track): + def __init__(self, track: db.tracks.Track, *, listenid: int = None, + listened_at: datetime.datetime = None): """Initialize our Listen class.""" album = track.get_medium().get_album() artists = [a.mbid for a in track.get_artists() if len(a.mbid) > 0] @@ -18,3 +21,8 @@ class Listen(liblistenbrainz.Listen): tracknumber=track.number, additional_info={"media_player": f"emmental{gsetup.DEBUG_STR}"}) + self.listenid = listenid + + if listened_at is not None: + when = listened_at.replace(tzinfo=dateutil.tz.tzutc()) + self.listened_at = when.astimezone().timestamp() diff --git a/emmental/listenbrainz/task.py b/emmental/listenbrainz/task.py index 33469a4..26da475 100644 --- a/emmental/listenbrainz/task.py +++ b/emmental/listenbrainz/task.py @@ -28,4 +28,4 @@ class Queue: self._set_token = None elif (res := self._now_playing) is not None: self._now_playing = None - return res + return res if res is not None else ("submit-listens",) diff --git a/emmental/listenbrainz/thread.py b/emmental/listenbrainz/thread.py index 5e4bad7..62d114c 100644 --- a/emmental/listenbrainz/thread.py +++ b/emmental/listenbrainz/thread.py @@ -29,6 +29,16 @@ class Thread(thread.Thread): except liblistenbrainz.errors.ListenBrainzAPIException: self.set_result("now-playing", valid=False) + def __submit_listens(self, listens: list[liblistenbrainz.Listen]) -> None: + try: + if len(listens) == 1: + self._client.submit_single_listen(listens[0]) + else: + self._client.submit_multiple_listens(listens) + self.set_result("submit-listens", listens=listens) + except liblistenbrainz.errors.ListenBrainzAPIException: + self.set_result("submit-listens", listens=listens, valid=False) + def do_run_task(self, task: thread.Data) -> None: """Call a specific listenbrainz operation.""" match task.op: @@ -39,6 +49,8 @@ class Thread(thread.Thread): self.__submit_now_playing(task.listen) case "set-token": self.__set_user_token(task.token) + case "submit-listens": + self.__submit_listens(task.listens) def clear_user_token(self) -> None: """Schedule clearing the user token.""" @@ -66,3 +78,9 @@ class Thread(thread.Thread): self.__print(f"now playing '{listen.track_name}' " + f"by '{listen.artist_name}'") self.set_task(op="now-playing", listen=listen) + + def submit_listens(self, listens: list[liblistenbrainz.Listen]) -> None: + """Submit listens to listenbrainz.""" + num = len(listens) + self.__print(f"submitting {num} listen{'s' if num != 1 else ''}") + self.set_task(op="submit-listens", listens=listens) diff --git a/tests/listenbrainz/test_listen.py b/tests/listenbrainz/test_listen.py index a24fe3d..2ef211e 100644 --- a/tests/listenbrainz/test_listen.py +++ b/tests/listenbrainz/test_listen.py @@ -1,5 +1,7 @@ # Copyright 2024 (c) Anna Schumaker. """Test creating a liblistenbrainz.Listen from a Track.""" +import datetime +import dateutil.tz import emmental.listenbrainz.listen import liblistenbrainz import pathlib @@ -44,3 +46,13 @@ class TestListen(tests.util.TestCase): {"media_player": "emmental-debug"}) self.assertListEqual(self.listen.artist_mbids, ["mbid-ar1", "mbid-ar3"]) + self.assertIsNone(self.listen.listened_at) + self.assertIsNone(self.listen.listenid) + + utc_now = datetime.datetime.utcnow() + local_now = utc_now.replace(tzinfo=dateutil.tz.tzutc()).astimezone() + listen = emmental.listenbrainz.listen.Listen(self.track, + listenid=1234, + listened_at=utc_now) + self.assertEqual(listen.listenid, 1234) + self.assertEqual(listen.listened_at, local_now.timestamp()) diff --git a/tests/listenbrainz/test_listenbrainz.py b/tests/listenbrainz/test_listenbrainz.py index f5c97e6..f005836 100644 --- a/tests/listenbrainz/test_listenbrainz.py +++ b/tests/listenbrainz/test_listenbrainz.py @@ -1,5 +1,6 @@ # Copyright 2024 (c) Anna Schumaker. """Tests our custom ListenBrainz GObject.""" +import datetime import emmental.listenbrainz import io import pathlib @@ -18,7 +19,7 @@ class TestListenBrainz(tests.util.TestCase): def setUp(self): """Set up common variables.""" super().setUp() - self.listenbrainz = emmental.listenbrainz.ListenBrainz() + self.listenbrainz = emmental.listenbrainz.ListenBrainz(self.sql) self.library = self.sql.libraries.create(pathlib.Path("/a/b")) self.album = self.sql.albums.create("Test Album", "Test Artist", release="1988-06", @@ -29,7 +30,7 @@ class TestListenBrainz(tests.util.TestCase): pathlib.Path("/a/b/c.ogg"), self.medium, self.year, title="Track 1", number=1, - artist="Track Artist") + artist="Track Artist", length=10) @unittest.mock.patch("gi.repository.GLib.source_remove") def tearDown(self, mock_source_remove: unittest.mock.Mock): @@ -46,6 +47,7 @@ class TestListenBrainz(tests.util.TestCase): self.assertIsInstance(self.listenbrainz._thread, emmental.listenbrainz.thread.Thread) self.assertIsNone(self.listenbrainz._idle_id) + self.assertEqual(self.listenbrainz.sql, self.sql) def test_stop(self, mock_idle_add: unittest.mock.Mock, mock_source_remove: unittest.mock.Mock, @@ -179,3 +181,63 @@ class TestListenBrainz(tests.util.TestCase): self.listenbrainz.now_playing = None self.assertIsNone(self.listenbrainz._queue._now_playing) self.assertIsNone(self.listenbrainz._idle_id) + + def test_submit_listens(self, mock_idle_add: unittest.mock.Mock, + mock_source_remove: unittest.mock.Mock, + mock_stdout: io.StringIO): + """Test submitting recently listened tracks.""" + ts1 = datetime.datetime.utcnow() + ts2 = datetime.datetime.utcnow() + idle_work = self.listenbrainz._ListenBrainz__idle_work + listens = [emmental.listenbrainz.listen.Listen(self.track, listenid=1, + listened_at=ts1), + emmental.listenbrainz.listen.Listen(self.track, listenid=2, + listened_at=ts2)] + + self.listenbrainz.user_token = "abcde" + self.listenbrainz.valid_token = True + self.listenbrainz._queue.pop() + self.listenbrainz._ListenBrainz__source_stop("_idle_id") + + self.listenbrainz.submit_listens("ignored", "args") + self.assertIsNotNone(self.listenbrainz._idle_id) + + with unittest.mock.patch.object(self.sql.tracks, + "get_n_listens") as mock_get_listens: + mock_get_listens.return_value = [(1, self.track, ts1), + (2, self.track, ts2)] + + with unittest.mock.patch.object(self.listenbrainz._thread, + "submit_listens") as mock_submit: + self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE) + mock_get_listens.assert_called_with(50) + mock_submit.assert_called() + + with unittest.mock.patch.object(self.sql.tracks, + "delete_listens") as mock_delete: + for valid in [True, False]: + mock_delete.reset_mock() + with self.subTest(valid=valid): + self.listenbrainz._thread.set_result(op="submit-listens", + listens=listens, + valid=valid) + self.assertEqual(idle_work(), GLib.SOURCE_REMOVE) + self.assertEqual(self.listenbrainz.valid_token, valid) + if valid: + mock_delete.assert_called_with([1, 2]) + else: + mock_delete.assert_not_called() + + def test_submit_listens_later(self, mock_idle_add: unittest.mock.Mock, + mock_source_remove: unittest.mock.Mock, + mock_stdout: io.StringIO): + """Test submitting listens when ListenBrainz is disconnected.""" + self.listenbrainz.submit_listens("ignored", "args") + self.assertIsNone(self.listenbrainz._idle_id) + + self.listenbrainz.user_token = "abcde" + self.listenbrainz.valid_token = False + self.listenbrainz._queue.pop() + self.listenbrainz._idle_id = None + self.listenbrainz.submit_listens("ignored", "args") + self.assertIsNone(self.listenbrainz._idle_id) diff --git a/tests/listenbrainz/test_task.py b/tests/listenbrainz/test_task.py index 4d673a8..c981d2f 100644 --- a/tests/listenbrainz/test_task.py +++ b/tests/listenbrainz/test_task.py @@ -15,6 +15,7 @@ class TestTaskQueue(unittest.TestCase): def test_init(self): """Test that the queue was set up properly.""" self.assertIsNotNone(self.queue) + self.assertTupleEqual(self.queue.pop(), ("submit-listens",)) def test_push_set_token(self): """Test calling push() with the 'set-token' operation.""" @@ -32,6 +33,8 @@ class TestTaskQueue(unittest.TestCase): self.queue.clear("set-token") self.assertIsNone(self.queue._set_token) + self.assertTupleEqual(self.queue.pop(), ("submit-listens",)) + def test_push_clear_token(self): """Test calling push() with the 'clear-token' operation.""" self.queue.push("clear-token") @@ -43,6 +46,8 @@ class TestTaskQueue(unittest.TestCase): self.queue.clear("clear-token") self.assertIsNone(self.queue._set_token) + self.assertTupleEqual(self.queue.pop(), ("submit-listens",)) + def test_push_now_playing(self): """Test the push_now_playing() function.""" self.assertIsNone(self.queue._now_playing) @@ -59,3 +64,5 @@ class TestTaskQueue(unittest.TestCase): self.queue.push("now-playing", listen) self.queue.clear("now-playing") self.assertIsNone(self.queue._now_playing) + + self.assertTupleEqual(self.queue.pop(), ("submit-listens",)) diff --git a/tests/listenbrainz/test_thread.py b/tests/listenbrainz/test_thread.py index b8eaf19..f8336be 100644 --- a/tests/listenbrainz/test_thread.py +++ b/tests/listenbrainz/test_thread.py @@ -104,3 +104,58 @@ class TestThread(unittest.TestCase): "listenbrainz: now playing 'Track Name' " + "by 'Artist Name'\n" + "listenbrainz: user token is invalid\n") + + def test_submit_single_listen(self, mock_stdout: io.StringIO): + """Test submitting a single listen.""" + listens = [liblistenbrainz.Listen("Track Name", "Artist Name")] + with unittest.mock.patch.object(self.thread._client, + "submit_single_listen") as mock_submit: + self.thread.submit_listens(listens) + self.assertFalse(self.thread.ready.is_set()) + self.assertEqual(self.thread._task, {"op": "submit-listens", + "listens": listens}) + self.assertEqual(mock_stdout.getvalue(), + "listenbrainz: submitting 1 listen\n") + + self.thread.ready.wait() + mock_submit.assert_called_with(listens[0]) + self.assertEqual(self.thread.get_result(), + {"op": "submit-listens", "listens": listens, + "valid": True}) + + def test_submit_multiple_listens(self, mock_stdout: io.StringIO): + """Test submitting multiple listens.""" + listens = [liblistenbrainz.Listen("Track 1", "Artist"), + liblistenbrainz.Listen("Track 2", "Artist"), + liblistenbrainz.Listen("Track 3", "Artist")] + with unittest.mock.patch.object(self.thread._client, + "submit_multiple_listens") \ + as mock_submit: + self.thread.submit_listens(listens) + self.assertFalse(self.thread.ready.is_set()) + self.assertEqual(self.thread._task, {"op": "submit-listens", + "listens": listens}) + self.assertEqual(mock_stdout.getvalue(), + "listenbrainz: submitting 3 listens\n") + + self.thread.ready.wait() + mock_submit.assert_called_with(listens) + self.assertEqual(self.thread.get_result(), + {"op": "submit-listens", "listens": listens, + "valid": True}) + + def test_submit_listens_exceptions(self, mock_stdout: io.StringIO): + """Test exception handling when submitting listens.""" + listens = [liblistenbrainz.Listen("Track Name", "Artist Name")] + with unittest.mock.patch.object(self.thread._client, + "submit_single_listen") as mock_submit: + mock_submit.side_effect = \ + liblistenbrainz.errors.ListenBrainzAPIException(401) + self.thread.submit_listens(listens) + self.thread.ready.wait() + self.assertEqual(self.thread.get_result(), + {"op": "submit-listens", "listens": listens, + "valid": False}) + self.assertEqual(mock_stdout.getvalue(), + "listenbrainz: submitting 1 listen\n" + + "listenbrainz: user token is invalid\n")