Browse Source

Merge pull request #2733 from OancaAndrei/syncplay

Add syncplay feature
Joshua M. Boniface 5 năm trước cách đây
mục cha
commit
7c72f69189
25 tập tin đã thay đổi với 2248 bổ sung2 xóa
  1. 4 0
      Emby.Server.Implementations/ApplicationHost.cs
  2. 39 1
      Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
  3. 17 0
      Emby.Server.Implementations/Session/SessionManager.cs
  4. 244 1
      Emby.Server.Implementations/Session/SessionWebSocketListener.cs
  5. 517 0
      Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
  6. 398 0
      Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
  7. 302 0
      MediaBrowser.Api/SyncPlay/SyncPlayService.cs
  8. 52 0
      MediaBrowser.Api/SyncPlay/TimeSyncService.cs
  9. 6 0
      MediaBrowser.Controller/Net/IWebSocketConnection.cs
  10. 19 0
      MediaBrowser.Controller/Session/ISessionManager.cs
  11. 169 0
      MediaBrowser.Controller/SyncPlay/GroupInfo.cs
  12. 28 0
      MediaBrowser.Controller/SyncPlay/GroupMember.cs
  13. 67 0
      MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs
  14. 69 0
      MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
  15. 23 0
      MediaBrowser.Model/Configuration/SyncplayAccess.cs
  16. 40 0
      MediaBrowser.Model/SyncPlay/GroupInfoView.cs
  17. 26 0
      MediaBrowser.Model/SyncPlay/GroupUpdate.cs
  18. 53 0
      MediaBrowser.Model/SyncPlay/GroupUpdateType.cs
  19. 22 0
      MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs
  20. 34 0
      MediaBrowser.Model/SyncPlay/PlaybackRequest.cs
  21. 33 0
      MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs
  22. 38 0
      MediaBrowser.Model/SyncPlay/SendCommand.cs
  23. 21 0
      MediaBrowser.Model/SyncPlay/SendCommandType.cs
  24. 20 0
      MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs
  25. 7 0
      MediaBrowser.Model/Users/UserPolicy.cs

+ 4 - 0
Emby.Server.Implementations/ApplicationHost.cs

@@ -45,6 +45,7 @@ using Emby.Server.Implementations.Services;
 using Emby.Server.Implementations.Session;
 using Emby.Server.Implementations.TV;
 using Emby.Server.Implementations.Updates;
+using Emby.Server.Implementations.SyncPlay;
 using MediaBrowser.Api;
 using MediaBrowser.Common;
 using MediaBrowser.Common.Configuration;
@@ -78,6 +79,7 @@ using MediaBrowser.Controller.Session;
 using MediaBrowser.Controller.Sorting;
 using MediaBrowser.Controller.Subtitles;
 using MediaBrowser.Controller.TV;
+using MediaBrowser.Controller.SyncPlay;
 using MediaBrowser.LocalMetadata.Savers;
 using MediaBrowser.MediaEncoding.BdInfo;
 using MediaBrowser.Model.Configuration;
@@ -613,6 +615,8 @@ namespace Emby.Server.Implementations
 
             serviceCollection.AddSingleton<IPlaylistManager, PlaylistManager>();
 
+            serviceCollection.AddSingleton<ISyncPlayManager, SyncPlayManager>();
+
             serviceCollection.AddSingleton<LiveTvDtoService>();
             serviceCollection.AddSingleton<ILiveTvManager, LiveTvManager>();
 

+ 39 - 1
Emby.Server.Implementations/HttpServer/WebSocketConnection.cs

@@ -78,6 +78,9 @@ namespace Emby.Server.Implementations.HttpServer
         /// <value>The last activity date.</value>
         public DateTime LastActivityDate { get; private set; }
 
+        /// <inheritdoc />
+        public DateTime LastKeepAliveDate { get; set; }
+
         /// <summary>
         /// Gets or sets the query string.
         /// </summary>
@@ -218,7 +221,42 @@ namespace Emby.Server.Implementations.HttpServer
                 Connection = this
             };
 
-            await OnReceive(info).ConfigureAwait(false);
+            if (info.MessageType.Equals("KeepAlive", StringComparison.Ordinal))
+            {
+                await SendKeepAliveResponse();
+            }
+            else
+            {
+                await OnReceive(info).ConfigureAwait(false);
+            }
+        }
+
+        private Task SendKeepAliveResponse()
+        {
+            LastKeepAliveDate = DateTime.UtcNow;
+            return SendAsync(new WebSocketMessage<string>
+            {
+                MessageType = "KeepAlive"
+            }, CancellationToken.None);
+        }
+
+        /// <inheritdoc />
+        public void Dispose()
+        {
+            Dispose(true);
+            GC.SuppressFinalize(this);
+        }
+
+        /// <summary>
+        /// Releases unmanaged and - optionally - managed resources.
+        /// </summary>
+        /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+        protected virtual void Dispose(bool dispose)
+        {
+            if (dispose)
+            {
+                _socket.Dispose();
+            }
         }
     }
 }

+ 17 - 0
Emby.Server.Implementations/Session/SessionManager.cs

@@ -25,6 +25,7 @@ using MediaBrowser.Model.Events;
 using MediaBrowser.Model.Library;
 using MediaBrowser.Model.Querying;
 using MediaBrowser.Model.Session;
+using MediaBrowser.Model.SyncPlay;
 using Microsoft.Extensions.Logging;
 
 namespace Emby.Server.Implementations.Session
@@ -1153,6 +1154,22 @@ namespace Emby.Server.Implementations.Session
             await SendMessageToSession(session, "Play", command, cancellationToken).ConfigureAwait(false);
         }
 
