Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Broadcast playlist edits in real time using websockets #1212

Merged
merged 13 commits into from
Feb 10, 2021
Merged
2 changes: 1 addition & 1 deletion docker/beta/uwsgi/uwsgi.service
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ fi
if [ "${CONTAINER_NAME}" = "listenbrainz-follow-dispatcher-beta" ]
then
cd /code/listenbrainz
exec python3 manage.py run_follow_server -h 0.0.0.0 -p 3031
exec python3 manage.py run_websockets -h 0.0.0.0 -p 3031
fi

if [ "${CONTAINER_NAME}" = "listenbrainz-spark-reader-beta" ]
Expand Down
6 changes: 3 additions & 3 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,13 @@ services:
- redis
- rabbitmq

follow_server:
websockets:
image: web
volumes:
- ..:/code/listenbrainz:z
command: python manage.py run_follow_server -h 0.0.0.0 -p 8081
command: python manage.py run_websockets -h 0.0.0.0 -p 8082
ports:
- "8081:8081"
- "8082:8082"
depends_on:
- redis
- rabbitmq
Expand Down
2 changes: 1 addition & 1 deletion docker/prod/uwsgi/uwsgi.service
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ fi
if [ "${CONTAINER_NAME}" = "listenbrainz-follow-dispatcher-prod" ]
then
cd /code/listenbrainz
exec python manage.py run_follow_server -h 0.0.0.0 -p 3031
exec python manage.py run_websockets -h 0.0.0.0 -p 3031
fi

if [ "${CONTAINER_NAME}" = "listenbrainz-spotify-reader-prod" ]
Expand Down
2 changes: 1 addition & 1 deletion docker/test/uwsgi/uwsgi.service
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ fi
if [ "${CONTAINER_NAME}" = "listenbrainz-follow-dispatcher-test" ]
then
cd /code/listenbrainz
exec python3 manage.py run_follow_server -h 0.0.0.0 -p 3031
exec python3 manage.py run_websockets -h 0.0.0.0 -p 3031
fi

if [ "${CONTAINER_NAME}" = "listenbrainz-spark-reader-test" ]
Expand Down
2 changes: 1 addition & 1 deletion docs/dev/devel-env.rst
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ different services. We provide a small description of each container here:
* ``rabbitmq``: Used for passing listens between different services
* ``web``: This is the main ListenBrainz server
* ``api_compat``: A Last.fm-compatible API server
* ``follow_server``: A helper server used for the user-following component of ListenBrainz
* ``websockets``: A websocket server used for the user-following and playlist updates on the front-end
* ``static_builder``: A helper service to build Javascript/Typescript and CSS assets if they are changed

.. note::
Expand Down
100 changes: 57 additions & 43 deletions listenbrainz/webserver/static/js/src/playlists/Playlist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import * as React from "react";
import * as ReactDOM from "react-dom";
import { get, findIndex, omit, isNil, has } from "lodash";
import { get, findIndex, omit, isNil, has, defaultsDeep } from "lodash";
import * as io from "socket.io-client";

import { ActionMeta, InputActionMeta, ValueType } from "react-select";
Expand Down Expand Up @@ -115,13 +115,19 @@ export default class PlaylistPage extends React.Component<
});
}

