using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.SyncPlay; using MediaBrowser.Model.Session; using MediaBrowser.Model.SyncPlay; namespace Emby.Server.Implementations.SyncPlay { /// <summary> /// Class SyncPlayController. /// </summary> /// <remarks> /// Class is not thread-safe, external locking is required when accessing methods. /// </remarks> public class SyncPlayController : ISyncPlayController { /// <summary> /// Used to filter the sessions of a group. /// </summary> private enum BroadcastType { /// <summary> /// All sessions will receive the message. /// </summary> AllGroup = 0, /// <summary> /// Only the specified session will receive the message. /// </summary> CurrentSession = 1, /// <summary> /// All sessions, except the current one, will receive the message. /// </summary> AllExceptCurrentSession = 2, /// <summary> /// Only sessions that are not buffering will receive the message. /// </summary> AllReady = 3 } /// <summary> /// The session manager. /// </summary> private readonly ISessionManager _sessionManager; /// <summary> /// The SyncPlay manager. /// </summary> private readonly ISyncPlayManager _syncPlayManager; /// <summary> /// The group to manage. /// </summary> private readonly GroupInfo _group = new GroupInfo(); /// <summary> /// Initializes a new instance of the <see cref="SyncPlayController" /> class. /// </summary> /// <param name="sessionManager">The session manager.</param> /// <param name="syncPlayManager">The SyncPlay manager.</param> public SyncPlayController( ISessionManager sessionManager, ISyncPlayManager syncPlayManager) { _sessionManager = sessionManager; _syncPlayManager = syncPlayManager; } /// <inheritdoc /> public Guid GetGroupId() => _group.GroupId; /// <inheritdoc /> public Guid GetPlayingItemId() => _group.PlayingItem.Id; /// <inheritdoc /> public bool IsGroupEmpty() => _group.IsEmpty(); /// <summary> /// Converts DateTime to UTC string. /// </summary> /// <param name="date">The date to convert.</param> /// <value>The UTC string.</value> private string DateToUTCString(DateTime date) { return date.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); } /// <summary> /// Filters sessions of this group. /// </summary> /// <param name="from">The current session.</param> /// <param name="type">The filtering type.</param> /// <value>The array of sessions matching the filter.</value> private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, BroadcastType type) { switch (type) { case BroadcastType.CurrentSession: return new SessionInfo[] { from }; case BroadcastType.AllGroup: return _group.Participants.Values .Select(session => session.Session); case BroadcastType.AllExceptCurrentSession: return _group.Participants.Values .Select(session => session.Session) .Where(session => !session.Id.Equals(from.Id, StringComparison.Ordinal)); case BroadcastType.AllReady: return _group.Participants.Values .Where(session => !session.IsBuffering) .Select(session => session.Session); default: return Array.Empty<SessionInfo>(); } } /// <summary> /// Sends a GroupUpdate message to the interested sessions. /// </summary> /// <param name="from">The current session.</param> /// <param name="type">The filtering type.</param> /// <param name="message">The message to send.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <value>The task.</value> private Task SendGroupUpdate<T>(SessionInfo from, BroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken) { IEnumerable<Task> GetTasks() { foreach (var session in FilterSessions(from, type)) { yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id, message, cancellationToken); } } return Task.WhenAll(GetTasks()); } /// <summary> /// Sends a playback command to the interested sessions. /// </summary> /// <param name="from">The current session.</param> /// <param name="type">The filtering type.</param> /// <param name="message">The message to send.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <value>The task.</value> private Task SendCommand(SessionInfo from, BroadcastType type, SendCommand message, CancellationToken cancellationToken) { IEnumerable<Task> GetTasks() { foreach (var session in FilterSessions(from, type)) { yield return _sessionManager.SendSyncPlayCommand(session.Id, message, cancellationToken); } } return Task.WhenAll(GetTasks()); } /// <summary> /// Builds a new playback command with some default values. /// </summary> /// <param name="type">The command type.</param> /// <value>The SendCommand.</value> private SendCommand NewSyncPlayCommand(SendCommandType type) { return new SendCommand() { GroupId = _group.GroupId.ToString(), Command = type, PositionTicks = _group.PositionTicks, When = DateToUTCString(_group.LastActivity), EmittedAt = DateToUTCString(DateTime.UtcNow) }; } /// <summary> /// Builds a new group update message. /// </summary> /// <param name="type">The update type.</param> /// <param name="data">The data to send.</param> /// <value>The GroupUpdate.</value> private GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data) { return new GroupUpdate<T>() { GroupId = _group.GroupId.ToString(), Type = type, Data = data }; } /// <inheritdoc /> public void CreateGroup(SessionInfo session, CancellationToken cancellationToken) { _group.AddSession(session); _syncPlayManager.AddSessionToGroup(session, this); _group.PlayingItem = session.FullNowPlayingItem; _group.IsPaused = session.PlayState.IsPaused; _group.PositionTicks = session.PlayState.PositionTicks ?? 0; _group.LastActivity = DateTime.UtcNow; var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow)); SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken); } /// <inheritdoc /> public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) { if (session.NowPlayingItem?.Id == _group.PlayingItem.Id) { _group.AddSession(session); _syncPlayManager.AddSessionToGroup(session, this); var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow)); SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken); var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); // Syncing will happen client-side if (!_group.IsPaused) { var playCommand = NewSyncPlayCommand(SendCommandType.Play); SendCommand(session, BroadcastType.CurrentSession, playCommand, cancellationToken); } else { var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause); SendCommand(session, BroadcastType.CurrentSession, pauseCommand, cancellationToken); } } else { var playRequest = new PlayRequest { ItemIds = new Guid[] { _group.PlayingItem.Id }, StartPositionTicks = _group.PositionTicks }; var update = NewSyncPlayGroupUpdate(GroupUpdateType.PrepareSession, playRequest); SendGroupUpdate(session, BroadcastType.CurrentSession, update, cancellationToken); } } /// <inheritdoc /> public void SessionLeave(SessionInfo session, CancellationToken cancellationToken) { _group.RemoveSession(session); _syncPlayManager.RemoveSessionFromGroup(session, this); var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, _group.PositionTicks); SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken); var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName); SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); } /// <inheritdoc /> public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) { // The server's job is to maintain a consistent state for clients to reference // and notify clients of state changes. The actual syncing of media playback // happens client side. Clients are aware of the server's time and use it to sync. switch (request.Type) { case PlaybackRequestType.Play: HandlePlayRequest(session, request, cancellationToken); break; case PlaybackRequestType.Pause: HandlePauseRequest(session, request, cancellationToken); break; case PlaybackRequestType.Seek: HandleSeekRequest(session, request, cancellationToken); break; case PlaybackRequestType.Buffer: HandleBufferingRequest(session, request, cancellationToken); break; case PlaybackRequestType.Ready: HandleBufferingDoneRequest(session, request, cancellationToken); break; case PlaybackRequestType.Ping: HandlePingUpdateRequest(session, request); break; } } /// <summary> /// Handles a play action requested by a session. /// </summary> /// <param name="session">The session.</param> /// <param name="request">The play action.</param> /// <param name="cancellationToken">The cancellation token.</param> private void HandlePlayRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) { if (_group.IsPaused) { // Pick a suitable time that accounts for latency var delay = Math.Max(_group.GetHighestPing() * 2, GroupInfo.DefaultPing); // Unpause group and set starting point in future // Clients will start playback at LastActivity (datetime) from PositionTicks (playback position) // The added delay does not guarantee, of course, that the command will be received in time // Playback synchronization will mainly happen client side _group.IsPaused = false; _group.LastActivity = DateTime.UtcNow.AddMilliseconds( delay); var command = NewSyncPlayCommand(SendCommandType.Play); SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); } else { // Client got lost, sending current state var command = NewSyncPlayCommand(SendCommandType.Play); SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); } } /// <summary> /// Handles a pause action requested by a session. /// </summary> /// <param name="session">The session.</param> /// <param name="request">The pause action.</param> /// <param name="cancellationToken">The cancellation token.</param> private void HandlePauseRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) { if (!_group.IsPaused) { // Pause group and compute the media playback position _group.IsPaused = true; var currentTime = DateTime.UtcNow; var elapsedTime = currentTime - _group.LastActivity; _group.LastActivity = currentTime; // Seek only if playback actually started // Pause request may be issued during the delay added to account for latency _group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; var command = NewSyncPlayCommand(SendCommandType.Pause); SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); } else { // Client got lost, sending current state var command = NewSyncPlayCommand(SendCommandType.Pause); SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); } } /// <summary> /// Handles a seek action requested by a session. /// </summary> /// <param name="session">The session.</param> /// <param name="request">The seek action.</param> /// <param name="cancellationToken">The cancellation token.</param> private void HandleSeekRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) { // Sanitize PositionTicks var ticks = SanitizePositionTicks(request.PositionTicks); // Pause and seek _group.IsPaused = true; _group.PositionTicks = ticks; _group.LastActivity = DateTime.UtcNow; var command = NewSyncPlayCommand(SendCommandType.Seek); SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); } /// <summary> /// Handles a buffering action requested by a session. /// </summary> /// <param name="session">The session.</param> /// <param name="request">The buffering action.</param> /// <param name="cancellationToken">The cancellation token.</param> private void HandleBufferingRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) { if (!_group.IsPaused) { // Pause group and compute the media playback position _group.IsPaused = true; var currentTime = DateTime.UtcNow; var elapsedTime = currentTime - _group.LastActivity; _group.LastActivity = currentTime; _group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; _group.SetBuffering(session, true); // Send pause command to all non-buffering sessions var command = NewSyncPlayCommand(SendCommandType.Pause); SendCommand(session, BroadcastType.AllReady, command, cancellationToken); var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.GroupWait, session.UserName); SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); } else { // Client got lost, sending current state var command = NewSyncPlayCommand(SendCommandType.Pause); SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); } } /// <summary> /// Handles a buffering-done action requested by a session. /// </summary> /// <param name="session">The session.</param> /// <param name="request">The buffering-done action.</param> /// <param name="cancellationToken">The cancellation token.</param> private void HandleBufferingDoneRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) { if (_group.IsPaused) { _group.SetBuffering(session, false); var requestTicks = SanitizePositionTicks(request.PositionTicks); var when = request.When ?? DateTime.UtcNow; var currentTime = DateTime.UtcNow; var elapsedTime = currentTime - when; var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime; var delay = _group.PositionTicks - clientPosition.Ticks; if (_group.IsBuffering()) { // Others are still buffering, tell this client to pause when ready var command = NewSyncPlayCommand(SendCommandType.Pause); var pauseAtTime = currentTime.AddMilliseconds(delay); command.When = DateToUTCString(pauseAtTime); SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); } else { // Let other clients resume as soon as the buffering client catches up _group.IsPaused = false; if (delay > _group.GetHighestPing() * 2) { // Client that was buffering is recovering, notifying others to resume _group.LastActivity = currentTime.AddMilliseconds( delay); var command = NewSyncPlayCommand(SendCommandType.Play); SendCommand(session, BroadcastType.AllExceptCurrentSession, command, cancellationToken); } else { // Client, that was buffering, resumed playback but did not update others in time delay = Math.Max(_group.GetHighestPing() * 2, GroupInfo.DefaultPing); _group.LastActivity = currentTime.AddMilliseconds( delay); var command = NewSyncPlayCommand(SendCommandType.Play); SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); } } } else { // Group was not waiting, make sure client has latest state var command = NewSyncPlayCommand(SendCommandType.Play); SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); } } /// <summary> /// Sanitizes the PositionTicks, considers the current playing item when available. /// </summary> /// <param name="positionTicks">The PositionTicks.</param> /// <value>The sanitized PositionTicks.</value> private long SanitizePositionTicks(long? positionTicks) { var ticks = positionTicks ?? 0; ticks = ticks >= 0 ? ticks : 0; if (_group.PlayingItem != null) { var runTimeTicks = _group.PlayingItem.RunTimeTicks ?? 0; ticks = ticks > runTimeTicks ? runTimeTicks : ticks; } return ticks; } /// <summary> /// Updates ping of a session. /// </summary> /// <param name="session">The session.</param> /// <param name="request">The update.</param> private void HandlePingUpdateRequest(SessionInfo session, PlaybackRequest request) { // Collected pings are used to account for network latency when unpausing playback _group.UpdatePing(session, request.Ping ?? GroupInfo.DefaultPing); } /// <inheritdoc /> public GroupInfoView GetInfo() { return new GroupInfoView() { GroupId = GetGroupId().ToString(), PlayingItemName = _group.PlayingItem.Name, PlayingItemId = _group.PlayingItem.Id.ToString(), PositionTicks = _group.PositionTicks, Participants = _group.Participants.Values.Select(session => session.Session.UserName).Distinct().ToList() }; } } }