diff --git a/emmental/listenbrainz/__init__.py b/emmental/listenbrainz/__init__.py index e0d61e0..95fd599 100644 --- a/emmental/listenbrainz/__init__.py +++ b/emmental/listenbrainz/__init__.py @@ -12,6 +12,7 @@ class ListenBrainz(GObject.GObject): """Our main ListenBrainz GObject.""" sql = GObject.Property(type=db.Connection) + offline = GObject.Property(type=bool, default=True) user_token = GObject.Property(type=str) valid_token = GObject.Property(type=bool, default=True) now_playing = GObject.Property(type=db.tracks.Track) @@ -23,17 +24,24 @@ class ListenBrainz(GObject.GObject): self._thread = thread.Thread() self._idle_id = None + self._timeout_id = None + self.connect("notify::offline", self.__notify_offline) 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 + return len(self.user_token) and self.valid_token and not self.offline + + def __check_online(self) -> None: + self.notify("user-token") 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: + self.offline = res.offline + if res.op == "submit-listens" and self.valid_token \ + and not self.offline: listens = [lsn.listenid for lsn in res.listens] self.sql.tracks.delete_listens(listens) @@ -65,7 +73,15 @@ class ListenBrainz(GObject.GObject): if self._idle_id is None: self._idle_id = GLib.idle_add(self.__idle_work) - def __notify_user_token(self, listenbrainz: GObject.GObject, + def __notify_offline(self, listenbrainz: GObject.GObject, + param: GObject.ParamSpec) -> None: + if self.offline and self._timeout_id is None: + self._timeout_id = GLib.timeout_add_seconds(300, + self.__check_online) + elif not self.offline and self._timeout_id is not None: + self.__source_stop("_timeout_id") + + def __notify_user_token(self, scrobbler: GObject.GObject, param: GObject.ParamSpec) -> None: match self.user_token: case "": self._queue.push("clear-token") @@ -89,6 +105,7 @@ class ListenBrainz(GObject.GObject): def stop(self) -> None: """Stop the ListenBrainz thread.""" self.__source_stop("_idle_id") + self.__source_stop("_timeout_id") self._thread.stop() def submit_listens(self, *args) -> None: diff --git a/emmental/listenbrainz/thread.py b/emmental/listenbrainz/thread.py index 62d114c..0d500ec 100644 --- a/emmental/listenbrainz/thread.py +++ b/emmental/listenbrainz/thread.py @@ -1,6 +1,7 @@ # Copyright 2024 (c) Anna Schumaker. """Our ListenBrainz client thread.""" import liblistenbrainz +import requests from .. import thread @@ -21,6 +22,8 @@ class Thread(thread.Thread): self.set_result("set-token", token=token) except liblistenbrainz.errors.InvalidAuthTokenException: self.set_result("set-token", token=token, valid=False) + except requests.exceptions.ConnectionError: + self.set_result("set-token", token=token, offline=True) def __submit_now_playing(self, listen: liblistenbrainz.Listen) -> None: try: @@ -28,6 +31,8 @@ class Thread(thread.Thread): self.set_result("now-playing") except liblistenbrainz.errors.ListenBrainzAPIException: self.set_result("now-playing", valid=False) + except requests.exceptions.ConnectionError: + self.set_result("now-playing", offline=True) def __submit_listens(self, listens: list[liblistenbrainz.Listen]) -> None: try: @@ -38,6 +43,8 @@ class Thread(thread.Thread): self.set_result("submit-listens", listens=listens) except liblistenbrainz.errors.ListenBrainzAPIException: self.set_result("submit-listens", listens=listens, valid=False) + except requests.exceptions.ConnectionError: + self.set_result("submit-listens", listens=listens, offline=True) def do_run_task(self, task: thread.Data) -> None: """Call a specific listenbrainz operation.""" @@ -62,11 +69,14 @@ class Thread(thread.Thread): if (res := super().get_result(**kwargs)) is not None: if not res.valid: self.__print("user token is invalid") + if res.offline: + self.__print("offline") return res - def set_result(self, op: str, *, valid: bool = True, **kwargs) -> None: + def set_result(self, op: str, *, valid: bool = True, + offline: bool = False, **kwargs) -> None: """Set the Thread result with a standard format for all ops.""" - super().set_result(op=op, valid=valid, **kwargs) + super().set_result(op=op, valid=valid, offline=offline, **kwargs) def set_user_token(self, token: str) -> None: """Schedule setting the user token.""" diff --git a/tests/listenbrainz/test_listenbrainz.py b/tests/listenbrainz/test_listenbrainz.py index f005836..42874fb 100644 --- a/tests/listenbrainz/test_listenbrainz.py +++ b/tests/listenbrainz/test_listenbrainz.py @@ -48,17 +48,21 @@ class TestListenBrainz(tests.util.TestCase): emmental.listenbrainz.thread.Thread) self.assertIsNone(self.listenbrainz._idle_id) self.assertEqual(self.listenbrainz.sql, self.sql) + self.assertIsNone(self.listenbrainz._timeout_id) def test_stop(self, mock_idle_add: unittest.mock.Mock, mock_source_remove: unittest.mock.Mock, mock_stdout: io.StringIO): """Test stopping the thread during shutdown.""" self.listenbrainz._idle_id = 12345 + self.listenbrainz._timeout_id = 67890 self.listenbrainz.stop() self.assertFalse(self.listenbrainz._thread.is_alive()) self.assertIsNone(self.listenbrainz._idle_id) - mock_source_remove.assert_called_with(12345) + self.assertIsNone(self.listenbrainz._timeout_id) + mock_source_remove.assert_has_calls([unittest.mock.call(12345), + unittest.mock.call(67890)]) def test_set_user_token(self, mock_idle_add: unittest.mock.Mock, mock_source_remove: unittest.mock.Mock, @@ -66,6 +70,7 @@ class TestListenBrainz(tests.util.TestCase): """Test setting the user-token property.""" self.assertEqual(self.listenbrainz.user_token, "") self.assertTrue(self.listenbrainz.valid_token) + self.assertTrue(self.listenbrainz.offline) idle_work = self.listenbrainz._ListenBrainz__idle_work with unittest.mock.patch.object(self.listenbrainz._thread, @@ -94,13 +99,16 @@ class TestListenBrainz(tests.util.TestCase): self.listenbrainz._thread.ready.clear() self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE) - for valid in [False, True]: - with self.subTest(valid=valid): + for valid, offline in [(False, False), (False, True), + (True, False), (True, True)]: + with self.subTest(valid=valid, offline=offline): self.listenbrainz._thread.set_result(op="set-token", token="abcde", - valid=valid) + valid=valid, + offline=offline) self.assertEqual(idle_work(), GLib.SOURCE_REMOVE) self.assertEqual(self.listenbrainz.valid_token, valid) + self.assertEqual(self.listenbrainz.offline, offline) self.assertIsNone(self.listenbrainz._idle_id) def test_clear_user_token(self, mock_idle_add: unittest.mock.Mock, @@ -133,6 +141,7 @@ class TestListenBrainz(tests.util.TestCase): self.listenbrainz.user_token = "abcde" self.listenbrainz.valid_token = True + self.listenbrainz.offline = False self.listenbrainz._queue.pop() self.listenbrainz._ListenBrainz__source_stop("_idle_id") @@ -149,12 +158,15 @@ class TestListenBrainz(tests.util.TestCase): self.assertIsInstance(mock_playing.call_args.args[0], emmental.listenbrainz.listen.Listen) - for valid in [True, False]: - with self.subTest(valid=valid): + for valid, offline in [(False, False), (False, True), + (True, False), (True, True)]: + with self.subTest(valid=valid, offline=offline): self.listenbrainz._thread.set_result(op="now-playing", - valid=valid) + valid=valid, + offline=offline) self.assertEqual(idle_work(), GLib.SOURCE_REMOVE) self.assertEqual(self.listenbrainz.valid_token, valid) + self.assertEqual(self.listenbrainz.offline, offline) def test_submit_now_playing_later(self, mock_idle_add: unittest.mock.Mock, mock_source_remove: unittest.mock.Mock, @@ -182,6 +194,12 @@ class TestListenBrainz(tests.util.TestCase): self.assertIsNone(self.listenbrainz._queue._now_playing) self.assertIsNone(self.listenbrainz._idle_id) + self.listenbrainz.offline = True + self.listenbrainz.now_playing = self.track + self.assertTupleEqual(self.listenbrainz._queue._now_playing, + ("now-playing", self.track)) + 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): @@ -198,7 +216,7 @@ class TestListenBrainz(tests.util.TestCase): self.listenbrainz.valid_token = True self.listenbrainz._queue.pop() self.listenbrainz._ListenBrainz__source_stop("_idle_id") - + self.listenbrainz.offline = False self.listenbrainz.submit_listens("ignored", "args") self.assertIsNotNone(self.listenbrainz._idle_id) @@ -215,15 +233,18 @@ class TestListenBrainz(tests.util.TestCase): with unittest.mock.patch.object(self.sql.tracks, "delete_listens") as mock_delete: - for valid in [True, False]: + for valid, offline in [(False, False), (False, True), + (True, False), (True, True)]: mock_delete.reset_mock() - with self.subTest(valid=valid): + with self.subTest(valid=valid, offline=offline): self.listenbrainz._thread.set_result(op="submit-listens", listens=listens, - valid=valid) + valid=valid, + offline=offline) self.assertEqual(idle_work(), GLib.SOURCE_REMOVE) self.assertEqual(self.listenbrainz.valid_token, valid) - if valid: + self.assertEqual(self.listenbrainz.offline, offline) + if valid is True and offline is False: mock_delete.assert_called_with([1, 2]) else: mock_delete.assert_not_called() @@ -241,3 +262,35 @@ class TestListenBrainz(tests.util.TestCase): self.listenbrainz._idle_id = None self.listenbrainz.submit_listens("ignored", "args") self.assertIsNone(self.listenbrainz._idle_id) + + self.listenbrainz.valid_token = True + self.listenbrainz.offline = True + self.listenbrainz.submit_listens("ignored", "args") + self.assertIsNone(self.listenbrainz._idle_id) + + @unittest.mock.patch("gi.repository.GLib.timeout_add_seconds") + def test_offline_recovery(self, mock_timeout_add: unittest.mock.Mock, + mock_idle_add: unittest.mock.Mock, + mock_source_remove: unittest.mock.Mock, + mock_stdout: io.StringIO): + """Test handling an offline response.""" + self.assertTrue(self.listenbrainz.offline) + + check_func = self.listenbrainz._ListenBrainz__check_online + mock_timeout_add.return_value = 67890 + self.listenbrainz.offline = True + self.assertEqual(self.listenbrainz._timeout_id, 67890) + mock_timeout_add.assert_called_with(300, check_func) + + mock_timeout_add.reset_mock() + mock_timeout_add.return_value = 99999 + self.listenbrainz.offline = True + self.assertEqual(self.listenbrainz._timeout_id, 67890) + mock_timeout_add.assert_not_called() + + self.listenbrainz.offline = False + mock_source_remove.assert_called_with(67890) + + mock_source_remove.reset_mock() + self.listenbrainz.offline = False + mock_source_remove.assert_not_called() diff --git a/tests/listenbrainz/test_thread.py b/tests/listenbrainz/test_thread.py index f8336be..8c2b69a 100644 --- a/tests/listenbrainz/test_thread.py +++ b/tests/listenbrainz/test_thread.py @@ -3,6 +3,7 @@ import emmental.listenbrainz.thread import io import liblistenbrainz +import requests import unittest @@ -37,7 +38,8 @@ class TestThread(unittest.TestCase): self.thread.ready.wait() mock_set_auth.assert_called_with(None, check_validity=False) self.assertEqual(self.thread.get_result(), - {"op": "clear-token", "valid": True}) + {"op": "clear-token", "valid": True, + "offline": False}) def test_set_user_token(self, mock_stdout: io.StringIO): """Test setting the user auth token.""" @@ -54,7 +56,7 @@ class TestThread(unittest.TestCase): mock_set_auth.assert_called_with("abcde") self.assertEqual(self.thread.get_result(), {"op": "set-token", "token": "abcde", - "valid": True}) + "valid": True, "offline": False}) def test_set_user_token_exceptions(self, mock_stdout: io.StringIO): """Test exception handling when setting the user auth token.""" @@ -66,11 +68,19 @@ class TestThread(unittest.TestCase): self.thread.ready.wait() self.assertEqual(self.thread.get_result(), {"op": "set-token", "token": "abcde", - "valid": False}) + "valid": False, "offline": False}) self.assertEqual(mock_stdout.getvalue(), "listenbrainz: setting user token\n" + "listenbrainz: user token is invalid\n") + mock_set_auth.side_effect = requests.exceptions.ConnectionError() + self.thread.set_user_token("abcde") + self.thread.ready.wait() + self.assertEqual(self.thread.get_result(), + {"op": "set-token", "token": "abcde", + "valid": True, "offline": True}) + self.assertRegex(mock_stdout.getvalue(), "listenbrainz: offline") + def test_submit_now_playing(self, mock_stdout: io.StringIO): """Test submitting the now playing track.""" listen = liblistenbrainz.Listen("Track Name", "Artist Name") @@ -87,7 +97,8 @@ class TestThread(unittest.TestCase): self.thread.ready.wait() mock_submit.assert_called_with(listen) self.assertEqual(self.thread.get_result(), - {"op": "now-playing", "valid": True}) + {"op": "now-playing", "valid": True, + "offline": False}) def test_submit_now_playing_exceptions(self, mock_stdout: io.StringIO): """Test exception handling when submitting the now playing track.""" @@ -99,12 +110,21 @@ class TestThread(unittest.TestCase): self.thread.submit_now_playing(listen) self.thread.ready.wait() self.assertEqual(self.thread.get_result(), - {"op": "now-playing", "valid": False}) + {"op": "now-playing", "valid": False, + "offline": False}) self.assertEqual(mock_stdout.getvalue(), "listenbrainz: now playing 'Track Name' " + "by 'Artist Name'\n" + "listenbrainz: user token is invalid\n") + mock_submit.side_effect = requests.exceptions.ConnectionError() + self.thread.submit_now_playing(listen) + self.thread.ready.wait() + self.assertEqual(self.thread.get_result(), + {"op": "now-playing", "valid": True, + "offline": True}) + self.assertRegex(mock_stdout.getvalue(), "listenbrainz: offline") + def test_submit_single_listen(self, mock_stdout: io.StringIO): """Test submitting a single listen.""" listens = [liblistenbrainz.Listen("Track Name", "Artist Name")] @@ -121,7 +141,7 @@ class TestThread(unittest.TestCase): mock_submit.assert_called_with(listens[0]) self.assertEqual(self.thread.get_result(), {"op": "submit-listens", "listens": listens, - "valid": True}) + "valid": True, "offline": False}) def test_submit_multiple_listens(self, mock_stdout: io.StringIO): """Test submitting multiple listens.""" @@ -142,7 +162,7 @@ class TestThread(unittest.TestCase): mock_submit.assert_called_with(listens) self.assertEqual(self.thread.get_result(), {"op": "submit-listens", "listens": listens, - "valid": True}) + "valid": True, "offline": False}) def test_submit_listens_exceptions(self, mock_stdout: io.StringIO): """Test exception handling when submitting listens.""" @@ -155,7 +175,15 @@ class TestThread(unittest.TestCase): self.thread.ready.wait() self.assertEqual(self.thread.get_result(), {"op": "submit-listens", "listens": listens, - "valid": False}) + "valid": False, "offline": False}) self.assertEqual(mock_stdout.getvalue(), "listenbrainz: submitting 1 listen\n" + "listenbrainz: user token is invalid\n") + + mock_submit.side_effect = requests.exceptions.ConnectionError() + self.thread.submit_listens(listens) + self.thread.ready.wait() + self.assertEqual(self.thread.get_result(), + {"op": "submit-listens", "listens": listens, + "valid": True, "offline": True}) + self.assertRegex(mock_stdout.getvalue(), "listenbrainz: offline")