listenbrainz: Offline handling
If we get a connection error from any listenbrainz operation, then we need to set up an occasional timer to retry connecting to listenbrainz to see if the connection has been restored. Implements: #69 ("Add ListenBrainz support") Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
parent
b1490fd447
commit
2ae5fd0969
|
@ -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:
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue