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" +}