async componentDidMount() {
// this.connectWebsockets();
componentDidMount(): void {
this.connectWebsockets();
MonkeyDo marked this conversation as resolved.
Show resolved Hide resolved
/* Deactivating feedback until the feedback system works with MBIDs instead of MSIDs */
/* const recordingFeedbackMap = await this.loadFeedback();
this.setState({ recordingFeedbackMap }); */
}

componentWillUnmount(): void {
if (this.socket?.connected) {
this.socket.disconnect();
}
}

connectWebsockets = (): void => {
// Do we want to show live updates for everyone, or just owner & collaborators?
this.createWebsocketsConnection();
Expand All @@ -134,26 +140,33 @@ export default class PlaylistPage extends React.Component<
};

addWebsocketsHandlers = (): void => {
// this.socket.on("connect", () => {
// });
this.socket.on("playlist_change", (data: string) => {
this.socket.on("connect", () => {
const { playlist } = this.state;
this.socket.emit("joined", {
playlist_id: getPlaylistId(playlist),
});
});
this.socket.on("playlist_changed", (data: JSPFPlaylist) => {
this.handlePlaylistChange(data);
});
};

handlePlaylistChange = (data: string): void => {
const newPlaylist = JSON.parse(data);
emitPlaylistChanged = (): void => {
const { playlist } = this.state;
this.socket.emit("change_playlist", playlist);
};

handlePlaylistChange = (data: JSPFPlaylist): void => {
const newPlaylist = data;
Comment on lines +159 to +160
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
handlePlaylistChange = (data: JSPFPlaylist): void => {
const newPlaylist = data;
handlePlaylistChange = (newPlaylist: JSPFPlaylist): void => {

// rerun fetching metadata for all tracks?
// or find new tracks and fetch metadata for them, add them to local Map

// React-SortableJS expects an 'id' attribute and we can't change it, so add it to each object
// eslint-disable-next-line no-unused-expressions
newPlaylist?.playlist?.track?.forEach(
(jspfTrack: JSPFTrack, index: number) => {
// eslint-disable-next-line no-param-reassign
jspfTrack.id = getRecordingMBIDFromJSPFTrack(jspfTrack);
}
);
newPlaylist?.track?.forEach((jspfTrack: JSPFTrack, index: number) => {
// eslint-disable-next-line no-param-reassign
jspfTrack.id = getRecordingMBIDFromJSPFTrack(jspfTrack);
});
this.setState({ playlist: newPlaylist });
};

Expand Down Expand Up @@ -196,10 +209,13 @@ export default class PlaylistPage extends React.Component<
selectedRecording.recording_mbid,
]); */
jspfTrack.id = selectedRecording.recording_mbid;
this.setState({
playlist: { ...playlist, track: [...playlist.track, jspfTrack] },
// recordingFeedbackMap,
});
this.setState(
{
playlist: { ...playlist, track: [...playlist.track, jspfTrack] },
// recordingFeedbackMap,
},
this.emitPlaylistChanged
);
} catch (error) {
this.handleError(error);
}
Expand Down Expand Up @@ -465,12 +481,15 @@ export default class PlaylistPage extends React.Component<
);
if (status === 200) {
tracks.splice(trackIndex, 1);
this.setState({
playlist: {
...playlist,
track: [...tracks],
this.setState(
{
playlist: {
...playlist,
track: [...tracks],
},
},
});
this.emitPlaylistChanged
);
}
} catch (error) {
this.handleError(error);
Expand All @@ -497,6 +516,7 @@ export default class PlaylistPage extends React.Component<
evt.newIndex,
1
);
this.emitPlaylistChanged();
} catch (error) {
this.handleError(error);
// Revert the move in state.playlist order
Expand Down Expand Up @@ -547,35 +567,29 @@ export default class PlaylistPage extends React.Component<
return;
}
try {
const editedPlaylist: JSPFPlaylist = {
...playlist,
annotation: description,
title: name,
extension: {
[MUSICBRAINZ_JSPF_PLAYLIST_EXTENSION]: {
public: isPublic,
collaborators,
// Replace keys that have changed but keep all other attributres
const editedPlaylist: JSPFPlaylist = defaultsDeep(
{
annotation: description,
title: name,
extension: {
[MUSICBRAINZ_JSPF_PLAYLIST_EXTENSION]: {
public: isPublic,
collaborators,
},
},
},
};
playlist
);

await this.APIService.editPlaylist(currentUser.auth_token, id, {
playlist: omit(editedPlaylist, "track") as JSPFPlaylist,
});

this.setState({ playlist: editedPlaylist }, this.emitPlaylistChanged);
this.newAlert("success", "Saved playlist", "");
} catch (error) {
this.handleError(error);
}
try {
// Fetch the newly editd playlist and save it to state
const JSPFObject: JSPFObject = await this.APIService.getPlaylist(
id,
currentUser.auth_token
);
this.setState({ playlist: JSPFObject.playlist });
} catch (error) {
this.handleError(error);
}
};

alertMustBeLoggedIn = () => {
Expand Down
1 change: 1 addition & 0 deletions listenbrainz/webserver/views/playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def load_playlist(playlist_mbid: str):
"current_user": current_user_data,
"spotify": spotify_data,
"api_url": current_app.config["API_URL"],
"web_sockets_server_url": current_app.config['WEBSOCKETS_SERVER_URL'],
"playlist": serialize_jspf(playlist)
}

Expand Down
48 changes: 29 additions & 19 deletions listenbrainz/follow_server/follow_server.py → listenbrainz/websockets/websockets.py
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,38 +1,30 @@
import eventlet
eventlet.monkey_patch()

from flask import Flask, current_app, request
from flask_socketio import SocketIO, join_room, leave_room, emit, rooms
from flask import request
from flask_socketio import SocketIO, join_room, emit, rooms, leave_room
from werkzeug.exceptions import BadRequest
import argparse
import json
from brainzutils.flask import CustomFlask

from listenbrainz.webserver import load_config
from brainzutils.flask import CustomFlask
from listenbrainz.follow_server.dispatcher import FollowDispatcher
from listenbrainz.webserver.errors import init_error_handlers
from listenbrainz.websockets.follow_server_dispatcher import FollowDispatcher

app = CustomFlask(
import_name=__name__,
use_flask_uuid=True,
)
load_config(app)
eventlet.monkey_patch()

# Error handling
from listenbrainz.webserver.errors import init_error_handlers
app = CustomFlask(import_name=__name__, use_flask_uuid=True)
load_config(app)
init_error_handlers(app)

# Logging
app.init_loggers(
file_config=app.config.get('LOG_FILE'),
email_config=app.config.get('LOG_EMAIL'),
sentry_config=app.config.get('LOG_SENTRY')
)

socketio = SocketIO(app, cors_allowed_origins='*')


@socketio.on('json')
def handle_json(data):

try:
user = data['user']
except KeyError:
Expand All @@ -48,7 +40,7 @@ def handle_json(data):

current_rooms = rooms()
for user in rooms():

# Don't remove the user from its own room
if user == request.sid:
continue
Expand All @@ -61,7 +53,25 @@ def handle_json(data):
join_room(user)


def run_follow_server(host='0.0.0.0', port=8081, debug=True):
@socketio.on('change_playlist')
def dispatch_playlist_updates(data):
identifier = data['identifier']
idx = identifier.rfind('/')
playlist_id = identifier[idx + 1:]
emit('playlist_changed', data, room=playlist_id)


@socketio.on('joined')
def joined(data):
if 'playlist_id' not in data:
raise BadRequest("Missing key 'playlist_id'")
MonkeyDo marked this conversation as resolved.
Show resolved Hide resolved

room = data['playlist_id']
join_room(room)
emit('joined', {'status': 'success'}, room=room)


def run_websockets(host='0.0.0.0', port=8082, debug=True):
fd = FollowDispatcher(app, socketio)
fd.start()
socketio.run(app, debug=debug, host=host, port=port)
11 changes: 6 additions & 5 deletions manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,16 @@ def run_api_compat_server(host, port, debug=False):
processes=5
)

@cli.command(name="run_follow_server")

@cli.command(name="run_websockets")
@click.option("--host", "-h", default="0.0.0.0", show_default=True)
@click.option("--port", "-p", default=8081, show_default=True)
@click.option("--port", "-p", default=8082, show_default=True)
@click.option("--debug", "-d", is_flag=True,
help="Turns debugging mode on or off. If specified, overrides "
"'DEBUG' value in the config file.")
def run_follow_server(host, port, debug=True):
from listenbrainz.follow_server.follow_server import run_follow_server
run_follow_server(host=host, port=port, debug=debug)
def run_websockets(host, port, debug=True):
from listenbrainz.websockets.websockets import run_websockets
run_websockets(host=host, port=port, debug=debug)


@cli.command(name="init_db")
Expand Down