listenbrainz: Set the user API token

I added a "user-token" property to the ListenBrainz object, and watch for
changes to query the liblistenbrainz client. I also set up an idle
callback so we can set the "valid-token" property so the user can know
if there is a problem.

Implements: #69 ("Add ListenBrainz support")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2024-01-30 16:34:00 -05:00
parent 14c153733d
commit 924f65fddd
6 changed files with 216 additions and 4 deletions

View File

@ -1,6 +1,7 @@
# Copyright 2024 (c) Anna Schumaker. # Copyright 2024 (c) Anna Schumaker.
"""Our ListenBrainz custom GObject.""" """Our ListenBrainz custom GObject."""
from gi.repository import GObject from gi.repository import GObject
from gi.repository import GLib
from . import thread from . import thread
from . import task from . import task
@ -8,12 +9,56 @@ from . import task
class ListenBrainz(GObject.GObject): class ListenBrainz(GObject.GObject):
"""Our main ListenBrainz GObject.""" """Our main ListenBrainz GObject."""
user_token = GObject.Property(type=str)
valid_token = GObject.Property(type=bool, default=True)
def __init__(self): def __init__(self):
"""Initialize the ListenBrainz GObject.""" """Initialize the ListenBrainz GObject."""
super().__init__() super().__init__()
self._queue = task.Queue() self._queue = task.Queue()
self._thread = thread.Thread() self._thread = thread.Thread()
self._idle_id = None
self.connect("notify::user-token", self.__notify_user_token)
def __check_result(self) -> None:
if (res := self._thread.get_result()) is not None:
self.valid_token = res.valid
def __parse_task(self, op: str, *args) -> bool:
match op:
case "set-token":
self._thread.set_user_token(*args)
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 GLib.SOURCE_CONTINUE
def __idle_start(self) -> None:
if self._idle_id is None:
self._idle_id = GLib.idle_add(self.__idle_work)
def __notify_user_token(self, listenbrainz: GObject.GObject,
param: GObject.ParamSpec) -> None:
self._queue.push("set-token", self.user_token)
self.__idle_start()
def __source_stop(self, srcid: str) -> None:
if (id := getattr(self, srcid)) is not None:
GLib.source_remove(id)
setattr(self, srcid, None)
def stop(self) -> None: def stop(self) -> None:
"""Stop the ListenBrainz thread.""" """Stop the ListenBrainz thread."""
self.__source_stop("_idle_id")
self._thread.stop() self._thread.stop()

View File

@ -4,3 +4,21 @@
class Queue: class Queue:
"""A queue for prioritizing ListenBrainz operations.""" """A queue for prioritizing ListenBrainz operations."""
def __init__(self):
"""Initialize the task Queue."""
self._set_token = None
def clear(self, op: str) -> None:
"""Clear a pending operation."""
self._set_token = None
def push(self, op: str, *args) -> None:
"""Push an operation onto the queue."""
self._set_token = (op, *args)
def pop(self) -> tuple:
"""Pop an operation off the queue."""
res = self._set_token
self._set_token = None
return res

View File

@ -1,7 +1,45 @@
# Copyright 2024 (c) Anna Schumaker. # Copyright 2024 (c) Anna Schumaker.
"""Our ListenBrainz client thread.""" """Our ListenBrainz client thread."""
import liblistenbrainz
from .. import thread from .. import thread
class Thread(thread.Thread): class Thread(thread.Thread):
"""Thread for submitting listens to ListenBrainz.""" """Thread for submitting listens to ListenBrainz."""
def __init__(self):
"""Initialize the ListenBrainz Thread object."""
super().__init__()
self._client = liblistenbrainz.client.ListenBrainz()
def __print(self, text: str) -> None:
print(f"listenbrainz: {text}")
def __set_user_token(self, token: str) -> None:
try:
self._client.set_auth_token(token)
self.set_result("set-token", token=token)
except liblistenbrainz.errors.InvalidAuthTokenException:
self.set_result("set-token", token=token, valid=False)
def do_run_task(self, task: thread.Data) -> None:
"""Call a specific listenbrainz operation."""
match task.op:
case "set-token":
self.__set_user_token(task.token)
def get_result(self, **kwargs) -> thread.Data:
"""Get the result of a listenbrainz task."""
if (res := super().get_result(**kwargs)) is not None:
if not res.valid:
self.__print("user token is invalid")
return res
def set_result(self, op: str, *, valid: bool = True, **kwargs) -> None:
"""Set the Thread result with a standard format for all ops."""
super().set_result(op=op, valid=valid, **kwargs)
def set_user_token(self, token: str) -> None:
"""Schedule setting the user token."""
self.__print("setting user token")
self.set_task(op="set-token", token=token)

