diff --git a/listenbrainz/webserver/static/js/src/RecentListens.test.tsx b/listenbrainz/webserver/static/js/src/RecentListens.test.tsx
index 2087ae0727..0b40ed8261 100644
--- a/listenbrainz/webserver/static/js/src/RecentListens.test.tsx
+++ b/listenbrainz/webserver/static/js/src/RecentListens.test.tsx
@@ -1,10 +1,17 @@
import * as React from "react";
import { shallow } from "enzyme";
import * as timeago from "time-ago";
+import * as io from "socket.io-client";
-import * as recentListensProfilePageProps from "./__mocks__/recentListensProfilePageProps.json";
+import * as recentListensProps from "./__mocks__/recentListensProps.json";
+import * as recentListensPropsTooManyListens from "./__mocks__/recentListensPropsTooManyListens.json";
+import * as recentListensPropsOneListen from "./__mocks__/recentListensPropsOneListen.json";
+import * as recentListensPropsPlayingNow from "./__mocks__/recentListensPropsPlayingNow.json";
-import RecentListens, { ListensListMode } from "./RecentListens";
+import RecentListens, {
+ ListensListMode,
+ RecentListensProps,
+} from "./RecentListens";
const {
apiUrl,
@@ -21,7 +28,7 @@ const {
spotify,
user,
webSocketsServerUrl,
-} = recentListensProfilePageProps;
+} = recentListensProps;
const props = {
apiUrl,
@@ -43,7 +50,537 @@ const props = {
describe("RecentListens", () => {
it("renders correctly on the profile page", () => {
timeago.ago = jest.fn().mockImplementation(() => "1 day ago");
- const wrapper = shallow();
+ const wrapper = shallow();
expect(wrapper.html()).toMatchSnapshot();
});
});
+
+describe("componentDidMount", () => {
+ it('calls connectWebsockets if mode is "listens" or "follow"', () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+ instance.connectWebsockets = jest.fn();
+
+ wrapper.setState({ mode: "listens" });
+ instance.componentDidMount();
+
+ wrapper.setState({ mode: "follow" });
+ instance.componentDidMount();
+
+ expect(instance.connectWebsockets).toHaveBeenCalledTimes(2);
+ });
+
+ it('calls getRecentListensForFollowList if mode "follow"', () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+ instance.getRecentListensForFollowList = jest.fn();
+
+ wrapper.setState({ mode: "follow", listens: [] });
+ instance.componentDidMount();
+
+ expect(instance.getRecentListensForFollowList).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("createWebsocketsConnection", () => {
+ it("calls io.connect with correct parameters", () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ const spy = jest.spyOn(io, "connect");
+ instance.createWebsocketsConnection();
+
+ expect(spy).toHaveBeenCalledWith("http://localhost:8081");
+ jest.clearAllMocks();
+ });
+});
+
+describe("addWebsocketsHandlers", () => {
+ it('calls correct handler for "connect" event', () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ wrapper.setState({
+ mode: "follow",
+ followList: ["foo", "bar"],
+ });
+ // eslint-disable-next-line dot-notation
+ const spy = jest.spyOn(instance["socket"], "on");
+ spy.mockImplementation((event, fn): any => {
+ if (event === "connect") {
+ fn();
+ }
+ });
+ instance.handleFollowUserListChange = jest.fn();
+ instance.addWebsocketsHandlers();
+
+ expect(instance.handleFollowUserListChange).toHaveBeenCalledWith(
+ ["foo", "bar"],
+ false
+ );
+ });
+
+ it('calls correct handler for "listen" event', () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ // eslint-disable-next-line dot-notation
+ const spy = jest.spyOn(instance["socket"], "on");
+ spy.mockImplementation((event, fn): any => {
+ if (event === "listen") {
+ fn(JSON.stringify(recentListensPropsOneListen.listens[0]));
+ }
+ });
+ instance.receiveNewListen = jest.fn();
+ instance.addWebsocketsHandlers();
+
+ expect(instance.receiveNewListen).toHaveBeenCalledWith(
+ JSON.stringify(recentListensPropsOneListen.listens[0])
+ );
+ });
+
+ it('calls correct event for "playing_now" event', () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ // eslint-disable-next-line dot-notation
+ const spy = jest.spyOn(instance["socket"], "on");
+ spy.mockImplementation((event, fn): any => {
+ if (event === "playing_now") {
+ fn(JSON.stringify(recentListensPropsPlayingNow.listens[0]));
+ }
+ });
+ instance.receiveNewPlayingNow = jest.fn();
+ instance.addWebsocketsHandlers();
+
+ expect(instance.receiveNewPlayingNow).toHaveBeenCalledWith(
+ JSON.stringify(recentListensPropsPlayingNow.listens[0])
+ );
+ });
+});
+describe("handleFollowUserListChange", () => {
+ it("sets the state correctly", () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ instance.handleFollowUserListChange(["foo", "bar"], true);
+
+ expect(wrapper.state("followList")).toEqual(["foo", "bar"]);
+ });
+
+ it("doesn't do anything if dontSendUpdate is true", () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ wrapper.setState({ mode: "follow", followList: ["bar"] });
+ instance.getRecentListensForFollowList = jest.fn();
+ // eslint-disable-next-line dot-notation
+ const spy = jest.spyOn(instance["socket"], "emit");
+ instance.handleFollowUserListChange(["foo"], true);
+
+ expect(spy).not.toHaveBeenCalled();
+ expect(instance.getRecentListensForFollowList).not.toHaveBeenCalled();
+ });
+
+ it("calls connectWebsockets if socket object hasn't been created", () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ // @ts-ignore undefined can't be assigned to socket but can happen in real life
+ // eslint-disable-next-line dot-notation
+ instance["socket"] = undefined;
+ instance.connectWebsockets = jest.fn();
+ instance.handleFollowUserListChange(["follow"]);
+
+ expect(instance.connectWebsockets).toHaveBeenCalledTimes(1);
+ });
+
+ it("calls socket.emit with correct parameters", () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ // eslint-disable-next-line dot-notation
+ const spy = jest.spyOn(instance["socket"], "emit");
+ instance.handleFollowUserListChange(["foo"]);
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ expect(spy).toHaveBeenCalledWith("json", {
+ user: "iliekcomputers",
+ follow: ["foo"],
+ });
+ });
+
+ it('calls getRecentListensForFollowList if mode is "follow"', () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ wrapper.setState({ mode: "follow", followList: ["bar"] });
+ instance.getRecentListensForFollowList = jest.fn();
+ instance.handleFollowUserListChange(["foo"]);
+
+ expect(instance.getRecentListensForFollowList).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("handleSpotifyAccountError", () => {
+ it("calls newAlert", () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+ instance.newAlert = jest.fn();
+
+ instance.handleSpotifyAccountError(Test
);
+ expect(instance.newAlert).toHaveBeenCalledTimes(1);
+ expect(instance.newAlert).toHaveBeenCalledWith(
+ "danger",
+ "Spotify account error",
+ Test
+ );
+ });
+
+ it('sets "canPlayMusic" to false', () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ wrapper.setState({ canPlayMusic: true });
+
+ instance.handleSpotifyAccountError(Test
);
+ expect(wrapper.state().canPlayMusic).toBe(false);
+ });
+});
+
+describe("handleSpotifyPermissionError", () => {
+ it("calls newAlert", () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+ instance.newAlert = jest.fn();
+
+ instance.handleSpotifyPermissionError(Test
);
+ expect(instance.newAlert).toHaveBeenCalledTimes(1);
+ expect(instance.newAlert).toHaveBeenCalledWith(
+ "danger",
+ "Spotify permission error",
+ Test
+ );
+ });
+
+ it('sets "canPlayMusic" to false', () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ wrapper.setState({ canPlayMusic: true });
+
+ instance.handleSpotifyPermissionError(Test
);
+ expect(wrapper.state().canPlayMusic).toBe(false);
+ });
+});
+
+describe("receiveNewListen", () => {
+ const mockListen: Listen = {
+ track_metadata: {
+ artist_name: "Coldplay",
+ track_name: "Viva La Vida",
+ },
+ listened_at: 1586580524,
+ listened_at_iso: "2020-04-10T10:12:04Z",
+ };
+
+ it("crops the listens array if length is more than or equal to 100", () => {
+ /* JSON.parse(JSON.stringify(object) is a fast way to deep copy an object,
+ * so that it doesn't get passed as a reference.
+ */
+ const wrapper = shallow(
+
+ );
+ const instance = wrapper.instance();
+
+ wrapper.setState({ mode: "follow" });
+ instance.receiveNewListen(JSON.stringify(mockListen));
+
+ expect(wrapper.state("listens").length).toBeLessThanOrEqual(100);
+
+ /* JSON.parse(JSON.stringify(object) is a fast way to deep copy an object,
+ * so that it doesn't get passed as a reference.
+ */
+ wrapper.setState({
+ mode: "listens",
+ listens: JSON.parse(
+ JSON.stringify(recentListensPropsTooManyListens.listens)
+ ),
+ });
+ instance.receiveNewListen(JSON.stringify(mockListen));
+
+ expect(wrapper.state("listens").length).toBeLessThanOrEqual(100);
+ });
+
+ it('inserts the received listen for "follow"', () => {
+ /* JSON.parse(JSON.stringify(object) is a fast way to deep copy an object,
+ * so that it doesn't get passed as a reference.
+ */
+ const wrapper = shallow(
+
+ );
+ const instance = wrapper.instance();
+ wrapper.setState({ mode: "follow" });
+ /* JSON.parse(JSON.stringify(object) is a fast way to deep copy an object,
+ * so that it doesn't get passed as a reference.
+ */
+ const result: Array = JSON.parse(
+ JSON.stringify(recentListensPropsOneListen.listens)
+ );
+ result.push(mockListen);
+ instance.receiveNewListen(JSON.stringify(mockListen));
+
+ expect(wrapper.state("listens")).toHaveLength(result.length);
+ expect(wrapper.state("listens")).toEqual(result);
+ });
+
+ it("inserts the received listen for other modes", () => {
+ /* JSON.parse(JSON.stringify(object) is a fast way to deep copy an object,
+ * so that it doesn't get passed as a reference.
+ */
+ const wrapper = shallow(
+
+ );
+ const instance = wrapper.instance();
+ wrapper.setState({ mode: "recent" });
+ /* JSON.parse(JSON.stringify(object) is a fast way to deep copy an object,
+ * so that it doesn't get passed as a reference.
+ */
+ const result: Array = JSON.parse(
+ JSON.stringify(recentListensPropsOneListen.listens)
+ );
+ result.unshift(mockListen);
+ instance.receiveNewListen(JSON.stringify(mockListen));
+
+ expect(wrapper.state("listens")).toHaveLength(result.length);
+ expect(wrapper.state("listens")).toEqual(result);
+ });
+});
+
+describe("receiveNewPlayingNow", () => {
+ const mockListenOne: Listen = {
+ track_metadata: {
+ artist_name: "Coldplay",
+ track_name: "Viva La Vida",
+ },
+ user_name: "ishaanshah",
+ listened_at: 1586580524,
+ listened_at_iso: "2020-04-10T10:12:04Z",
+ };
+ const mockListenTwo: Listen = {
+ track_metadata: {
+ artist_name: "SOHN",
+ track_name: "Falling",
+ },
+ playing_now: true,
+ listened_at: 1586513524,
+ listened_at_iso: "2020-04-10T10:12:04Z",
+ };
+
+ it('sets state correctly if mode is "follow"', () => {
+ const wrapper = shallow(
+
+ );
+ const instance = wrapper.instance();
+
+ wrapper.setState({
+ mode: "follow",
+ playingNowByUser: {
+ iliekcomputers: mockListenTwo,
+ },
+ });
+ instance.receiveNewPlayingNow(JSON.stringify(mockListenOne));
+
+ expect(wrapper.state("playingNowByUser")).toEqual({
+ ishaanshah: {
+ playing_now: true,
+ ...mockListenOne,
+ },
+ iliekcomputers: mockListenTwo,
+ });
+ });
+
+ it("sets state correctly for other modes", () => {
+ /* JSON.parse(JSON.stringify(object) is a fast way to deep copy an object,
+ * so that it doesn't get passed as a reference.
+ */
+ const wrapper = shallow(
+
+ );
+ const instance = wrapper.instance();
+
+ wrapper.setState({ mode: "listens" });
+ /* JSON.parse(JSON.stringify(object) is a fast way to deep copy an object,
+ * so that it doesn't get passed as a reference.
+ */
+ const result = JSON.parse(
+ JSON.stringify(recentListensPropsPlayingNow.listens)
+ );
+ result.shift();
+ result.unshift({ ...mockListenOne, playing_now: true });
+ instance.receiveNewPlayingNow(JSON.stringify(mockListenOne));
+
+ expect(wrapper.state("listens")).toEqual(result);
+ });
+});
+
+describe("handleCurrentListenChange", () => {
+ it("sets the state correctly", () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ const listen: Listen = {
+ listened_at: 0,
+ track_metadata: {
+ artist_name: "George Erza",
+ track_name: "Shotgun",
+ },
+ };
+ instance.handleCurrentListenChange(listen);
+
+ expect(wrapper.state().currentListen).toEqual(listen);
+ });
+});
+
+describe("isCurrentListen", () => {
+ it("returns true if currentListen and passed listen is same", () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ const listen: Listen = {
+ listened_at: 0,
+ track_metadata: {
+ artist_name: "Coldplay",
+ track_name: "Up & Up",
+ },
+ };
+ wrapper.setState({ currentListen: listen });
+
+ expect(instance.isCurrentListen(listen)).toBe(true);
+ });
+
+ it("returns false if currentListen is not set", () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ wrapper.setState({ currentListen: undefined });
+
+ expect(instance.isCurrentListen({} as Listen)).toBeFalsy();
+ });
+});
+
+describe("getRecentListensForFollowList", () => {
+ it("calls getRecentListensForUsers", () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ wrapper.setState({
+ followList: ["ishaanshah", "iliekcomputers", "puneruns"],
+ });
+ // eslint-disable-next-line dot-notation
+ const spy = jest.spyOn(instance["APIService"], "getRecentListensForUsers");
+ instance.getRecentListensForFollowList();
+
+ expect(spy).toHaveBeenCalledWith([
+ "ishaanshah",
+ "iliekcomputers",
+ "puneruns",
+ ]);
+ });
+
+ it("creates new alert if anything fails", () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ wrapper.setState({
+ followList: ["ishaanshah", "iliekcomputers", "puneruns"],
+ });
+ // eslint-disable-next-line dot-notation
+ const spy = jest.spyOn(instance["APIService"], "getRecentListensForUsers");
+ spy.mockImplementation(() => {
+ throw new Error("foobar");
+ });
+ instance.newAlert = jest.fn();
+ instance.getRecentListensForFollowList();
+
+ expect(instance.newAlert).toHaveBeenCalledWith(
+ "danger",
+ "Could not get recent listens",
+ "foobar"
+ );
+ });
+});
+
+describe("newAlert", () => {
+ it("creates a new alert", () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ // Mock Date().getTime()
+ jest.spyOn(Date.prototype, "getTime").mockImplementation(() => 0);
+
+ expect(wrapper.state().alerts).toEqual([]);
+
+ instance.newAlert("warning", "Test", "foobar");
+ expect(wrapper.state().alerts).toEqual([
+ { id: 0, type: "warning", title: "Test", message: "foobar" },
+ ]);
+
+ instance.newAlert("danger", "test", foobar
);
+ expect(wrapper.state().alerts).toEqual([
+ { id: 0, type: "warning", title: "Test", message: "foobar" },
+ { id: 0, type: "danger", title: "test", message: foobar
},
+ ]);
+ });
+});
+
+describe("onAlertDismissed", () => {
+ it("deletes a alert", () => {
+ const wrapper = shallow();
+ const instance = wrapper.instance();
+
+ // Mock Date().getTime()
+ jest.spyOn(Date.prototype, "getTime").mockImplementation(() => 0);
+
+ const alert1 = {
+ id: 0,
+ type: "warning",
+ title: "Test",
+ message: "foobar",
+ } as Alert;
+ const alert2 = {
+ id: 0,
+ type: "danger",
+ title: "test",
+ message: foobar
,
+ } as Alert;
+ wrapper.setState({
+ alerts: [alert1, alert2],
+ });
+ expect(wrapper.state().alerts).toEqual([alert1, alert2]);
+
+ instance.onAlertDismissed(alert1);
+ expect(wrapper.state().alerts).toEqual([alert2]);
+
+ instance.onAlertDismissed(alert2);
+ expect(wrapper.state().alerts).toEqual([]);
+ });
+});
diff --git a/listenbrainz/webserver/static/js/src/RecentListens.tsx b/listenbrainz/webserver/static/js/src/RecentListens.tsx
index e4b65b8413..0cab703d50 100644
--- a/listenbrainz/webserver/static/js/src/RecentListens.tsx
+++ b/listenbrainz/webserver/static/js/src/RecentListens.tsx
@@ -45,15 +45,14 @@ export interface RecentListensProps {
export interface RecentListensState {
alerts: Array;
canPlayMusic?: boolean;
- currentListen: Listen;
+ currentListen?: Listen;
direction: SpotifyPlayDirection;
followList: Array;
listId?: number;
listName: string;
listens: Array;
mode: "listens" | "follow" | "recent";
- // TODO: put correct value
- playingNowByUser: any;
+ playingNowByUser: FollowUsersPlayingNow;
saveUrl: string;
}
@@ -72,7 +71,6 @@ export default class RecentListens extends React.Component<
this.state = {
alerts: [],
listens: props.listens || [],
- currentListen: {} as Listen,
mode: props.mode,
followList: props.followList || [],
playingNowByUser: {},
@@ -98,19 +96,24 @@ export default class RecentListens extends React.Component<
}
connectWebsockets = (): void => {
- const { mode, followList } = this.state;
- const { webSocketsServerUrl, user } = this.props;
+ this.createWebsocketsConnection();
+ this.addWebsocketsHandlers();
+ };
+ createWebsocketsConnection = (): void => {
+ const { webSocketsServerUrl } = this.props;
this.socket = io.connect(webSocketsServerUrl);
+ };
+
+ addWebsocketsHandlers = (): void => {
+ const { mode, followList } = this.state;
+ const { user } = this.props;
+
this.socket.on("connect", () => {
- switch (mode) {
- case "follow":
- this.handleFollowUserListChange(followList, false);
- break;
- case "listens":
- default:
- this.handleFollowUserListChange([user.name], false);
- break;
+ if (mode === "follow") {
+ this.handleFollowUserListChange(followList, false);
+ } else {
+ this.handleFollowUserListChange([user.name], false);
}
});
this.socket.on("listen", (data: string) => {
@@ -159,14 +162,13 @@ export default class RecentListens extends React.Component<
this.setState({ canPlayMusic: false });
};
- handleSpotifyPermissionError = (error: string): void => {
+ handleSpotifyPermissionError = (error: string | JSX.Element): void => {
this.newAlert("danger", "Spotify permission error", error);
this.setState({ canPlayMusic: false });
};
playListen = (listen: Listen): void => {
if (this.spotifyPlayer.current) {
- // @ts-ignore
this.spotifyPlayer.current.playListen(listen);
} else {
// For fallback embedded player
@@ -179,7 +181,7 @@ export default class RecentListens extends React.Component<
this.setState((prevState) => {
const { listens } = prevState;
// Crop listens array to 100 max
- if (listens.length >= 100) {
+ while (listens.length >= 100) {
if (prevState.mode === "follow") {
listens.shift();
} else {
@@ -228,7 +230,7 @@ export default class RecentListens extends React.Component<
isCurrentListen = (listen: Listen): boolean => {
const { currentListen } = this.state;
- return currentListen && _.isEqual(listen, currentListen);
+ return Boolean(currentListen && _.isEqual(listen, currentListen));
};
getRecentListensForFollowList = async () => {
diff --git a/listenbrainz/webserver/static/js/src/__mocks__/recentListensProfilePageProps.json b/listenbrainz/webserver/static/js/src/__mocks__/recentListensProps.json
similarity index 100%
rename from listenbrainz/webserver/static/js/src/__mocks__/recentListensProfilePageProps.json
rename to listenbrainz/webserver/static/js/src/__mocks__/recentListensProps.json
diff --git a/listenbrainz/webserver/static/js/src/__mocks__/recentListensPropsOneListen.json b/listenbrainz/webserver/static/js/src/__mocks__/recentListensPropsOneListen.json
new file mode 100644
index 0000000000..c664c6fd84
--- /dev/null
+++ b/listenbrainz/webserver/static/js/src/__mocks__/recentListensPropsOneListen.json
@@ -0,0 +1,32 @@
+{
+ "user": {
+ "id": 1,
+ "name": "iliekcomputers"
+ },
+ "listens": [
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ }
+ ],
+ "previousListenTs": 1586513524,
+ "nextListenTs": 1586440536,
+ "latestListenTs": 1586632818,
+ "latestSpotifyUri": "spotify:track:5svEhroDfeTt0ePvASUtZ6",
+ "haveListenCount": true,
+ "listenCount": "72,662",
+ "artistCount": null,
+ "profileUrl": "/user/iliekcomputers",
+ "mode": "listens",
+ "spotify": {
+ "access_token": "access token",
+ "permission": "streaming user-read-email user-read-private"
+ },
+ "webSocketsServerUrl": "http://localhost:8081",
+ "apiUrl": "http://0.0.0.0"
+}
+
diff --git a/listenbrainz/webserver/static/js/src/__mocks__/recentListensPropsPlayingNow.json b/listenbrainz/webserver/static/js/src/__mocks__/recentListensPropsPlayingNow.json
new file mode 100644
index 0000000000..2b746b8efa
--- /dev/null
+++ b/listenbrainz/webserver/static/js/src/__mocks__/recentListensPropsPlayingNow.json
@@ -0,0 +1,49 @@
+{
+ "user": {
+ "id": 1,
+ "name": "iliekcomputers"
+ },
+ "listens": [
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "playing_now": true,
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "Ed Sheeran",
+ "track_name": "Castle on th Hill"
+ },
+ "listened_at": 1582512514,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "Coldplay",
+ "track_name": "Orphans"
+ },
+ "listened_at": 158221354,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ }
+ ],
+ "previousListenTs": 1586513524,
+ "nextListenTs": 1586440536,
+ "latestListenTs": 1586632818,
+ "latestSpotifyUri": "spotify:track:5svEhroDfeTt0ePvASUtZ6",
+ "haveListenCount": true,
+ "listenCount": "72,662",
+ "artistCount": null,
+ "profileUrl": "/user/iliekcomputers",
+ "mode": "listens",
+ "spotify": {
+ "access_token": "access token",
+ "permission": "streaming user-read-email user-read-private"
+ },
+ "webSocketsServerUrl": "http://localhost:8081",
+ "apiUrl": "http://0.0.0.0"
+}
+
diff --git a/listenbrainz/webserver/static/js/src/__mocks__/recentListensPropsTooManyListens.json b/listenbrainz/webserver/static/js/src/__mocks__/recentListensPropsTooManyListens.json
new file mode 100644
index 0000000000..4274ec9898
--- /dev/null
+++ b/listenbrainz/webserver/static/js/src/__mocks__/recentListensPropsTooManyListens.json
@@ -0,0 +1,847 @@
+{
+ "user": {
+ "id": 1,
+ "name": "iliekcomputers"
+ },
+ "listens": [
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ },
+ {
+ "track_metadata": {
+ "artist_name": "SOHN",
+ "track_name": "Falling"
+ },
+ "listened_at": 1586513524,
+ "listened_at_iso": "2020-04-10T10:12:04Z"
+ }
+ ],
+ "previousListenTs": 1586513524,
+ "nextListenTs": 1586440536,
+ "latestListenTs": 1586632818,
+ "latestSpotifyUri": "spotify:track:5svEhroDfeTt0ePvASUtZ6",
+ "haveListenCount": true,
+ "listenCount": "72,662",
+ "artistCount": null,
+ "profileUrl": "/user/iliekcomputers",
+ "mode": "listens",
+ "spotify": {
+ "access_token": "access token",
+ "permission": "streaming user-read-email user-read-private"
+ },
+ "webSocketsServerUrl": "http://localhost:8081",
+ "apiUrl": "http://0.0.0.0"
+}