+        /// <inheritdoc />
+        public async Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken)
+        {
+            CheckDisposed();
+            var session = GetSessionToRemoteControl(sessionId);
+            await SendMessageToSession(session, "SyncPlayCommand", command, cancellationToken).ConfigureAwait(false);
+        }
+
+        /// <inheritdoc />
+        public async Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken)
+        {
+            CheckDisposed();
+            var session = GetSessionToRemoteControl(sessionId);
+            await SendMessageToSession(session, "SyncPlayGroupUpdate", command, cancellationToken).ConfigureAwait(false);
+        }
+
         private IEnumerable<BaseItem> TranslateItemForPlayback(Guid id, User user)
         {
             var item = _libraryManager.GetItemById(id);

+ 244 - 1
Emby.Server.Implementations/Session/SessionWebSocketListener.cs

@@ -1,8 +1,13 @@
 using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.WebSockets;
+using System.Threading;
 using System.Threading.Tasks;
 using MediaBrowser.Controller.Net;
 using MediaBrowser.Controller.Session;
 using MediaBrowser.Model.Events;
+using MediaBrowser.Model.Net;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.Logging;
 
@@ -13,6 +18,21 @@ namespace Emby.Server.Implementations.Session
     /// </summary>
     public sealed class SessionWebSocketListener : IWebSocketListener, IDisposable
     {
+        /// <summary>
+        /// The timeout in seconds after which a WebSocket is considered to be lost.
+        /// </summary>
+        public const int WebSocketLostTimeout = 60;
+
+        /// <summary>
+        /// The keep-alive interval factor; controls how often the watcher will check on the status of the WebSockets.
+        /// </summary>
+        public const float IntervalFactor = 0.2f;
+
+        /// <summary>
+        /// The ForceKeepAlive factor; controls when a ForceKeepAlive is sent.
+        /// </summary>
+        public const float ForceKeepAliveFactor = 0.75f;
+
         /// <summary>
         /// The _session manager
         /// </summary>
@@ -26,6 +46,26 @@ namespace Emby.Server.Implementations.Session
 
         private readonly IHttpServer _httpServer;
 
+        /// <summary>
+        /// The KeepAlive cancellation token.
+        /// </summary>
+        private CancellationTokenSource _keepAliveCancellationToken;
+
+        /// <summary>
+        /// Lock used for accesing the KeepAlive cancellation token.
+        /// </summary>
+        private readonly object _keepAliveLock = new object();
+
+        /// <summary>
+        /// The WebSocket watchlist.
+        /// </summary>
+        private readonly HashSet<IWebSocketConnection> _webSockets = new HashSet<IWebSocketConnection>();
+
+        /// <summary>
+        /// Lock used for accesing the WebSockets watchlist.
+        /// </summary>
+        private readonly object _webSocketsLock = new object();
+
         /// <summary>
         /// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class.
         /// </summary>
@@ -47,12 +87,13 @@ namespace Emby.Server.Implementations.Session
             httpServer.WebSocketConnected += OnServerManagerWebSocketConnected;
         }
 
-        private void OnServerManagerWebSocketConnected(object sender, GenericEventArgs<IWebSocketConnection> e)
+        private async void OnServerManagerWebSocketConnected(object sender, GenericEventArgs<IWebSocketConnection> e)
         {
             var session = GetSession(e.Argument.QueryString, e.Argument.RemoteEndPoint.ToString());
             if (session != null)
             {
                 EnsureController(session, e.Argument);
+                await KeepAliveWebSocket(e.Argument);
             }
             else
             {
@@ -81,6 +122,7 @@ namespace Emby.Server.Implementations.Session
         public void Dispose()
         {
             _httpServer.WebSocketConnected -= OnServerManagerWebSocketConnected;
+            StopKeepAlive();
         }
 
         /// <summary>
@@ -99,5 +141,206 @@ namespace Emby.Server.Implementations.Session
             var controller = (WebSocketController)controllerInfo.Item1;
             controller.AddWebSocket(connection);
         }
+
+        /// <summary>
+        /// Called when a WebSocket is closed.
+        /// </summary>
+        /// <param name="sender">The WebSocket.</param>
+        /// <param name="e">The event arguments.</param>
+        private void OnWebSocketClosed(object sender, EventArgs e)
+        {
+            var webSocket = (IWebSocketConnection)sender;
+            _logger.LogDebug("WebSocket {0} is closed.", webSocket);
+            RemoveWebSocket(webSocket);
+        }
+
+        /// <summary>
+        /// Adds a WebSocket to the KeepAlive watchlist.
+        /// </summary>
+        /// <param name="webSocket">The WebSocket to monitor.</param>
+        private async Task KeepAliveWebSocket(IWebSocketConnection webSocket)
+        {
+            lock (_webSocketsLock)
+            {
+                if (!_webSockets.Add(webSocket))
+                {
+                    _logger.LogWarning("Multiple attempts to keep alive single WebSocket {0}", webSocket);
+                    return;
+                }
+                webSocket.Closed += OnWebSocketClosed;
+                webSocket.LastKeepAliveDate = DateTime.UtcNow;
+
+                StartKeepAlive();
+            }
+
+            // Notify WebSocket about timeout
+            try
+            {
+                await SendForceKeepAlive(webSocket);
+            }
+            catch (WebSocketException exception)
+            {
+                _logger.LogWarning(exception, "Cannot send ForceKeepAlive message to WebSocket {0}.", webSocket);
+            }
+        }
+
+        /// <summary>
+        /// Removes a WebSocket from the KeepAlive watchlist.
+        /// </summary>
+        /// <param name="webSocket">The WebSocket to remove.</param>
+        private void RemoveWebSocket(IWebSocketConnection webSocket)
+        {
+            lock (_webSocketsLock)
+            {
+                if (!_webSockets.Remove(webSocket))
+                {
+                    _logger.LogWarning("WebSocket {0} not on watchlist.", webSocket);
+                }
+                else
+                {
+                    webSocket.Closed -= OnWebSocketClosed;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Starts the KeepAlive watcher.
+        /// </summary>
+        private void StartKeepAlive()
+        {
+            lock (_keepAliveLock)
+            {
+                if (_keepAliveCancellationToken == null)
+                {
+                    _keepAliveCancellationToken = new CancellationTokenSource();
+                    // Start KeepAlive watcher
+                    _ = RepeatAsyncCallbackEvery(
+                        KeepAliveSockets,
+                        TimeSpan.FromSeconds(WebSocketLostTimeout * IntervalFactor),
+                        _keepAliveCancellationToken.Token);
+                }
+            }
+        }
+
+        /// <summary>
+        /// Stops the KeepAlive watcher.
+        /// </summary>
+        private void StopKeepAlive()
+        {
+            lock (_keepAliveLock)
+            {
+                if (_keepAliveCancellationToken != null)
+                {
+                    _keepAliveCancellationToken.Cancel();
+                    _keepAliveCancellationToken = null;
+                }
+            }
+
+            lock (_webSocketsLock)
+            {
+                foreach (var webSocket in _webSockets)
+                {
+                    webSocket.Closed -= OnWebSocketClosed;
+                }
+
+                _webSockets.Clear();
+            }
+        }
+
+        /// <summary>
+        /// Checks status of KeepAlive of WebSockets.
+        /// </summary>
+        private async Task KeepAliveSockets()
+        {
+            List<IWebSocketConnection> inactive;
+            List<IWebSocketConnection> lost;
+
+            lock (_webSocketsLock)
+            {
+                _logger.LogDebug("Watching {0} WebSockets.", _webSockets.Count);
+
+                inactive = _webSockets.Where(i =>
+                {
+                    var elapsed = (DateTime.UtcNow - i.LastKeepAliveDate).TotalSeconds;
+                    return (elapsed > WebSocketLostTimeout * ForceKeepAliveFactor) && (elapsed < WebSocketLostTimeout);
+                }).ToList();
+                lost = _webSockets.Where(i => (DateTime.UtcNow - i.LastKeepAliveDate).TotalSeconds >= WebSocketLostTimeout).ToList();
+            }
+
+            if (inactive.Any())
+            {
+                _logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count);
+            }
+
+            foreach (var webSocket in inactive)
+            {
+                try
+                {
+                    await SendForceKeepAlive(webSocket);
+                }
+                catch (WebSocketException exception)
+                {
+                    _logger.LogInformation(exception, "Error sending ForceKeepAlive message to WebSocket.");
+                    lost.Add(webSocket);
+                }
+            }
+
+            lock (_webSocketsLock)
+            {
+                if (lost.Any())
+                {
+                    _logger.LogInformation("Lost {0} WebSockets.", lost.Count);
+                    foreach (var webSocket in lost)
+                    {
+                        // TODO: handle session relative to the lost webSocket
+                        RemoveWebSocket(webSocket);
+                    }
+                }
+
+                if (!_webSockets.Any())
+                {
+                    StopKeepAlive();
+                }
+            }
+        }
+
+        /// <summary>
+        /// Sends a ForceKeepAlive message to a WebSocket.
+        /// </summary>
+        /// <param name="webSocket">The WebSocket.</param>
+        /// <returns>Task.</returns>
+        private Task SendForceKeepAlive(IWebSocketConnection webSocket)
+        {
+            return webSocket.SendAsync(new WebSocketMessage<int>
+            {
+                MessageType = "ForceKeepAlive",
+                Data = WebSocketLostTimeout
+            }, CancellationToken.None);
+        }
+
+        /// <summary>
+        /// Runs a given async callback once every specified interval time, until cancelled.
+        /// </summary>
+        /// <param name="callback">The async callback.</param>
+        /// <param name="interval">The interval time.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>Task.</returns>
+        private async Task RepeatAsyncCallbackEvery(Func<Task> callback, TimeSpan interval, CancellationToken cancellationToken)
+        {
+            while (!cancellationToken.IsCancellationRequested)
+            {
+                await callback();
+                Task task = Task.Delay(interval, cancellationToken);
+
+                try
+                {
+                    await task;
+                }
+                catch (TaskCanceledException)
+                {
+                    return;
+                }
+            }
+        }
     }
 }

+ 517 - 0
Emby.Server.Implementations/SyncPlay/SyncPlayController.cs

@@ -0,0 +1,517 @@
+using System;
+using System.Collections.Generic;
+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();
+
+        /// <inheritdoc />
+        public Guid GetGroupId() => _group.GroupId;
+
+        /// <inheritdoc />
+        public Guid GetPlayingItemId() => _group.PlayingItem.Id;
+
+        /// <inheritdoc />
+        public bool IsGroupEmpty() => _group.IsEmpty();
+
+        public SyncPlayController(
+            ISessionManager sessionManager,
+            ISyncPlayManager syncPlayManager)
+        {
+            _sessionManager = sessionManager;
+            _syncPlayManager = syncPlayManager;
+        }
+
+        /// <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");
+        }
+
+        /// <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 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
+                    ).ToArray();
+                case BroadcastType.AllExceptCurrentSession:
+                    return _group.Participants.Values.Select(
+                        session => session.Session
+                    ).Where(
+                        session => !session.Id.Equals(from.Id)
+                    ).ToArray();
+                case BroadcastType.AllReady:
+                    return _group.Participants.Values.Where(
+                        session => !session.IsBuffering
+                    ).Select(
+                        session => session.Session
+                    ).ToArray();
+                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()
+            {
+                SessionInfo[] sessions = FilterSessions(from, type);
+                foreach (var session in sessions)
+                {
+                    yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), 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()
+            {
+                SessionInfo[] sessions = FilterSessions(from, type);
+                foreach (var session in sessions)
+                {
+                    yield return _sessionManager.SendSyncPlayCommand(session.Id.ToString(), 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 InitGroup(SessionInfo session, CancellationToken cancellationToken)
+        {
+            _group.AddSession(session);
+            _syncPlayManager.AddSessionToGroup(session, this);
+
+            _group.PlayingItem = session.FullNowPlayingItem;
+            _group.IsPaused = true;
+            _group.PositionTicks = session.PlayState.PositionTicks ?? 0;
+            _group.LastActivity = DateTime.UtcNow;
+
+            var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow));
+            SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken);
+            var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause);
+            SendCommand(session, BroadcastType.CurrentSession, pauseCommand, cancellationToken);
+        }
+
+        /// <inheritdoc />
+        public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken)
+        {
+            if (session.NowPlayingItem?.Id == _group.PlayingItem.Id && request.PlayingItemId == _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);
+
+                // Client join and play, 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();
+                playRequest.ItemIds = new Guid[] { _group.PlayingItem.Id };
+                playRequest.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 mantain a consistent state to which clients refer to,
+            // as also to 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.Buffering:
+                    HandleBufferingRequest(session, request, cancellationToken);
+                    break;
+                case PlaybackRequestType.BufferingDone:
+                    HandleBufferingDoneRequest(session, request, cancellationToken);
+                    break;
+                case PlaybackRequestType.UpdatePing:
+                    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 = _group.GetHighestPing() * 2;
+                delay = delay < _group.DefaulPing ? _group.DefaulPing : delay;
+
+                // 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
+                // (a 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 = _group.GetHighestPing() * 2;
+                        delay = delay < _group.DefaulPing ? _group.DefaulPing : delay;
+
+                        _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 ?? _group.DefaulPing);
+        }
+
+        /// <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()
+            };
+        }
+    }
+}

