aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'Emby.Server.Implementations')
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs4
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketConnection.cs40
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs17
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs245
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayController.cs517
-rw-r--r--Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs398
6 files changed, 1219 insertions, 2 deletions
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index e6410f857..be4e05a64 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/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>();
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
index 095725c50..0680c5ffe 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
+++ b/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();
+ }
}
}
}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index df98a35bc..2b09a93ef 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/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);
diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
index d4e4ba1f2..e7b4b0ec3 100644
--- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
+++ b/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;
@@ -14,6 +19,21 @@ namespace Emby.Server.Implementations.Session
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>
private readonly ISessionManager _sessionManager;
@@ -27,6 +47,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>
/// <param name="logger">The logger.</param>
@@ -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;
+ }
+ }
+ }
}
}
diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
new file mode 100644
index 000000000..d430d4d16
--- /dev/null
+++ b/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()
+ };
+ }
+ }
+}
diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
new file mode 100644
index 000000000..1f76dd4e3
--- /dev/null
+++ b/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!");
+ }
+ }
+ }
+}