listenbrainz: Submit Listens to ListenBrainz
I query the database for up to 50 tracks to submit at once. If there is only one track to submit then I use the submit_single_listen() function as intended by ListenBrainz. Implements: #69 ("Add ListenBrainz support") Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
parent
4c5d3c78c0
commit
b1490fd447
|
@ -11,13 +11,14 @@ from . import task
|
||||||
class ListenBrainz(GObject.GObject):
|
class ListenBrainz(GObject.GObject):
|
||||||
"""Our main ListenBrainz GObject."""
|
"""Our main ListenBrainz GObject."""
|
||||||
|
|
||||||
|
sql = GObject.Property(type=db.Connection)
|
||||||
user_token = GObject.Property(type=str)
|
user_token = GObject.Property(type=str)
|
||||||
valid_token = GObject.Property(type=bool, default=True)
|
valid_token = GObject.Property(type=bool, default=True)
|
||||||
now_playing = GObject.Property(type=db.tracks.Track)
|
now_playing = GObject.Property(type=db.tracks.Track)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, sql: db.Connection):
|
||||||
"""Initialize the ListenBrainz GObject."""
|
"""Initialize the ListenBrainz GObject."""
|
||||||
super().__init__()
|
super().__init__(sql=sql)
|
||||||
self._queue = task.Queue()
|
self._queue = task.Queue()
|
||||||
self._thread = thread.Thread()
|
self._thread = thread.Thread()
|
||||||
|
|
||||||
|
@ -32,6 +33,9 @@ class ListenBrainz(GObject.GObject):
|
||||||
def __check_result(self) -> None:
|
def __check_result(self) -> None:
|
||||||
if (res := self._thread.get_result()) is not None:
|
if (res := self._thread.get_result()) is not None:
|
||||||
self.valid_token = res.valid
|
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:
|
def __parse_task(self, op: str, *args) -> bool:
|
||||||
match op:
|
match op:
|
||||||
|
@ -41,18 +45,20 @@ class ListenBrainz(GObject.GObject):
|
||||||
self._thread.submit_now_playing(listen.Listen(*args))
|
self._thread.submit_now_playing(listen.Listen(*args))
|
||||||
case "set-token":
|
case "set-token":
|
||||||
self._thread.set_user_token(*args)
|
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
|
return GLib.SOURCE_CONTINUE
|
||||||
|
|
||||||
def __idle_work(self) -> bool:
|
def __idle_work(self) -> bool:
|
||||||
if self._thread.ready.is_set():
|
if self._thread.ready.is_set():
|
||||||
self.__check_result()
|
self.__check_result()
|
||||||
|
return self.__parse_task(*self._queue.pop())
|
||||||
if (task := self._queue.pop()) is None:
|
|
||||||
self._idle_id = None
|
|
||||||
return GLib.SOURCE_REMOVE
|
|
||||||
|
|
||||||
return self.__parse_task(*task)
|
|
||||||
return GLib.SOURCE_CONTINUE
|
return GLib.SOURCE_CONTINUE
|
||||||
|
|
||||||
def __idle_start(self) -> None:
|
def __idle_start(self) -> None:
|
||||||
|
@ -84,3 +90,8 @@ class ListenBrainz(GObject.GObject):
|
||||||
"""Stop the ListenBrainz thread."""
|
"""Stop the ListenBrainz thread."""
|
||||||
self.__source_stop("_idle_id")
|
self.__source_stop("_idle_id")
|
||||||
self._thread.stop()
|
self._thread.stop()
|
||||||
|
|
||||||
|
def submit_listens(self, *args) -> None:
|
||||||
|
"""Submit recent listens to ListenBrainz."""
|
||||||
|
if self.__check_connected():
|
||||||
|
self.__idle_start()
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
# Copyright 2024 (c) Anna Schumaker.
|
# Copyright 2024 (c) Anna Schumaker.
|
||||||
"""Convert a db.track.Track to a liblistenbrainz.Listen."""
|
"""Convert a db.track.Track to a liblistenbrainz.Listen."""
|
||||||
|
import datetime
|
||||||
|
import dateutil.tz
|
||||||
import liblistenbrainz
|
import liblistenbrainz
|
||||||
from .. import db
|
from .. import db
|
||||||
from .. import gsetup
|
from .. import gsetup
|
||||||
|
@ -8,7 +10,8 @@ from .. import gsetup
|
||||||
class Listen(liblistenbrainz.Listen):
|
class Listen(liblistenbrainz.Listen):
|
||||||
"""A single ListenBrainz 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."""
|
"""Initialize our Listen class."""
|
||||||
album = track.get_medium().get_album()
|
album = track.get_medium().get_album()
|
||||||
artists = [a.mbid for a in track.get_artists() if len(a.mbid) > 0]
|
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,
|
tracknumber=track.number,
|
||||||
additional_info={"media_player":
|
additional_info={"media_player":
|
||||||
f"emmental{gsetup.DEBUG_STR}"})
|
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()
|
||||||
|
|
|
@ -28,4 +28,4 @@ class Queue:
|
||||||
self._set_token = None
|
self._set_token = None
|
||||||
elif (res := self._now_playing) is not None:
|
elif (res := self._now_playing) is not None:
|
||||||
self._now_playing = None
|
self._now_playing = None
|
||||||
return res
|
return res if res is not None else ("submit-listens",)
|
||||||
|
|
|
@ -29,6 +29,16 @@ class Thread(thread.Thread):
|
||||||
except liblistenbrainz.errors.ListenBrainzAPIException:
|
except liblistenbrainz.errors.ListenBrainzAPIException:
|
||||||
self.set_result("now-playing", valid=False)
|
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:
|
def do_run_task(self, task: thread.Data) -> None:
|
||||||
"""Call a specific listenbrainz operation."""
|
"""Call a specific listenbrainz operation."""
|
||||||
match task.op:
|
match task.op:
|
||||||
|
@ -39,6 +49,8 @@ class Thread(thread.Thread):
|
||||||
self.__submit_now_playing(task.listen)
|
self.__submit_now_playing(task.listen)
|
||||||
case "set-token":
|
case "set-token":
|
||||||
self.__set_user_token(task.token)
|
self.__set_user_token(task.token)
|
||||||
|
case "submit-listens":
|
||||||
|
self.__submit_listens(task.listens)
|
||||||
|
|
||||||
def clear_user_token(self) -> None:
|
def clear_user_token(self) -> None:
|
||||||
"""Schedule clearing the user token."""
|
"""Schedule clearing the user token."""
|
||||||
|
@ -66,3 +78,9 @@ class Thread(thread.Thread):
|
||||||
self.__print(f"now playing '{listen.track_name}' " +
|
self.__print(f"now playing '{listen.track_name}' " +
|
||||||
f"by '{listen.artist_name}'")
|
f"by '{listen.artist_name}'")
|
||||||
self.set_task(op="now-playing", listen=listen)
|
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)
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
# Copyright 2024 (c) Anna Schumaker.
|
# Copyright 2024 (c) Anna Schumaker.
|
||||||
"""Test creating a liblistenbrainz.Listen from a Track."""
|
"""Test creating a liblistenbrainz.Listen from a Track."""
|
||||||
|
import datetime
|
||||||
|
import dateutil.tz
|
||||||
import emmental.listenbrainz.listen
|
import emmental.listenbrainz.listen
|
||||||
import liblistenbrainz
|
import liblistenbrainz
|
||||||
import pathlib
|
import pathlib
|
||||||
|
@ -44,3 +46,13 @@ class TestListen(tests.util.TestCase):
|
||||||
{"media_player": "emmental-debug"})
|
{"media_player": "emmental-debug"})
|
||||||
self.assertListEqual(self.listen.artist_mbids,
|
self.assertListEqual(self.listen.artist_mbids,
|
||||||
["mbid-ar1", "mbid-ar3"])
|
["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())
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# Copyright 2024 (c) Anna Schumaker.
|
# Copyright 2024 (c) Anna Schumaker.
|
||||||
"""Tests our custom ListenBrainz GObject."""
|
"""Tests our custom ListenBrainz GObject."""
|
||||||
|
import datetime
|
||||||
import emmental.listenbrainz
|
import emmental.listenbrainz
|
||||||
import io
|
import io
|
||||||
import pathlib
|
import pathlib
|
||||||
|
@ -18,7 +19,7 @@ class TestListenBrainz(tests.util.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Set up common variables."""
|
"""Set up common variables."""
|
||||||
super().setUp()
|
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.library = self.sql.libraries.create(pathlib.Path("/a/b"))
|
||||||
self.album = self.sql.albums.create("Test Album", "Test Artist",
|
self.album = self.sql.albums.create("Test Album", "Test Artist",
|
||||||
release="1988-06",
|
release="1988-06",
|
||||||
|
@ -29,7 +30,7 @@ class TestListenBrainz(tests.util.TestCase):
|
||||||
pathlib.Path("/a/b/c.ogg"),
|
pathlib.Path("/a/b/c.ogg"),
|
||||||
self.medium, self.year,
|
self.medium, self.year,
|
||||||
title="Track 1", number=1,
|
title="Track 1", number=1,
|
||||||
artist="Track Artist")
|
artist="Track Artist", length=10)
|
||||||
|
|
||||||
@unittest.mock.patch("gi.repository.GLib.source_remove")
|
@unittest.mock.patch("gi.repository.GLib.source_remove")
|
||||||
def tearDown(self, mock_source_remove: unittest.mock.Mock):
|
def tearDown(self, mock_source_remove: unittest.mock.Mock):
|
||||||
|
@ -46,6 +47,7 @@ class TestListenBrainz(tests.util.TestCase):
|
||||||
self.assertIsInstance(self.listenbrainz._thread,
|
self.assertIsInstance(self.listenbrainz._thread,
|
||||||
emmental.listenbrainz.thread.Thread)
|
emmental.listenbrainz.thread.Thread)
|
||||||
self.assertIsNone(self.listenbrainz._idle_id)
|
self.assertIsNone(self.listenbrainz._idle_id)
|
||||||
|
self.assertEqual(self.listenbrainz.sql, self.sql)
|
||||||
|
|
||||||
def test_stop(self, mock_idle_add: unittest.mock.Mock,
|
def test_stop(self, mock_idle_add: unittest.mock.Mock,
|
||||||
mock_source_remove: unittest.mock.Mock,
|
mock_source_remove: unittest.mock.Mock,
|
||||||
|
@ -179,3 +181,63 @@ class TestListenBrainz(tests.util.TestCase):
|
||||||
self.listenbrainz.now_playing = None
|
self.listenbrainz.now_playing = None
|
||||||
self.assertIsNone(self.listenbrainz._queue._now_playing)
|
self.assertIsNone(self.listenbrainz._queue._now_playing)
|
||||||
self.assertIsNone(self.listenbrainz._idle_id)
|
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)
|
||||||
|
|
|
@ -15,6 +15,7 @@ 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)
|
||||||
|
self.assertTupleEqual(self.queue.pop(), ("submit-listens",))
|
||||||
|
|
||||||
def test_push_set_token(self):
|
def test_push_set_token(self):
|
||||||
"""Test calling push() with the 'set-token' operation."""
|
"""Test calling push() with the 'set-token' operation."""
|
||||||
|
@ -32,6 +33,8 @@ class TestTaskQueue(unittest.TestCase):
|
||||||
self.queue.clear("set-token")
|
self.queue.clear("set-token")
|
||||||
self.assertIsNone(self.queue._set_token)
|
self.assertIsNone(self.queue._set_token)
|
||||||
|
|
||||||
|
self.assertTupleEqual(self.queue.pop(), ("submit-listens",))
|
||||||
|
|
||||||
def test_push_clear_token(self):
|
def test_push_clear_token(self):
|
||||||
"""Test calling push() with the 'clear-token' operation."""
|
"""Test calling push() with the 'clear-token' operation."""
|
||||||
self.queue.push("clear-token")
|
self.queue.push("clear-token")
|
||||||
|
@ -43,6 +46,8 @@ class TestTaskQueue(unittest.TestCase):
|
||||||
self.queue.clear("clear-token")
|
self.queue.clear("clear-token")
|
||||||
self.assertIsNone(self.queue._set_token)
|
self.assertIsNone(self.queue._set_token)
|
||||||
|
|
||||||
|
self.assertTupleEqual(self.queue.pop(), ("submit-listens",))
|
||||||
|
|
||||||
def test_push_now_playing(self):
|
def test_push_now_playing(self):
|
||||||
"""Test the push_now_playing() function."""
|
"""Test the push_now_playing() function."""
|
||||||
self.assertIsNone(self.queue._now_playing)
|
self.assertIsNone(self.queue._now_playing)
|
||||||
|
@ -59,3 +64,5 @@ class TestTaskQueue(unittest.TestCase):
|
||||||
self.queue.push("now-playing", listen)
|
self.queue.push("now-playing", listen)
|
||||||
self.queue.clear("now-playing")
|
self.queue.clear("now-playing")
|
||||||
self.assertIsNone(self.queue._now_playing)
|
self.assertIsNone(self.queue._now_playing)
|
||||||
|
|
||||||
|
self.assertTupleEqual(self.queue.pop(), ("submit-listens",))
|
||||||
|
|
|
@ -104,3 +104,58 @@ class TestThread(unittest.TestCase):
|
||||||
"listenbrainz: now playing 'Track Name' " +
|
"listenbrainz: now playing 'Track Name' " +
|
||||||
"by 'Artist Name'\n" +
|
"by 'Artist Name'\n" +
|
||||||
"listenbrainz: user token is invalid\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")
|
||||||
|
|
Loading…
Reference in New Issue