+ 398 - 0
Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs

@@ -0,0 +1,398 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using Microsoft.Extensions.Logging;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.SyncPlay;
+
+namespace Emby.Server.Implementations.SyncPlay
+{
+    /// <summary>
+    /// Class SyncPlayManager.
+    /// </summary>
+    public class SyncPlayManager : ISyncPlayManager, IDisposable
+    {
+        /// <summary>
+        /// The logger.
+        /// </summary>
+        private readonly ILogger _logger;
+
+        /// <summary>
+        /// The user manager.
+        /// </summary>
+        private readonly IUserManager _userManager;
+
+        /// <summary>
+        /// The session manager.
+        /// </summary>
+        private readonly ISessionManager _sessionManager;
+
+        /// <summary>
+        /// The library manager.
+        /// </summary>
+        private readonly ILibraryManager _libraryManager;
+
+        /// <summary>
+        /// The map between sessions and groups.
+        /// </summary>
+        private readonly Dictionary<string, ISyncPlayController> _sessionToGroupMap =
+            new Dictionary<string, ISyncPlayController>(StringComparer.OrdinalIgnoreCase);
+
+        /// <summary>
+        /// The groups.
+        /// </summary>
+        private readonly Dictionary<Guid, ISyncPlayController> _groups =
+            new Dictionary<Guid, ISyncPlayController>();
+
+        /// <summary>
+        /// Lock used for accesing any group.
+        /// </summary>
+        private readonly object _groupsLock = new object();
+
+        private bool _disposed = false;
+
+        public SyncPlayManager(
+            ILogger<SyncPlayManager> logger,
+            IUserManager userManager,
+            ISessionManager sessionManager,
+            ILibraryManager libraryManager)
+        {
+            _logger = logger;
+            _userManager = userManager;
+            _sessionManager = sessionManager;
+            _libraryManager = libraryManager;
+
+            _sessionManager.SessionEnded += OnSessionManagerSessionEnded;
+            _sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped;
+        }
+
+        /// <summary>
+        /// Gets all groups.
+        /// </summary>
+        /// <value>All groups.</value>
+        public IEnumerable<ISyncPlayController> Groups => _groups.Values;
+
+        /// <inheritdoc />
+        public void Dispose()
+        {
+            Dispose(true);
+            GC.SuppressFinalize(this);
+        }
+
+        /// <summary>
+        /// Releases unmanaged and optionally managed resources.
+        /// </summary>
+        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
+        protected virtual void Dispose(bool disposing)
+        {
+            if (_disposed)
+            {
+                return;
+            }
+
+            _sessionManager.SessionEnded -= OnSessionManagerSessionEnded;
+            _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped;
+
+            _disposed = true;
+        }
+
+        private void CheckDisposed()
+        {
+            if (_disposed)
+            {
+                throw new ObjectDisposedException(GetType().Name);
+            }
+        }
+
+        private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e)
+        {
+            var session = e.SessionInfo;
+            if (!IsSessionInGroup(session))
+            {
+                return;
+            }
+
+            LeaveGroup(session, CancellationToken.None);
+        }
+
+        private void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e)
+        {
+            var session = e.Session;
+            if (!IsSessionInGroup(session))
+            {
+                return;
+            }
+
+            LeaveGroup(session, CancellationToken.None);
+        }
+
+        private bool IsSessionInGroup(SessionInfo session)
+        {
+            return _sessionToGroupMap.ContainsKey(session.Id);
+        }
+
+        private bool HasAccessToItem(User user, Guid itemId)
+        {
+            var item = _libraryManager.GetItemById(itemId);
+
+            // Check ParentalRating access
+            var hasParentalRatingAccess = true;
+            if (user.Policy.MaxParentalRating.HasValue)
+            {
+                hasParentalRatingAccess = item.InheritedParentalRatingValue <= user.Policy.MaxParentalRating;
+            }
+
+            if (!user.Policy.EnableAllFolders && hasParentalRatingAccess)
+            {
+                var collections = _libraryManager.GetCollectionFolders(item).Select(
+                    folder => folder.Id.ToString("N", CultureInfo.InvariantCulture)
+                );
+                var intersect = collections.Intersect(user.Policy.EnabledFolders);
+                return intersect.Any();
+            }
+            else
+            {
+                return hasParentalRatingAccess;
+            }
+        }
+
+        private Guid? GetSessionGroup(SessionInfo session)
+        {
+            ISyncPlayController group;
+            _sessionToGroupMap.TryGetValue(session.Id, out group);
+            if (group != null)
+            {
+                return group.GetGroupId();
+            }
+            else
+            {
+                return null;
+            }
+        }
+
+        /// <inheritdoc />
+        public void NewGroup(SessionInfo session, CancellationToken cancellationToken)
+        {
+            var user = _userManager.GetUserById(session.UserId);
+
+            if (user.Policy.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups)
+            {
+                _logger.LogWarning("NewGroup: {0} does not have permission to create groups.", session.Id);
+
+                var error = new GroupUpdate<string>()
+                {
+                    Type = GroupUpdateType.CreateGroupDenied
+                };
+                _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
+                return;
+            }
+
+            lock (_groupsLock)
+            {
+                if (IsSessionInGroup(session))
+                {
+                    LeaveGroup(session, cancellationToken);
+                }
+
+                var group = new SyncPlayController(_sessionManager, this);
+                _groups[group.GetGroupId()] = group;
+
+                group.InitGroup(session, cancellationToken);
+            }
+        }
+
+        /// <inheritdoc />
+        public void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken)
+        {
+            var user = _userManager.GetUserById(session.UserId);
+
+            if (user.Policy.SyncPlayAccess == SyncPlayAccess.None)
+            {
+                _logger.LogWarning("JoinGroup: {0} does not have access to SyncPlay.", session.Id);
+
+                var error = new GroupUpdate<string>()
+                {
+                    Type = GroupUpdateType.JoinGroupDenied
+                };
+                _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
+                return;
+            }
+
+            lock (_groupsLock)
+            {
+                ISyncPlayController group;
+                _groups.TryGetValue(groupId, out group);
+
+                if (group == null)
+                {
+                    _logger.LogWarning("JoinGroup: {0} tried to join group {0} that does not exist.", session.Id, groupId);
+
+                    var error = new GroupUpdate<string>()
+                    {
+                        Type = GroupUpdateType.GroupDoesNotExist
+                    };
+                    _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
+                    return;
+                }
+
+                if (!HasAccessToItem(user, group.GetPlayingItemId()))
+                {
+                    _logger.LogWarning("JoinGroup: {0} does not have access to {1}.", session.Id, group.GetPlayingItemId());
+
+                    var error = new GroupUpdate<string>()
+                    {
+                        GroupId = group.GetGroupId().ToString(),
+                        Type = GroupUpdateType.LibraryAccessDenied
+                    };
+                    _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
+                    return;
+                }
+
+                if (IsSessionInGroup(session))
+                {
+                    if (GetSessionGroup(session).Equals(groupId))
+                    {
+                        return;
+                    }
+
+                    LeaveGroup(session, cancellationToken);
+                }
+
+                group.SessionJoin(session, request, cancellationToken);
+            }
+        }
+
+        /// <inheritdoc />
+        public void LeaveGroup(SessionInfo session, CancellationToken cancellationToken)
+        {
+            // TODO: determine what happens to users that are in a group and get their permissions revoked
+            lock (_groupsLock)
+            {
+                ISyncPlayController group;
+                _sessionToGroupMap.TryGetValue(session.Id, out group);
+
+                if (group == null)
+                {
+                    _logger.LogWarning("LeaveGroup: {0} does not belong to any group.", session.Id);
+
+                    var error = new GroupUpdate<string>()
+                    {
+                        Type = GroupUpdateType.NotInGroup
+                    };
+                    _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
+                    return;
+                }
+
+                group.SessionLeave(session, cancellationToken);
+
+                if (group.IsGroupEmpty())
+                {
+                    _logger.LogInformation("LeaveGroup: removing empty group {0}.", group.GetGroupId());
+                    _groups.Remove(group.GetGroupId(), out _);
+                }
+            }
+        }
+
+        /// <inheritdoc />
+        public List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId)
+        {
+            var user = _userManager.GetUserById(session.UserId);
+
+            if (user.Policy.SyncPlayAccess == SyncPlayAccess.None)
+            {
+                return new List<GroupInfoView>();
+            }
+
+            // Filter by item if requested
+            if (!filterItemId.Equals(Guid.Empty))
+            {
+                return _groups.Values.Where(
+                    group => group.GetPlayingItemId().Equals(filterItemId) && HasAccessToItem(user, group.GetPlayingItemId())
+                ).Select(
+                    group => group.GetInfo()
+                ).ToList();
+            }
+            // Otherwise show all available groups
+            else
+            {
+                return _groups.Values.Where(
+                    group => HasAccessToItem(user, group.GetPlayingItemId())
+                ).Select(
+                    group => group.GetInfo()
+                ).ToList();
+            }
+        }
+
+        /// <inheritdoc />
+        public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken)
+        {
+            var user = _userManager.GetUserById(session.UserId);
+
+            if (user.Policy.SyncPlayAccess == SyncPlayAccess.None)
+            {
+                _logger.LogWarning("HandleRequest: {0} does not have access to SyncPlay.", session.Id);
+
+                var error = new GroupUpdate<string>()
+                {
+                    Type = GroupUpdateType.JoinGroupDenied
+                };
+                _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
+                return;
+            }
+
+            lock (_groupsLock)
+            {
+                ISyncPlayController group;
+                _sessionToGroupMap.TryGetValue(session.Id, out group);
+
+                if (group == null)
+                {
+                    _logger.LogWarning("HandleRequest: {0} does not belong to any group.", session.Id);
+
+                    var error = new GroupUpdate<string>()
+                    {
+                        Type = GroupUpdateType.NotInGroup
+                    };
+                    _sessionManager.SendSyncPlayGroupUpdate(session.Id.ToString(), error, CancellationToken.None);
+                    return;
+                }
+
+                group.HandleRequest(session, request, cancellationToken);
+            }
+        }
+
+        /// <inheritdoc />
+        public void AddSessionToGroup(SessionInfo session, ISyncPlayController group)
+        {
+            if (IsSessionInGroup(session))
+            {
+                throw new InvalidOperationException("Session in other group already!");
+            }
+
+            _sessionToGroupMap[session.Id] = group;
+        }
+
+        /// <inheritdoc />
+        public void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group)
+        {
+            if (!IsSessionInGroup(session))
+            {
+                throw new InvalidOperationException("Session not in any group!");
+            }
+
+            ISyncPlayController tempGroup;
+            _sessionToGroupMap.Remove(session.Id, out tempGroup);
+
+            if (!tempGroup.GetGroupId().Equals(group.GetGroupId()))
+            {
+                throw new InvalidOperationException("Session was in wrong group!");
+            }
+        }
+    }
+}

+ 302 - 0
MediaBrowser.Api/SyncPlay/SyncPlayService.cs

@@ -0,0 +1,302 @@
+using System.Threading;
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay;
+using MediaBrowser.Model.Services;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Api.SyncPlay
+{
+    [Route("/SyncPlay/{SessionId}/NewGroup", "POST", Summary = "Create a new SyncPlay group")]
+    [Authenticated]
+    public class SyncPlayNewGroup : IReturnVoid
+    {
+        [ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
+        public string SessionId { get; set; }
+    }
+
+    [Route("/SyncPlay/{SessionId}/JoinGroup", "POST", Summary = "Join an existing SyncPlay group")]
+    [Authenticated]
+    public class SyncPlayJoinGroup : IReturnVoid
+    {
+        [ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
+        public string SessionId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the Group id.
+        /// </summary>
+        /// <value>The Group id to join.</value>
+        [ApiMember(Name = "GroupId", Description = "Group Id", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
+        public string GroupId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the playing item id.
+        /// </summary>
+        /// <value>The client's currently playing item id.</value>
+        [ApiMember(Name = "PlayingItemId", Description = "Client's playing item id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
+        public string PlayingItemId { get; set; }
+    }
+
+    [Route("/SyncPlay/{SessionId}/LeaveGroup", "POST", Summary = "Leave joined SyncPlay group")]
+    [Authenticated]
+    public class SyncPlayLeaveGroup : IReturnVoid
+    {
+        [ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
+        public string SessionId { get; set; }
+    }
+
+    [Route("/SyncPlay/{SessionId}/ListGroups", "POST", Summary = "List SyncPlay groups")]
+    [Authenticated]
+    public class SyncPlayListGroups : IReturnVoid
+    {
+        [ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
+        public string SessionId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the filter item id.
+        /// </summary>
+        /// <value>The filter item id.</value>
+        [ApiMember(Name = "FilterItemId", Description = "Filter by item id", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")]
+        public string FilterItemId { get; set; }
+    }
+
+    [Route("/SyncPlay/{SessionId}/PlayRequest", "POST", Summary = "Request play in SyncPlay group")]
+    [Authenticated]
+    public class SyncPlayPlayRequest : IReturnVoid
+    {
+        [ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
+        public string SessionId { get; set; }
+    }
+
+    [Route("/SyncPlay/{SessionId}/PauseRequest", "POST", Summary = "Request pause in SyncPlay group")]
+    [Authenticated]
+    public class SyncPlayPauseRequest : IReturnVoid
+    {
+        [ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
+        public string SessionId { get; set; }
+    }
+
+    [Route("/SyncPlay/{SessionId}/SeekRequest", "POST", Summary = "Request seek in SyncPlay group")]
+    [Authenticated]
+    public class SyncPlaySeekRequest : IReturnVoid
+    {
+        [ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
+        public string SessionId { get; set; }
+
+        [ApiMember(Name = "PositionTicks", IsRequired = true, DataType = "long", ParameterType = "query", Verb = "POST")]
+        public long PositionTicks { get; set; }
+    }
+
+    [Route("/SyncPlay/{SessionId}/BufferingRequest", "POST", Summary = "Request group wait in SyncPlay group while buffering")]
+    [Authenticated]
+    public class SyncPlayBufferingRequest : IReturnVoid
+    {
+        [ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
+        public string SessionId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the date used to pin PositionTicks in time.
+        /// </summary>
+        /// <value>The date related to PositionTicks.</value>
+        [ApiMember(Name = "When", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")]
+        public string When { get; set; }
+
+        [ApiMember(Name = "PositionTicks", IsRequired = true, DataType = "long", ParameterType = "query", Verb = "POST")]
+        public long PositionTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets whether this is a buffering or a buffering-done request.
+        /// </summary>
+        /// <value><c>true</c> if buffering is complete; <c>false</c> otherwise.</value>
+        [ApiMember(Name = "BufferingDone", IsRequired = true, DataType = "bool", ParameterType = "query", Verb = "POST")]
+        public bool BufferingDone { get; set; }
+    }
+
+    [Route("/SyncPlay/{SessionId}/UpdatePing", "POST", Summary = "Update session ping")]
+    [Authenticated]
+    public class SyncPlayUpdatePing : IReturnVoid
+    {
+        [ApiMember(Name = "SessionId", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")]
+        public string SessionId { get; set; }
+
+        [ApiMember(Name = "Ping", IsRequired = true, DataType = "double", ParameterType = "query", Verb = "POST")]
+        public double Ping { get; set; }
+    }
+
+    /// <summary>
+    /// Class SyncPlayService.
+    /// </summary>
+    public class SyncPlayService : BaseApiService
+    {
+        /// <summary>
+        /// The session context.
+        /// </summary>
+        private readonly ISessionContext _sessionContext;
+
+        /// <summary>
+        /// The SyncPlay manager.
+        /// </summary>
+        private readonly ISyncPlayManager _syncPlayManager;
+
+        public SyncPlayService(
+            ILogger<SyncPlayService> logger,
+            IServerConfigurationManager serverConfigurationManager,
+            IHttpResultFactory httpResultFactory,
+            ISessionContext sessionContext,
+            ISyncPlayManager syncPlayManager)
+            : base(logger, serverConfigurationManager, httpResultFactory)
+        {
+            _sessionContext = sessionContext;
+            _syncPlayManager = syncPlayManager;
+        }
+
+        /// <summary>
+        /// Handles the specified request.
+        /// </summary>
+        /// <param name="request">The request.</param>
+        public void Post(SyncPlayNewGroup request)
+        {
+            var currentSession = GetSession(_sessionContext);
+            _syncPlayManager.NewGroup(currentSession, CancellationToken.None);
+        }
+
+        /// <summary>
+        /// Handles the specified request.
+        /// </summary>
+        /// <param name="request">The request.</param>
+        public void Post(SyncPlayJoinGroup request)
+        {
+            var currentSession = GetSession(_sessionContext);
+
+            Guid groupId;
+            Guid playingItemId = Guid.Empty;
+
+            if (!Guid.TryParse(request.GroupId, out groupId))
+            {
+                Logger.LogError("JoinGroup: {0} is not a valid format for GroupId. Ignoring request.", request.GroupId);
+                return;
+            }
+
+            // Both null and empty strings mean that client isn't playing anything
+            if (!string.IsNullOrEmpty(request.PlayingItemId) && !Guid.TryParse(request.PlayingItemId, out playingItemId))
+            {
+                Logger.LogError("JoinGroup: {0} is not a valid format for PlayingItemId. Ignoring request.", request.PlayingItemId);
+                return;
+            }
+
+            var joinRequest = new JoinGroupRequest()
+            {
+                GroupId = groupId,
+                PlayingItemId = playingItemId
+            };
+
+            _syncPlayManager.JoinGroup(currentSession, groupId, joinRequest, CancellationToken.None);
+        }
+
+        /// <summary>
+        /// Handles the specified request.
+        /// </summary>
+        /// <param name="request">The request.</param>
+        public void Post(SyncPlayLeaveGroup request)
+        {
+            var currentSession = GetSession(_sessionContext);
+            _syncPlayManager.LeaveGroup(currentSession, CancellationToken.None);
+        }
+
+        /// <summary>
+        /// Handles the specified request.
+        /// </summary>
+        /// <param name="request">The request.</param>
+        /// <value>The requested list of groups.</value>
+        public List<GroupInfoView> Post(SyncPlayListGroups request)
+        {
+            var currentSession = GetSession(_sessionContext);
+            var filterItemId = Guid.Empty;
+
+            if (!string.IsNullOrEmpty(request.FilterItemId) && !Guid.TryParse(request.FilterItemId, out filterItemId))
+            {
+                Logger.LogWarning("ListGroups: {0} is not a valid format for FilterItemId. Ignoring filter.", request.FilterItemId);
+            }
+
+            return _syncPlayManager.ListGroups(currentSession, filterItemId);
+        }
+
+        /// <summary>
+        /// Handles the specified request.
+        /// </summary>
+        /// <param name="request">The request.</param>
+        public void Post(SyncPlayPlayRequest request)
+        {
+            var currentSession = GetSession(_sessionContext);
+            var syncPlayRequest = new PlaybackRequest()
+            {
+                Type = PlaybackRequestType.Play
+            };
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        }
+
+        /// <summary>
+        /// Handles the specified request.
+        /// </summary>
+        /// <param name="request">The request.</param>
+        public void Post(SyncPlayPauseRequest request)
+        {
+            var currentSession = GetSession(_sessionContext);
+            var syncPlayRequest = new PlaybackRequest()
+            {
+                Type = PlaybackRequestType.Pause
+            };
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        }
+
+        /// <summary>
+        /// Handles the specified request.
+        /// </summary>
+        /// <param name="request">The request.</param>
+        public void Post(SyncPlaySeekRequest request)
+        {
+            var currentSession = GetSession(_sessionContext);
+            var syncPlayRequest = new PlaybackRequest()
+            {
+                Type = PlaybackRequestType.Seek,
+                PositionTicks = request.PositionTicks
+            };
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        }
+
+        /// <summary>
+        /// Handles the specified request.
+        /// </summary>
+        /// <param name="request">The request.</param>
+        public void Post(SyncPlayBufferingRequest request)
+        {
+            var currentSession = GetSession(_sessionContext);
+            var syncPlayRequest = new PlaybackRequest()
+            {
+                Type = request.BufferingDone ? PlaybackRequestType.BufferingDone : PlaybackRequestType.Buffering,
+                When = DateTime.Parse(request.When),
+                PositionTicks = request.PositionTicks
+            };
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        }
+
+        /// <summary>
+        /// Handles the specified request.
+        /// </summary>
+        /// <param name="request">The request.</param>
+        public void Post(SyncPlayUpdatePing request)
+        {
+            var currentSession = GetSession(_sessionContext);
+            var syncPlayRequest = new PlaybackRequest()
+            {
+                Type = PlaybackRequestType.UpdatePing,
+                Ping = Convert.ToInt64(request.Ping)
+            };
+            _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None);
+        }
+    }
+}

+ 52 - 0
MediaBrowser.Api/SyncPlay/TimeSyncService.cs

@@ -0,0 +1,52 @@
+using System;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Model.Services;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging;
+
+namespace MediaBrowser.Api.SyncPlay
+{
+    [Route("/GetUtcTime", "GET", Summary = "Get UtcTime")]
+    public class GetUtcTime : IReturnVoid
+    {
+        // Nothing
+    }
+
+    /// <summary>
+    /// Class TimeSyncService.
+    /// </summary>
+    public class TimeSyncService : BaseApiService
+    {
+        public TimeSyncService(
+            ILogger<TimeSyncService> logger,
+            IServerConfigurationManager serverConfigurationManager,
+            IHttpResultFactory httpResultFactory)
+            : base(logger, serverConfigurationManager, httpResultFactory)
+        {
+            // Do nothing
+        }
+
+        /// <summary>
+        /// Handles the specified request.
+        /// </summary>
+        /// <param name="request">The request.</param>
+        /// <value>The current UTC time response.</value>
+        public UtcTimeResponse Get(GetUtcTime request)
+        {
+            // Important to keep the following line at the beginning
+            var requestReceptionTime = DateTime.UtcNow.ToUniversalTime().ToString("o");
+
+            var response = new UtcTimeResponse();
+            response.RequestReceptionTime = requestReceptionTime;
+
+            // Important to keep the following two lines at the end
+            var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime().ToString("o");
+            response.ResponseTransmissionTime = responseTransmissionTime;
+
+            // Implementing NTP on such a high level results in this useless
+            // information being sent. On the other hand it enables future additions.
+            return response;
+        }
+    }
+}

+ 6 - 0
MediaBrowser.Controller/Net/IWebSocketConnection.cs

@@ -23,6 +23,12 @@ namespace MediaBrowser.Controller.Net
         /// <value>The last activity date.</value>
         DateTime LastActivityDate { get; }
 
+        /// <summary>
+        /// Gets or sets the date of last Keeplive received.
+        /// </summary>
+        /// <value>The date of last Keeplive received.</value>
+        DateTime LastKeepAliveDate { get; set; }
+
         /// <summary>
         /// Gets or sets the query string.
         /// </summary>

+ 19 - 0
MediaBrowser.Controller/Session/ISessionManager.cs

@@ -9,6 +9,7 @@ using MediaBrowser.Controller.Security;
 using MediaBrowser.Model.Dto;
 using MediaBrowser.Model.Events;
 using MediaBrowser.Model.Session;
+using MediaBrowser.Model.SyncPlay;
 
 namespace MediaBrowser.Controller.Session
 {
@@ -140,6 +141,24 @@ namespace MediaBrowser.Controller.Session
         /// <returns>Task.</returns>
         Task SendPlayCommand(string controllingSessionId, string sessionId, PlayRequest command, CancellationToken cancellationToken);
 
+        /// <summary>
+        /// Sends the SyncPlayCommand.
+        /// </summary>
+        /// <param name="sessionId">The session id.</param>
+        /// <param name="command">The command.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>Task.</returns>
+        Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Sends the SyncPlayGroupUpdate.
+        /// </summary>
+        /// <param name="sessionId">The session id.</param>
+        /// <param name="command">The group update.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        /// <returns>Task.</returns>
+        Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken);
+
         /// <summary>
         /// Sends the browse command.
         /// </summary>

+ 169 - 0
MediaBrowser.Controller/SyncPlay/GroupInfo.cs

@@ -0,0 +1,169 @@
+using System;
+using System.Collections.Generic;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Session;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Class GroupInfo.
+    /// </summary>
+    /// <remarks>
+    /// Class is not thread-safe, external locking is required when accessing methods.
+    /// </remarks>
+    public class GroupInfo
+    {
+        /// <summary>
+        /// Default ping value used for sessions.
+        /// </summary>
+        public long DefaulPing { get; } = 500;
+
+        /// <summary>
+        /// Gets or sets the group identifier.
+        /// </summary>
+        /// <value>The group identifier.</value>
+        public Guid GroupId { get; } = Guid.NewGuid();
+
+        /// <summary>
+        /// Gets or sets the playing item.
+        /// </summary>
+        /// <value>The playing item.</value>
+        public BaseItem PlayingItem { get; set; }
+
+        /// <summary>
+        /// Gets or sets whether playback is paused.
+        /// </summary>
+        /// <value>Playback is paused.</value>
+        public bool IsPaused { get; set; }
+
+        /// <summary>
+        /// Gets or sets the position ticks.
+        /// </summary>
+        /// <value>The position ticks.</value>
+        public long PositionTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets the last activity.
+        /// </summary>
+        /// <value>The last activity.</value>
+        public DateTime LastActivity { get; set; }
+
+        /// <summary>
+        /// Gets the participants.
+        /// </summary>
+        /// <value>The participants, or members of the group.</value>
+        public Dictionary<string, GroupMember> Participants { get; } =
+            new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase);
+
+        /// <summary>
+        /// Checks if a session is in this group.
+        /// </summary>
+        /// <value><c>true</c> if the session is in this group; <c>false</c> otherwise.</value>
+        public bool ContainsSession(string sessionId)
+        {
+            return Participants.ContainsKey(sessionId);
+        }
+
+        /// <summary>
+        /// Adds the session to the group.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        public void AddSession(SessionInfo session)
+        {
+            if (ContainsSession(session.Id.ToString()))
+            {
+                return;
+            }
+
+            var member = new GroupMember();
+            member.Session = session;
+            member.Ping = DefaulPing;
+            member.IsBuffering = false;
+            Participants[session.Id.ToString()] = member;
+        }
+
+        /// <summary>
+        /// Removes the session from the group.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        public void RemoveSession(SessionInfo session)
+        {
+            if (!ContainsSession(session.Id.ToString()))
+            {
+                return;
+            }
+
+            Participants.Remove(session.Id.ToString(), out _);
+        }
+
+        /// <summary>
+        /// Updates the ping of a session.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="ping">The ping.</param>
+        public void UpdatePing(SessionInfo session, long ping)
+        {
+            if (!ContainsSession(session.Id.ToString()))
+            {
+                return;
+            }
+
+            Participants[session.Id.ToString()].Ping = ping;
+        }
+
+        /// <summary>
+        /// Gets the highest ping in the group.
+        /// </summary>
+        /// <value name="session">The highest ping in the group.</value>
+        public long GetHighestPing()
+        {
+            long max = Int64.MinValue;
+            foreach (var session in Participants.Values)
+            {
+                max = Math.Max(max, session.Ping);
+            }
+            return max;
+        }
+
+        /// <summary>
+        /// Sets the session's buffering state.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="isBuffering">The state.</param>
+        public void SetBuffering(SessionInfo session, bool isBuffering)
+        {
+            if (!ContainsSession(session.Id.ToString()))
+            {
+                return;
+            }
+
+            Participants[session.Id.ToString()].IsBuffering = isBuffering;
+        }
+
+        /// <summary>
+        /// Gets the group buffering state.
+        /// </summary>
+        /// <value><c>true</c> if there is a session buffering in the group; <c>false</c> otherwise.</value>
+        public bool IsBuffering()
+        {
+            foreach (var session in Participants.Values)
+            {
+                if (session.IsBuffering)
+                {
+                    return true;
+                }
+            }
+
+            return false;
+        }
+
+        /// <summary>
+        /// Checks if the group is empty.
+        /// </summary>
+        /// <value><c>true</c> if the group is empty; <c>false</c> otherwise.</value>
+        public bool IsEmpty()
+        {
+            return Participants.Count == 0;
+        }
+    }
+}

+ 28 - 0
MediaBrowser.Controller/SyncPlay/GroupMember.cs

@@ -0,0 +1,28 @@
+using MediaBrowser.Controller.Session;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Class GroupMember.
+    /// </summary>
+    public class GroupMember
+    {
+        /// <summary>
+        /// Gets or sets whether this member is buffering.
+        /// </summary>
+        /// <value><c>true</c> if member is buffering; <c>false</c> otherwise.</value>
+        public bool IsBuffering { get; set; }
+
+        /// <summary>
+        /// Gets or sets the session.
+        /// </summary>
+        /// <value>The session.</value>
+        public SessionInfo Session { get; set; }
+
+        /// <summary>
+        /// Gets or sets the ping.
+        /// </summary>
+        /// <value>The ping.</value>
+        public long Ping { get; set; }
+    }
+}

+ 67 - 0
MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs

@@ -0,0 +1,67 @@
+using System;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Interface ISyncPlayController.
+    /// </summary>
+    public interface ISyncPlayController
+    {
+        /// <summary>
+        /// Gets the group id.
+        /// </summary>
+        /// <value>The group id.</value>
+        Guid GetGroupId();
+
+        /// <summary>
+        /// Gets the playing item id.
+        /// </summary>
+        /// <value>The playing item id.</value>
+        Guid GetPlayingItemId();
+
+        /// <summary>
+        /// Checks if the group is empty.
+        /// </summary>
+        /// <value>If the group is empty.</value>
+        bool IsGroupEmpty();
+
+        /// <summary>
+        /// Initializes the group with the session's info.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void InitGroup(SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Adds the session to the group.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="request">The request.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Removes the session from the group.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void SessionLeave(SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Handles the requested action by the session.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="request">The requested action.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Gets the info about the group for the clients.
+        /// </summary>
+        /// <value>The group info for the clients.</value>
+        GroupInfoView GetInfo();
+    }
+}

+ 69 - 0
MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs

@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.SyncPlay;
+
+namespace MediaBrowser.Controller.SyncPlay
+{
+    /// <summary>
+    /// Interface ISyncPlayManager.
+    /// </summary>
+    public interface ISyncPlayManager
+    {
+        /// <summary>
+        /// Creates a new group.
+        /// </summary>
+        /// <param name="session">The session that's creating the group.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void NewGroup(SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Adds the session to a group.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="groupId">The group id.</param>
+        /// <param name="request">The request.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Removes the session from a group.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void LeaveGroup(SessionInfo session, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Gets list of available groups for a session.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="filterItemId">The item id to filter by.</param>
+        /// <value>The list of available groups.</value>
+        List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId);
+
+        /// <summary>
+        /// Handle a request by a session in a group.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="request">The request.</param>
+        /// <param name="cancellationToken">The cancellation token.</param>
+        void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken);
+
+        /// <summary>
+        /// Maps a session to a group.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="group">The group.</param>
+        /// <exception cref="InvalidOperationException"></exception>
+        void AddSessionToGroup(SessionInfo session, ISyncPlayController group);
+
+        /// <summary>
+        /// Unmaps a session from a group.
+        /// </summary>
+        /// <param name="session">The session.</param>
+        /// <param name="group">The group.</param>
+        /// <exception cref="InvalidOperationException"></exception>
+        void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group);
+    }
+}

+ 23 - 0
MediaBrowser.Model/Configuration/SyncplayAccess.cs

@@ -0,0 +1,23 @@
+namespace MediaBrowser.Model.Configuration
+{
+    /// <summary>
+    /// Enum SyncPlayAccess.
+    /// </summary>
+    public enum SyncPlayAccess
+    {
+        /// <summary>
+        /// User can create groups and join them.
+        /// </summary>
+        CreateAndJoinGroups,
+
+        /// <summary>
+        /// User can only join already existing groups.
+        /// </summary>
+        JoinGroups,
+
+        /// <summary>
+        /// SyncPlay is disabled for the user.
+        /// </summary>
+        None
+    }
+}

+ 40 - 0
MediaBrowser.Model/SyncPlay/GroupInfoView.cs

@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Class GroupInfoView.
+    /// </summary>
+    public class GroupInfoView
+    {
+        /// <summary>
+        /// Gets or sets the group identifier.
+        /// </summary>
+        /// <value>The group identifier.</value>
+        public string GroupId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the playing item id.
+        /// </summary>
+        /// <value>The playing item id.</value>
+        public string PlayingItemId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the playing item name.
+        /// </summary>
+        /// <value>The playing item name.</value>
+        public string PlayingItemName { get; set; }
+
+        /// <summary>
+        /// Gets or sets the position ticks.
+        /// </summary>
+        /// <value>The position ticks.</value>
+        public long PositionTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets the participants.
+        /// </summary>
+        /// <value>The participants.</value>
+        public IReadOnlyList<string> Participants { get; set; }
+    }
+}

+ 26 - 0
MediaBrowser.Model/SyncPlay/GroupUpdate.cs

@@ -0,0 +1,26 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Class GroupUpdate.
+    /// </summary>
+    public class GroupUpdate<T>
+    {
+        /// <summary>
+        /// Gets or sets the group identifier.
+        /// </summary>
+        /// <value>The group identifier.</value>
+        public string GroupId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the update type.
+        /// </summary>
+        /// <value>The update type.</value>
+        public GroupUpdateType Type { get; set; }
+
+        /// <summary>
+        /// Gets or sets the data.
+        /// </summary>
+        /// <value>The data.</value>
+        public T Data { get; set; }
+    }
+}

+ 53 - 0
MediaBrowser.Model/SyncPlay/GroupUpdateType.cs

@@ -0,0 +1,53 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Enum GroupUpdateType.
+    /// </summary>
+    public enum GroupUpdateType
+    {
+        /// <summary>
+        /// The user-joined update. Tells members of a group about a new user.
+        /// </summary>
+        UserJoined,
+        /// <summary>
+        /// The user-left update. Tells members of a group that a user left.
+        /// </summary>
+        UserLeft,
+        /// <summary>
+        /// The group-joined update. Tells a user that the group has been joined.
+        /// </summary>
+        GroupJoined,
+        /// <summary>
+        /// The group-left update. Tells a user that the group has been left.
+        /// </summary>
+        GroupLeft,
+        /// <summary>
+        /// The group-wait update. Tells members of the group that a user is buffering.
+        /// </summary>
+        GroupWait,
+        /// <summary>
+        /// The prepare-session update. Tells a user to load some content.
+        /// </summary>
+        PrepareSession,
+        /// <summary>
+        /// The not-in-group error. Tells a user that they don't belong to a group.
+        /// </summary>
+        NotInGroup,
+        /// <summary>
+        /// The group-does-not-exist error. Sent when trying to join a non-existing group.
+        /// </summary>
+        GroupDoesNotExist,
+        /// <summary>
+        /// The create-group-denied error. Sent when a user tries to create a group without required permissions.
+        /// </summary>
+        CreateGroupDenied,
+        /// <summary>
+        /// The join-group-denied error. Sent when a user tries to join a group without required permissions.
+        /// </summary>
+        JoinGroupDenied,
+        /// <summary>
+        /// The library-access-denied error. Sent when a user tries to join a group without required access to the library.
+        /// </summary>
+        LibraryAccessDenied
+    }
+}

+ 22 - 0
MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs

@@ -0,0 +1,22 @@
+using System;
+
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Class JoinGroupRequest.
+    /// </summary>
+    public class JoinGroupRequest
+    {
+        /// <summary>
+        /// Gets or sets the Group id.
+        /// </summary>
+        /// <value>The Group id to join.</value>
+        public Guid GroupId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the playing item id.
+        /// </summary>
+        /// <value>The client's currently playing item id.</value>
+        public Guid PlayingItemId { get; set; }
+    }
+}

+ 34 - 0
MediaBrowser.Model/SyncPlay/PlaybackRequest.cs

@@ -0,0 +1,34 @@
+using System;
+
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Class PlaybackRequest.
+    /// </summary>
+    public class PlaybackRequest
+    {
+        /// <summary>
+        /// Gets or sets the request type.
+        /// </summary>
+        /// <value>The request type.</value>
+        public PlaybackRequestType Type { get; set; }
+
+        /// <summary>
+        /// Gets or sets when the request has been made by the client.
+        /// </summary>
+        /// <value>The date of the request.</value>
+        public DateTime? When { get; set; }
+
+        /// <summary>
+        /// Gets or sets the position ticks.
+        /// </summary>
+        /// <value>The position ticks.</value>
+        public long? PositionTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets the ping time.
+        /// </summary>
+        /// <value>The ping time.</value>
+        public long? Ping { get; set; }
+    }
+}

+ 33 - 0
MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs

@@ -0,0 +1,33 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Enum PlaybackRequestType
+    /// </summary>
+    public enum PlaybackRequestType
+    {
+        /// <summary>
+        /// A user is requesting a play command for the group.
+        /// </summary>
+        Play = 0,
+        /// <summary>
+        /// A user is requesting a pause command for the group.
+        /// </summary>
+        Pause = 1,
+        /// <summary>
+        /// A user is requesting a seek command for the group.
+        /// </summary>
+        Seek = 2,
+        /// <summary>
+        /// A user is signaling that playback is buffering.
+        /// </summary>
+        Buffering = 3,
+        /// <summary>
+        /// A user is signaling that playback resumed.
+        /// </summary>
+        BufferingDone = 4,
+        /// <summary>
+        /// A user is reporting its ping.
+        /// </summary>
+        UpdatePing = 5
+    }
+}

+ 38 - 0
MediaBrowser.Model/SyncPlay/SendCommand.cs

@@ -0,0 +1,38 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Class SendCommand.
+    /// </summary>
+    public class SendCommand
+    {
+        /// <summary>
+        /// Gets or sets the group identifier.
+        /// </summary>
+        /// <value>The group identifier.</value>
+        public string GroupId { get; set; }
+
+        /// <summary>
+        /// Gets or sets the UTC time when to execute the command.
+        /// </summary>
+        /// <value>The UTC time when to execute the command.</value>
+        public string When { get; set; }
+
+        /// <summary>
+        /// Gets or sets the position ticks.
+        /// </summary>
+        /// <value>The position ticks.</value>
+        public long? PositionTicks { get; set; }
+
+        /// <summary>
+        /// Gets or sets the command.
+        /// </summary>
+        /// <value>The command.</value>
+        public SendCommandType Command { get; set; }
+
+        /// <summary>
+        /// Gets or sets the UTC time when this command has been emitted.
+        /// </summary>
+        /// <value>The UTC time when this command has been emitted.</value>
+        public string EmittedAt { get; set; }
+    }
+}

+ 21 - 0
MediaBrowser.Model/SyncPlay/SendCommandType.cs

@@ -0,0 +1,21 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Enum SendCommandType.
+    /// </summary>
+    public enum SendCommandType
+    {
+        /// <summary>
+        /// The play command. Instructs users to start playback.
+        /// </summary>
+        Play = 0,
+        /// <summary>
+        /// The pause command. Instructs users to pause playback.
+        /// </summary>
+        Pause = 1,
+        /// <summary>
+        /// The seek command. Instructs users to seek to a specified time.
+        /// </summary>
+        Seek = 2
+    }
+}

+ 20 - 0
MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs

@@ -0,0 +1,20 @@
+namespace MediaBrowser.Model.SyncPlay
+{
+    /// <summary>
+    /// Class UtcTimeResponse.
+    /// </summary>
+    public class UtcTimeResponse
+    {
+        /// <summary>
+        /// Gets or sets the UTC time when request has been received.
+        /// </summary>
+        /// <value>The UTC time when request has been received.</value>
+        public string RequestReceptionTime { get; set; }
+
+        /// <summary>
+        /// Gets or sets the UTC time when response has been sent.
+        /// </summary>
+        /// <value>The UTC time when response has been sent.</value>
+        public string ResponseTransmissionTime { get; set; }
+    }
+}

+ 7 - 0
MediaBrowser.Model/Users/UserPolicy.cs

@@ -80,6 +80,12 @@ namespace MediaBrowser.Model.Users
         public string AuthenticationProviderId { get; set; }
         public string PasswordResetProviderId { get; set; }
 
+        /// <summary>
+        /// Gets or sets a value indicating what SyncPlay features the user can access.
+        /// </summary>
+        /// <value>Access level to SyncPlay features.</value>
+        public SyncPlayAccess SyncPlayAccess { get; set; }
+
         public UserPolicy()
         {
             IsHidden = true;
@@ -125,6 +131,7 @@ namespace MediaBrowser.Model.Users
             EnableContentDownloading = true;
             EnablePublicSharing = true;
             EnableRemoteAccess = true;
+            SyncPlayAccess = SyncPlayAccess.CreateAndJoinGroups;
         }
     }
 }