diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index edc8b08646..a63db6ed7c 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -79,6 +79,7 @@ - [Nickbert7](https://github.com/Nickbert7) - [nvllsvm](https://github.com/nvllsvm) - [nyanmisaka](https://github.com/nyanmisaka) + - [OancaAndrei](https://github.com/OancaAndrei) - [oddstr13](https://github.com/oddstr13) - [orryverducci](https://github.com/orryverducci) - [petermcneil](https://github.com/petermcneil) diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs index 71ece80a75..d6cf6233e4 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs @@ -2,9 +2,9 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.WebSockets; using System.Threading.Tasks; -using Jellyfin.Data.Events; using MediaBrowser.Controller.Net; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -13,32 +13,23 @@ namespace Emby.Server.Implementations.HttpServer { public class WebSocketManager : IWebSocketManager { - private readonly Lazy> _webSocketListeners; + private readonly IWebSocketListener[] _webSocketListeners; private readonly ILogger _logger; private readonly ILoggerFactory _loggerFactory; - private bool _disposed = false; - public WebSocketManager( - Lazy> webSocketListeners, + IEnumerable webSocketListeners, ILogger logger, ILoggerFactory loggerFactory) { - _webSocketListeners = webSocketListeners; + _webSocketListeners = webSocketListeners.ToArray(); _logger = logger; _loggerFactory = loggerFactory; } - public event EventHandler> WebSocketConnected; - /// public async Task WebSocketRequestHandler(HttpContext context) { - if (_disposed) - { - return; - } - try { _logger.LogInformation("WS {IP} request", context.Connection.RemoteIpAddress); @@ -54,7 +45,13 @@ namespace Emby.Server.Implementations.HttpServer OnReceive = ProcessWebSocketMessageReceived }; - WebSocketConnected?.Invoke(this, new GenericEventArgs(connection)); + var tasks = new Task[_webSocketListeners.Length]; + for (var i = 0; i < _webSocketListeners.Length; ++i) + { + tasks[i] = _webSocketListeners[i].ProcessWebSocketConnectedAsync(connection); + } + + await Task.WhenAll(tasks).ConfigureAwait(false); await connection.ProcessAsync().ConfigureAwait(false); _logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress); @@ -75,21 +72,13 @@ namespace Emby.Server.Implementations.HttpServer /// The result. private Task ProcessWebSocketMessageReceived(WebSocketMessageInfo result) { - if (_disposed) + var tasks = new Task[_webSocketListeners.Length]; + for (var i = 0; i < _webSocketListeners.Length; ++i) { - return Task.CompletedTask; + tasks[i] = _webSocketListeners[i].ProcessMessageAsync(result); } - IEnumerable GetTasks() - { - var listeners = _webSocketListeners.Value; - foreach (var x in listeners) - { - yield return x.ProcessMessageAsync(result); - } - } - - return Task.WhenAll(GetTasks()); + return Task.WhenAll(tasks); } } } diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index afddfa856b..b3965fccad 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -1181,18 +1181,16 @@ namespace Emby.Server.Implementations.Session } /// - public async Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken) + public async Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken) { CheckDisposed(); - var session = GetSessionToRemoteControl(sessionId); await SendMessageToSession(session, SessionMessageType.SyncPlayCommand, command, cancellationToken).ConfigureAwait(false); } /// - public async Task SendSyncPlayGroupUpdate(string sessionId, GroupUpdate command, CancellationToken cancellationToken) + public async Task SendSyncPlayGroupUpdate(SessionInfo session, GroupUpdate command, CancellationToken cancellationToken) { CheckDisposed(); - var session = GetSessionToRemoteControl(sessionId); await SendMessageToSession(session, SessionMessageType.SyncPlayGroupUpdate, command, cancellationToken).ConfigureAwait(false); } diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index a5f8479537..169eaefd8b 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Data.Events; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Net; @@ -22,35 +21,17 @@ namespace Emby.Server.Implementations.Session /// /// The timeout in seconds after which a WebSocket is considered to be lost. /// - public const int WebSocketLostTimeout = 60; + private const int WebSocketLostTimeout = 60; /// /// The keep-alive interval factor; controls how often the watcher will check on the status of the WebSockets. /// - public const float IntervalFactor = 0.2f; + private const float IntervalFactor = 0.2f; /// /// The ForceKeepAlive factor; controls when a ForceKeepAlive is sent. /// - public const float ForceKeepAliveFactor = 0.75f; - - /// - /// The _session manager. - /// - private readonly ISessionManager _sessionManager; - - /// - /// The _logger. - /// - private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; - - private readonly IWebSocketManager _webSocketManager; - - /// - /// The KeepAlive cancellation token. - /// - private CancellationTokenSource _keepAliveCancellationToken; + private const float ForceKeepAliveFactor = 0.75f; /// /// Lock used for accesing the KeepAlive cancellation token. @@ -63,42 +44,68 @@ namespace Emby.Server.Implementations.Session private readonly HashSet _webSockets = new HashSet(); /// - /// Lock used for accesing the WebSockets watchlist. + /// Lock used for accessing the WebSockets watchlist. /// private readonly object _webSocketsLock = new object(); + /// + /// The _session manager. + /// + private readonly ISessionManager _sessionManager; + + /// + /// The _logger. + /// + private readonly ILogger _logger; + private readonly ILoggerFactory _loggerFactory; + + /// + /// The KeepAlive cancellation token. + /// + private CancellationTokenSource _keepAliveCancellationToken; + /// /// Initializes a new instance of the class. /// /// The logger. /// The session manager. /// The logger factory. - /// The HTTP server. public SessionWebSocketListener( ILogger logger, ISessionManager sessionManager, - ILoggerFactory loggerFactory, - IWebSocketManager webSocketManager) + ILoggerFactory loggerFactory) { _logger = logger; _sessionManager = sessionManager; _loggerFactory = loggerFactory; - _webSocketManager = webSocketManager; - - webSocketManager.WebSocketConnected += OnServerManagerWebSocketConnected; } - private async void OnServerManagerWebSocketConnected(object sender, GenericEventArgs e) + /// + public void Dispose() { - var session = GetSession(e.Argument.QueryString, e.Argument.RemoteEndPoint.ToString()); + StopKeepAlive(); + } + + /// + /// Processes the message. + /// + /// The message. + /// Task. + public Task ProcessMessageAsync(WebSocketMessageInfo message) + => Task.CompletedTask; + + /// + public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection) + { + var session = GetSession(connection.QueryString, connection.RemoteEndPoint.ToString()); if (session != null) { - EnsureController(session, e.Argument); - await KeepAliveWebSocket(e.Argument).ConfigureAwait(false); + EnsureController(session, connection); + await KeepAliveWebSocket(connection).ConfigureAwait(false); } else { - _logger.LogWarning("Unable to determine session based on query string: {0}", e.Argument.QueryString); + _logger.LogWarning("Unable to determine session based on query string: {0}", connection.QueryString); } } @@ -119,21 +126,6 @@ namespace Emby.Server.Implementations.Session return _sessionManager.GetSessionByAuthenticationToken(token, deviceId, remoteEndpoint); } - /// - public void Dispose() - { - _webSocketManager.WebSocketConnected -= OnServerManagerWebSocketConnected; - StopKeepAlive(); - } - - /// - /// Processes the message. - /// - /// The message. - /// Task. - public Task ProcessMessageAsync(WebSocketMessageInfo message) - => Task.CompletedTask; - private void EnsureController(SessionInfo session, IWebSocketConnection connection) { var controllerInfo = session.EnsureController( diff --git a/Emby.Server.Implementations/SyncPlay/Group.cs b/Emby.Server.Implementations/SyncPlay/Group.cs new file mode 100644 index 0000000000..7c2ad2477a --- /dev/null +++ b/Emby.Server.Implementations/SyncPlay/Group.cs @@ -0,0 +1,674 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Controller.SyncPlay.GroupStates; +using MediaBrowser.Controller.SyncPlay.Queue; +using MediaBrowser.Controller.SyncPlay.Requests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.SyncPlay +{ + /// + /// Class Group. + /// + /// + /// Class is not thread-safe, external locking is required when accessing methods. + /// + public class Group : IGroupStateContext + { + /// + /// The logger. + /// + private readonly ILogger _logger; + + /// + /// The logger factory. + /// + private readonly ILoggerFactory _loggerFactory; + + /// + /// The user manager. + /// + private readonly IUserManager _userManager; + + /// + /// The session manager. + /// + private readonly ISessionManager _sessionManager; + + /// + /// The library manager. + /// + private readonly ILibraryManager _libraryManager; + + /// + /// The participants, or members of the group. + /// + private readonly Dictionary _participants = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// The internal group state. + /// + private IGroupState _state; + + /// + /// Initializes a new instance of the class. + /// + /// The logger factory. + /// The user manager. + /// The session manager. + /// The library manager. + public Group( + ILoggerFactory loggerFactory, + IUserManager userManager, + ISessionManager sessionManager, + ILibraryManager libraryManager) + { + _loggerFactory = loggerFactory; + _userManager = userManager; + _sessionManager = sessionManager; + _libraryManager = libraryManager; + _logger = loggerFactory.CreateLogger(); + + _state = new IdleGroupState(loggerFactory); + } + + /// + /// Gets the default ping value used for sessions. + /// + /// The default ping. + public long DefaultPing { get; } = 500; + + /// + /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds. + /// + /// The maximum time offset error. + public long TimeSyncOffset { get; } = 2000; + + /// + /// Gets the maximum offset error accepted for position reported by clients, in milliseconds. + /// + /// The maximum offset error. + public long MaxPlaybackOffset { get; } = 500; + + /// + /// Gets the group identifier. + /// + /// The group identifier. + public Guid GroupId { get; } = Guid.NewGuid(); + + /// + /// Gets the group name. + /// + /// The group name. + public string GroupName { get; private set; } + + /// + /// Gets the group identifier. + /// + /// The group identifier. + public PlayQueueManager PlayQueue { get; } = new PlayQueueManager(); + + /// + /// Gets the runtime ticks of current playing item. + /// + /// The runtime ticks of current playing item. + public long RunTimeTicks { get; private set; } + + /// + /// Gets or sets the position ticks. + /// + /// The position ticks. + public long PositionTicks { get; set; } + + /// + /// Gets or sets the last activity. + /// + /// The last activity. + public DateTime LastActivity { get; set; } + + /// + /// Adds the session to the group. + /// + /// The session. + private void AddSession(SessionInfo session) + { + _participants.TryAdd( + session.Id, + new GroupMember(session) + { + Ping = DefaultPing, + IsBuffering = false + }); + } + + /// + /// Removes the session from the group. + /// + /// The session. + private void RemoveSession(SessionInfo session) + { + _participants.Remove(session.Id); + } + + /// + /// Filters sessions of this group. + /// + /// The current session. + /// The filtering type. + /// The list of sessions matching the filter. + private IEnumerable FilterSessions(SessionInfo from, SyncPlayBroadcastType type) + { + return type switch + { + SyncPlayBroadcastType.CurrentSession => new SessionInfo[] { from }, + SyncPlayBroadcastType.AllGroup => _participants + .Values + .Select(session => session.Session), + SyncPlayBroadcastType.AllExceptCurrentSession => _participants + .Values + .Select(session => session.Session) + .Where(session => !session.Id.Equals(from.Id, StringComparison.OrdinalIgnoreCase)), + SyncPlayBroadcastType.AllReady => _participants + .Values + .Where(session => !session.IsBuffering) + .Select(session => session.Session), + _ => Enumerable.Empty() + }; + } + + /// + /// Checks if a given user can access all items of a given queue, that is, + /// the user has the required minimum parental access and has access to all required folders. + /// + /// The user. + /// The queue. + /// true if the user can access all the items in the queue, false otherwise. + private bool HasAccessToQueue(User user, IReadOnlyList queue) + { + // Check if queue is empty. + if (queue == null || queue.Count == 0) + { + return true; + } + + foreach (var itemId in queue) + { + var item = _libraryManager.GetItemById(itemId); + if (!item.IsVisibleStandalone(user)) + { + return false; + } + } + + return true; + } + + private bool AllUsersHaveAccessToQueue(IReadOnlyList queue) + { + // Check if queue is empty. + if (queue == null || queue.Count == 0) + { + return true; + } + + // Get list of users. + var users = _participants + .Values + .Select(participant => _userManager.GetUserById(participant.Session.UserId)); + + // Find problematic users. + var usersWithNoAccess = users.Where(user => !HasAccessToQueue(user, queue)); + + // All users must be able to access the queue. + return !usersWithNoAccess.Any(); + } + + /// + /// Checks if the group is empty. + /// + /// true if the group is empty, false otherwise. + public bool IsGroupEmpty() => _participants.Count == 0; + + /// + /// Initializes the group with the session's info. + /// + /// The session. + /// The request. + /// The cancellation token. + public void CreateGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) + { + GroupName = request.GroupName; + AddSession(session); + + var sessionIsPlayingAnItem = session.FullNowPlayingItem != null; + + RestartCurrentItem(); + + if (sessionIsPlayingAnItem) + { + var playlist = session.NowPlayingQueue.Select(item => item.Id).ToList(); + PlayQueue.Reset(); + PlayQueue.SetPlaylist(playlist); + PlayQueue.SetPlayingItemById(session.FullNowPlayingItem.Id); + RunTimeTicks = session.FullNowPlayingItem.RunTimeTicks ?? 0; + PositionTicks = session.PlayState.PositionTicks ?? 0; + + // Maintain playstate. + var waitingState = new WaitingGroupState(_loggerFactory) + { + ResumePlaying = !session.PlayState.IsPaused + }; + SetState(waitingState); + } + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + _state.SessionJoined(this, _state.Type, session, cancellationToken); + + _logger.LogInformation("Session {SessionId} created group {GroupId}.", session.Id, GroupId.ToString()); + } + + /// + /// Adds the session to the group. + /// + /// The session. + /// The request. + /// The cancellation token. + public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) + { + AddSession(session); + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); + SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); + + _state.SessionJoined(this, _state.Type, session, cancellationToken); + + _logger.LogInformation("Session {SessionId} joined group {GroupId}.", session.Id, GroupId.ToString()); + } + + /// + /// Removes the session from the group. + /// + /// The session. + /// The request. + /// The cancellation token. + public void SessionLeave(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken) + { + _state.SessionLeaving(this, _state.Type, session, cancellationToken); + + RemoveSession(session); + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, GroupId.ToString()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName); + SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); + + _logger.LogInformation("Session {SessionId} left group {GroupId}.", session.Id, GroupId.ToString()); + } + + /// + /// Handles the requested action by the session. + /// + /// The session. + /// The requested action. + /// The cancellation token. + public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken) + { + // The server's job is to maintain a consistent state for clients to reference + // and notify clients of state changes. The actual syncing of media playback + // happens client side. Clients are aware of the server's time and use it to sync. + _logger.LogInformation("Session {SessionId} requested {RequestType} in group {GroupId} that is {StateType}.", session.Id, request.Action, GroupId.ToString(), _state.Type); + + // Apply requested changes to this group given its current state. + // Every request has a slightly different outcome depending on the group's state. + // There are currently four different group states that accomplish different goals: + // - Idle: in this state no media is playing and clients should be idle (playback is stopped). + // - Waiting: in this state the group is waiting for all the clients to be ready to start the playback, + // that is, they've either finished loading the media for the first time or they've finished buffering. + // Once all clients report to be ready the group's state can change to Playing or Paused. + // - Playing: clients have some media loaded and playback is unpaused. + // - Paused: clients have some media loaded but playback is currently paused. + request.Apply(this, _state, session, cancellationToken); + } + + /// + /// Gets the info about the group for the clients. + /// + /// The group info for the clients. + public GroupInfoDto GetInfo() + { + var participants = _participants.Values.Select(session => session.Session.UserName).Distinct().ToList(); + return new GroupInfoDto(GroupId, GroupName, _state.Type, participants, DateTime.UtcNow); + } + + /// + /// Checks if a user has access to all content in the play queue. + /// + /// The user. + /// true if the user can access the play queue; false otherwise. + public bool HasAccessToPlayQueue(User user) + { + var items = PlayQueue.GetPlaylist().Select(item => item.ItemId).ToList(); + return HasAccessToQueue(user, items); + } + + /// + public void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait) + { + if (_participants.TryGetValue(session.Id, out GroupMember value)) + { + value.IgnoreGroupWait = ignoreGroupWait; + } + } + + /// + public void SetState(IGroupState state) + { + _logger.LogInformation("Group {GroupId} switching from {FromStateType} to {ToStateType}.", GroupId.ToString(), _state.Type, state.Type); + this._state = state; + } + + /// + public Task SendGroupUpdate(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate message, CancellationToken cancellationToken) + { + IEnumerable GetTasks() + { + foreach (var session in FilterSessions(from, type)) + { + yield return _sessionManager.SendSyncPlayGroupUpdate(session, message, cancellationToken); + } + } + + return Task.WhenAll(GetTasks()); + } + + /// + public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken) + { + IEnumerable GetTasks() + { + foreach (var session in FilterSessions(from, type)) + { + yield return _sessionManager.SendSyncPlayCommand(session, message, cancellationToken); + } + } + + return Task.WhenAll(GetTasks()); + } + + /// + public SendCommand NewSyncPlayCommand(SendCommandType type) + { + return new SendCommand( + GroupId, + PlayQueue.GetPlayingItemPlaylistId(), + LastActivity, + type, + PositionTicks, + DateTime.UtcNow); + } + + /// + public GroupUpdate NewSyncPlayGroupUpdate(GroupUpdateType type, T data) + { + return new GroupUpdate(GroupId, type, data); + } + + /// + public long SanitizePositionTicks(long? positionTicks) + { + var ticks = positionTicks ?? 0; + return Math.Clamp(ticks, 0, RunTimeTicks); + } + + /// + public void UpdatePing(SessionInfo session, long ping) + { + if (_participants.TryGetValue(session.Id, out GroupMember value)) + { + value.Ping = ping; + } + } + + /// + public long GetHighestPing() + { + long max = long.MinValue; + foreach (var session in _participants.Values) + { + max = Math.Max(max, session.Ping); + } + + return max; + } + + /// + public void SetBuffering(SessionInfo session, bool isBuffering) + { + if (_participants.TryGetValue(session.Id, out GroupMember value)) + { + value.IsBuffering = isBuffering; + } + } + + /// + public void SetAllBuffering(bool isBuffering) + { + foreach (var session in _participants.Values) + { + session.IsBuffering = isBuffering; + } + } + + /// + public bool IsBuffering() + { + foreach (var session in _participants.Values) + { + if (session.IsBuffering && !session.IgnoreGroupWait) + { + return true; + } + } + + return false; + } + + /// + public bool SetPlayQueue(IReadOnlyList playQueue, int playingItemPosition, long startPositionTicks) + { + // Ignore on empty queue or invalid item position. + if (playQueue.Count == 0 || playingItemPosition >= playQueue.Count || playingItemPosition < 0) + { + return false; + } + + // Check if participants can access the new playing queue. + if (!AllUsersHaveAccessToQueue(playQueue)) + { + return false; + } + + PlayQueue.Reset(); + PlayQueue.SetPlaylist(playQueue); + PlayQueue.SetPlayingItemByIndex(playingItemPosition); + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + PositionTicks = startPositionTicks; + LastActivity = DateTime.UtcNow; + + return true; + } + + /// + public bool SetPlayingItem(Guid playlistItemId) + { + var itemFound = PlayQueue.SetPlayingItemByPlaylistId(playlistItemId); + + if (itemFound) + { + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + } + else + { + RunTimeTicks = 0; + } + + RestartCurrentItem(); + + return itemFound; + } + + /// + public bool RemoveFromPlayQueue(IReadOnlyList playlistItemIds) + { + var playingItemRemoved = PlayQueue.RemoveFromPlaylist(playlistItemIds); + if (playingItemRemoved) + { + var itemId = PlayQueue.GetPlayingItemId(); + if (!itemId.Equals(Guid.Empty)) + { + var item = _libraryManager.GetItemById(itemId); + RunTimeTicks = item.RunTimeTicks ?? 0; + } + else + { + RunTimeTicks = 0; + } + + RestartCurrentItem(); + } + + return playingItemRemoved; + } + + /// + public bool MoveItemInPlayQueue(Guid playlistItemId, int newIndex) + { + return PlayQueue.MovePlaylistItem(playlistItemId, newIndex); + } + + /// + public bool AddToPlayQueue(IReadOnlyList newItems, GroupQueueMode mode) + { + // Ignore on empty list. + if (newItems.Count == 0) + { + return false; + } + + // Check if participants can access the new playing queue. + if (!AllUsersHaveAccessToQueue(newItems)) + { + return false; + } + + if (mode.Equals(GroupQueueMode.QueueNext)) + { + PlayQueue.QueueNext(newItems); + } + else + { + PlayQueue.Queue(newItems); + } + + return true; + } + + /// + public void RestartCurrentItem() + { + PositionTicks = 0; + LastActivity = DateTime.UtcNow; + } + + /// + public bool NextItemInQueue() + { + var update = PlayQueue.Next(); + if (update) + { + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + RestartCurrentItem(); + return true; + } + else + { + return false; + } + } + + /// + public bool PreviousItemInQueue() + { + var update = PlayQueue.Previous(); + if (update) + { + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + RestartCurrentItem(); + return true; + } + else + { + return false; + } + } + + /// + public void SetRepeatMode(GroupRepeatMode mode) + { + PlayQueue.SetRepeatMode(mode); + } + + /// + public void SetShuffleMode(GroupShuffleMode mode) + { + PlayQueue.SetShuffleMode(mode); + } + + /// + public PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason) + { + var startPositionTicks = PositionTicks; + + if (_state.Type.Equals(GroupStateType.Playing)) + { + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - LastActivity; + // Elapsed time is negative if event happens + // during the delay added to account for latency. + // In this phase clients haven't started the playback yet. + // In other words, LastActivity is in the future, + // when playback unpause is supposed to happen. + // Adjust ticks only if playback actually started. + startPositionTicks += Math.Max(elapsedTime.Ticks, 0); + } + + return new PlayQueueUpdate( + reason, + PlayQueue.LastChange, + PlayQueue.GetPlaylist(), + PlayQueue.PlayingItemIndex, + startPositionTicks, + PlayQueue.ShuffleMode, + PlayQueue.RepeatMode); + } + } +} diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs deleted file mode 100644 index 5384795122..0000000000 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs +++ /dev/null @@ -1,514 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Session; -using MediaBrowser.Controller.SyncPlay; -using MediaBrowser.Model.Session; -using MediaBrowser.Model.SyncPlay; - -namespace Emby.Server.Implementations.SyncPlay -{ - /// - /// Class SyncPlayController. - /// - /// - /// Class is not thread-safe, external locking is required when accessing methods. - /// - public class SyncPlayController : ISyncPlayController - { - /// - /// Used to filter the sessions of a group. - /// - private enum BroadcastType - { - /// - /// All sessions will receive the message. - /// - AllGroup = 0, - - /// - /// Only the specified session will receive the message. - /// - CurrentSession = 1, - - /// - /// All sessions, except the current one, will receive the message. - /// - AllExceptCurrentSession = 2, - - /// - /// Only sessions that are not buffering will receive the message. - /// - AllReady = 3 - } - - /// - /// The session manager. - /// - private readonly ISessionManager _sessionManager; - - /// - /// The SyncPlay manager. - /// - private readonly ISyncPlayManager _syncPlayManager; - - /// - /// The group to manage. - /// - private readonly GroupInfo _group = new GroupInfo(); - - /// - /// Initializes a new instance of the class. - /// - /// The session manager. - /// The SyncPlay manager. - public SyncPlayController( - ISessionManager sessionManager, - ISyncPlayManager syncPlayManager) - { - _sessionManager = sessionManager; - _syncPlayManager = syncPlayManager; - } - - /// - public Guid GetGroupId() => _group.GroupId; - - /// - public Guid GetPlayingItemId() => _group.PlayingItem.Id; - - /// - public bool IsGroupEmpty() => _group.IsEmpty(); - - /// - /// Converts DateTime to UTC string. - /// - /// The date to convert. - /// The UTC string. - private string DateToUTCString(DateTime date) - { - return date.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); - } - - /// - /// Filters sessions of this group. - /// - /// The current session. - /// The filtering type. - /// The array of sessions matching the filter. - private IEnumerable FilterSessions(SessionInfo from, BroadcastType type) - { - switch (type) - { - case BroadcastType.CurrentSession: - return new SessionInfo[] { from }; - case BroadcastType.AllGroup: - return _group.Participants.Values - .Select(session => session.Session); - case BroadcastType.AllExceptCurrentSession: - return _group.Participants.Values - .Select(session => session.Session) - .Where(session => !session.Id.Equals(from.Id, StringComparison.Ordinal)); - case BroadcastType.AllReady: - return _group.Participants.Values - .Where(session => !session.IsBuffering) - .Select(session => session.Session); - default: - return Array.Empty(); - } - } - - /// - /// Sends a GroupUpdate message to the interested sessions. - /// - /// The current session. - /// The filtering type. - /// The message to send. - /// The cancellation token. - /// The task. - private Task SendGroupUpdate(SessionInfo from, BroadcastType type, GroupUpdate message, CancellationToken cancellationToken) - { - IEnumerable GetTasks() - { - foreach (var session in FilterSessions(from, type)) - { - yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id, message, cancellationToken); - } - } - - return Task.WhenAll(GetTasks()); - } - - /// - /// Sends a playback command to the interested sessions. - /// - /// The current session. - /// The filtering type. - /// The message to send. - /// The cancellation token. - /// The task. - private Task SendCommand(SessionInfo from, BroadcastType type, SendCommand message, CancellationToken cancellationToken) - { - IEnumerable GetTasks() - { - foreach (var session in FilterSessions(from, type)) - { - yield return _sessionManager.SendSyncPlayCommand(session.Id, message, cancellationToken); - } - } - - return Task.WhenAll(GetTasks()); - } - - /// - /// Builds a new playback command with some default values. - /// - /// The command type. - /// The SendCommand. - 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) - }; - } - - /// - /// Builds a new group update message. - /// - /// The update type. - /// The data to send. - /// The GroupUpdate. - private GroupUpdate NewSyncPlayGroupUpdate(GroupUpdateType type, T data) - { - return new GroupUpdate() - { - GroupId = _group.GroupId.ToString(), - Type = type, - Data = data - }; - } - - /// - public void CreateGroup(SessionInfo session, CancellationToken cancellationToken) - { - _group.AddSession(session); - _syncPlayManager.AddSessionToGroup(session, this); - - _group.PlayingItem = session.FullNowPlayingItem; - _group.IsPaused = session.PlayState.IsPaused; - _group.PositionTicks = session.PlayState.PositionTicks ?? 0; - _group.LastActivity = DateTime.UtcNow; - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow)); - SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken); - } - - /// - public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) - { - if (session.NowPlayingItem?.Id == _group.PlayingItem.Id) - { - _group.AddSession(session); - _syncPlayManager.AddSessionToGroup(session, this); - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow)); - SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken); - - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); - SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - - // Syncing will happen client-side - if (!_group.IsPaused) - { - var playCommand = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.CurrentSession, playCommand, cancellationToken); - } - else - { - var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.CurrentSession, pauseCommand, cancellationToken); - } - } - else - { - var playRequest = new PlayRequest - { - ItemIds = new Guid[] { _group.PlayingItem.Id }, - StartPositionTicks = _group.PositionTicks - }; - var update = NewSyncPlayGroupUpdate(GroupUpdateType.PrepareSession, playRequest); - SendGroupUpdate(session, BroadcastType.CurrentSession, update, cancellationToken); - } - } - - /// - 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); - } - - /// - public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - // The server's job is to maintain a consistent state for clients to reference - // and notify clients of state changes. The actual syncing of media playback - // happens client side. Clients are aware of the server's time and use it to sync. - switch (request.Type) - { - case PlaybackRequestType.Play: - HandlePlayRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Pause: - HandlePauseRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Seek: - HandleSeekRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Buffer: - HandleBufferingRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Ready: - HandleBufferingDoneRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Ping: - HandlePingUpdateRequest(session, request); - break; - } - } - - /// - /// Handles a play action requested by a session. - /// - /// The session. - /// The play action. - /// The cancellation token. - private void HandlePlayRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (_group.IsPaused) - { - // Pick a suitable time that accounts for latency - var delay = Math.Max(_group.GetHighestPing() * 2, GroupInfo.DefaultPing); - - // Unpause group and set starting point in future - // Clients will start playback at LastActivity (datetime) from PositionTicks (playback position) - // The added delay does not guarantee, of course, that the command will be received in time - // Playback synchronization will mainly happen client side - _group.IsPaused = false; - _group.LastActivity = DateTime.UtcNow.AddMilliseconds( - delay); - - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - else - { - // Client got lost, sending current state - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// - /// Handles a pause action requested by a session. - /// - /// The session. - /// The pause action. - /// The cancellation token. - private void HandlePauseRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (!_group.IsPaused) - { - // Pause group and compute the media playback position - _group.IsPaused = true; - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - _group.LastActivity; - _group.LastActivity = currentTime; - - // Seek only if playback actually started - // Pause request may be issued during the delay added to account for latency - _group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; - - var command = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - else - { - // Client got lost, sending current state - var command = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// - /// Handles a seek action requested by a session. - /// - /// The session. - /// The seek action. - /// The cancellation token. - 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); - } - - /// - /// Handles a buffering action requested by a session. - /// - /// The session. - /// The buffering action. - /// The cancellation token. - 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); - } - } - - /// - /// Handles a buffering-done action requested by a session. - /// - /// The session. - /// The buffering-done action. - /// The cancellation token. - private void HandleBufferingDoneRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (_group.IsPaused) - { - _group.SetBuffering(session, false); - - var requestTicks = SanitizePositionTicks(request.PositionTicks); - - var when = request.When ?? DateTime.UtcNow; - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - when; - var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime; - var delay = _group.PositionTicks - clientPosition.Ticks; - - if (_group.IsBuffering()) - { - // Others are still buffering, tell this client to pause when ready - var command = NewSyncPlayCommand(SendCommandType.Pause); - var pauseAtTime = currentTime.AddMilliseconds(delay); - command.When = DateToUTCString(pauseAtTime); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - else - { - // Let other clients resume as soon as the buffering client catches up - _group.IsPaused = false; - - if (delay > _group.GetHighestPing() * 2) - { - // Client that was buffering is recovering, notifying others to resume - _group.LastActivity = currentTime.AddMilliseconds( - delay); - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.AllExceptCurrentSession, command, cancellationToken); - } - else - { - // Client, that was buffering, resumed playback but did not update others in time - delay = Math.Max(_group.GetHighestPing() * 2, GroupInfo.DefaultPing); - - _group.LastActivity = currentTime.AddMilliseconds( - delay); - - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - } - } - else - { - // Group was not waiting, make sure client has latest state - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// - /// Sanitizes the PositionTicks, considers the current playing item when available. - /// - /// The PositionTicks. - /// The sanitized PositionTicks. - 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; - } - - /// - /// Updates ping of a session. - /// - /// The session. - /// The update. - private void HandlePingUpdateRequest(SessionInfo session, PlaybackRequest request) - { - // Collected pings are used to account for network latency when unpausing playback - _group.UpdatePing(session, request.Ping ?? GroupInfo.DefaultPing); - } - - /// - public GroupInfoView GetInfo() - { - return new GroupInfoView() - { - GroupId = GetGroupId().ToString(), - PlayingItemName = _group.PlayingItem.Name, - PlayingItemId = _group.PlayingItem.Id.ToString(), - PositionTicks = _group.PositionTicks, - Participants = _group.Participants.Values.Select(session => session.Session.UserName).Distinct().ToList() - }; - } - } -} diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index 7c4e003112..348213ee15 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -1,13 +1,11 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; -using System.Globalization; -using System.Linq; using System.Threading; -using Jellyfin.Data.Entities; -using Jellyfin.Data.Enums; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Controller.SyncPlay.Requests; using MediaBrowser.Model.SyncPlay; using Microsoft.Extensions.Logging; @@ -23,6 +21,11 @@ namespace Emby.Server.Implementations.SyncPlay /// private readonly ILogger _logger; + /// + /// The logger factory. + /// + private readonly ILoggerFactory _loggerFactory; + /// /// The user manager. /// @@ -41,18 +44,21 @@ namespace Emby.Server.Implementations.SyncPlay /// /// The map between sessions and groups. /// - private readonly Dictionary _sessionToGroupMap = - new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _sessionToGroupMap = + new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); /// /// The groups. /// - private readonly Dictionary _groups = - new Dictionary(); + private readonly ConcurrentDictionary _groups = + new ConcurrentDictionary(); /// - /// Lock used for accessing any group. + /// Lock used for accessing multiple groups at once. /// + /// + /// This lock has priority on locks made on . + /// private readonly object _groupsLock = new object(); private bool _disposed = false; @@ -60,31 +66,24 @@ namespace Emby.Server.Implementations.SyncPlay /// /// Initializes a new instance of the class. /// - /// The logger. + /// The logger factory. /// The user manager. /// The session manager. /// The library manager. public SyncPlayManager( - ILogger logger, + ILoggerFactory loggerFactory, IUserManager userManager, ISessionManager sessionManager, ILibraryManager libraryManager) { - _logger = logger; + _loggerFactory = loggerFactory; _userManager = userManager; _sessionManager = sessionManager; _libraryManager = libraryManager; - - _sessionManager.SessionEnded += OnSessionManagerSessionEnded; - _sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped; + _logger = loggerFactory.CreateLogger(); + _sessionManager.SessionStarted += OnSessionManagerSessionStarted; } - /// - /// Gets all groups. - /// - /// All groups. - public IEnumerable Groups => _groups.Values; - /// public void Dispose() { @@ -92,6 +91,233 @@ namespace Emby.Server.Implementations.SyncPlay GC.SuppressFinalize(this); } + /// + public void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) + { + if (session == null) + { + throw new InvalidOperationException("Session is null!"); + } + + if (request == null) + { + throw new InvalidOperationException("Request is null!"); + } + + // Locking required to access list of groups. + lock (_groupsLock) + { + // Make sure that session has not joined another group. + if (_sessionToGroupMap.ContainsKey(session.Id)) + { + var leaveGroupRequest = new LeaveGroupRequest(); + LeaveGroup(session, leaveGroupRequest, cancellationToken); + } + + var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager); + _groups[group.GroupId] = group; + + if (!_sessionToGroupMap.TryAdd(session.Id, group)) + { + throw new InvalidOperationException("Could not add session to group!"); + } + + group.CreateGroup(session, request, cancellationToken); + } + } + + /// + public void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) + { + if (session == null) + { + throw new InvalidOperationException("Session is null!"); + } + + if (request == null) + { + throw new InvalidOperationException("Request is null!"); + } + + var user = _userManager.GetUserById(session.UserId); + + // Locking required to access list of groups. + lock (_groupsLock) + { + _groups.TryGetValue(request.GroupId, out Group group); + + if (group == null) + { + _logger.LogWarning("Session {SessionId} tried to join group {GroupId} that does not exist.", session.Id, request.GroupId); + + var error = new GroupUpdate(Guid.Empty, GroupUpdateType.GroupDoesNotExist, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + return; + } + + // Group lock required to let other requests end first. + lock (group) + { + if (!group.HasAccessToPlayQueue(user)) + { + _logger.LogWarning("Session {SessionId} tried to join group {GroupId} but does not have access to some content of the playing queue.", session.Id, group.GroupId.ToString()); + + var error = new GroupUpdate(group.GroupId, GroupUpdateType.LibraryAccessDenied, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + return; + } + + if (_sessionToGroupMap.TryGetValue(session.Id, out var existingGroup)) + { + if (existingGroup.GroupId.Equals(request.GroupId)) + { + // Restore session. + group.SessionJoin(session, request, cancellationToken); + return; + } + + var leaveGroupRequest = new LeaveGroupRequest(); + LeaveGroup(session, leaveGroupRequest, cancellationToken); + } + + if (!_sessionToGroupMap.TryAdd(session.Id, group)) + { + throw new InvalidOperationException("Could not add session to group!"); + } + + group.SessionJoin(session, request, cancellationToken); + } + } + } + + /// + public void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken) + { + if (session == null) + { + throw new InvalidOperationException("Session is null!"); + } + + if (request == null) + { + throw new InvalidOperationException("Request is null!"); + } + + // Locking required to access list of groups. + lock (_groupsLock) + { + if (_sessionToGroupMap.TryGetValue(session.Id, out var group)) + { + // Group lock required to let other requests end first. + lock (group) + { + if (_sessionToGroupMap.TryRemove(session.Id, out var tempGroup)) + { + if (!tempGroup.GroupId.Equals(group.GroupId)) + { + throw new InvalidOperationException("Session was in wrong group!"); + } + } + else + { + throw new InvalidOperationException("Could not remove session from group!"); + } + + group.SessionLeave(session, request, cancellationToken); + + if (group.IsGroupEmpty()) + { + _logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId); + _groups.Remove(group.GroupId, out _); + } + } + } + else + { + _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id); + + var error = new GroupUpdate(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + return; + } + } + } + + /// + public List ListGroups(SessionInfo session, ListGroupsRequest request) + { + if (session == null) + { + throw new InvalidOperationException("Session is null!"); + } + + if (request == null) + { + throw new InvalidOperationException("Request is null!"); + } + + var user = _userManager.GetUserById(session.UserId); + List list = new List(); + + foreach (var group in _groups.Values) + { + // Locking required as group is not thread-safe. + lock (group) + { + if (group.HasAccessToPlayQueue(user)) + { + list.Add(group.GetInfo()); + } + } + } + + return list; + } + + /// + public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken) + { + if (session == null) + { + throw new InvalidOperationException("Session is null!"); + } + + if (request == null) + { + throw new InvalidOperationException("Request is null!"); + } + + if (_sessionToGroupMap.TryGetValue(session.Id, out var group)) + { + // Group lock required as Group is not thread-safe. + lock (group) + { + // Make sure that session still belongs to this group. + if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId)) + { + // Drop request. + return; + } + + // Drop request if group is empty. + if (group.IsGroupEmpty()) + { + return; + } + + // Apply requested changes to group. + group.HandleRequest(session, request, cancellationToken); + } + } + else + { + _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id); + + var error = new GroupUpdate(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + } + } + /// /// Releases unmanaged and optionally managed resources. /// @@ -103,275 +329,18 @@ namespace Emby.Server.Implementations.SyncPlay return; } - _sessionManager.SessionEnded -= OnSessionManagerSessionEnded; - _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped; - + _sessionManager.SessionStarted -= OnSessionManagerSessionStarted; _disposed = true; } - private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e) + private void OnSessionManagerSessionStarted(object sender, SessionEventArgs e) { var session = e.SessionInfo; - if (!IsSessionInGroup(session)) + + if (_sessionToGroupMap.TryGetValue(session.Id, out var group)) { - 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 = !user.MaxParentalAgeRating.HasValue - || item.InheritedParentalRatingValue <= user.MaxParentalAgeRating; - - if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess) - { - var collections = _libraryManager.GetCollectionFolders(item).Select( - folder => folder.Id.ToString("N", CultureInfo.InvariantCulture)); - - return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any(); - } - - return hasParentalRatingAccess; - } - - private Guid? GetSessionGroup(SessionInfo session) - { - _sessionToGroupMap.TryGetValue(session.Id, out var group); - return group?.GetGroupId(); - } - - /// - public void NewGroup(SessionInfo session, CancellationToken cancellationToken) - { - var user = _userManager.GetUserById(session.UserId); - - if (user.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups) - { - _logger.LogWarning("NewGroup: {0} does not have permission to create groups.", session.Id); - - var error = new GroupUpdate - { - Type = GroupUpdateType.CreateGroupDenied - }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; - } - - lock (_groupsLock) - { - if (IsSessionInGroup(session)) - { - LeaveGroup(session, cancellationToken); - } - - var group = new SyncPlayController(_sessionManager, this); - _groups[group.GetGroupId()] = group; - - group.CreateGroup(session, cancellationToken); - } - } - - /// - public void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken) - { - var user = _userManager.GetUserById(session.UserId); - - if (user.SyncPlayAccess == SyncPlayAccess.None) - { - _logger.LogWarning("JoinGroup: {0} does not have access to SyncPlay.", session.Id); - - var error = new GroupUpdate() - { - Type = GroupUpdateType.JoinGroupDenied - }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, 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() - { - Type = GroupUpdateType.GroupDoesNotExist - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, 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() - { - GroupId = group.GetGroupId().ToString(), - Type = GroupUpdateType.LibraryAccessDenied - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; - } - - if (IsSessionInGroup(session)) - { - if (GetSessionGroup(session).Equals(groupId)) - { - return; - } - - LeaveGroup(session, cancellationToken); - } - - group.SessionJoin(session, request, cancellationToken); - } - } - - /// - 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) - { - _sessionToGroupMap.TryGetValue(session.Id, out var group); - - if (group == null) - { - _logger.LogWarning("LeaveGroup: {0} does not belong to any group.", session.Id); - - var error = new GroupUpdate() - { - Type = GroupUpdateType.NotInGroup - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, 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 _); - } - } - } - - /// - public List ListGroups(SessionInfo session, Guid filterItemId) - { - var user = _userManager.GetUserById(session.UserId); - - if (user.SyncPlayAccess == SyncPlayAccess.None) - { - return new List(); - } - - // 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(); - } - else - { - // Otherwise show all available groups - return _groups.Values.Where( - group => HasAccessToItem(user, group.GetPlayingItemId())).Select( - group => group.GetInfo()).ToList(); - } - } - - /// - public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - var user = _userManager.GetUserById(session.UserId); - - if (user.SyncPlayAccess == SyncPlayAccess.None) - { - _logger.LogWarning("HandleRequest: {0} does not have access to SyncPlay.", session.Id); - - var error = new GroupUpdate() - { - Type = GroupUpdateType.JoinGroupDenied - }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; - } - - lock (_groupsLock) - { - _sessionToGroupMap.TryGetValue(session.Id, out var group); - - if (group == null) - { - _logger.LogWarning("HandleRequest: {0} does not belong to any group.", session.Id); - - var error = new GroupUpdate() - { - Type = GroupUpdateType.NotInGroup - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; - } - - group.HandleRequest(session, request, cancellationToken); - } - } - - /// - public void AddSessionToGroup(SessionInfo session, ISyncPlayController group) - { - if (IsSessionInGroup(session)) - { - throw new InvalidOperationException("Session in other group already!"); - } - - _sessionToGroupMap[session.Id] = group; - } - - /// - public void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group) - { - if (!IsSessionInGroup(session)) - { - throw new InvalidOperationException("Session not in any group!"); - } - - _sessionToGroupMap.Remove(session.Id, out var tempGroup); - if (!tempGroup.GetGroupId().Equals(group.GetGroupId())) - { - throw new InvalidOperationException("Session was in wrong group!"); + var request = new JoinGroupRequest(group.GroupId); + JoinGroup(session, request, CancellationToken.None); } } } diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs new file mode 100644 index 0000000000..b5932ea6b4 --- /dev/null +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessHandler.cs @@ -0,0 +1,58 @@ +using System.Threading.Tasks; +using Jellyfin.Api.Helpers; +using Jellyfin.Data.Enums; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy +{ + /// + /// Default authorization handler. + /// + public class SyncPlayAccessHandler : BaseAuthorizationHandler + { + private readonly IUserManager _userManager; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + /// Instance of the interface. + public SyncPlayAccessHandler( + IUserManager userManager, + INetworkManager networkManager, + IHttpContextAccessor httpContextAccessor) + : base(userManager, networkManager, httpContextAccessor) + { + _userManager = userManager; + } + + /// + protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncPlayAccessRequirement requirement) + { + if (!ValidateClaims(context.User)) + { + context.Fail(); + return Task.CompletedTask; + } + + var userId = ClaimHelpers.GetUserId(context.User); + var user = _userManager.GetUserById(userId!.Value); + + if ((requirement.RequiredAccess.HasValue && user.SyncPlayAccess == requirement.RequiredAccess) + || user.SyncPlayAccess == SyncPlayAccess.CreateAndJoinGroups) + { + context.Succeed(requirement); + } + else + { + context.Fail(); + } + + return Task.CompletedTask; + } + } +} diff --git a/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs new file mode 100644 index 0000000000..7fcaf69f6e --- /dev/null +++ b/Jellyfin.Api/Auth/SyncPlayAccessPolicy/SyncPlayAccessRequirement.cs @@ -0,0 +1,33 @@ +using Jellyfin.Data.Enums; +using Microsoft.AspNetCore.Authorization; + +namespace Jellyfin.Api.Auth.SyncPlayAccessPolicy +{ + /// + /// The default authorization requirement. + /// + public class SyncPlayAccessRequirement : IAuthorizationRequirement + { + /// + /// Initializes a new instance of the class. + /// + /// A value of . + public SyncPlayAccessRequirement(SyncPlayAccess requiredAccess) + { + RequiredAccess = requiredAccess; + } + + /// + /// Initializes a new instance of the class. + /// + public SyncPlayAccessRequirement() + { + RequiredAccess = null; + } + + /// + /// Gets the required SyncPlay access. + /// + public SyncPlayAccess? RequiredAccess { get; } + } +} diff --git a/Jellyfin.Api/Constants/Policies.cs b/Jellyfin.Api/Constants/Policies.cs index 7d77674700..b35ceea1a3 100644 --- a/Jellyfin.Api/Constants/Policies.cs +++ b/Jellyfin.Api/Constants/Policies.cs @@ -49,5 +49,15 @@ namespace Jellyfin.Api.Constants /// Policy name for escaping schedule controls or requiring first time setup. /// public const string FirstTimeSetupOrIgnoreParentalControl = "FirstTimeSetupOrIgnoreParentalControl"; + + /// + /// Policy name for requiring access to SyncPlay. + /// + public const string SyncPlayAccess = "SyncPlayAccess"; + + /// + /// Policy name for requiring group creation access to SyncPlay. + /// + public const string SyncPlayCreateGroupAccess = "SyncPlayCreateGroupAccess"; } } diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs index 346431e60c..471c9180da 100644 --- a/Jellyfin.Api/Controllers/SyncPlayController.cs +++ b/Jellyfin.Api/Controllers/SyncPlayController.cs @@ -4,9 +4,12 @@ using System.ComponentModel.DataAnnotations; using System.Threading; using Jellyfin.Api.Constants; using Jellyfin.Api.Helpers; +using Jellyfin.Api.Models.SyncPlayDtos; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Controller.SyncPlay.Requests; using MediaBrowser.Model.SyncPlay; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -17,7 +20,7 @@ namespace Jellyfin.Api.Controllers /// /// The sync play controller. /// - [Authorize(Policy = Policies.DefaultAuthorization)] + [Authorize(Policy = Policies.SyncPlayAccess)] public class SyncPlayController : BaseJellyfinApiController { private readonly ISessionManager _sessionManager; @@ -43,35 +46,36 @@ namespace Jellyfin.Api.Controllers /// /// Create a new SyncPlay group. /// + /// The settings of the new group. /// New group created. /// A indicating success. [HttpPost("New")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayCreateGroup() + [Authorize(Policy = Policies.SyncPlayCreateGroupAccess)] + public ActionResult SyncPlayCreateGroup( + [FromBody, Required] NewGroupRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - _syncPlayManager.NewGroup(currentSession, CancellationToken.None); + var syncPlayRequest = new NewGroupRequest(requestData.GroupName); + _syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } /// /// Join an existing SyncPlay group. /// - /// The sync play group id. + /// The group to join. /// Group join successful. /// A indicating success. [HttpPost("Join")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayJoinGroup([FromQuery, Required] Guid groupId) + [Authorize(Policy = Policies.SyncPlayAccess)] + public ActionResult SyncPlayJoinGroup( + [FromBody, Required] JoinGroupRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - - var joinRequest = new JoinGroupRequest() - { - GroupId = groupId - }; - - _syncPlayManager.JoinGroup(currentSession, groupId, joinRequest, CancellationToken.None); + var syncPlayRequest = new JoinGroupRequest(requestData.GroupId); + _syncPlayManager.JoinGroup(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -85,38 +89,125 @@ namespace Jellyfin.Api.Controllers public ActionResult SyncPlayLeaveGroup() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - _syncPlayManager.LeaveGroup(currentSession, CancellationToken.None); + var syncPlayRequest = new LeaveGroupRequest(); + _syncPlayManager.LeaveGroup(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } /// /// Gets all SyncPlay groups. /// - /// Optional. Filter by item id. /// Groups returned. /// An containing the available SyncPlay groups. [HttpGet("List")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult> SyncPlayGetGroups([FromQuery] Guid? filterItemId) + [Authorize(Policy = Policies.SyncPlayAccess)] + public ActionResult> SyncPlayGetGroups() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - return Ok(_syncPlayManager.ListGroups(currentSession, filterItemId.HasValue ? filterItemId.Value : Guid.Empty)); + var syncPlayRequest = new ListGroupsRequest(); + return Ok(_syncPlayManager.ListGroups(currentSession, syncPlayRequest)); } /// - /// Request play in SyncPlay group. + /// Request to set new playlist in SyncPlay group. /// - /// Play request sent to all group members. + /// The new playlist to play in the group. + /// Queue update sent to all group members. /// A indicating success. - [HttpPost("Play")] + [HttpPost("SetNewQueue")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayPlay() + public ActionResult SyncPlaySetNewQueue( + [FromBody, Required] PlayRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Play - }; + var syncPlayRequest = new PlayGroupRequest( + requestData.PlayingQueue, + requestData.PlayingItemPosition, + requestData.StartPositionTicks); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request to change playlist item in SyncPlay group. + /// + /// The new item to play. + /// Queue update sent to all group members. + /// A indicating success. + [HttpPost("SetPlaylistItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlaySetPlaylistItem( + [FromBody, Required] SetPlaylistItemRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new SetPlaylistItemGroupRequest(requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request to remove items from the playlist in SyncPlay group. + /// + /// The items to remove. + /// Queue update sent to all group members. + /// A indicating success. + [HttpPost("RemoveFromPlaylist")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayRemoveFromPlaylist( + [FromBody, Required] RemoveFromPlaylistRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new RemoveFromPlaylistGroupRequest(requestData.PlaylistItemIds); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request to move an item in the playlist in SyncPlay group. + /// + /// The new position for the item. + /// Queue update sent to all group members. + /// A indicating success. + [HttpPost("MovePlaylistItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayMovePlaylistItem( + [FromBody, Required] MovePlaylistItemRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new MovePlaylistItemGroupRequest(requestData.PlaylistItemId, requestData.NewIndex); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request to queue items to the playlist of a SyncPlay group. + /// + /// The items to add. + /// Queue update sent to all group members. + /// A indicating success. + [HttpPost("Queue")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayQueue( + [FromBody, Required] QueueRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new QueueGroupRequest(requestData.ItemIds, requestData.Mode); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request unpause in SyncPlay group. + /// + /// Unpause update sent to all group members. + /// A indicating success. + [HttpPost("Unpause")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayUnpause() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new UnpauseGroupRequest(); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -124,17 +215,29 @@ namespace Jellyfin.Api.Controllers /// /// Request pause in SyncPlay group. /// - /// Pause request sent to all group members. + /// Pause update sent to all group members. /// A indicating success. [HttpPost("Pause")] [ProducesResponseType(StatusCodes.Status204NoContent)] public ActionResult SyncPlayPause() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Pause - }; + var syncPlayRequest = new PauseGroupRequest(); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request stop in SyncPlay group. + /// + /// Stop update sent to all group members. + /// A indicating success. + [HttpPost("Stop")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayStop() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new StopGroupRequest(); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -142,42 +245,143 @@ namespace Jellyfin.Api.Controllers /// /// Request seek in SyncPlay group. /// - /// The playback position in ticks. - /// Seek request sent to all group members. + /// The new playback position. + /// Seek update sent to all group members. /// A indicating success. [HttpPost("Seek")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlaySeek([FromQuery] long positionTicks) + public ActionResult SyncPlaySeek( + [FromBody, Required] SeekRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Seek, - PositionTicks = positionTicks - }; + var syncPlayRequest = new SeekGroupRequest(requestData.PositionTicks); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } /// - /// Request group wait in SyncPlay group while buffering. + /// Notify SyncPlay group that member is buffering. /// - /// When the request has been made by the client. - /// The playback position in ticks. - /// Whether the buffering is done. - /// Buffering request sent to all group members. + /// The player status. + /// Group state update sent to all group members. /// A indicating success. [HttpPost("Buffering")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayBuffering([FromQuery] DateTime when, [FromQuery] long positionTicks, [FromQuery] bool bufferingDone) + public ActionResult SyncPlayBuffering( + [FromBody, Required] BufferRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = bufferingDone ? PlaybackRequestType.Ready : PlaybackRequestType.Buffer, - When = when, - PositionTicks = positionTicks - }; + var syncPlayRequest = new BufferGroupRequest( + requestData.When, + requestData.PositionTicks, + requestData.IsPlaying, + requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Notify SyncPlay group that member is ready for playback. + /// + /// The player status. + /// Group state update sent to all group members. + /// A indicating success. + [HttpPost("Ready")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayReady( + [FromBody, Required] ReadyRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new ReadyGroupRequest( + requestData.When, + requestData.PositionTicks, + requestData.IsPlaying, + requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request SyncPlay group to ignore member during group-wait. + /// + /// The settings to set. + /// Member state updated. + /// A indicating success. + [HttpPost("SetIgnoreWait")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlaySetIgnoreWait( + [FromBody, Required] IgnoreWaitRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new IgnoreWaitGroupRequest(requestData.IgnoreWait); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request next item in SyncPlay group. + /// + /// The current item information. + /// Next item update sent to all group members. + /// A indicating success. + [HttpPost("NextItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayNextItem( + [FromBody, Required] NextItemRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new NextItemGroupRequest(requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request previous item in SyncPlay group. + /// + /// The current item information. + /// Previous item update sent to all group members. + /// A indicating success. + [HttpPost("PreviousItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayPreviousItem( + [FromBody, Required] PreviousItemRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new PreviousItemGroupRequest(requestData.PlaylistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request to set repeat mode in SyncPlay group. + /// + /// The new repeat mode. + /// Play queue update sent to all group members. + /// A indicating success. + [HttpPost("SetRepeatMode")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlaySetRepeatMode( + [FromBody, Required] SetRepeatModeRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new SetRepeatModeGroupRequest(requestData.Mode); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// + /// Request to set shuffle mode in SyncPlay group. + /// + /// The new shuffle mode. + /// Play queue update sent to all group members. + /// A indicating success. + [HttpPost("SetShuffleMode")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlaySetShuffleMode( + [FromBody, Required] SetShuffleModeRequestDto requestData) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new SetShuffleModeGroupRequest(requestData.Mode); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -185,19 +389,16 @@ namespace Jellyfin.Api.Controllers /// /// Update session ping. /// - /// The ping. + /// The new ping. /// Ping updated. /// A indicating success. [HttpPost("Ping")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayPing([FromQuery] double ping) + public ActionResult SyncPlayPing( + [FromBody, Required] PingRequestDto requestData) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Ping, - Ping = Convert.ToInt64(ping) - }; + var syncPlayRequest = new PingGroupRequest(requestData.Ping); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs index 27c7186fcb..c730ac12b3 100644 --- a/Jellyfin.Api/Controllers/TimeSyncController.cs +++ b/Jellyfin.Api/Controllers/TimeSyncController.cs @@ -13,7 +13,7 @@ namespace Jellyfin.Api.Controllers public class TimeSyncController : BaseJellyfinApiController { /// - /// Gets the current utc time. + /// Gets the current UTC time. /// /// Time returned. /// An to sync the client and server time. @@ -22,18 +22,14 @@ namespace Jellyfin.Api.Controllers public ActionResult GetUtcTime() { // Important to keep the following line at the beginning - var requestReceptionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo); + var requestReceptionTime = DateTime.UtcNow.ToUniversalTime(); - var response = new UtcTimeResponse(); - response.RequestReceptionTime = requestReceptionTime; - - // Important to keep the following two lines at the end - var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo); - response.ResponseTransmissionTime = responseTransmissionTime; + // Important to keep the following line at the end + var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime(); // Implementing NTP on such a high level results in this useless // information being sent. On the other hand it enables future additions. - return response; + return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime); } } } diff --git a/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs new file mode 100644 index 0000000000..479c440840 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/BufferRequestDto.cs @@ -0,0 +1,42 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class BufferRequestDto. + /// + public class BufferRequestDto + { + /// + /// Initializes a new instance of the class. + /// + public BufferRequestDto() + { + PlaylistItemId = Guid.Empty; + } + + /// + /// Gets or sets when the request has been made by the client. + /// + /// The date of the request. + public DateTime When { get; set; } + + /// + /// Gets or sets the position ticks. + /// + /// The position ticks. + public long PositionTicks { get; set; } + + /// + /// Gets or sets a value indicating whether the client playback is unpaused. + /// + /// The client playback status. + public bool IsPlaying { get; set; } + + /// + /// Gets or sets the playlist item identifier of the playing item. + /// + /// The playlist item identifier. + public Guid PlaylistItemId { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs new file mode 100644 index 0000000000..4c30b7be43 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/IgnoreWaitRequestDto.cs @@ -0,0 +1,14 @@ +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class IgnoreWaitRequestDto. + /// + public class IgnoreWaitRequestDto + { + /// + /// Gets or sets a value indicating whether the client should be ignored. + /// + /// The client group-wait status. + public bool IgnoreWait { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs new file mode 100644 index 0000000000..ed97b8d6a5 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/JoinGroupRequestDto.cs @@ -0,0 +1,16 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class JoinGroupRequestDto. + /// + public class JoinGroupRequestDto + { + /// + /// Gets or sets the group identifier. + /// + /// The identifier of the group to join. + public Guid GroupId { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs new file mode 100644 index 0000000000..3af25f3e3e --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/MovePlaylistItemRequestDto.cs @@ -0,0 +1,30 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class MovePlaylistItemRequestDto. + /// + public class MovePlaylistItemRequestDto + { + /// + /// Initializes a new instance of the class. + /// + public MovePlaylistItemRequestDto() + { + PlaylistItemId = Guid.Empty; + } + + /// + /// Gets or sets the playlist identifier of the item. + /// + /// The playlist identifier of the item. + public Guid PlaylistItemId { get; set; } + + /// + /// Gets or sets the new position. + /// + /// The new position. + public int NewIndex { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs new file mode 100644 index 0000000000..441d7be367 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/NewGroupRequestDto.cs @@ -0,0 +1,22 @@ +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class NewGroupRequestDto. + /// + public class NewGroupRequestDto + { + /// + /// Initializes a new instance of the class. + /// + public NewGroupRequestDto() + { + GroupName = string.Empty; + } + + /// + /// Gets or sets the group name. + /// + /// The name of the new group. + public string GroupName { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs new file mode 100644 index 0000000000..f59a93f13d --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/NextItemRequestDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class NextItemRequestDto. + /// + public class NextItemRequestDto + { + /// + /// Initializes a new instance of the class. + /// + public NextItemRequestDto() + { + PlaylistItemId = Guid.Empty; + } + + /// + /// Gets or sets the playing item identifier. + /// + /// The playing item identifier. + public Guid PlaylistItemId { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs new file mode 100644 index 0000000000..c4ac068565 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/PingRequestDto.cs @@ -0,0 +1,14 @@ +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class PingRequestDto. + /// + public class PingRequestDto + { + /// + /// Gets or sets the ping time. + /// + /// The ping time. + public long Ping { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs new file mode 100644 index 0000000000..844388cd99 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/PlayRequestDto.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class PlayRequestDto. + /// + public class PlayRequestDto + { + /// + /// Initializes a new instance of the class. + /// + public PlayRequestDto() + { + PlayingQueue = Array.Empty(); + } + + /// + /// Gets or sets the playing queue. + /// + /// The playing queue. + public IReadOnlyList PlayingQueue { get; set; } + + /// + /// Gets or sets the position of the playing item in the queue. + /// + /// The playing item position. + public int PlayingItemPosition { get; set; } + + /// + /// Gets or sets the start position ticks. + /// + /// The start position ticks. + public long StartPositionTicks { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs new file mode 100644 index 0000000000..7fd4a49be2 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/PreviousItemRequestDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class PreviousItemRequestDto. + /// + public class PreviousItemRequestDto + { + /// + /// Initializes a new instance of the class. + /// + public PreviousItemRequestDto() + { + PlaylistItemId = Guid.Empty; + } + + /// + /// Gets or sets the playing item identifier. + /// + /// The playing item identifier. + public Guid PlaylistItemId { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs new file mode 100644 index 0000000000..2b187f443f --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/QueueRequestDto.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using MediaBrowser.Model.SyncPlay; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class QueueRequestDto. + /// + public class QueueRequestDto + { + /// + /// Initializes a new instance of the class. + /// + public QueueRequestDto() + { + ItemIds = Array.Empty(); + } + + /// + /// Gets or sets the items to enqueue. + /// + /// The items to enqueue. + public IReadOnlyList ItemIds { get; set; } + + /// + /// Gets or sets the mode in which to add the new items. + /// + /// The enqueue mode. + public GroupQueueMode Mode { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs new file mode 100644 index 0000000000..d9c193016a --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/ReadyRequestDto.cs @@ -0,0 +1,42 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class ReadyRequest. + /// + public class ReadyRequestDto + { + /// + /// Initializes a new instance of the class. + /// + public ReadyRequestDto() + { + PlaylistItemId = Guid.Empty; + } + + /// + /// Gets or sets when the request has been made by the client. + /// + /// The date of the request. + public DateTime When { get; set; } + + /// + /// Gets or sets the position ticks. + /// + /// The position ticks. + public long PositionTicks { get; set; } + + /// + /// Gets or sets a value indicating whether the client playback is unpaused. + /// + /// The client playback status. + public bool IsPlaying { get; set; } + + /// + /// Gets or sets the playlist item identifier of the playing item. + /// + /// The playlist item identifier. + public Guid PlaylistItemId { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs new file mode 100644 index 0000000000..e9b2b2cb37 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/RemoveFromPlaylistRequestDto.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class RemoveFromPlaylistRequestDto. + /// + public class RemoveFromPlaylistRequestDto + { + /// + /// Initializes a new instance of the class. + /// + public RemoveFromPlaylistRequestDto() + { + PlaylistItemIds = Array.Empty(); + } + + /// + /// Gets or sets the playlist identifiers ot the items. + /// + /// The playlist identifiers ot the items. + public IReadOnlyList PlaylistItemIds { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs new file mode 100644 index 0000000000..b9af0be7ff --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/SeekRequestDto.cs @@ -0,0 +1,14 @@ +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class SeekRequestDto. + /// + public class SeekRequestDto + { + /// + /// Gets or sets the position ticks. + /// + /// The position ticks. + public long PositionTicks { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs new file mode 100644 index 0000000000..b937679fc1 --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetPlaylistItemRequestDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class SetPlaylistItemRequestDto. + /// + public class SetPlaylistItemRequestDto + { + /// + /// Initializes a new instance of the class. + /// + public SetPlaylistItemRequestDto() + { + PlaylistItemId = Guid.Empty; + } + + /// + /// Gets or sets the playlist identifier of the playing item. + /// + /// The playlist identifier of the playing item. + public Guid PlaylistItemId { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs new file mode 100644 index 0000000000..e748fc3e0f --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetRepeatModeRequestDto.cs @@ -0,0 +1,16 @@ +using MediaBrowser.Model.SyncPlay; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class SetRepeatModeRequestDto. + /// + public class SetRepeatModeRequestDto + { + /// + /// Gets or sets the repeat mode. + /// + /// The repeat mode. + public GroupRepeatMode Mode { get; set; } + } +} diff --git a/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs b/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs new file mode 100644 index 0000000000..0e427f4a4d --- /dev/null +++ b/Jellyfin.Api/Models/SyncPlayDtos/SetShuffleModeRequestDto.cs @@ -0,0 +1,16 @@ +using MediaBrowser.Model.SyncPlay; + +namespace Jellyfin.Api.Models.SyncPlayDtos +{ + /// + /// Class SetShuffleModeRequestDto. + /// + public class SetShuffleModeRequestDto + { + /// + /// Gets or sets the shuffle mode. + /// + /// The shuffle mode. + public GroupShuffleMode Mode { get; set; } + } +} diff --git a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs index ce54651166..288e03fcff 100644 --- a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs @@ -58,7 +58,7 @@ namespace Jellyfin.Api.WebSocketListeners private void OnEntryCreated(object? sender, GenericEventArgs e) { - SendData(true); + SendData(true).GetAwaiter().GetResult(); } } } diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index 78f596a5c9..b76aa5e141 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -82,13 +82,11 @@ namespace Jellyfin.Server ServiceCollection.AddSingleton(); ServiceCollection.AddSingleton(); - ServiceCollection.AddScoped(); - ServiceCollection.AddScoped(); - ServiceCollection.AddScoped(); - ServiceCollection.AddScoped(); - - // TODO fix circular dependency on IWebSocketManager - ServiceCollection.AddScoped(serviceProvider => new Lazy>(serviceProvider.GetRequiredService>)); + // TODO search the assemblies instead of adding them manually? + ServiceCollection.AddSingleton(); + ServiceCollection.AddSingleton(); + ServiceCollection.AddSingleton(); + ServiceCollection.AddSingleton(); base.RegisterServices(); } diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 618a4e92b4..74e7bb4b15 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -15,9 +15,11 @@ using Jellyfin.Api.Auth.IgnoreParentalControlPolicy; using Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy; using Jellyfin.Api.Auth.LocalAccessPolicy; using Jellyfin.Api.Auth.RequiresElevationPolicy; +using Jellyfin.Api.Auth.SyncPlayAccessPolicy; using Jellyfin.Api.Constants; using Jellyfin.Api.Controllers; using Jellyfin.Api.ModelBinders; +using Jellyfin.Data.Enums; using Jellyfin.Server.Configuration; using Jellyfin.Server.Filters; using Jellyfin.Server.Formatters; @@ -58,6 +60,7 @@ namespace Jellyfin.Server.Extensions serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); return serviceCollection.AddAuthorizationCore(options => { options.AddPolicy( @@ -123,6 +126,20 @@ namespace Jellyfin.Server.Extensions policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); policy.AddRequirements(new RequiresElevationRequirement()); }); + options.AddPolicy( + Policies.SyncPlayAccess, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccess.JoinGroups)); + }); + options.AddPolicy( + Policies.SyncPlayCreateGroupAccess, + policy => + { + policy.AddAuthenticationSchemes(AuthenticationSchemes.CustomAuthentication); + policy.AddRequirements(new SyncPlayAccessRequirement(SyncPlayAccess.CreateAndJoinGroups)); + }); }); } diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index 28227603b2..163a9c8f86 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -92,6 +92,9 @@ namespace MediaBrowser.Controller.Net return Task.CompletedTask; } + /// + public Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection) => Task.CompletedTask; + /// /// Starts sending messages over a web socket. /// diff --git a/MediaBrowser.Controller/Net/IWebSocketListener.cs b/MediaBrowser.Controller/Net/IWebSocketListener.cs index 7250a57b0a..f1a75d5180 100644 --- a/MediaBrowser.Controller/Net/IWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/IWebSocketListener.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; namespace MediaBrowser.Controller.Net { /// - ///This is an interface for listening to messages coming through a web socket connection. + /// Interface for listening to messages coming through a web socket connection. /// public interface IWebSocketListener { @@ -13,5 +13,12 @@ namespace MediaBrowser.Controller.Net /// The message. /// Task. Task ProcessMessageAsync(WebSocketMessageInfo message); + + /// + /// Processes a new web socket connection. + /// + /// An instance of the interface. + /// Task. + Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection); } } diff --git a/MediaBrowser.Controller/Net/IWebSocketManager.cs b/MediaBrowser.Controller/Net/IWebSocketManager.cs index ce74173e70..bb0ae83bea 100644 --- a/MediaBrowser.Controller/Net/IWebSocketManager.cs +++ b/MediaBrowser.Controller/Net/IWebSocketManager.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Threading.Tasks; -using Jellyfin.Data.Events; using Microsoft.AspNetCore.Http; namespace MediaBrowser.Controller.Net @@ -11,11 +8,6 @@ namespace MediaBrowser.Controller.Net /// public interface IWebSocketManager { - /// - /// Occurs when [web socket connected]. - /// - event EventHandler> WebSocketConnected; - /// /// The HTTP request handler. /// diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index 04c3004ee6..9ad8557ce6 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -143,22 +143,22 @@ namespace MediaBrowser.Controller.Session Task SendPlayCommand(string controllingSessionId, string sessionId, PlayRequest command, CancellationToken cancellationToken); /// - /// Sends the SyncPlayCommand. + /// Sends a SyncPlayCommand to a session. /// - /// The session id. + /// The session. /// The command. /// The cancellation token. /// Task. - Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken); + Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken); /// - /// Sends the SyncPlayGroupUpdate. + /// Sends a SyncPlayGroupUpdate to a session. /// - /// The session id. + /// The session. /// The group update. /// The cancellation token. /// Task. - Task SendSyncPlayGroupUpdate(string sessionId, GroupUpdate command, CancellationToken cancellationToken); + Task SendSyncPlayGroupUpdate(SessionInfo session, GroupUpdate command, CancellationToken cancellationToken); /// /// Sends the browse command. diff --git a/MediaBrowser.Controller/SyncPlay/GroupInfo.cs b/MediaBrowser.Controller/SyncPlay/GroupInfo.cs deleted file mode 100644 index a1cada25cc..0000000000 --- a/MediaBrowser.Controller/SyncPlay/GroupInfo.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Session; - -namespace MediaBrowser.Controller.SyncPlay -{ - /// - /// Class GroupInfo. - /// - /// - /// Class is not thread-safe, external locking is required when accessing methods. - /// - public class GroupInfo - { - /// - /// The default ping value used for sessions. - /// - public const long DefaultPing = 500; - - /// - /// Gets the group identifier. - /// - /// The group identifier. - public Guid GroupId { get; } = Guid.NewGuid(); - - /// - /// Gets or sets the playing item. - /// - /// The playing item. - public BaseItem PlayingItem { get; set; } - - /// - /// Gets or sets a value indicating whether playback is paused. - /// - /// Playback is paused. - public bool IsPaused { get; set; } - - /// - /// Gets or sets a value indicating whether there are position ticks. - /// - /// The position ticks. - public long PositionTicks { get; set; } - - /// - /// Gets or sets the last activity. - /// - /// The last activity. - public DateTime LastActivity { get; set; } - - /// - /// Gets the participants. - /// - /// The participants, or members of the group. - public Dictionary Participants { get; } = - new Dictionary(StringComparer.OrdinalIgnoreCase); - - /// - /// Checks if a session is in this group. - /// - /// The session id to check. - /// true if the session is in this group; false otherwise. - public bool ContainsSession(string sessionId) - { - return Participants.ContainsKey(sessionId); - } - - /// - /// Adds the session to the group. - /// - /// The session. - public void AddSession(SessionInfo session) - { - Participants.TryAdd( - session.Id, - new GroupMember - { - Session = session, - Ping = DefaultPing, - IsBuffering = false - }); - } - - /// - /// Removes the session from the group. - /// - /// The session. - public void RemoveSession(SessionInfo session) - { - Participants.Remove(session.Id); - } - - /// - /// Updates the ping of a session. - /// - /// The session. - /// The ping. - public void UpdatePing(SessionInfo session, long ping) - { - if (Participants.TryGetValue(session.Id, out GroupMember value)) - { - value.Ping = ping; - } - } - - /// - /// Gets the highest ping in the group. - /// - /// The highest ping in the group. - public long GetHighestPing() - { - long max = long.MinValue; - foreach (var session in Participants.Values) - { - max = Math.Max(max, session.Ping); - } - - return max; - } - - /// - /// Sets the session's buffering state. - /// - /// The session. - /// The state. - public void SetBuffering(SessionInfo session, bool isBuffering) - { - if (Participants.TryGetValue(session.Id, out GroupMember value)) - { - value.IsBuffering = isBuffering; - } - } - - /// - /// Gets the group buffering state. - /// - /// true if there is a session buffering in the group; false otherwise. - public bool IsBuffering() - { - foreach (var session in Participants.Values) - { - if (session.IsBuffering) - { - return true; - } - } - - return false; - } - - /// - /// Checks if the group is empty. - /// - /// true if the group is empty; false otherwise. - public bool IsEmpty() - { - return Participants.Count == 0; - } - } -} diff --git a/MediaBrowser.Controller/SyncPlay/GroupMember.cs b/MediaBrowser.Controller/SyncPlay/GroupMember.cs index cde6f8e8ce..5fb982e85a 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupMember.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupMember.cs @@ -7,6 +7,27 @@ namespace MediaBrowser.Controller.SyncPlay /// public class GroupMember { + /// + /// Initializes a new instance of the class. + /// + /// The session. + public GroupMember(SessionInfo session) + { + Session = session; + } + + /// + /// Gets the session. + /// + /// The session. + public SessionInfo Session { get; } + + /// + /// Gets or sets the ping, in milliseconds. + /// + /// The ping. + public long Ping { get; set; } + /// /// Gets or sets a value indicating whether this member is buffering. /// @@ -14,15 +35,9 @@ namespace MediaBrowser.Controller.SyncPlay public bool IsBuffering { get; set; } /// - /// Gets or sets the session. + /// Gets or sets a value indicating whether this member is following group playback. /// - /// The session. - public SessionInfo Session { get; set; } - - /// - /// Gets or sets the ping. - /// - /// The ping. - public long Ping { get; set; } + /// true to ignore member on group wait; false if they're following group playback. + public bool IgnoreGroupWait { get; set; } } } diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs new file mode 100644 index 0000000000..e3de22db38 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs @@ -0,0 +1,222 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// + /// Class AbstractGroupState. + /// + /// + /// Class is not thread-safe, external locking is required when accessing methods. + /// + public abstract class AbstractGroupState : IGroupState + { + /// + /// The logger. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + protected AbstractGroupState(ILoggerFactory loggerFactory) + { + LoggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(); + } + + /// + public abstract GroupStateType Type { get; } + + /// + /// Gets the logger factory. + /// + protected ILoggerFactory LoggerFactory { get; } + + /// + public abstract void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + public abstract void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + public virtual void HandleRequest(IGroupPlaybackRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public virtual void HandleRequest(RemoveFromPlaylistGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + var playingItemRemoved = context.RemoveFromPlayQueue(request.PlaylistItemIds); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.RemoveItems); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + if (playingItemRemoved && !context.PlayQueue.IsItemPlaying()) + { + _logger.LogDebug("Play queue in group {GroupId} is now empty.", context.GroupId.ToString()); + + IGroupState idleState = new IdleGroupState(LoggerFactory); + context.SetState(idleState); + var stopRequest = new StopGroupRequest(); + idleState.HandleRequest(stopRequest, context, Type, session, cancellationToken); + } + } + + /// + public virtual void HandleRequest(MovePlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + var result = context.MoveItemInPlayQueue(request.PlaylistItemId, request.NewIndex); + + if (!result) + { + _logger.LogError("Unable to move item in group {GroupId}.", context.GroupId.ToString()); + return; + } + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.MoveItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// + public virtual void HandleRequest(QueueGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + var result = context.AddToPlayQueue(request.ItemIds, request.Mode); + + if (!result) + { + _logger.LogError("Unable to add items to play queue in group {GroupId}.", context.GroupId.ToString()); + return; + } + + var reason = request.Mode switch + { + GroupQueueMode.QueueNext => PlayQueueUpdateReason.QueueNext, + _ => PlayQueueUpdateReason.Queue + }; + var playQueueUpdate = context.GetPlayQueueUpdate(reason); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// + public virtual void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// + public virtual void HandleRequest(SetRepeatModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + context.SetRepeatMode(request.Mode); + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.RepeatMode); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// + public virtual void HandleRequest(SetShuffleModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + context.SetShuffleMode(request.Mode); + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.ShuffleMode); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// + public virtual void HandleRequest(PingGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Collected pings are used to account for network latency when unpausing playback. + context.UpdatePing(session, request.Ping); + } + + /// + public virtual void HandleRequest(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + context.SetIgnoreGroupWait(session, request.IgnoreWait); + } + + /// + /// Sends a group state update to all group. + /// + /// The context of the state. + /// The reason of the state change. + /// The session. + /// The cancellation token. + protected void SendGroupStateUpdate(IGroupStateContext context, IGroupPlaybackRequest reason, SessionInfo session, CancellationToken cancellationToken) + { + // Notify relevant state change event. + var stateUpdate = new GroupStateUpdate(Type, reason.Action); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.StateUpdate, stateUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + private void UnhandledRequest(IGroupPlaybackRequest request) + { + _logger.LogWarning("Unhandled request of type {RequestType} in {StateType} state.", request.Action, Type); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs new file mode 100644 index 0000000000..12ce6c8f82 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs @@ -0,0 +1,126 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// + /// Class IdleGroupState. + /// + /// + /// Class is not thread-safe, external locking is required when accessing methods. + /// + public class IdleGroupState : AbstractGroupState + { + /// + /// The logger. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public IdleGroupState(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _logger = LoggerFactory.CreateLogger(); + } + + /// + public override GroupStateType Type { get; } = GroupStateType.Idle; + + /// + public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, Type, session, cancellationToken); + } + + /// + public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Do nothing. + } + + /// + public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// + public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// + public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// + public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// + public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// + public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + private void SendStopCommand(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + var command = context.NewSyncPlayCommand(SendCommandType.Stop); + if (!prevState.Equals(Type)) + { + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + } + else + { + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs new file mode 100644 index 0000000000..fba8ba9e2e --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs @@ -0,0 +1,165 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// + /// Class PausedGroupState. + /// + /// + /// Class is not thread-safe, external locking is required when accessing methods. + /// + public class PausedGroupState : AbstractGroupState + { + /// + /// The logger. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public PausedGroupState(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _logger = LoggerFactory.CreateLogger(); + } + + /// + public override GroupStateType Type { get; } = GroupStateType.Paused; + + /// + public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Wait for session to be ready. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.SessionJoined(context, Type, session, cancellationToken); + } + + /// + public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Do nothing. + } + + /// + public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var playingState = new PlayingGroupState(LoggerFactory); + context.SetState(playingState); + playingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + if (!prevState.Equals(Type)) + { + // Pause group and compute the media playback position. + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - context.LastActivity; + context.LastActivity = currentTime; + // Elapsed time is negative if event happens + // during the delay added to account for latency. + // In this phase clients haven't started the playback yet. + // In other words, LastActivity is in the future, + // when playback unpause is supposed to happen. + // Seek only if playback actually started. + context.PositionTicks += Math.Max(elapsedTime.Ticks, 0); + + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + else + { + // Client got lost, sending current state. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + + /// + public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var idleState = new IdleGroupState(LoggerFactory); + context.SetState(idleState); + idleState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + if (prevState.Equals(Type)) + { + // Client got lost, sending current state. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + else if (prevState.Equals(GroupStateType.Waiting)) + { + // Sending current state to all clients. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + } + + /// + public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs new file mode 100644 index 0000000000..9797b247ce --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs @@ -0,0 +1,168 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// + /// Class PlayingGroupState. + /// + /// + /// Class is not thread-safe, external locking is required when accessing methods. + /// + public class PlayingGroupState : AbstractGroupState + { + /// + /// The logger. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public PlayingGroupState(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _logger = LoggerFactory.CreateLogger(); + } + + /// + public override GroupStateType Type { get; } = GroupStateType.Playing; + + /// + /// Gets or sets a value indicating whether requests for buffering should be ignored. + /// + public bool IgnoreBuffering { get; set; } + + /// + public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Wait for session to be ready. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.SessionJoined(context, Type, session, cancellationToken); + } + + /// + public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Do nothing. + } + + /// + public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + if (!prevState.Equals(Type)) + { + // Pick a suitable time that accounts for latency. + var delayMillis = Math.Max(context.GetHighestPing() * 2, context.DefaultPing); + + // Unpause group and set starting point in future. + // Clients will start playback at LastActivity (datetime) from PositionTicks (playback position). + // The added delay does not guarantee, of course, that the command will be received in time. + // Playback synchronization will mainly happen client side. + context.LastActivity = DateTime.UtcNow.AddMilliseconds(delayMillis); + + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + else + { + // Client got lost, sending current state. + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + + /// + public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var pausedState = new PausedGroupState(LoggerFactory); + context.SetState(pausedState); + pausedState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var idleState = new IdleGroupState(LoggerFactory); + context.SetState(idleState); + idleState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + if (IgnoreBuffering) + { + return; + } + + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + if (prevState.Equals(Type)) + { + // Group was not waiting, make sure client has latest state. + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + else if (prevState.Equals(GroupStateType.Waiting)) + { + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + } + + /// + public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(request, context, Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs new file mode 100644 index 0000000000..507573653f --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs @@ -0,0 +1,680 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// + /// Class WaitingGroupState. + /// + /// + /// Class is not thread-safe, external locking is required when accessing methods. + /// + public class WaitingGroupState : AbstractGroupState + { + /// + /// The logger. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + public WaitingGroupState(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _logger = LoggerFactory.CreateLogger(); + } + + /// + public override GroupStateType Type { get; } = GroupStateType.Waiting; + + /// + /// Gets or sets a value indicating whether playback should resume when group is ready. + /// + public bool ResumePlaying { get; set; } = false; + + /// + /// Gets or sets a value indicating whether the initial state has been set. + /// + private bool InitialStateSet { get; set; } = false; + + /// + /// Gets or sets the group state before the first ever event. + /// + private GroupStateType InitialState { get; set; } + + /// + public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupStateType.Playing)) + { + ResumePlaying = true; + // Pause group and compute the media playback position. + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - context.LastActivity; + context.LastActivity = currentTime; + // Elapsed time is negative if event happens + // during the delay added to account for latency. + // In this phase clients haven't started the playback yet. + // In other words, LastActivity is in the future, + // when playback unpause is supposed to happen. + // Seek only if playback actually started. + context.PositionTicks += Math.Max(elapsedTime.Ticks, 0); + } + + // Prepare new session. + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken); + + context.SetBuffering(session, true); + + // Send pause command to all non-buffering sessions. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken); + } + + /// + public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + context.SetBuffering(session, false); + + if (!context.IsBuffering()) + { + if (ResumePlaying) + { + _logger.LogDebug("Session {SessionId} left group {GroupId}, notifying others to resume.", session.Id, context.GroupId.ToString()); + + // Client, that was buffering, left the group. + var playingState = new PlayingGroupState(LoggerFactory); + context.SetState(playingState); + var unpauseRequest = new UnpauseGroupRequest(); + playingState.HandleRequest(unpauseRequest, context, Type, session, cancellationToken); + } + else + { + _logger.LogDebug("Session {SessionId} left group {GroupId}, returning to previous state.", session.Id, context.GroupId.ToString()); + + // Group is ready, returning to previous state. + var pausedState = new PausedGroupState(LoggerFactory); + context.SetState(pausedState); + } + } + } + + /// + public override void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + ResumePlaying = true; + + var setQueueStatus = context.SetPlayQueue(request.PlayingQueue, request.PlayingItemPosition, request.StartPositionTicks); + if (!setQueueStatus) + { + _logger.LogError("Unable to set playing queue in group {GroupId}.", context.GroupId.ToString()); + + // Ignore request and return to previous state. + IGroupState newState = prevState switch { + GroupStateType.Playing => new PlayingGroupState(LoggerFactory), + GroupStateType.Paused => new PausedGroupState(LoggerFactory), + _ => new IdleGroupState(LoggerFactory) + }; + + context.SetState(newState); + return; + } + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + + _logger.LogDebug("Session {SessionId} set a new play queue in group {GroupId}.", session.Id, context.GroupId.ToString()); + } + + /// + public override void HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + ResumePlaying = true; + + var result = context.SetPlayingItem(request.PlaylistItemId); + if (result) + { + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + } + else + { + // Return to old state. + IGroupState newState = prevState switch + { + GroupStateType.Playing => new PlayingGroupState(LoggerFactory), + GroupStateType.Paused => new PausedGroupState(LoggerFactory), + _ => new IdleGroupState(LoggerFactory) + }; + + context.SetState(newState); + + _logger.LogDebug("Unable to change current playing item in group {GroupId}.", context.GroupId.ToString()); + } + } + + /// + public override void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupStateType.Idle)) + { + ResumePlaying = true; + context.RestartCurrentItem(); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + + _logger.LogDebug("Group {GroupId} is waiting for all ready events.", context.GroupId.ToString()); + } + else + { + if (ResumePlaying) + { + _logger.LogDebug("Forcing the playback to start in group {GroupId}. Group-wait is disabled until next state change.", context.GroupId.ToString()); + + // An Unpause request is forcing the playback to start, ignoring sessions that are not ready. + context.SetAllBuffering(false); + + // Change state. + var playingState = new PlayingGroupState(LoggerFactory) + { + IgnoreBuffering = true + }; + context.SetState(playingState); + playingState.HandleRequest(request, context, Type, session, cancellationToken); + } + else + { + // Group would have gone to paused state, now will go to playing state when ready. + ResumePlaying = true; + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + } + } + + /// + public override void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + // Wait for sessions to be ready, then switch to paused state. + ResumePlaying = false; + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + + /// + public override void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + // Change state. + var idleState = new IdleGroupState(LoggerFactory); + context.SetState(idleState); + idleState.HandleRequest(request, context, Type, session, cancellationToken); + } + + /// + public override void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupStateType.Playing)) + { + ResumePlaying = true; + } + else if (prevState.Equals(GroupStateType.Paused)) + { + ResumePlaying = false; + } + + // Sanitize PositionTicks. + var ticks = context.SanitizePositionTicks(request.PositionTicks); + + // Seek. + context.PositionTicks = ticks; + context.LastActivity = DateTime.UtcNow; + + var command = context.NewSyncPlayCommand(SendCommandType.Seek); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + + /// + public override void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + // Make sure the client is playing the correct item. + if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId())) + { + _logger.LogDebug("Session {SessionId} reported wrong playlist item in group {GroupId}.", session.Id, context.GroupId.ToString()); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); + var updateSession = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + context.SetBuffering(session, true); + + return; + } + + if (prevState.Equals(GroupStateType.Playing)) + { + // Resume playback when all ready. + ResumePlaying = true; + + context.SetBuffering(session, true); + + // Pause group and compute the media playback position. + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - context.LastActivity; + context.LastActivity = currentTime; + // Elapsed time is negative if event happens + // during the delay added to account for latency. + // In this phase clients haven't started the playback yet. + // In other words, LastActivity is in the future, + // when playback unpause is supposed to happen. + // Seek only if playback actually started. + context.PositionTicks += Math.Max(elapsedTime.Ticks, 0); + + // Send pause command to all non-buffering sessions. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken); + } + else if (prevState.Equals(GroupStateType.Paused)) + { + // Don't resume playback when all ready. + ResumePlaying = false; + + context.SetBuffering(session, true); + + // Send pause command to buffering session. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + else if (prevState.Equals(GroupStateType.Waiting)) + { + // Another session is now buffering. + context.SetBuffering(session, true); + + if (!ResumePlaying) + { + // Force update for this session that should be paused. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + + /// + public override void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + // Make sure the client is playing the correct item. + if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId())) + { + _logger.LogDebug("Session {SessionId} reported wrong playlist item in group {GroupId}.", session.Id, context.GroupId.ToString()); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken); + context.SetBuffering(session, true); + + return; + } + + // Compute elapsed time between the client reported time and now. + // Elapsed time is used to estimate the client position when playback is unpaused. + // Ideally, the request is received and handled without major delays. + // However, to avoid waiting indefinitely when a client is not reporting a correct time, + // the elapsed time is ignored after a certain threshold. + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime.Subtract(request.When); + var timeSyncThresholdTicks = TimeSpan.FromMilliseconds(context.TimeSyncOffset).Ticks; + if (Math.Abs(elapsedTime.Ticks) > timeSyncThresholdTicks) + { + _logger.LogWarning("Session {SessionId} is not time syncing properly. Ignoring elapsed time.", session.Id); + + elapsedTime = TimeSpan.Zero; + } + + // Ignore elapsed time if client is paused. + if (!request.IsPlaying) + { + elapsedTime = TimeSpan.Zero; + } + + var requestTicks = context.SanitizePositionTicks(request.PositionTicks); + var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime; + var delayTicks = context.PositionTicks - clientPosition.Ticks; + var maxPlaybackOffsetTicks = TimeSpan.FromMilliseconds(context.MaxPlaybackOffset).Ticks; + + _logger.LogDebug("Session {SessionId} is at {PositionTicks} (delay of {Delay} seconds) in group {GroupId}.", session.Id, clientPosition, TimeSpan.FromTicks(delayTicks).TotalSeconds, context.GroupId.ToString()); + + if (ResumePlaying) + { + // Handle case where session reported as ready but in reality + // it has no clue of the real position nor the playback state. + if (!request.IsPlaying && Math.Abs(delayTicks) > maxPlaybackOffsetTicks) + { + // Session not ready at all. + context.SetBuffering(session, true); + + // Correcting session's position. + var command = context.NewSyncPlayCommand(SendCommandType.Seek); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + + _logger.LogWarning("Session {SessionId} got lost in time, correcting.", session.Id); + return; + } + + // Session is ready. + context.SetBuffering(session, false); + + if (context.IsBuffering()) + { + // Others are still buffering, tell this client to pause when ready. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + command.When = currentTime.AddTicks(delayTicks); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + + _logger.LogInformation("Session {SessionId} will pause when ready in {Delay} seconds. Group {GroupId} is waiting for all ready events.", session.Id, TimeSpan.FromTicks(delayTicks).TotalSeconds, context.GroupId.ToString()); + } + else + { + // If all ready, then start playback. + // Let other clients resume as soon as the buffering client catches up. + if (delayTicks > context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond) + { + // Client that was buffering is recovering, notifying others to resume. + context.LastActivity = currentTime.AddTicks(delayTicks); + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + var filter = SyncPlayBroadcastType.AllExceptCurrentSession; + if (!request.IsPlaying) + { + filter = SyncPlayBroadcastType.AllGroup; + } + + context.SendCommand(session, filter, command, cancellationToken); + + _logger.LogInformation("Session {SessionId} is recovering, group {GroupId} will resume in {Delay} seconds.", session.Id, context.GroupId.ToString(), TimeSpan.FromTicks(delayTicks).TotalSeconds); + } + else + { + // Client, that was buffering, resumed playback but did not update others in time. + delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond; + delayTicks = Math.Max(delayTicks, context.DefaultPing); + + context.LastActivity = currentTime.AddTicks(delayTicks); + + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + _logger.LogWarning("Session {SessionId} resumed playback, group {GroupId} has {Delay} seconds to recover.", session.Id, context.GroupId.ToString(), TimeSpan.FromTicks(delayTicks).TotalSeconds); + } + + // Change state. + var playingState = new PlayingGroupState(LoggerFactory); + context.SetState(playingState); + playingState.HandleRequest(request, context, Type, session, cancellationToken); + } + } + else + { + // Check that session is really ready, tolerate player imperfections under a certain threshold. + if (Math.Abs(context.PositionTicks - requestTicks) > maxPlaybackOffsetTicks) + { + // Session still not ready. + context.SetBuffering(session, true); + // Session is seeking to wrong position, correcting. + var command = context.NewSyncPlayCommand(SendCommandType.Seek); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + + _logger.LogWarning("Session {SessionId} is seeking to wrong position, correcting.", session.Id); + return; + } + else + { + // Session is ready. + context.SetBuffering(session, false); + } + + if (!context.IsBuffering()) + { + _logger.LogDebug("Session {SessionId} is ready, group {GroupId} is ready.", session.Id, context.GroupId.ToString()); + + // Group is ready, returning to previous state. + var pausedState = new PausedGroupState(LoggerFactory); + context.SetState(pausedState); + + if (InitialState.Equals(GroupStateType.Playing)) + { + // Group went from playing to waiting state and a pause request occured while waiting. + var pauseRequest = new PauseGroupRequest(); + pausedState.HandleRequest(pauseRequest, context, Type, session, cancellationToken); + } + else if (InitialState.Equals(GroupStateType.Paused)) + { + pausedState.HandleRequest(request, context, Type, session, cancellationToken); + } + } + } + } + + /// + public override void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + ResumePlaying = true; + + // Make sure the client knows the playing item, to avoid duplicate requests. + if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId())) + { + _logger.LogDebug("Session {SessionId} provided the wrong playlist item for group {GroupId}.", session.Id, context.GroupId.ToString()); + return; + } + + var newItem = context.NextItemInQueue(); + if (newItem) + { + // Send playing-queue update. + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NextItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + } + else + { + // Return to old state. + IGroupState newState = prevState switch + { + GroupStateType.Playing => new PlayingGroupState(LoggerFactory), + GroupStateType.Paused => new PausedGroupState(LoggerFactory), + _ => new IdleGroupState(LoggerFactory) + }; + + context.SetState(newState); + + _logger.LogDebug("No next item available in group {GroupId}.", context.GroupId.ToString()); + } + } + + /// + public override void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + ResumePlaying = true; + + // Make sure the client knows the playing item, to avoid duplicate requests. + if (!request.PlaylistItemId.Equals(context.PlayQueue.GetPlayingItemPlaylistId())) + { + _logger.LogDebug("Session {SessionId} provided the wrong playlist item for group {GroupId}.", session.Id, context.GroupId.ToString()); + return; + } + + var newItem = context.PreviousItemInQueue(); + if (newItem) + { + // Send playing-queue update. + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.PreviousItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + } + else + { + // Return to old state. + IGroupState newState = prevState switch + { + GroupStateType.Playing => new PlayingGroupState(LoggerFactory), + GroupStateType.Paused => new PausedGroupState(LoggerFactory), + _ => new IdleGroupState(LoggerFactory) + }; + + context.SetState(newState); + + _logger.LogDebug("No previous item available in group {GroupId}.", context.GroupId.ToString()); + } + } + + /// + public override void HandleRequest(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + context.SetIgnoreGroupWait(session, request.IgnoreWait); + + if (!context.IsBuffering()) + { + _logger.LogDebug("Ignoring session {SessionId}, group {GroupId} is ready.", session.Id, context.GroupId.ToString()); + + if (ResumePlaying) + { + // Client, that was buffering, stopped following playback. + var playingState = new PlayingGroupState(LoggerFactory); + context.SetState(playingState); + var unpauseRequest = new UnpauseGroupRequest(); + playingState.HandleRequest(unpauseRequest, context, Type, session, cancellationToken); + } + else + { + // Group is ready, returning to previous state. + var pausedState = new PausedGroupState(LoggerFactory); + context.SetState(pausedState); + } + } + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs b/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs new file mode 100644 index 0000000000..201f29952f --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs @@ -0,0 +1,27 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Interface IGroupPlaybackRequest. + /// + public interface IGroupPlaybackRequest : ISyncPlayRequest + { + /// + /// Gets the playback request type. + /// + /// The playback request type. + PlaybackRequestType Action { get; } + + /// + /// Applies the request to a group. + /// + /// The context of the state. + /// The current state. + /// The session. + /// The cancellation token. + void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Controller/SyncPlay/IGroupState.cs b/MediaBrowser.Controller/SyncPlay/IGroupState.cs new file mode 100644 index 0000000000..95ee09985f --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/IGroupState.cs @@ -0,0 +1,217 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Interface IGroupState. + /// + public interface IGroupState + { + /// + /// Gets the group state type. + /// + /// The group state type. + GroupStateType Type { get; } + + /// + /// Handles a session that joined the group. + /// + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a session that is leaving the group. + /// + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Generic handler. Context's state can change. + /// + /// The generic request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(IGroupPlaybackRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a play request from a session. Context's state can change. + /// + /// The play request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(PlayGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a set-playlist-item request from a session. Context's state can change. + /// + /// The set-playlist-item request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(SetPlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a remove-items request from a session. Context's state can change. + /// + /// The remove-items request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(RemoveFromPlaylistGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a move-playlist-item request from a session. Context's state should not change. + /// + /// The move-playlist-item request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(MovePlaylistItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a queue request from a session. Context's state should not change. + /// + /// The queue request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(QueueGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles an unpause request from a session. Context's state can change. + /// + /// The unpause request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(UnpauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a pause request from a session. Context's state can change. + /// + /// The pause request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(PauseGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a stop request from a session. Context's state can change. + /// + /// The stop request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(StopGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a seek request from a session. Context's state can change. + /// + /// The seek request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(SeekGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a buffer request from a session. Context's state can change. + /// + /// The buffer request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(BufferGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a ready request from a session. Context's state can change. + /// + /// The ready request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(ReadyGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a next-item request from a session. Context's state can change. + /// + /// The next-item request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(NextItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a previous-item request from a session. Context's state can change. + /// + /// The previous-item request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(PreviousItemGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a set-repeat-mode request from a session. Context's state should not change. + /// + /// The repeat-mode request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(SetRepeatModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a set-shuffle-mode request from a session. Context's state should not change. + /// + /// The shuffle-mode request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(SetShuffleModeGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Updates the ping of a session. Context's state should not change. + /// + /// The ping request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(PingGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// + /// Handles a ignore-wait request from a session. Context's state can change. + /// + /// The ignore-wait request. + /// The context of the state. + /// The previous state. + /// The session. + /// The cancellation token. + void HandleRequest(IgnoreWaitGroupRequest request, IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs new file mode 100644 index 0000000000..aa263638aa --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.Queue; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Interface IGroupStateContext. + /// + public interface IGroupStateContext + { + /// + /// Gets the default ping value used for sessions, in milliseconds. + /// + /// The default ping value used for sessions, in milliseconds. + long DefaultPing { get; } + + /// + /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds. + /// + /// The maximum offset error accepted, in milliseconds. + long TimeSyncOffset { get; } + + /// + /// Gets the maximum offset error accepted for position reported by clients, in milliseconds. + /// + /// The maximum offset error accepted, in milliseconds. + long MaxPlaybackOffset { get; } + + /// + /// Gets the group identifier. + /// + /// The group identifier. + Guid GroupId { get; } + + /// + /// Gets or sets the position ticks. + /// + /// The position ticks. + long PositionTicks { get; set; } + + /// + /// Gets or sets the last activity. + /// + /// The last activity. + DateTime LastActivity { get; set; } + + /// + /// Gets the play queue. + /// + /// The play queue. + PlayQueueManager PlayQueue { get; } + + /// + /// Sets a new state. + /// + /// The new state. + void SetState(IGroupState state); + + /// + /// Sends a GroupUpdate message to the interested sessions. + /// + /// The type of the data of the message. + /// The current session. + /// The filtering type. + /// The message to send. + /// The cancellation token. + /// The task. + Task SendGroupUpdate(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate message, CancellationToken cancellationToken); + + /// + /// Sends a playback command to the interested sessions. + /// + /// The current session. + /// The filtering type. + /// The message to send. + /// The cancellation token. + /// The task. + Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken); + + /// + /// Builds a new playback command with some default values. + /// + /// The command type. + /// The command. + SendCommand NewSyncPlayCommand(SendCommandType type); + + /// + /// Builds a new group update message. + /// + /// The type of the data of the message. + /// The update type. + /// The data to send. + /// The group update. + GroupUpdate NewSyncPlayGroupUpdate(GroupUpdateType type, T data); + + /// + /// Sanitizes the PositionTicks, considers the current playing item when available. + /// + /// The PositionTicks. + /// The sanitized position ticks. + long SanitizePositionTicks(long? positionTicks); + + /// + /// Updates the ping of a session, in milliseconds. + /// + /// The session. + /// The ping, in milliseconds. + void UpdatePing(SessionInfo session, long ping); + + /// + /// Gets the highest ping in the group, in milliseconds. + /// + /// The highest ping in the group. + long GetHighestPing(); + + /// + /// Sets the session's buffering state. + /// + /// The session. + /// The state. + void SetBuffering(SessionInfo session, bool isBuffering); + + /// + /// Sets the buffering state of all the sessions. + /// + /// The state. + void SetAllBuffering(bool isBuffering); + + /// + /// Gets the group buffering state. + /// + /// true if there is a session buffering in the group; false otherwise. + bool IsBuffering(); + + /// + /// Sets the session's group wait state. + /// + /// The session. + /// The state. + void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait); + + /// + /// Sets a new play queue. + /// + /// The new play queue. + /// The playing item position in the play queue. + /// The start position ticks. + /// true if the play queue has been changed; false if something went wrong. + bool SetPlayQueue(IReadOnlyList playQueue, int playingItemPosition, long startPositionTicks); + + /// + /// Sets the playing item. + /// + /// The new playing item identifier. + /// true if the play queue has been changed; false if something went wrong. + bool SetPlayingItem(Guid playlistItemId); + + /// + /// Removes items from the play queue. + /// + /// The items to remove. + /// true if playing item got removed; false otherwise. + bool RemoveFromPlayQueue(IReadOnlyList playlistItemIds); + + /// + /// Moves an item in the play queue. + /// + /// The playlist identifier of the item to move. + /// The new position. + /// true if item has been moved; false if something went wrong. + bool MoveItemInPlayQueue(Guid playlistItemId, int newIndex); + + /// + /// Updates the play queue. + /// + /// The new items to add to the play queue. + /// The mode with which the items will be added. + /// true if the play queue has been changed; false if something went wrong. + bool AddToPlayQueue(IReadOnlyList newItems, GroupQueueMode mode); + + /// + /// Restarts current item in play queue. + /// + void RestartCurrentItem(); + + /// + /// Picks next item in play queue. + /// + /// true if the item changed; false otherwise. + bool NextItemInQueue(); + + /// + /// Picks previous item in play queue. + /// + /// true if the item changed; false otherwise. + bool PreviousItemInQueue(); + + /// + /// Sets the repeat mode. + /// + /// The new mode. + void SetRepeatMode(GroupRepeatMode mode); + + /// + /// Sets the shuffle mode. + /// + /// The new mode. + void SetShuffleMode(GroupShuffleMode mode); + + /// + /// Creates a play queue update. + /// + /// The reason for the update. + /// The play queue update. + PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason); + } +} diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs deleted file mode 100644 index 60d17fe367..0000000000 --- a/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Threading; -using MediaBrowser.Controller.Session; -using MediaBrowser.Model.SyncPlay; - -namespace MediaBrowser.Controller.SyncPlay -{ - /// - /// Interface ISyncPlayController. - /// - public interface ISyncPlayController - { - /// - /// Gets the group id. - /// - /// The group id. - Guid GetGroupId(); - - /// - /// Gets the playing item id. - /// - /// The playing item id. - Guid GetPlayingItemId(); - - /// - /// Checks if the group is empty. - /// - /// If the group is empty. - bool IsGroupEmpty(); - - /// - /// Initializes the group with the session's info. - /// - /// The session. - /// The cancellation token. - void CreateGroup(SessionInfo session, CancellationToken cancellationToken); - - /// - /// Adds the session to the group. - /// - /// The session. - /// The request. - /// The cancellation token. - void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken); - - /// - /// Removes the session from the group. - /// - /// The session. - /// The cancellation token. - void SessionLeave(SessionInfo session, CancellationToken cancellationToken); - - /// - /// Handles the requested action by the session. - /// - /// The session. - /// The requested action. - /// The cancellation token. - void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken); - - /// - /// Gets the info about the group for the clients. - /// - /// The group info for the clients. - GroupInfoView GetInfo(); - } -} diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs index 006fb687b8..d0244563a4 100644 --- a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs +++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Threading; using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.Requests; using MediaBrowser.Model.SyncPlay; namespace MediaBrowser.Controller.SyncPlay @@ -15,32 +16,33 @@ namespace MediaBrowser.Controller.SyncPlay /// Creates a new group. /// /// The session that's creating the group. + /// The request. /// The cancellation token. - void NewGroup(SessionInfo session, CancellationToken cancellationToken); + void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken); /// /// Adds the session to a group. /// /// The session. - /// The group id. /// The request. /// The cancellation token. - void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken); + void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken); /// /// Removes the session from a group. /// /// The session. + /// The request. /// The cancellation token. - void LeaveGroup(SessionInfo session, CancellationToken cancellationToken); + void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken); /// /// Gets list of available groups for a session. /// /// The session. - /// The item id to filter by. - /// The list of available groups. - List ListGroups(SessionInfo session, Guid filterItemId); + /// The request. + /// The list of available groups. + List ListGroups(SessionInfo session, ListGroupsRequest request); /// /// Handle a request by a session in a group. @@ -48,22 +50,6 @@ namespace MediaBrowser.Controller.SyncPlay /// The session. /// The request. /// The cancellation token. - void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken); - - /// - /// Maps a session to a group. - /// - /// The session. - /// The group. - /// - void AddSessionToGroup(SessionInfo session, ISyncPlayController group); - - /// - /// Unmaps a session from a group. - /// - /// The session. - /// The group. - /// - void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group); + void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs new file mode 100644 index 0000000000..bf19817732 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayRequest.cs @@ -0,0 +1,16 @@ +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// + /// Interface ISyncPlayRequest. + /// + public interface ISyncPlayRequest + { + /// + /// Gets the request type. + /// + /// The request type. + RequestType Type { get; } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs new file mode 100644 index 0000000000..4090f65b9c --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/AbstractPlaybackRequest.cs @@ -0,0 +1,29 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class AbstractPlaybackRequest. + /// + public abstract class AbstractPlaybackRequest : IGroupPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + protected AbstractPlaybackRequest() + { + // Do nothing. + } + + /// + public RequestType Type { get; } = RequestType.Playback; + + /// + public abstract PlaybackRequestType Action { get; } + + /// + public abstract void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs new file mode 100644 index 0000000000..11cc99fcda --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class BufferGroupRequest. + /// + public class BufferGroupRequest : AbstractPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + /// When the request has been made, as reported by the client. + /// The position ticks. + /// Whether the client playback is unpaused. + /// The playlist item identifier of the playing item. + public BufferGroupRequest(DateTime when, long positionTicks, bool isPlaying, Guid playlistItemId) + { + When = when; + PositionTicks = positionTicks; + IsPlaying = isPlaying; + PlaylistItemId = playlistItemId; + } + + /// + /// Gets when the request has been made by the client. + /// + /// The date of the request. + public DateTime When { get; } + + /// + /// Gets the position ticks. + /// + /// The position ticks. + public long PositionTicks { get; } + + /// + /// Gets a value indicating whether the client playback is unpaused. + /// + /// The client playback status. + public bool IsPlaying { get; } + + /// + /// Gets the playlist item identifier of the playing item. + /// + /// The playlist item identifier. + public Guid PlaylistItemId { get; } + + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Buffer; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs new file mode 100644 index 0000000000..64ef791ed7 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs @@ -0,0 +1,36 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class IgnoreWaitGroupRequest. + /// + public class IgnoreWaitGroupRequest : AbstractPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + /// Whether the client should be ignored. + public IgnoreWaitGroupRequest(bool ignoreWait) + { + IgnoreWait = ignoreWait; + } + + /// + /// Gets a value indicating whether the client should be ignored. + /// + /// The client group-wait status. + public bool IgnoreWait { get; } + + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.IgnoreWait; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs new file mode 100644 index 0000000000..9cd8da5668 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class MovePlaylistItemGroupRequest. + /// + public class MovePlaylistItemGroupRequest : AbstractPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The playlist identifier of the item. + /// The new position. + public MovePlaylistItemGroupRequest(Guid playlistItemId, int newIndex) + { + PlaylistItemId = playlistItemId; + NewIndex = newIndex; + } + + /// + /// Gets the playlist identifier of the item. + /// + /// The playlist identifier of the item. + public Guid PlaylistItemId { get; } + + /// + /// Gets the new position. + /// + /// The new position. + public int NewIndex { get; } + + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.MovePlaylistItem; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs new file mode 100644 index 0000000000..e0ae0deb76 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextItemGroupRequest.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class NextItemGroupRequest. + /// + public class NextItemGroupRequest : AbstractPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The playing item identifier. + public NextItemGroupRequest(Guid playlistItemId) + { + PlaylistItemId = playlistItemId; + } + + /// + /// Gets the playing item identifier. + /// + /// The playing item identifier. + public Guid PlaylistItemId { get; } + + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.NextItem; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs new file mode 100644 index 0000000000..2869b35f77 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs @@ -0,0 +1,21 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class PauseGroupRequest. + /// + public class PauseGroupRequest : AbstractPlaybackRequest + { + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Pause; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs new file mode 100644 index 0000000000..8ef3b20303 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs @@ -0,0 +1,36 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class PingGroupRequest. + /// + public class PingGroupRequest : AbstractPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The ping time. + public PingGroupRequest(long ping) + { + Ping = ping; + } + + /// + /// Gets the ping time. + /// + /// The ping time. + public long Ping { get; } + + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Ping; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs new file mode 100644 index 0000000000..16f9b40874 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class PlayGroupRequest. + /// + public class PlayGroupRequest : AbstractPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The playing queue. + /// The playing item position. + /// The start position ticks. + public PlayGroupRequest(IReadOnlyList playingQueue, int playingItemPosition, long startPositionTicks) + { + PlayingQueue = playingQueue ?? Array.Empty(); + PlayingItemPosition = playingItemPosition; + StartPositionTicks = startPositionTicks; + } + + /// + /// Gets the playing queue. + /// + /// The playing queue. + public IReadOnlyList PlayingQueue { get; } + + /// + /// Gets the position of the playing item in the queue. + /// + /// The playing item position. + public int PlayingItemPosition { get; } + + /// + /// Gets the start position ticks. + /// + /// The start position ticks. + public long StartPositionTicks { get; } + + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Play; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs new file mode 100644 index 0000000000..166ee08007 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousItemGroupRequest.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class PreviousItemGroupRequest. + /// + public class PreviousItemGroupRequest : AbstractPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The playing item identifier. + public PreviousItemGroupRequest(Guid playlistItemId) + { + PlaylistItemId = playlistItemId; + } + + /// + /// Gets the playing item identifier. + /// + /// The playing item identifier. + public Guid PlaylistItemId { get; } + + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.PreviousItem; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs new file mode 100644 index 0000000000..d4af63b6d4 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class QueueGroupRequest. + /// + public class QueueGroupRequest : AbstractPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The items to add to the queue. + /// The enqueue mode. + public QueueGroupRequest(IReadOnlyList items, GroupQueueMode mode) + { + ItemIds = items ?? Array.Empty(); + Mode = mode; + } + + /// + /// Gets the items to enqueue. + /// + /// The items to enqueue. + public IReadOnlyList ItemIds { get; } + + /// + /// Gets the mode in which to add the new items. + /// + /// The enqueue mode. + public GroupQueueMode Mode { get; } + + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Queue; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs new file mode 100644 index 0000000000..74f01cbeaf --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class ReadyGroupRequest. + /// + public class ReadyGroupRequest : AbstractPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + /// When the request has been made, as reported by the client. + /// The position ticks. + /// Whether the client playback is unpaused. + /// The playlist item identifier of the playing item. + public ReadyGroupRequest(DateTime when, long positionTicks, bool isPlaying, Guid playlistItemId) + { + When = when; + PositionTicks = positionTicks; + IsPlaying = isPlaying; + PlaylistItemId = playlistItemId; + } + + /// + /// Gets when the request has been made by the client. + /// + /// The date of the request. + public DateTime When { get; } + + /// + /// Gets the position ticks. + /// + /// The position ticks. + public long PositionTicks { get; } + + /// + /// Gets a value indicating whether the client playback is unpaused. + /// + /// The client playback status. + public bool IsPlaying { get; } + + /// + /// Gets the playlist item identifier of the playing item. + /// + /// The playlist item identifier. + public Guid PlaylistItemId { get; } + + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Ready; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs new file mode 100644 index 0000000000..47c06c2227 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class RemoveFromPlaylistGroupRequest. + /// + public class RemoveFromPlaylistGroupRequest : AbstractPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The playlist ids of the items to remove. + public RemoveFromPlaylistGroupRequest(IReadOnlyList items) + { + PlaylistItemIds = items ?? Array.Empty(); + } + + /// + /// Gets the playlist identifiers ot the items. + /// + /// The playlist identifiers ot the items. + public IReadOnlyList PlaylistItemIds { get; } + + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.RemoveFromPlaylist; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs new file mode 100644 index 0000000000..ecaa689ae3 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs @@ -0,0 +1,36 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class SeekGroupRequest. + /// + public class SeekGroupRequest : AbstractPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The position ticks. + public SeekGroupRequest(long positionTicks) + { + PositionTicks = positionTicks; + } + + /// + /// Gets the position ticks. + /// + /// The position ticks. + public long PositionTicks { get; } + + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Seek; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs new file mode 100644 index 0000000000..c3451703ed --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class SetPlaylistItemGroupRequest. + /// + public class SetPlaylistItemGroupRequest : AbstractPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The playlist identifier of the item. + public SetPlaylistItemGroupRequest(Guid playlistItemId) + { + PlaylistItemId = playlistItemId; + } + + /// + /// Gets the playlist identifier of the playing item. + /// + /// The playlist identifier of the playing item. + public Guid PlaylistItemId { get; } + + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetPlaylistItem; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs new file mode 100644 index 0000000000..51011672ea --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs @@ -0,0 +1,36 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class SetRepeatModeGroupRequest. + /// + public class SetRepeatModeGroupRequest : AbstractPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The repeat mode. + public SetRepeatModeGroupRequest(GroupRepeatMode mode) + { + Mode = mode; + } + + /// + /// Gets the repeat mode. + /// + /// The repeat mode. + public GroupRepeatMode Mode { get; } + + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetRepeatMode; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs new file mode 100644 index 0000000000..d7b2504b4b --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs @@ -0,0 +1,36 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class SetShuffleModeGroupRequest. + /// + public class SetShuffleModeGroupRequest : AbstractPlaybackRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The shuffle mode. + public SetShuffleModeGroupRequest(GroupShuffleMode mode) + { + Mode = mode; + } + + /// + /// Gets the shuffle mode. + /// + /// The shuffle mode. + public GroupShuffleMode Mode { get; } + + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.SetShuffleMode; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs new file mode 100644 index 0000000000..ad739213c5 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs @@ -0,0 +1,21 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class StopGroupRequest. + /// + public class StopGroupRequest : AbstractPlaybackRequest + { + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Stop; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs new file mode 100644 index 0000000000..aaf3d65a84 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs @@ -0,0 +1,21 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// + /// Class UnpauseGroupRequest. + /// + public class UnpauseGroupRequest : AbstractPlaybackRequest + { + /// + public override PlaybackRequestType Action { get; } = PlaybackRequestType.Unpause; + + /// + public override void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(this, context, state.Type, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs new file mode 100644 index 0000000000..fdec29417e --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs @@ -0,0 +1,577 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.Queue +{ + /// + /// Class PlayQueueManager. + /// + public class PlayQueueManager + { + /// + /// Placeholder index for when no item is playing. + /// + /// The no-playing item index. + private const int NoPlayingItemIndex = -1; + + /// + /// Random number generator used to shuffle lists. + /// + /// The random number generator. + private readonly Random _randomNumberGenerator = new Random(); + + /// + /// Initializes a new instance of the class. + /// + public PlayQueueManager() + { + Reset(); + } + + /// + /// Gets the playing item index. + /// + /// The playing item index. + public int PlayingItemIndex { get; private set; } + + /// + /// Gets the last time the queue has been changed. + /// + /// The last time the queue has been changed. + public DateTime LastChange { get; private set; } + + /// + /// Gets the shuffle mode. + /// + /// The shuffle mode. + public GroupShuffleMode ShuffleMode { get; private set; } = GroupShuffleMode.Sorted; + + /// + /// Gets the repeat mode. + /// + /// The repeat mode. + public GroupRepeatMode RepeatMode { get; private set; } = GroupRepeatMode.RepeatNone; + + /// + /// Gets or sets the sorted playlist. + /// + /// The sorted playlist, or play queue of the group. + private List SortedPlaylist { get; set; } = new List(); + + /// + /// Gets or sets the shuffled playlist. + /// + /// The shuffled playlist, or play queue of the group. + private List ShuffledPlaylist { get; set; } = new List(); + + /// + /// Checks if an item is playing. + /// + /// true if an item is playing; false otherwise. + public bool IsItemPlaying() + { + return PlayingItemIndex != NoPlayingItemIndex; + } + + /// + /// Gets the current playlist considering the shuffle mode. + /// + /// The playlist. + public IReadOnlyList GetPlaylist() + { + return GetPlaylistInternal(); + } + + /// + /// Sets a new playlist. Playing item is reset. + /// + /// The new items of the playlist. + public void SetPlaylist(IReadOnlyList items) + { + SortedPlaylist.Clear(); + ShuffledPlaylist.Clear(); + + SortedPlaylist = CreateQueueItemsFromArray(items); + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + ShuffledPlaylist = new List(SortedPlaylist); + Shuffle(ShuffledPlaylist); + } + + PlayingItemIndex = NoPlayingItemIndex; + LastChange = DateTime.UtcNow; + } + + /// + /// Appends new items to the playlist. The specified order is mantained. + /// + /// The items to add to the playlist. + public void Queue(IReadOnlyList items) + { + var newItems = CreateQueueItemsFromArray(items); + + SortedPlaylist.AddRange(newItems); + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + ShuffledPlaylist.AddRange(newItems); + } + + LastChange = DateTime.UtcNow; + } + + /// + /// Shuffles the playlist. Shuffle mode is changed. The playlist gets re-shuffled if already shuffled. + /// + public void ShufflePlaylist() + { + if (PlayingItemIndex == NoPlayingItemIndex) + { + ShuffledPlaylist = new List(SortedPlaylist); + Shuffle(ShuffledPlaylist); + } + else if (ShuffleMode.Equals(GroupShuffleMode.Sorted)) + { + // First time shuffle. + var playingItem = SortedPlaylist[PlayingItemIndex]; + ShuffledPlaylist = new List(SortedPlaylist); + ShuffledPlaylist.RemoveAt(PlayingItemIndex); + Shuffle(ShuffledPlaylist); + ShuffledPlaylist.Insert(0, playingItem); + PlayingItemIndex = 0; + } + else + { + // Re-shuffle playlist. + var playingItem = ShuffledPlaylist[PlayingItemIndex]; + ShuffledPlaylist.RemoveAt(PlayingItemIndex); + Shuffle(ShuffledPlaylist); + ShuffledPlaylist.Insert(0, playingItem); + PlayingItemIndex = 0; + } + + ShuffleMode = GroupShuffleMode.Shuffle; + LastChange = DateTime.UtcNow; + } + + /// + /// Resets the playlist to sorted mode. Shuffle mode is changed. + /// + public void RestoreSortedPlaylist() + { + if (PlayingItemIndex != NoPlayingItemIndex) + { + var playingItem = ShuffledPlaylist[PlayingItemIndex]; + PlayingItemIndex = SortedPlaylist.IndexOf(playingItem); + } + + ShuffledPlaylist.Clear(); + + ShuffleMode = GroupShuffleMode.Sorted; + LastChange = DateTime.UtcNow; + } + + /// + /// Clears the playlist. Shuffle mode is preserved. + /// + /// Whether to remove the playing item as well. + public void ClearPlaylist(bool clearPlayingItem) + { + var playingItem = GetPlayingItem(); + SortedPlaylist.Clear(); + ShuffledPlaylist.Clear(); + LastChange = DateTime.UtcNow; + + if (!clearPlayingItem && playingItem != null) + { + SortedPlaylist.Add(playingItem); + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + ShuffledPlaylist.Add(playingItem); + } + + PlayingItemIndex = 0; + } + else + { + PlayingItemIndex = NoPlayingItemIndex; + } + } + + /// + /// Adds new items to the playlist right after the playing item. The specified order is mantained. + /// + /// The items to add to the playlist. + public void QueueNext(IReadOnlyList items) + { + var newItems = CreateQueueItemsFromArray(items); + + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + var playingItem = GetPlayingItem(); + var sortedPlayingItemIndex = SortedPlaylist.IndexOf(playingItem); + // Append items to sorted and shuffled playlist as they are. + SortedPlaylist.InsertRange(sortedPlayingItemIndex + 1, newItems); + ShuffledPlaylist.InsertRange(PlayingItemIndex + 1, newItems); + } + else + { + SortedPlaylist.InsertRange(PlayingItemIndex + 1, newItems); + } + + LastChange = DateTime.UtcNow; + } + + /// + /// Gets playlist identifier of the playing item, if any. + /// + /// The playlist identifier of the playing item. + public Guid GetPlayingItemPlaylistId() + { + var playingItem = GetPlayingItem(); + return playingItem?.PlaylistItemId ?? Guid.Empty; + } + + /// + /// Gets the playing item identifier, if any. + /// + /// The playing item identifier. + public Guid GetPlayingItemId() + { + var playingItem = GetPlayingItem(); + return playingItem?.ItemId ?? Guid.Empty; + } + + /// + /// Sets the playing item using its identifier. If not in the playlist, the playing item is reset. + /// + /// The new playing item identifier. + public void SetPlayingItemById(Guid itemId) + { + var playlist = GetPlaylistInternal(); + PlayingItemIndex = playlist.FindIndex(item => item.ItemId.Equals(itemId)); + LastChange = DateTime.UtcNow; + } + + /// + /// Sets the playing item using its playlist identifier. If not in the playlist, the playing item is reset. + /// + /// The new playing item identifier. + /// true if playing item has been set; false if item is not in the playlist. + public bool SetPlayingItemByPlaylistId(Guid playlistItemId) + { + var playlist = GetPlaylistInternal(); + PlayingItemIndex = playlist.FindIndex(item => item.PlaylistItemId.Equals(playlistItemId)); + LastChange = DateTime.UtcNow; + + return PlayingItemIndex != NoPlayingItemIndex; + } + + /// + /// Sets the playing item using its position. If not in range, the playing item is reset. + /// + /// The new playing item index. + public void SetPlayingItemByIndex(int playlistIndex) + { + var playlist = GetPlaylistInternal(); + if (playlistIndex < 0 || playlistIndex > playlist.Count) + { + PlayingItemIndex = NoPlayingItemIndex; + } + else + { + PlayingItemIndex = playlistIndex; + } + + LastChange = DateTime.UtcNow; + } + + /// + /// Removes items from the playlist. If not removed, the playing item is preserved. + /// + /// The items to remove. + /// true if playing item got removed; false otherwise. + public bool RemoveFromPlaylist(IReadOnlyList playlistItemIds) + { + var playingItem = GetPlayingItem(); + + SortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId)); + ShuffledPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId)); + + LastChange = DateTime.UtcNow; + + if (playingItem != null) + { + if (playlistItemIds.Contains(playingItem.PlaylistItemId)) + { + // Playing item has been removed, picking previous item. + PlayingItemIndex--; + if (PlayingItemIndex < 0) + { + // Was first element, picking next if available. + // Default to no playing item otherwise. + PlayingItemIndex = SortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex; + } + + return true; + } + else + { + // Restoring playing item. + SetPlayingItemByPlaylistId(playingItem.PlaylistItemId); + return false; + } + } + else + { + return false; + } + } + + /// + /// Moves an item in the playlist to another position. + /// + /// The item to move. + /// The new position. + /// true if the item has been moved; false otherwise. + public bool MovePlaylistItem(Guid playlistItemId, int newIndex) + { + var playlist = GetPlaylistInternal(); + var playingItem = GetPlayingItem(); + + var oldIndex = playlist.FindIndex(item => item.PlaylistItemId.Equals(playlistItemId)); + if (oldIndex < 0) + { + return false; + } + + var queueItem = playlist[oldIndex]; + playlist.RemoveAt(oldIndex); + newIndex = Math.Clamp(newIndex, 0, playlist.Count); + playlist.Insert(newIndex, queueItem); + + LastChange = DateTime.UtcNow; + PlayingItemIndex = playlist.IndexOf(playingItem); + return true; + } + + /// + /// Resets the playlist to its initial state. + /// + public void Reset() + { + SortedPlaylist.Clear(); + ShuffledPlaylist.Clear(); + PlayingItemIndex = NoPlayingItemIndex; + ShuffleMode = GroupShuffleMode.Sorted; + RepeatMode = GroupRepeatMode.RepeatNone; + LastChange = DateTime.UtcNow; + } + + /// + /// Sets the repeat mode. + /// + /// The new mode. + public void SetRepeatMode(GroupRepeatMode mode) + { + RepeatMode = mode; + LastChange = DateTime.UtcNow; + } + + /// + /// Sets the shuffle mode. + /// + /// The new mode. + public void SetShuffleMode(GroupShuffleMode mode) + { + if (mode.Equals(GroupShuffleMode.Shuffle)) + { + ShufflePlaylist(); + } + else + { + RestoreSortedPlaylist(); + } + } + + /// + /// Toggles the shuffle mode between sorted and shuffled. + /// + public void ToggleShuffleMode() + { + if (ShuffleMode.Equals(GroupShuffleMode.Sorted)) + { + ShufflePlaylist(); + } + else + { + RestoreSortedPlaylist(); + } + } + + /// + /// Gets the next item in the playlist considering repeat mode and shuffle mode. + /// + /// The next item in the playlist. + public QueueItem GetNextItemPlaylistId() + { + int newIndex; + var playlist = GetPlaylistInternal(); + + switch (RepeatMode) + { + case GroupRepeatMode.RepeatOne: + newIndex = PlayingItemIndex; + break; + case GroupRepeatMode.RepeatAll: + newIndex = PlayingItemIndex + 1; + if (newIndex >= playlist.Count) + { + newIndex = 0; + } + + break; + default: + newIndex = PlayingItemIndex + 1; + break; + } + + if (newIndex < 0 || newIndex >= playlist.Count) + { + return null; + } + + return playlist[newIndex]; + } + + /// + /// Sets the next item in the queue as playing item. + /// + /// true if the playing item changed; false otherwise. + public bool Next() + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatOne)) + { + LastChange = DateTime.UtcNow; + return true; + } + + PlayingItemIndex++; + if (PlayingItemIndex >= SortedPlaylist.Count) + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatAll)) + { + PlayingItemIndex = 0; + } + else + { + PlayingItemIndex = SortedPlaylist.Count - 1; + return false; + } + } + + LastChange = DateTime.UtcNow; + return true; + } + + /// + /// Sets the previous item in the queue as playing item. + /// + /// true if the playing item changed; false otherwise. + public bool Previous() + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatOne)) + { + LastChange = DateTime.UtcNow; + return true; + } + + PlayingItemIndex--; + if (PlayingItemIndex < 0) + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatAll)) + { + PlayingItemIndex = SortedPlaylist.Count - 1; + } + else + { + PlayingItemIndex = 0; + return false; + } + } + + LastChange = DateTime.UtcNow; + return true; + } + + /// + /// Shuffles a given list. + /// + /// The list to shuffle. + private void Shuffle(IList list) + { + int n = list.Count; + while (n > 1) + { + n--; + int k = _randomNumberGenerator.Next(n + 1); + T value = list[k]; + list[k] = list[n]; + list[n] = value; + } + } + + /// + /// Creates a list from the array of items. Each item is given an unique playlist identifier. + /// + /// The list of queue items. + private List CreateQueueItemsFromArray(IReadOnlyList items) + { + var list = new List(); + foreach (var item in items) + { + var queueItem = new QueueItem(item); + list.Add(queueItem); + } + + return list; + } + + /// + /// Gets the current playlist considering the shuffle mode. + /// + /// The playlist. + private List GetPlaylistInternal() + { + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + return ShuffledPlaylist; + } + else + { + return SortedPlaylist; + } + } + + /// + /// Gets the current playing item, depending on the shuffle mode. + /// + /// The playing item. + private QueueItem GetPlayingItem() + { + if (PlayingItemIndex == NoPlayingItemIndex) + { + return null; + } + else if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + return ShuffledPlaylist[PlayingItemIndex]; + } + else + { + return SortedPlaylist[PlayingItemIndex]; + } + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs new file mode 100644 index 0000000000..38c9e8e20c --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/Requests/JoinGroupRequest.cs @@ -0,0 +1,29 @@ +using System; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.Requests +{ + /// + /// Class JoinGroupRequest. + /// + public class JoinGroupRequest : ISyncPlayRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The identifier of the group to join. + public JoinGroupRequest(Guid groupId) + { + GroupId = groupId; + } + + /// + /// Gets the group identifier. + /// + /// The identifier of the group to join. + public Guid GroupId { get; } + + /// + public RequestType Type { get; } = RequestType.JoinGroup; + } +} diff --git a/MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs new file mode 100644 index 0000000000..545778264f --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/Requests/LeaveGroupRequest.cs @@ -0,0 +1,13 @@ +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.Requests +{ + /// + /// Class LeaveGroupRequest. + /// + public class LeaveGroupRequest : ISyncPlayRequest + { + /// + public RequestType Type { get; } = RequestType.LeaveGroup; + } +} diff --git a/MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs new file mode 100644 index 0000000000..4a234fdab5 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/Requests/ListGroupsRequest.cs @@ -0,0 +1,13 @@ +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.Requests +{ + /// + /// Class ListGroupsRequest. + /// + public class ListGroupsRequest : ISyncPlayRequest + { + /// + public RequestType Type { get; } = RequestType.ListGroups; + } +} diff --git a/MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs new file mode 100644 index 0000000000..1321f0de8e --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/Requests/NewGroupRequest.cs @@ -0,0 +1,28 @@ +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.Requests +{ + /// + /// Class NewGroupRequest. + /// + public class NewGroupRequest : ISyncPlayRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The name of the new group. + public NewGroupRequest(string groupName) + { + GroupName = groupName; + } + + /// + /// Gets the group name. + /// + /// The name of the new group. + public string GroupName { get; } + + /// + public RequestType Type { get; } = RequestType.NewGroup; + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs b/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs new file mode 100644 index 0000000000..8c0960b830 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; + +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Class GroupInfoDto. + /// + public class GroupInfoDto + { + /// + /// Initializes a new instance of the class. + /// + /// The group identifier. + /// The group name. + /// The group state. + /// The participants. + /// The date when this DTO has been created. + public GroupInfoDto(Guid groupId, string groupName, GroupStateType state, IReadOnlyList participants, DateTime lastUpdatedAt) + { + GroupId = groupId; + GroupName = groupName; + State = state; + Participants = participants; + LastUpdatedAt = lastUpdatedAt; + } + + /// + /// Gets the group identifier. + /// + /// The group identifier. + public Guid GroupId { get; } + + /// + /// Gets the group name. + /// + /// The group name. + public string GroupName { get; } + + /// + /// Gets the group state. + /// + /// The group state. + public GroupStateType State { get; } + + /// + /// Gets the participants. + /// + /// The participants. + public IReadOnlyList Participants { get; } + + /// + /// Gets the date when this DTO has been created. + /// + /// The date when this DTO has been created. + public DateTime LastUpdatedAt { get; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupInfoView.cs b/MediaBrowser.Model/SyncPlay/GroupInfoView.cs deleted file mode 100644 index f4c6859988..0000000000 --- a/MediaBrowser.Model/SyncPlay/GroupInfoView.cs +++ /dev/null @@ -1,42 +0,0 @@ -#nullable disable - -using System.Collections.Generic; - -namespace MediaBrowser.Model.SyncPlay -{ - /// - /// Class GroupInfoView. - /// - public class GroupInfoView - { - /// - /// Gets or sets the group identifier. - /// - /// The group identifier. - public string GroupId { get; set; } - - /// - /// Gets or sets the playing item id. - /// - /// The playing item id. - public string PlayingItemId { get; set; } - - /// - /// Gets or sets the playing item name. - /// - /// The playing item name. - public string PlayingItemName { get; set; } - - /// - /// Gets or sets the position ticks. - /// - /// The position ticks. - public long PositionTicks { get; set; } - - /// - /// Gets or sets the participants. - /// - /// The participants. - public IReadOnlyList Participants { get; set; } - } -} diff --git a/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs b/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs new file mode 100644 index 0000000000..5c9c2627b9 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs @@ -0,0 +1,18 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Enum GroupQueueMode. + /// + public enum GroupQueueMode + { + /// + /// Insert items at the end of the queue. + /// + Queue = 0, + + /// + /// Insert items after the currently playing item. + /// + QueueNext = 1 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs b/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs new file mode 100644 index 0000000000..4895e57b7f --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs @@ -0,0 +1,23 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Enum GroupRepeatMode. + /// + public enum GroupRepeatMode + { + /// + /// Repeat one item only. + /// + RepeatOne = 0, + + /// + /// Cycle the playlist. + /// + RepeatAll = 1, + + /// + /// Do not repeat. + /// + RepeatNone = 2 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs b/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs new file mode 100644 index 0000000000..de860883c0 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs @@ -0,0 +1,18 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Enum GroupShuffleMode. + /// + public enum GroupShuffleMode + { + /// + /// Sorted playlist. + /// + Sorted = 0, + + /// + /// Shuffled playlist. + /// + Shuffle = 1 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupStateType.cs b/MediaBrowser.Model/SyncPlay/GroupStateType.cs new file mode 100644 index 0000000000..7aa454f928 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupStateType.cs @@ -0,0 +1,28 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Enum GroupState. + /// + public enum GroupStateType + { + /// + /// The group is in idle state. No media is playing. + /// + Idle = 0, + + /// + /// The group is in wating state. Playback is paused. Will start playing when users are ready. + /// + Waiting = 1, + + /// + /// The group is in paused state. Playback is paused. Will resume on play command. + /// + Paused = 2, + + /// + /// The group is in playing state. Playback is advancing. + /// + Playing = 3 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs new file mode 100644 index 0000000000..7f7deb86bb --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs @@ -0,0 +1,31 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Class GroupStateUpdate. + /// + public class GroupStateUpdate + { + /// + /// Initializes a new instance of the class. + /// + /// The state of the group. + /// The reason of the state change. + public GroupStateUpdate(GroupStateType state, PlaybackRequestType reason) + { + State = state; + Reason = reason; + } + + /// + /// Gets the state of the group. + /// + /// The state of the group. + public GroupStateType State { get; } + + /// + /// Gets the reason of the state change. + /// + /// The reason of the state change. + public PlaybackRequestType Reason { get; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs index 8c7208211c..6f159d653c 100644 --- a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs @@ -1,28 +1,42 @@ -#nullable disable +using System; namespace MediaBrowser.Model.SyncPlay { /// /// Class GroupUpdate. /// + /// The type of the data of the message. public class GroupUpdate { /// - /// Gets or sets the group identifier. + /// Initializes a new instance of the class. + /// + /// The group identifier. + /// The update type. + /// The update data. + public GroupUpdate(Guid groupId, GroupUpdateType type, T data) + { + GroupId = groupId; + Type = type; + Data = data; + } + + /// + /// Gets the group identifier. /// /// The group identifier. - public string GroupId { get; set; } + public Guid GroupId { get; } /// - /// Gets or sets the update type. + /// Gets the update type. /// /// The update type. - public GroupUpdateType Type { get; set; } + public GroupUpdateType Type { get; } /// - /// Gets or sets the data. + /// Gets the update data. /// - /// The data. - public T Data { get; set; } + /// The update data. + public T Data { get; } } } diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs index c749f7b13a..907d1defe0 100644 --- a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs +++ b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs @@ -26,14 +26,14 @@ namespace MediaBrowser.Model.SyncPlay GroupLeft, /// - /// The group-wait update. Tells members of the group that a user is buffering. + /// The group-state update. Tells members of the group that the state changed. /// - GroupWait, + StateUpdate, /// - /// The prepare-session update. Tells a user to load some content. + /// The play-queue update. Tells a user the playing queue of the group. /// - PrepareSession, + PlayQueue, /// /// The not-in-group error. Tells a user that they don't belong to a group. diff --git a/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs b/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs deleted file mode 100644 index 0c77a61322..0000000000 --- a/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; - -namespace MediaBrowser.Model.SyncPlay -{ - /// - /// Class JoinGroupRequest. - /// - public class JoinGroupRequest - { - /// - /// Gets or sets the Group id. - /// - /// The Group id to join. - public Guid GroupId { get; set; } - } -} diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs new file mode 100644 index 0000000000..a851229f74 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; + +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Class PlayQueueUpdate. + /// + public class PlayQueueUpdate + { + /// + /// Initializes a new instance of the class. + /// + /// The reason for the update. + /// The UTC time of the last change to the playing queue. + /// The playlist. + /// The playing item index in the playlist. + /// The start position ticks. + /// The shuffle mode. + /// The repeat mode. + public PlayQueueUpdate(PlayQueueUpdateReason reason, DateTime lastUpdate, IReadOnlyList playlist, int playingItemIndex, long startPositionTicks, GroupShuffleMode shuffleMode, GroupRepeatMode repeatMode) + { + Reason = reason; + LastUpdate = lastUpdate; + Playlist = playlist; + PlayingItemIndex = playingItemIndex; + StartPositionTicks = startPositionTicks; + ShuffleMode = shuffleMode; + RepeatMode = repeatMode; + } + + /// + /// Gets the request type that originated this update. + /// + /// The reason for the update. + public PlayQueueUpdateReason Reason { get; } + + /// + /// Gets the UTC time of the last change to the playing queue. + /// + /// The UTC time of the last change to the playing queue. + public DateTime LastUpdate { get; } + + /// + /// Gets the playlist. + /// + /// The playlist. + public IReadOnlyList Playlist { get; } + + /// + /// Gets the playing item index in the playlist. + /// + /// The playing item index in the playlist. + public int PlayingItemIndex { get; } + + /// + /// Gets the start position ticks. + /// + /// The start position ticks. + public long StartPositionTicks { get; } + + /// + /// Gets the shuffle mode. + /// + /// The shuffle mode. + public GroupShuffleMode ShuffleMode { get; } + + /// + /// Gets the repeat mode. + /// + /// The repeat mode. + public GroupRepeatMode RepeatMode { get; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs new file mode 100644 index 0000000000..b609f4b1bd --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs @@ -0,0 +1,58 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Enum PlayQueueUpdateReason. + /// + public enum PlayQueueUpdateReason + { + /// + /// A user is requesting to play a new playlist. + /// + NewPlaylist = 0, + + /// + /// A user is changing the playing item. + /// + SetCurrentItem = 1, + + /// + /// A user is removing items from the playlist. + /// + RemoveItems = 2, + + /// + /// A user is moving an item in the playlist. + /// + MoveItem = 3, + + /// + /// A user is adding items the queue. + /// + Queue = 4, + + /// + /// A user is adding items to the queue, after the currently playing item. + /// + QueueNext = 5, + + /// + /// A user is requesting the next item in queue. + /// + NextItem = 6, + + /// + /// A user is requesting the previous item in queue. + /// + PreviousItem = 7, + + /// + /// A user is changing repeat mode. + /// + RepeatMode = 8, + + /// + /// A user is changing shuffle mode. + /// + ShuffleMode = 9 + } +} diff --git a/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs b/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs deleted file mode 100644 index 9de23194e3..0000000000 --- a/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; - -namespace MediaBrowser.Model.SyncPlay -{ - /// - /// Class PlaybackRequest. - /// - public class PlaybackRequest - { - /// - /// Gets or sets the request type. - /// - /// The request type. - public PlaybackRequestType Type { get; set; } - - /// - /// Gets or sets when the request has been made by the client. - /// - /// The date of the request. - public DateTime? When { get; set; } - - /// - /// Gets or sets the position ticks. - /// - /// The position ticks. - public long? PositionTicks { get; set; } - - /// - /// Gets or sets the ping time. - /// - /// The ping time. - public long? Ping { get; set; } - } -} diff --git a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs index e89efeed8a..4429623dd9 100644 --- a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs +++ b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs @@ -6,33 +6,88 @@ namespace MediaBrowser.Model.SyncPlay public enum PlaybackRequestType { /// - /// A user is requesting a play command for the group. + /// A user is setting a new playlist. /// Play = 0, + /// + /// A user is changing the playlist item. + /// + SetPlaylistItem = 1, + + /// + /// A user is removing items from the playlist. + /// + RemoveFromPlaylist = 2, + + /// + /// A user is moving an item in the playlist. + /// + MovePlaylistItem = 3, + + /// + /// A user is adding items to the playlist. + /// + Queue = 4, + + /// + /// A user is requesting an unpause command for the group. + /// + Unpause = 5, + /// /// A user is requesting a pause command for the group. /// - Pause = 1, + Pause = 6, + + /// + /// A user is requesting a stop command for the group. + /// + Stop = 7, /// /// A user is requesting a seek command for the group. /// - Seek = 2, + Seek = 8, - /// + /// /// A user is signaling that playback is buffering. /// - Buffer = 3, + Buffer = 9, /// /// A user is signaling that playback resumed. /// - Ready = 4, + Ready = 10, /// - /// A user is reporting its ping. + /// A user is requesting next item in playlist. /// - Ping = 5 + NextItem = 11, + + /// + /// A user is requesting previous item in playlist. + /// + PreviousItem = 12, + + /// + /// A user is setting the repeat mode. + /// + SetRepeatMode = 13, + + /// + /// A user is setting the shuffle mode. + /// + SetShuffleMode = 14, + + /// + /// A user is reporting their ping. + /// + Ping = 15, + + /// + /// A user is requesting to be ignored on group wait. + /// + IgnoreWait = 16 } } diff --git a/MediaBrowser.Model/SyncPlay/QueueItem.cs b/MediaBrowser.Model/SyncPlay/QueueItem.cs new file mode 100644 index 0000000000..a6dcc109ed --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/QueueItem.cs @@ -0,0 +1,31 @@ +using System; + +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Class QueueItem. + /// + public class QueueItem + { + /// + /// Initializes a new instance of the class. + /// + /// The item identifier. + public QueueItem(Guid itemId) + { + ItemId = itemId; + } + + /// + /// Gets the item identifier. + /// + /// The item identifier. + public Guid ItemId { get; } + + /// + /// Gets the playlist identifier of the item. + /// + /// The playlist identifier of the item. + public Guid PlaylistItemId { get; } = Guid.NewGuid(); + } +} diff --git a/MediaBrowser.Model/SyncPlay/RequestType.cs b/MediaBrowser.Model/SyncPlay/RequestType.cs new file mode 100644 index 0000000000..a6e397dcd6 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/RequestType.cs @@ -0,0 +1,33 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Enum RequestType. + /// + public enum RequestType + { + /// + /// A user is requesting to create a new group. + /// + NewGroup = 0, + + /// + /// A user is requesting to join a group. + /// + JoinGroup = 1, + + /// + /// A user is requesting to leave a group. + /// + LeaveGroup = 2, + + /// + /// A user is requesting the list of available groups. + /// + ListGroups = 3, + + /// + /// A user is sending a playback command to a group. + /// + Playback = 4 + } +} diff --git a/MediaBrowser.Model/SyncPlay/SendCommand.cs b/MediaBrowser.Model/SyncPlay/SendCommand.cs index 0f0be0152d..73cb50876d 100644 --- a/MediaBrowser.Model/SyncPlay/SendCommand.cs +++ b/MediaBrowser.Model/SyncPlay/SendCommand.cs @@ -1,4 +1,4 @@ -#nullable disable +using System; namespace MediaBrowser.Model.SyncPlay { @@ -8,33 +8,58 @@ namespace MediaBrowser.Model.SyncPlay public class SendCommand { /// - /// Gets or sets the group identifier. + /// Initializes a new instance of the class. + /// + /// The group identifier. + /// The playlist identifier of the playing item. + /// The UTC time when to execute the command. + /// The command. + /// The position ticks, for commands that require it. + /// The UTC time when this command has been emitted. + public SendCommand(Guid groupId, Guid playlistItemId, DateTime when, SendCommandType command, long? positionTicks, DateTime emittedAt) + { + GroupId = groupId; + PlaylistItemId = playlistItemId; + When = when; + Command = command; + PositionTicks = positionTicks; + EmittedAt = emittedAt; + } + + /// + /// Gets the group identifier. /// /// The group identifier. - public string GroupId { get; set; } + public Guid GroupId { get; } + + /// + /// Gets the playlist identifier of the playing item. + /// + /// The playlist identifier of the playing item. + public Guid PlaylistItemId { get; } /// /// Gets or sets the UTC time when to execute the command. /// /// The UTC time when to execute the command. - public string When { get; set; } + public DateTime When { get; set; } /// - /// Gets or sets the position ticks. + /// Gets the position ticks. /// /// The position ticks. - public long? PositionTicks { get; set; } + public long? PositionTicks { get; } /// - /// Gets or sets the command. + /// Gets the command. /// /// The command. - public SendCommandType Command { get; set; } + public SendCommandType Command { get; } /// - /// Gets or sets the UTC time when this command has been emitted. + /// Gets the UTC time when this command has been emitted. /// /// The UTC time when this command has been emitted. - public string EmittedAt { get; set; } + public DateTime EmittedAt { get; } } } diff --git a/MediaBrowser.Model/SyncPlay/SendCommandType.cs b/MediaBrowser.Model/SyncPlay/SendCommandType.cs index 86dec9e900..e6b17c60ae 100644 --- a/MediaBrowser.Model/SyncPlay/SendCommandType.cs +++ b/MediaBrowser.Model/SyncPlay/SendCommandType.cs @@ -6,18 +6,23 @@ namespace MediaBrowser.Model.SyncPlay public enum SendCommandType { /// - /// The play command. Instructs users to start playback. + /// The unpause command. Instructs users to unpause playback. /// - Play = 0, + Unpause = 0, /// /// The pause command. Instructs users to pause playback. /// Pause = 1, + /// + /// The stop command. Instructs users to stop playback. + /// + Stop = 2, + /// /// The seek command. Instructs users to seek to a specified time. /// - Seek = 2 + Seek = 3 } } diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs b/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs new file mode 100644 index 0000000000..29dbb11b38 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs @@ -0,0 +1,28 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// + /// Used to filter the sessions of a group. + /// + public enum SyncPlayBroadcastType + { + /// + /// All sessions will receive the message. + /// + AllGroup = 0, + + /// + /// Only the specified session will receive the message. + /// + CurrentSession = 1, + + /// + /// All sessions, except the current one, will receive the message. + /// + AllExceptCurrentSession = 2, + + /// + /// Only sessions that are not buffering will receive the message. + /// + AllReady = 3 + } +} diff --git a/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs b/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs index 8ec5eaab3b..219e7b1e0c 100644 --- a/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs +++ b/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs @@ -1,4 +1,4 @@ -#nullable disable +using System; namespace MediaBrowser.Model.SyncPlay { @@ -8,15 +8,26 @@ namespace MediaBrowser.Model.SyncPlay public class UtcTimeResponse { /// - /// Gets or sets the UTC time when request has been received. + /// Initializes a new instance of the class. /// - /// The UTC time when request has been received. - public string RequestReceptionTime { get; set; } + /// The UTC time when request has been received. + /// The UTC time when response has been sent. + public UtcTimeResponse(DateTime requestReceptionTime, DateTime responseTransmissionTime) + { + RequestReceptionTime = requestReceptionTime; + ResponseTransmissionTime = responseTransmissionTime; + } /// - /// Gets or sets the UTC time when response has been sent. + /// Gets the UTC time when request has been received. + /// + /// The UTC time when request has been received. + public DateTime RequestReceptionTime { get; } + + /// + /// Gets the UTC time when response has been sent. /// /// The UTC time when response has been sent. - public string ResponseTransmissionTime { get; set; } + public DateTime ResponseTransmissionTime { get; } } }