View File

@ -1,10 +1,15 @@
# Copyright 2024 (c) Anna Schumaker. # Copyright 2024 (c) Anna Schumaker.
"""Tests our custom ListenBrainz GObject.""" """Tests our custom ListenBrainz GObject."""
import emmental.listenbrainz import emmental.listenbrainz
import io
import unittest import unittest
from gi.repository import GObject from gi.repository import GObject
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(unittest.TestCase):
"""ListenBrainz GObject test case.""" """ListenBrainz GObject test case."""
@ -12,19 +17,72 @@ class TestListenBrainz(unittest.TestCase):
"""Set up common variables.""" """Set up common variables."""
self.listenbrainz = emmental.listenbrainz.ListenBrainz() self.listenbrainz = emmental.listenbrainz.ListenBrainz()
def tearDown(self): @unittest.mock.patch("gi.repository.GLib.source_remove")
def tearDown(self, mock_source_remove: unittest.mock.Mock):
"""Clean up.""" """Clean up."""
self.listenbrainz.stop() self.listenbrainz.stop()
def test_init(self): def test_init(self, mock_idle_add: unittest.mock.Mock,
mock_source_remove: unittest.mock.Mock,
mock_stdout: io.StringIO):
"""Test that the ListenBrainz GObject was set up properly.""" """Test that the ListenBrainz GObject was set up properly."""
self.assertIsInstance(self.listenbrainz, GObject.GObject) self.assertIsInstance(self.listenbrainz, GObject.GObject)
self.assertIsInstance(self.listenbrainz._queue, self.assertIsInstance(self.listenbrainz._queue,
emmental.listenbrainz.task.Queue) emmental.listenbrainz.task.Queue)
self.assertIsInstance(self.listenbrainz._thread, self.assertIsInstance(self.listenbrainz._thread,
emmental.listenbrainz.thread.Thread) emmental.listenbrainz.thread.Thread)
self.assertIsNone(self.listenbrainz._idle_id)
def test_stop(self): 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.""" """Test stopping the thread during shutdown."""
self.listenbrainz._idle_id = 12345
self.listenbrainz.stop() self.listenbrainz.stop()
self.assertFalse(self.listenbrainz._thread.is_alive()) self.assertFalse(self.listenbrainz._thread.is_alive())
self.assertIsNone(self.listenbrainz._idle_id)
mock_source_remove.assert_called_with(12345)
def test_set_user_token(self, mock_idle_add: unittest.mock.Mock,
mock_source_remove: unittest.mock.Mock,
mock_stdout: io.StringIO):
"""Test setting the user-token property."""
self.assertEqual(self.listenbrainz.user_token, "")
self.assertTrue(self.listenbrainz.valid_token)
idle_work = self.listenbrainz._ListenBrainz__idle_work
with unittest.mock.patch.object(self.listenbrainz._thread,
"set_user_token") as mock_set_token:
self.listenbrainz.user_token = "abc"
self.assertEqual(self.listenbrainz._queue._set_token,
("set-token", "abc"))
self.assertEqual(self.listenbrainz._idle_id, 42)
mock_idle_add.assert_called_with(idle_work)
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
mock_set_token.assert_called_with("abc")
mock_idle_add.reset_mock()
self.listenbrainz.user_token = "abcde"
self.assertEqual(self.listenbrainz._queue._set_token,
("set-token", "abcde"))
self.assertEqual(self.listenbrainz._idle_id, 42)
mock_idle_add.assert_not_called()
self.listenbrainz._thread.set_result(op="set-token", token="abc",
valid=True)
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
mock_set_token.assert_called_with("abcde")
self.listenbrainz._thread.ready.clear()
self.assertEqual(idle_work(), GLib.SOURCE_CONTINUE)
for valid in [False, True]:
with self.subTest(valid=valid):
self.listenbrainz._thread.set_result(op="set-token",
token="abcde",
valid=valid)
self.assertEqual(idle_work(), GLib.SOURCE_REMOVE)
self.assertEqual(self.listenbrainz.valid_token, valid)
self.assertIsNone(self.listenbrainz._idle_id)

View File

@ -14,3 +14,19 @@ class TestTaskQueue(unittest.TestCase):
def test_init(self): def test_init(self):
"""Test that the queue was set up properly.""" """Test that the queue was set up properly."""
self.assertIsNotNone(self.queue) self.assertIsNotNone(self.queue)
def test_push_set_token(self):
"""Test calling push() with the 'set-token' operation."""
self.assertIsNone(self.queue._set_token)
self.queue.push("set-token", "abcde")
self.assertTupleEqual(self.queue._set_token, ("set-token", "abcde"))
self.queue.push("set-token", "fghij")
self.assertTupleEqual(self.queue._set_token, ("set-token", "fghij"))
self.assertTupleEqual(self.queue.pop(), ("set-token", "fghij"))
self.assertIsNone(self.queue._set_token)
self.queue.push("set-token", "abcde")
self.queue.clear("set-token")
self.assertIsNone(self.queue._set_token)

View File

@ -1,9 +1,12 @@
# Copyright 2024 (c) Anna Schumaker. # Copyright 2024 (c) Anna Schumaker.
"""Tests our ListenBrainz client thread.""" """Tests our ListenBrainz client thread."""
import emmental.listenbrainz.thread import emmental.listenbrainz.thread
import io
import liblistenbrainz
import unittest import unittest
@unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
class TestThread(unittest.TestCase): class TestThread(unittest.TestCase):
"""ListenBrainz Thread test case.""" """ListenBrainz Thread test case."""
@ -15,6 +18,40 @@ class TestThread(unittest.TestCase):
"""Clean up.""" """Clean up."""
self.thread.stop() self.thread.stop()
def test_init(self): def test_init(self, mock_stdout: io.StringIO):
"""Test that the ListenBrainz thread was initialized properly.""" """Test that the ListenBrainz thread was initialized properly."""
self.assertIsInstance(self.thread, emmental.thread.Thread) self.assertIsInstance(self.thread, emmental.thread.Thread)
self.assertIsInstance(self.thread._client,
liblistenbrainz.client.ListenBrainz)
def test_set_user_token(self, mock_stdout: io.StringIO):
"""Test setting the user auth token."""
with unittest.mock.patch.object(self.thread._client,
"set_auth_token") as mock_set_auth:
self.thread.set_user_token("abcde")
self.assertFalse(self.thread.ready.is_set())
self.assertEqual(self.thread._task,
{"op": "set-token", "token": "abcde"})
self.assertEqual(mock_stdout.getvalue(),
"listenbrainz: setting user token\n")
self.thread.ready.wait()
mock_set_auth.assert_called_with("abcde")
self.assertEqual(self.thread.get_result(),
{"op": "set-token", "token": "abcde",
"valid": True})
def test_set_user_token_exceptions(self, mock_stdout: io.StringIO):
"""Test exception handling when setting the user auth token."""
with unittest.mock.patch.object(self.thread._client,
"set_auth_token") as mock_set_auth:
mock_set_auth.side_effect = \
liblistenbrainz.errors.InvalidAuthTokenException()
self.thread.set_user_token("abcde")
self.thread.ready.wait()
self.assertEqual(self.thread.get_result(),
{"op": "set-token", "token": "abcde",
"valid": False})
self.assertEqual(mock_stdout.getvalue(),
"listenbrainz: setting user token\n" +
"listenbrainz: user token is invalid\n")