mirror of
https://github.com/jellyfin/jellyfin.git
synced 2024-09-07 11:57:55 +02:00
Merge pull request #2733 from OancaAndrei/syncplay
Add syncplay feature
This commit is contained in:
commit
7c72f69189
25 changed files with 2248 additions and 2 deletions
|
@ -45,6 +45,7 @@ using Emby.Server.Implementations.Services;
|
||||||
using Emby.Server.Implementations.Session;
|
using Emby.Server.Implementations.Session;
|
||||||
using Emby.Server.Implementations.TV;
|
using Emby.Server.Implementations.TV;
|
||||||
using Emby.Server.Implementations.Updates;
|
using Emby.Server.Implementations.Updates;
|
||||||
|
using Emby.Server.Implementations.SyncPlay;
|
||||||
using MediaBrowser.Api;
|
using MediaBrowser.Api;
|
||||||
using MediaBrowser.Common;
|
using MediaBrowser.Common;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
|
@ -78,6 +79,7 @@ using MediaBrowser.Controller.Session;
|
||||||
using MediaBrowser.Controller.Sorting;
|
using MediaBrowser.Controller.Sorting;
|
||||||
using MediaBrowser.Controller.Subtitles;
|
using MediaBrowser.Controller.Subtitles;
|
||||||
using MediaBrowser.Controller.TV;
|
using MediaBrowser.Controller.TV;
|
||||||
|
using MediaBrowser.Controller.SyncPlay;
|
||||||
using MediaBrowser.LocalMetadata.Savers;
|
using MediaBrowser.LocalMetadata.Savers;
|
||||||
using MediaBrowser.MediaEncoding.BdInfo;
|
using MediaBrowser.MediaEncoding.BdInfo;
|
||||||
using MediaBrowser.Model.Configuration;
|
using MediaBrowser.Model.Configuration;
|
||||||
|
@ -613,6 +615,8 @@ namespace Emby.Server.Implementations
|
||||||
|
|
||||||
serviceCollection.AddSingleton<IPlaylistManager, PlaylistManager>();
|
serviceCollection.AddSingleton<IPlaylistManager, PlaylistManager>();
|
||||||
|
|
||||||
|
serviceCollection.AddSingleton<ISyncPlayManager, SyncPlayManager>();
|
||||||
|
|
||||||
serviceCollection.AddSingleton<LiveTvDtoService>();
|
serviceCollection.AddSingleton<LiveTvDtoService>();
|
||||||
serviceCollection.AddSingleton<ILiveTvManager, LiveTvManager>();
|
serviceCollection.AddSingleton<ILiveTvManager, LiveTvManager>();
|
||||||
|
|
||||||
|
|
|
@ -78,6 +78,9 @@ namespace Emby.Server.Implementations.HttpServer
|
||||||
/// <value>The last activity date.</value>
|
/// <value>The last activity date.</value>
|
||||||
public DateTime LastActivityDate { get; private set; }
|
public DateTime LastActivityDate { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTime LastKeepAliveDate { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the query string.
|
/// Gets or sets the query string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -218,7 +221,42 @@ namespace Emby.Server.Implementations.HttpServer
|
||||||
Connection = this
|
Connection = this
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (info.MessageType.Equals("KeepAlive", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
await SendKeepAliveResponse();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
await OnReceive(info).ConfigureAwait(false);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@ using MediaBrowser.Model.Events;
|
||||||
using MediaBrowser.Model.Library;
|
using MediaBrowser.Model.Library;
|
||||||
using MediaBrowser.Model.Querying;
|
using MediaBrowser.Model.Querying;
|
||||||
using MediaBrowser.Model.Session;
|
using MediaBrowser.Model.Session;
|
||||||
|
using MediaBrowser.Model.SyncPlay;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Session
|
namespace Emby.Server.Implementations.Session
|
||||||
|
@ -1153,6 +1154,22 @@ namespace Emby.Server.Implementations.Session
|
||||||
await SendMessageToSession(session, "Play", command, cancellationToken).ConfigureAwait(false);
|
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)
|
private IEnumerable<BaseItem> TranslateItemForPlayback(Guid id, User user)
|
||||||
{
|
{
|
||||||
var item = _libraryManager.GetItemById(id);
|
var item = _libraryManager.GetItemById(id);
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MediaBrowser.Controller.Net;
|
using MediaBrowser.Controller.Net;
|
||||||
using MediaBrowser.Controller.Session;
|
using MediaBrowser.Controller.Session;
|
||||||
using MediaBrowser.Model.Events;
|
using MediaBrowser.Model.Events;
|
||||||
|
using MediaBrowser.Model.Net;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
@ -13,6 +18,21 @@ namespace Emby.Server.Implementations.Session
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SessionWebSocketListener : IWebSocketListener, IDisposable
|
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>
|
/// <summary>
|
||||||
/// The _session manager
|
/// The _session manager
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -26,6 +46,26 @@ namespace Emby.Server.Implementations.Session
|
||||||
|
|
||||||
private readonly IHttpServer _httpServer;
|
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>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class.
|
/// Initializes a new instance of the <see cref="SessionWebSocketListener" /> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -47,12 +87,13 @@ namespace Emby.Server.Implementations.Session
|
||||||
httpServer.WebSocketConnected += OnServerManagerWebSocketConnected;
|
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());
|
var session = GetSession(e.Argument.QueryString, e.Argument.RemoteEndPoint.ToString());
|
||||||
if (session != null)
|
if (session != null)
|
||||||
{
|
{
|
||||||
EnsureController(session, e.Argument);
|
EnsureController(session, e.Argument);
|
||||||
|
await KeepAliveWebSocket(e.Argument);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
@ -81,6 +122,7 @@ namespace Emby.Server.Implementations.Session
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_httpServer.WebSocketConnected -= OnServerManagerWebSocketConnected;
|
_httpServer.WebSocketConnected -= OnServerManagerWebSocketConnected;
|
||||||
|
StopKeepAlive();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -99,5 +141,206 @@ namespace Emby.Server.Implementations.Session
|
||||||
var controller = (WebSocketController)controllerInfo.Item1;
|
var controller = (WebSocketController)controllerInfo.Item1;
|
||||||
controller.AddWebSocket(connection);
|
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
Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
Normal file
517
Emby.Server.Implementations/SyncPlay/SyncPlayController.cs
Normal file
|
@ -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
Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
Normal file
398
Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs
Normal file
|
@ -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
MediaBrowser.Api/SyncPlay/SyncPlayService.cs
Normal file
302
MediaBrowser.Api/SyncPlay/SyncPlayService.cs
Normal file
|
@ -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
MediaBrowser.Api/SyncPlay/TimeSyncService.cs
Normal file
52
MediaBrowser.Api/SyncPlay/TimeSyncService.cs
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -23,6 +23,12 @@ namespace MediaBrowser.Controller.Net
|
||||||
/// <value>The last activity date.</value>
|
/// <value>The last activity date.</value>
|
||||||
DateTime LastActivityDate { get; }
|
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>
|
/// <summary>
|
||||||
/// Gets or sets the query string.
|
/// Gets or sets the query string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
@ -9,6 +9,7 @@ using MediaBrowser.Controller.Security;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Events;
|
using MediaBrowser.Model.Events;
|
||||||
using MediaBrowser.Model.Session;
|
using MediaBrowser.Model.Session;
|
||||||
|
using MediaBrowser.Model.SyncPlay;
|
||||||
|
|
||||||
namespace MediaBrowser.Controller.Session
|
namespace MediaBrowser.Controller.Session
|
||||||
{
|
{
|
||||||
|
@ -140,6 +141,24 @@ namespace MediaBrowser.Controller.Session
|
||||||
/// <returns>Task.</returns>
|
/// <returns>Task.</returns>
|
||||||
Task SendPlayCommand(string controllingSessionId, string sessionId, PlayRequest command, CancellationToken cancellationToken);
|
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>
|
/// <summary>
|
||||||
/// Sends the browse command.
|
/// Sends the browse command.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
169
MediaBrowser.Controller/SyncPlay/GroupInfo.cs
Normal file
169
MediaBrowser.Controller/SyncPlay/GroupInfo.cs
Normal file
|
@ -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
MediaBrowser.Controller/SyncPlay/GroupMember.cs
Normal file
28
MediaBrowser.Controller/SyncPlay/GroupMember.cs
Normal file
|
@ -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
MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs
Normal file
67
MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs
Normal file
|
@ -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
MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
Normal file
69
MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs
Normal file
|
@ -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
MediaBrowser.Model/Configuration/SyncplayAccess.cs
Normal file
23
MediaBrowser.Model/Configuration/SyncplayAccess.cs
Normal file
|
@ -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
MediaBrowser.Model/SyncPlay/GroupInfoView.cs
Normal file
40
MediaBrowser.Model/SyncPlay/GroupInfoView.cs
Normal file
|
@ -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
MediaBrowser.Model/SyncPlay/GroupUpdate.cs
Normal file
26
MediaBrowser.Model/SyncPlay/GroupUpdate.cs
Normal file
|
@ -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
MediaBrowser.Model/SyncPlay/GroupUpdateType.cs
Normal file
53
MediaBrowser.Model/SyncPlay/GroupUpdateType.cs
Normal file
|
@ -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
MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs
Normal file
22
MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs
Normal file
|
@ -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
MediaBrowser.Model/SyncPlay/PlaybackRequest.cs
Normal file
34
MediaBrowser.Model/SyncPlay/PlaybackRequest.cs
Normal file
|
@ -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
MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs
Normal file
33
MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs
Normal file
|
@ -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
MediaBrowser.Model/SyncPlay/SendCommand.cs
Normal file
38
MediaBrowser.Model/SyncPlay/SendCommand.cs
Normal file
|
@ -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
MediaBrowser.Model/SyncPlay/SendCommandType.cs
Normal file
21
MediaBrowser.Model/SyncPlay/SendCommandType.cs
Normal file
|
@ -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
MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs
Normal file
20
MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs
Normal file
|
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -80,6 +80,12 @@ namespace MediaBrowser.Model.Users
|
||||||
public string AuthenticationProviderId { get; set; }
|
public string AuthenticationProviderId { get; set; }
|
||||||
public string PasswordResetProviderId { 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()
|
public UserPolicy()
|
||||||
{
|
{
|
||||||
IsHidden = true;
|
IsHidden = true;
|
||||||
|
@ -125,6 +131,7 @@ namespace MediaBrowser.Model.Users
|
||||||
EnableContentDownloading = true;
|
EnableContentDownloading = true;
|
||||||
EnablePublicSharing = true;
|
EnablePublicSharing = true;
|
||||||
EnableRemoteAccess = true;
|
EnableRemoteAccess = true;
|
||||||
|
SyncPlayAccess = SyncPlayAccess.CreateAndJoinGroups;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue