diff options
| author | Ionut Andrei Oanca <oancaionutandrei@gmail.com> | 2020-09-24 23:04:21 +0200 |
|---|---|---|
| committer | Ionut Andrei Oanca <oancaionutandrei@gmail.com> | 2020-10-16 12:06:29 +0200 |
| commit | 8819a9d478e6fc11dbfdcff80d9a2dc175953373 (patch) | |
| tree | 8a159745dd08ebfa6d83e881c8eb6a07df0a589d /Emby.Server.Implementations/SyncPlay | |
| parent | ed2eabec16aafdf795f5ea4f8834ffdc74bc149f (diff) | |
Add playlist-sync and group-wait to SyncPlay
Diffstat (limited to 'Emby.Server.Implementations/SyncPlay')
8 files changed, 1927 insertions, 483 deletions
diff --git a/Emby.Server.Implementations/SyncPlay/GroupController.cs b/Emby.Server.Implementations/SyncPlay/GroupController.cs new file mode 100644 index 000000000..ee2e9eb8f --- /dev/null +++ b/Emby.Server.Implementations/SyncPlay/GroupController.cs @@ -0,0 +1,681 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Entities; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.SyncPlay +{ + /// <summary> + /// Class SyncPlayGroupController. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public class SyncPlayGroupController : ISyncPlayGroupController, ISyncPlayStateContext + { + /// <summary> + /// Gets the default ping value used for sessions. + /// </summary> + public long DefaultPing { get; } = 500; + + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger _logger; + + /// <summary> + /// The user manager. + /// </summary> + private readonly IUserManager _userManager; + + /// <summary> + /// The session manager. + /// </summary> + private readonly ISessionManager _sessionManager; + + /// <summary> + /// The library manager. + /// </summary> + private readonly ILibraryManager _libraryManager; + + /// <summary> + /// The SyncPlay manager. + /// </summary> + private readonly ISyncPlayManager _syncPlayManager; + + /// <summary> + /// Internal group state. + /// </summary> + /// <value>The group's state.</value> + private ISyncPlayState State; + + /// <summary> + /// Gets the group identifier. + /// </summary> + /// <value>The group identifier.</value> + public Guid GroupId { get; } = Guid.NewGuid(); + + /// <summary> + /// Gets the group name. + /// </summary> + /// <value>The group name.</value> + public string GroupName { get; private set; } + + /// <summary> + /// Gets the group identifier. + /// </summary> + /// <value>The group identifier.</value> + public PlayQueueManager PlayQueue { get; } = new PlayQueueManager(); + + /// <summary> + /// Gets or sets the runtime ticks of current playing item. + /// </summary> + /// <value>The runtime ticks of current playing item.</value> + public long RunTimeTicks { get; private set; } + + /// <summary> + /// Gets or sets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; set; } + + /// <summary> + /// Gets or sets the last activity. + /// </summary> + /// <value>The last activity.</value> + public DateTime LastActivity { get; set; } + + /// <summary> + /// Gets the participants. + /// </summary> + /// <value>The participants, or members of the group.</value> + public Dictionary<string, GroupMember> Participants { get; } = + new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase); + + /// <summary> + /// Initializes a new instance of the <see cref="SyncPlayGroupController" /> class. + /// </summary> + /// <param name="logger">The logger.</param> + /// <param name="userManager">The user manager.</param> + /// <param name="sessionManager">The session manager.</param> + /// <param name="libraryManager">The library manager.</param> + /// <param name="syncPlayManager">The SyncPlay manager.</param> + public SyncPlayGroupController( + ILogger logger, + IUserManager userManager, + ISessionManager sessionManager, + ILibraryManager libraryManager, + ISyncPlayManager syncPlayManager) + { + _logger = logger; + _userManager = userManager; + _sessionManager = sessionManager; + _libraryManager = libraryManager; + _syncPlayManager = syncPlayManager; + + State = new IdleGroupState(_logger); + } + + /// <summary> + /// Checks if a session is in this group. + /// </summary> + /// <param name="sessionId">The session id to check.</param> + /// <returns><c>true</c> if the session is in this group; <c>false</c> otherwise.</returns> + private bool ContainsSession(string sessionId) + { + return Participants.ContainsKey(sessionId); + } + + /// <summary> + /// Adds the session to the group. + /// </summary> + /// <param name="session">The session.</param> + private void AddSession(SessionInfo session) + { + Participants.TryAdd( + session.Id, + new GroupMember + { + Session = session, + Ping = DefaultPing, + IsBuffering = false + }); + } + + /// <summary> + /// Removes the session from the group. + /// </summary> + /// <param name="session">The session.</param> + private void RemoveSession(SessionInfo session) + { + Participants.Remove(session.Id); + } + + /// <summary> + /// Filters sessions of this group. + /// </summary> + /// <param name="from">The current session.</param> + /// <param name="type">The filtering type.</param> + /// <returns>The array of sessions matching the filter.</returns> + private SessionInfo[] FilterSessions(SessionInfo from, SyncPlayBroadcastType type) + { + switch (type) + { + case SyncPlayBroadcastType.CurrentSession: + return new SessionInfo[] { from }; + case SyncPlayBroadcastType.AllGroup: + return Participants.Values.Select( + session => session.Session).ToArray(); + case SyncPlayBroadcastType.AllExceptCurrentSession: + return Participants.Values.Select( + session => session.Session).Where( + session => !session.Id.Equals(from.Id)).ToArray(); + case SyncPlayBroadcastType.AllReady: + return Participants.Values.Where( + session => !session.IsBuffering).Select( + session => session.Session).ToArray(); + default: + return Array.Empty<SessionInfo>(); + } + } + + private bool HasAccessToItem(User user, BaseItem item) + { + var collections = _libraryManager.GetCollectionFolders(item) + .Select(folder => folder.Id.ToString("N", CultureInfo.InvariantCulture)); + return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any(); + } + + private bool HasAccessToQueue(User user, Guid[] queue) + { + if (queue == null || queue.Length == 0) + { + return true; + } + + var items = queue.ToList() + .Select(item => _libraryManager.GetItemById(item)); + + // Find the highest rating value, which becomes the required minimum for the user + var MinParentalRatingAccessRequired = items + .Select(item => item.InheritedParentalRatingValue) + .Min(); + + // Check ParentalRating access, user must have the minimum required access level + var hasParentalRatingAccess = !user.MaxParentalAgeRating.HasValue + || MinParentalRatingAccessRequired <= user.MaxParentalAgeRating; + + // Check that user has access to all required folders + if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess) + { + // Get list of items that are not accessible + var blockedItems = items.Where(item => !HasAccessToItem(user, item)); + + // We need the user to be able to access all items + return !blockedItems.Any(); + } + + return hasParentalRatingAccess; + } + + private bool AllUsersHaveAccessToQueue(Guid[] queue) + { + if (queue == null || queue.Length == 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(); + } + + /// <inheritdoc /> + public bool IsGroupEmpty() => Participants.Count == 0; + + /// <inheritdoc /> + public void CreateGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) + { + GroupName = request.GroupName; + AddSession(session); + _syncPlayManager.AddSessionToGroup(session, this); + + var sessionIsPlayingAnItem = session.FullNowPlayingItem != null; + + RestartCurrentItem(); + + if (sessionIsPlayingAnItem) + { + var playlist = session.NowPlayingQueue.Select(item => item.Id).ToArray(); + PlayQueue.SetPlaylist(playlist); + PlayQueue.SetPlayingItemById(session.FullNowPlayingItem.Id); + RunTimeTicks = session.FullNowPlayingItem.RunTimeTicks ?? 0; + PositionTicks = session.PlayState.PositionTicks ?? 0; + + // Mantain playstate + var waitingState = new WaitingGroupState(_logger); + waitingState.ResumePlaying = !session.PlayState.IsPaused; + SetState(waitingState); + } + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + State.SessionJoined(this, State.GetGroupState(), session, cancellationToken); + + _logger.LogInformation("InitGroup: {0} created group {1}.", session.Id.ToString(), GroupId.ToString()); + } + + /// <inheritdoc /> + public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) + { + AddSession(session); + _syncPlayManager.AddSessionToGroup(session, this); + + 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.GetGroupState(), session, cancellationToken); + + _logger.LogInformation("SessionJoin: {0} joined group {1}.", session.Id.ToString(), GroupId.ToString()); + } + + /// <inheritdoc /> + public void SessionRestore(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) + { + 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.GetGroupState(), session, cancellationToken); + + _logger.LogInformation("SessionRestore: {0} re-joined group {1}.", session.Id.ToString(), GroupId.ToString()); + } + + /// <inheritdoc /> + public void SessionLeave(SessionInfo session, CancellationToken cancellationToken) + { + State.SessionLeaving(this, State.GetGroupState(), session, cancellationToken); + + RemoveSession(session); + _syncPlayManager.RemoveSessionFromGroup(session, this); + + 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("SessionLeave: {0} left group {1}.", session.Id.ToString(), GroupId.ToString()); + } + + /// <inheritdoc /> + public void HandleRequest(SessionInfo session, IPlaybackGroupRequest 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("HandleRequest: {0} requested {1}, group {2} in {3} state.", + session.Id.ToString(), request.GetRequestType(), GroupId.ToString(), State.GetGroupState()); + request.Apply(this, State, session, cancellationToken); + } + + /// <inheritdoc /> + public GroupInfoDto GetInfo() + { + return new GroupInfoDto() + { + GroupId = GroupId.ToString(), + GroupName = GroupName, + State = State.GetGroupState(), + Participants = Participants.Values.Select(session => session.Session.UserName).Distinct().ToList(), + LastUpdatedAt = DateToUTCString(DateTime.UtcNow) + }; + } + + /// <inheritdoc /> + public bool HasAccessToPlayQueue(User user) + { + var items = PlayQueue.GetPlaylist().Select(item => item.ItemId).ToArray(); + return HasAccessToQueue(user, items); + } + + /// <inheritdoc /> + public void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait) + { + if (!ContainsSession(session.Id)) + { + return; + } + + Participants[session.Id].IgnoreGroupWait = ignoreGroupWait; + } + + /// <inheritdoc /> + public void SetState(ISyncPlayState state) + { + _logger.LogInformation("SetState: {0} switching from {1} to {2}.", GroupId.ToString(), State.GetGroupState(), state.GetGroupState()); + this.State = state; + } + + /// <inheritdoc /> + public Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken) + { + IEnumerable<Task> GetTasks() + { + foreach (var session in FilterSessions(from, type)) + { + yield return _sessionManager.SendSyncPlayGroupUpdate(session, message, cancellationToken); + } + } + + return Task.WhenAll(GetTasks()); + } + + /// <inheritdoc /> + public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken) + { + IEnumerable<Task> GetTasks() + { + foreach (var session in FilterSessions(from, type)) + { + yield return _sessionManager.SendSyncPlayCommand(session, message, cancellationToken); + } + } + + return Task.WhenAll(GetTasks()); + } + + /// <inheritdoc /> + public SendCommand NewSyncPlayCommand(SendCommandType type) + { + return new SendCommand() + { + GroupId = GroupId.ToString(), + PlaylistItemId = PlayQueue.GetPlayingItemPlaylistId(), + PositionTicks = PositionTicks, + Command = type, + When = DateToUTCString(LastActivity), + EmittedAt = DateToUTCString(DateTime.UtcNow) + }; + } + + /// <inheritdoc /> + public GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data) + { + return new GroupUpdate<T>() + { + GroupId = GroupId.ToString(), + Type = type, + Data = data + }; + } + + /// <inheritdoc /> + public string DateToUTCString(DateTime dateTime) + { + return dateTime.ToUniversalTime().ToString("o"); + } + + /// <inheritdoc /> + public long SanitizePositionTicks(long? positionTicks) + { + var ticks = positionTicks ?? 0; + ticks = ticks >= 0 ? ticks : 0; + ticks = ticks > RunTimeTicks ? RunTimeTicks : ticks; + return ticks; + } + + /// <inheritdoc /> + public void UpdatePing(SessionInfo session, long ping) + { + if (Participants.TryGetValue(session.Id, out GroupMember value)) + { + value.Ping = ping; + } + } + + /// <inheritdoc /> + public long GetHighestPing() + { + long max = long.MinValue; + foreach (var session in Participants.Values) + { + max = Math.Max(max, session.Ping); + } + + return max; + } + + /// <inheritdoc /> + public void SetBuffering(SessionInfo session, bool isBuffering) + { + if (Participants.TryGetValue(session.Id, out GroupMember value)) + { + value.IsBuffering = isBuffering; + } + } + + /// <inheritdoc /> + public void SetAllBuffering(bool isBuffering) + { + foreach (var session in Participants.Values) + { + session.IsBuffering = isBuffering; + } + } + + /// <inheritdoc /> + public bool IsBuffering() + { + foreach (var session in Participants.Values) + { + if (session.IsBuffering && !session.IgnoreGroupWait) + { + return true; + } + } + + return false; + } + + /// <inheritdoc /> + public bool SetPlayQueue(Guid[] playQueue, int playingItemPosition, long startPositionTicks) + { + // Ignore on empty queue or invalid item position + if (playQueue.Length < 1 || playingItemPosition >= playQueue.Length || playingItemPosition < 0) + { + return false; + } + + // Check is participants can access the new playing queue + if (!AllUsersHaveAccessToQueue(playQueue)) + { + return false; + } + + PlayQueue.SetPlaylist(playQueue); + PlayQueue.SetPlayingItemByIndex(playingItemPosition); + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + PositionTicks = startPositionTicks; + LastActivity = DateTime.UtcNow; + + return true; + } + + /// <inheritdoc /> + public bool SetPlayingItem(string playlistItemId) + { + var itemFound = PlayQueue.SetPlayingItemByPlaylistId(playlistItemId); + + if (itemFound) + { + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + } + else + { + RunTimeTicks = 0; + } + + RestartCurrentItem(); + + return itemFound; + } + + /// <inheritdoc /> + public bool RemoveFromPlayQueue(string[] 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; + } + + /// <inheritdoc /> + public bool MoveItemInPlayQueue(string playlistItemId, int newIndex) + { + return PlayQueue.MovePlaylistItem(playlistItemId, newIndex); + } + + /// <inheritdoc /> + public bool AddToPlayQueue(Guid[] newItems, string mode) + { + // Ignore on empty list + if (newItems.Length < 1) + { + return false; + } + + // Check is participants can access the new playing queue + if (!AllUsersHaveAccessToQueue(newItems)) + { + return false; + } + + if (mode.Equals("next")) + { + PlayQueue.QueueNext(newItems); + } + else + { + PlayQueue.Queue(newItems); + } + + return true; + } + + /// <inheritdoc /> + public void RestartCurrentItem() + { + PositionTicks = 0; + LastActivity = DateTime.UtcNow; + } + + /// <inheritdoc /> + 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; + } + } + + /// <inheritdoc /> + 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; + } + } + + /// <inheritdoc /> + public void SetRepeatMode(string mode) { + PlayQueue.SetRepeatMode(mode); + } + + /// <inheritdoc /> + public void SetShuffleMode(string mode) { + PlayQueue.SetShuffleMode(mode); + } + + /// <inheritdoc /> + public PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason) + { + var startPositionTicks = PositionTicks; + + if (State.GetGroupState().Equals(GroupState.Playing)) + { + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - LastActivity; + // Event may happen during the delay added to account for latency + startPositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; + } + + return new PlayQueueUpdate() + { + Reason = reason, + LastUpdate = DateToUTCString(PlayQueue.LastChange), + Playlist = PlayQueue.GetPlaylist(), + PlayingItemIndex = PlayQueue.PlayingItemIndex, + StartPositionTicks = startPositionTicks, + ShuffleMode = PlayQueue.ShuffleMode, + RepeatMode = PlayQueue.RepeatMode + }; + } + + } +} diff --git a/Emby.Server.Implementations/SyncPlay/GroupStates/AbstractGroupState.cs b/Emby.Server.Implementations/SyncPlay/GroupStates/AbstractGroupState.cs new file mode 100644 index 000000000..1f0cb4287 --- /dev/null +++ b/Emby.Server.Implementations/SyncPlay/GroupStates/AbstractGroupState.cs @@ -0,0 +1,218 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// <summary> + /// Class AbstractGroupState. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public abstract class AbstractGroupState : ISyncPlayState + { + /// <summary> + /// The logger. + /// </summary> + protected readonly ILogger _logger; + + /// <summary> + /// Default constructor. + /// </summary> + public AbstractGroupState(ILogger logger) + { + _logger = logger; + } + + /// <summary> + /// Sends a group state update to all group. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="reason">The reason of the state change.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + protected void SendGroupStateUpdate(ISyncPlayStateContext context, IPlaybackGroupRequest reason, SessionInfo session, CancellationToken cancellationToken) + { + // Notify relevant state change event + var stateUpdate = new GroupStateUpdate() + { + State = GetGroupState(), + Reason = reason.GetRequestType() + }; + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.StateUpdate, stateUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// <inheritdoc /> + public abstract GroupState GetGroupState(); + + /// <inheritdoc /> + public abstract void SessionJoined(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <inheritdoc /> + public abstract void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, IPlaybackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SetPlaylistItemGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, RemoveFromPlaylistGroupRequest request, 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) + { + var PlayingItemIndex = context.PlayQueue.PlayingItemIndex; + if (context.PlayQueue.PlayingItemIndex == -1) + { + _logger.LogDebug("HandleRequest: {0} in group {1}, play queue is empty.", request.GetRequestType(), context.GroupId.ToString()); + + ISyncPlayState idleState = new IdleGroupState(_logger); + context.SetState(idleState); + var stopRequest = new StopGroupRequest(); + idleState.HandleRequest(context, GetGroupState(), stopRequest, session, cancellationToken); + } + } + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, MovePlaylistItemGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + var result = context.MoveItemInPlayQueue(request.PlaylistItemId, request.NewIndex); + + if (!result) + { + _logger.LogError("HandleRequest: {0} in group {1}, unable to move item in play queue.", request.GetRequestType(), context.GroupId.ToString()); + return; + } + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.MoveItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, QueueGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + var result = context.AddToPlayQueue(request.ItemIds, request.Mode); + + if (!result) + { + _logger.LogError("HandleRequest: {0} in group {1}, unable to add items to play queue.", request.GetRequestType(), context.GroupId.ToString()); + return; + } + + var reason = request.Mode.Equals("next") ? PlayQueueUpdateReason.QueueNext : PlayQueueUpdateReason.Queue; + var playQueueUpdate = context.GetPlayQueueUpdate(reason); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SetRepeatModeGroupRequest request, 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); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SetShuffleModeGroupRequest request, 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); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PingGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Collected pings are used to account for network latency when unpausing playback + context.UpdatePing(session, request.Ping); + } + + /// <inheritdoc /> + public virtual void HandleRequest(ISyncPlayStateContext context, GroupState prevState, IgnoreWaitGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + context.SetIgnoreGroupWait(session, request.IgnoreWait); + } + + private void UnhandledRequest(IPlaybackGroupRequest request) + { + _logger.LogWarning("HandleRequest: unhandled {0} request for {1} state.", request.GetRequestType(), this.GetGroupState()); + } + } +} diff --git a/Emby.Server.Implementations/SyncPlay/GroupStates/IdleGroupState.cs b/Emby.Server.Implementations/SyncPlay/GroupStates/IdleGroupState.cs new file mode 100644 index 000000000..d6b981c58 --- /dev/null +++ b/Emby.Server.Implementations/SyncPlay/GroupStates/IdleGroupState.cs @@ -0,0 +1,121 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// <summary> + /// Class IdleGroupState. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public class IdleGroupState : AbstractGroupState + { + /// <summary> + /// Default constructor. + /// </summary> + public IdleGroupState(ILogger logger) : base(logger) + { + // Do nothing + } + + /// <inheritdoc /> + public override GroupState GetGroupState() + { + return GroupState.Idle; + } + + /// <inheritdoc /> + public override void SessionJoined(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, GetGroupState(), session, cancellationToken); + } + + /// <inheritdoc /> + public override void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Do nothing + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + private void SendStopCommand(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) + { + var command = context.NewSyncPlayCommand(SendCommandType.Stop); + if (!prevState.Equals(GetGroupState())) + { + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + } + else + { + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + } +} diff --git a/Emby.Server.Implementations/SyncPlay/GroupStates/PausedGroupState.cs b/Emby.Server.Implementations/SyncPlay/GroupStates/PausedGroupState.cs index d3bf24f74..39c0511d9 100644 --- a/Emby.Server.Implementations/SyncPlay/GroupStates/PausedGroupState.cs +++ b/Emby.Server.Implementations/SyncPlay/GroupStates/PausedGroupState.cs @@ -1,11 +1,8 @@ -using System.Linq; using System; using System.Threading; -using System.Collections.Generic; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Session; using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.SyncPlay { @@ -15,8 +12,16 @@ namespace MediaBrowser.Controller.SyncPlay /// <remarks> /// Class is not thread-safe, external locking is required when accessing methods. /// </remarks> - public class PausedGroupState : SyncPlayAbstractState + public class PausedGroupState : AbstractGroupState { + /// <summary> + /// Default constructor. + /// </summary> + public PausedGroupState(ILogger logger) : base(logger) + { + // Do nothing + } + /// <inheritdoc /> public override GroupState GetGroupState() { @@ -24,31 +29,56 @@ namespace MediaBrowser.Controller.SyncPlay } /// <inheritdoc /> - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void SessionJoined(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Wait for session to be ready + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.SessionJoined(context, GetGroupState(), session, cancellationToken); + } + + /// <inheritdoc /> + public override void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Do nothing + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { // Change state - var playingState = new PlayingGroupState(); + var playingState = new PlayingGroupState(_logger); context.SetState(playingState); - return playingState.HandleRequest(context, true, request, session, cancellationToken); + playingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); } /// <inheritdoc /> - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { - if (newState) + if (!prevState.Equals(GetGroupState())) { - GroupInfo group = context.GetGroup(); - // Pause group and compute the media playback position var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - group.LastActivity; - group.LastActivity = currentTime; + var elapsedTime = currentTime - context.LastActivity; + context.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; + context.PositionTicks += elapsedTime.Ticks > 0 ? 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 { @@ -56,116 +86,71 @@ namespace MediaBrowser.Controller.SyncPlay var command = context.NewSyncPlayCommand(SendCommandType.Pause); context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); } - - return true; } /// <inheritdoc /> - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { - GroupInfo group = context.GetGroup(); - - // Sanitize PositionTicks - var ticks = context.SanitizePositionTicks(request.PositionTicks); - - // Seek - group.PositionTicks = ticks; - group.LastActivity = DateTime.UtcNow; - - var command = context.NewSyncPlayCommand(SendCommandType.Seek); - context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); - - return true; + // Change state + var idleState = new IdleGroupState(_logger); + context.SetState(idleState); + idleState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); } /// <inheritdoc /> - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { - GroupInfo group = context.GetGroup(); - - if (newState) - { - // Pause group and compute the media playback position - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - group.LastActivity; - group.LastActivity = currentTime; - group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; - - group.SetBuffering(session, true); + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } - // Send pause command to all non-buffering sessions - var command = context.NewSyncPlayCommand(SendCommandType.Pause); - context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken); + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } - var updateOthers = context.NewSyncPlayGroupUpdate(GroupUpdateType.GroupWait, session.UserName); - context.SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - } - else + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + if (prevState.Equals(GetGroupState())) { - // TODO: no idea? - // group.SetBuffering(session, true); - // Client got lost, sending current state var command = context.NewSyncPlayCommand(SendCommandType.Pause); context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); } + else if (prevState.Equals(GroupState.Waiting)) + { + // Sending current state to all clients + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); - return true; + // Notify relevant state change event + SendGroupStateUpdate(context, request, session, cancellationToken); + } } /// <inheritdoc /> - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { - GroupInfo group = context.GetGroup(); - - group.SetBuffering(session, false); - - var requestTicks = context.SanitizePositionTicks(request.PositionTicks); - - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - request.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 = context.NewSyncPlayCommand(SendCommandType.Pause); - var pauseAtTime = currentTime.AddMilliseconds(delay); - command.When = context.DateToUTCString(pauseAtTime); - context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); - } - else - { - // Let other clients resume as soon as the buffering client catches up - if (delay > group.GetHighestPing() * 2) - { - // Client that was buffering is recovering, notifying others to resume - group.LastActivity = currentTime.AddMilliseconds( - delay - ); - var command = context.NewSyncPlayCommand(SendCommandType.Play); - context.SendCommand(session, SyncPlayBroadcastType.AllExceptCurrentSession, command, cancellationToken); - } - else - { - // Client, that was buffering, resumed playback but did not update others in time - delay = Math.Max(group.GetHighestPing() * 2, group.DefaultPing); - - group.LastActivity = currentTime.AddMilliseconds( - delay - ); - - var command = context.NewSyncPlayCommand(SendCommandType.Play); - context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); - } - - // Change state - var playingState = new PlayingGroupState(); - context.SetState(playingState); - } + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } - return true; + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); } } } diff --git a/Emby.Server.Implementations/SyncPlay/GroupStates/PlayingGroupState.cs b/Emby.Server.Implementations/SyncPlay/GroupStates/PlayingGroupState.cs index 42c7779c1..e2909ff91 100644 --- a/Emby.Server.Implementations/SyncPlay/GroupStates/PlayingGroupState.cs +++ b/Emby.Server.Implementations/SyncPlay/GroupStates/PlayingGroupState.cs @@ -1,11 +1,8 @@ -using System.Linq; using System; using System.Threading; -using System.Collections.Generic; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Session; using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; namespace MediaBrowser.Controller.SyncPlay { @@ -15,8 +12,21 @@ namespace MediaBrowser.Controller.SyncPlay /// <remarks> /// Class is not thread-safe, external locking is required when accessing methods. /// </remarks> - public class PlayingGroupState : SyncPlayAbstractState + public class PlayingGroupState : AbstractGroupState { + /// <summary> + /// Ignore requests for buffering. + /// </summary> + public bool IgnoreBuffering { get; set; } + + /// <summary> + /// Default constructor. + /// </summary> + public PlayingGroupState(ILogger logger) : base(logger) + { + // Do nothing + } + /// <inheritdoc /> public override GroupState GetGroupState() { @@ -24,71 +34,132 @@ namespace MediaBrowser.Controller.SyncPlay } /// <inheritdoc /> - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void SessionJoined(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) { - GroupInfo group = context.GetGroup(); + // Wait for session to be ready + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.SessionJoined(context, GetGroupState(), session, cancellationToken); + } - if (newState) + /// <inheritdoc /> + public override void SessionLeaving(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Do nothing + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + if (!prevState.Equals(GetGroupState())) { // Pick a suitable time that accounts for latency - var delay = Math.Max(group.GetHighestPing() * 2, group.DefaultPing); + 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 - group.LastActivity = DateTime.UtcNow.AddMilliseconds( - delay + context.LastActivity = DateTime.UtcNow.AddMilliseconds( + delayMillis ); - var command = context.NewSyncPlayCommand(SendCommandType.Play); + 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.Play); + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); } - - return true; } /// <inheritdoc /> - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { // Change state - var pausedState = new PausedGroupState(); + var pausedState = new PausedGroupState(_logger); context.SetState(pausedState); - return pausedState.HandleRequest(context, true, request, session, cancellationToken); + pausedState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); } /// <inheritdoc /> - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { // Change state - var pausedState = new PausedGroupState(); - context.SetState(pausedState); - return pausedState.HandleRequest(context, true, request, session, cancellationToken); + var idleState = new IdleGroupState(_logger); + context.SetState(idleState); + idleState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); } /// <inheritdoc /> - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { // Change state - var pausedState = new PausedGroupState(); - context.SetState(pausedState); - return pausedState.HandleRequest(context, true, request, session, cancellationToken); + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + if (IgnoreBuffering) + { + return; + } + + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); } /// <inheritdoc /> - public override bool HandleRequest(ISyncPlayStateContext context, bool newState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) { - // Group was not waiting, make sure client has latest state - var command = context.NewSyncPlayCommand(SendCommandType.Play); - context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + if (prevState.Equals(GetGroupState())) + { + // 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(GroupState.Waiting)) + { + // Notify relevant state change event + SendGroupStateUpdate(context, request, session, cancellationToken); + } + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } - return true; + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state + var waitingState = new WaitingGroupState(_logger); + context.SetState(waitingState); + waitingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); } } } diff --git a/Emby.Server.Implementations/SyncPlay/GroupStates/WaitingGroupState.cs b/Emby.Server.Implementations/SyncPlay/GroupStates/WaitingGroupState.cs new file mode 100644 index 000000000..9d839b268 --- /dev/null +++ b/Emby.Server.Implementations/SyncPlay/GroupStates/WaitingGroupState.cs @@ -0,0 +1,653 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// <summary> + /// Class WaitingGroupState. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public class WaitingGroupState : AbstractGroupState + { + /// <summary> + /// Tells the state to switch to after buffering is done. + /// </summary> + public bool ResumePlaying { get; set; } = false; + + /// <summary> + /// Whether the initial state has been set. + /// </summary> + private bool InitialStateSet { get; set; } = false; + + /// <summary> + /// The group state before the first ever event. + /// </summary> + private GroupState InitialState { get; set; } + + /// <summary> + /// Default constructor. + /// </summary> + public WaitingGroupState(ILogger logger) : base(logger) + { + // Do nothing + } + + /// <inheritdoc /> + public override GroupState GetGroupState() + { + return GroupState.Waiting; + } + + /// <inheritdoc /> + public override void SessionJoined(ISyncPlayStateContext context, GroupState prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupState.Playing)) { + ResumePlaying = true; + // Pause group and compute the media playback position + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - context.LastActivity; + context.LastActivity = currentTime; + // Seek only if playback actually started + // Event may happen during the delay added to account for latency + context.PositionTicks += elapsedTime.Ticks > 0 ? 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); + } + + /// <inheritdoc /> + public override void SessionLeaving(ISyncPlayStateContext context, GroupState 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) + { + // Client, that was buffering, left the group + var playingState = new PlayingGroupState(_logger); + context.SetState(playingState); + var unpauseRequest = new UnpauseGroupRequest(); + playingState.HandleRequest(context, GetGroupState(), unpauseRequest, session, cancellationToken); + + _logger.LogDebug("SessionLeaving: {0} left the group {1}, notifying others to resume.", session.Id.ToString(), context.GroupId.ToString()); + } + else + { + // Group is ready, returning to previous state + var pausedState = new PausedGroupState(_logger); + context.SetState(pausedState); + + _logger.LogDebug("SessionLeaving: {0} left the group {1}, returning to previous state.", session.Id.ToString(), context.GroupId.ToString()); + } + } + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PlayGroupRequest request, 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("HandleRequest: {0} in group {1}, unable to set playing queue.", request.GetRequestType(), context.GroupId.ToString()); + + // Ignore request and return to previous state + ISyncPlayState newState; + switch (prevState) + { + case GroupState.Playing: + newState = new PlayingGroupState(_logger); + break; + case GroupState.Paused: + newState = new PausedGroupState(_logger); + break; + default: + newState = new IdleGroupState(_logger); + break; + } + + 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 before sending Play command + context.SetAllBuffering(true); + + _logger.LogDebug("HandleRequest: {0} in group {1}, {2} set a new play queue.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SetPlaylistItemGroupRequest request, 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 before sending Play command + context.SetAllBuffering(true); + } + else + { + // Return to old state + ISyncPlayState newState; + switch (prevState) + { + case GroupState.Playing: + newState = new PlayingGroupState(_logger); + break; + case GroupState.Paused: + newState = new PausedGroupState(_logger); + break; + default: + newState = new IdleGroupState(_logger); + break; + } + + context.SetState(newState); + + _logger.LogDebug("HandleRequest: {0} in group {1}, unable to change current playing item.", request.GetRequestType(), context.GroupId.ToString()); + } + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupState.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 before sending Play command + context.SetAllBuffering(true); + + _logger.LogDebug("HandleRequest: {0} in group {1}, waiting for all ready events.", request.GetRequestType(), context.GroupId.ToString()); + } + else + { + if (ResumePlaying) + { + _logger.LogDebug("HandleRequest: {0} in group {1}, ignoring sessions that are not ready and forcing the playback to start.", request.GetRequestType(), 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(_logger); + playingState.IgnoreBuffering = true; + context.SetState(playingState); + playingState.HandleRequest(context, GetGroupState(), request, 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); + } + } + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PauseGroupRequest request, 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); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + // Change state + var idleState = new IdleGroupState(_logger); + context.SetState(idleState); + idleState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupState.Playing)) + { + ResumePlaying = true; + } + else if(prevState.Equals(GroupState.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 before sending Play command + context.SetAllBuffering(true); + + // Notify relevant state change event + SendGroupStateUpdate(context, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, BufferGroupRequest request, 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("HandleRequest: {0} in group {1}, {2} has wrong playlist item.", request.GetRequestType(), context.GroupId.ToString(), session.Id.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(GroupState.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; + context.PositionTicks += elapsedTime.Ticks > 0 ? 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(GroupState.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(GroupState.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); + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, ReadyGroupRequest request, 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("HandleRequest: {0} in group {1}, {2} has wrong playlist item.", request.GetRequestType(), context.GroupId.ToString(), session.Id.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; + } + + var requestTicks = context.SanitizePositionTicks(request.PositionTicks); + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - request.When; + if (!request.IsPlaying) + { + elapsedTime = TimeSpan.Zero; + } + + var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime; + var delayTicks = context.PositionTicks - clientPosition.Ticks; + + if (delayTicks > TimeSpan.FromSeconds(5).Ticks) + { + // The client is really behind, other participants will have to wait a lot of time... + _logger.LogWarning("HandleRequest: {0} in group {1}, {2} got lost in time.", request.GetRequestType(), context.GroupId.ToString(), session.Id.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(context.PositionTicks - requestTicks) > TimeSpan.FromSeconds(0.5).Ticks) { + // 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.LogDebug("HandleRequest: {0} in group {1}, {2} got lost in time, correcting.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + 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); + var pauseAtTime = currentTime.AddTicks(delayTicks); + command.When = context.DateToUTCString(pauseAtTime); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + + _logger.LogDebug("HandleRequest: {0} in group {1}, others still buffering, {2} will pause when ready.", request.GetRequestType(), context.GroupId.ToString(), session.Id.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.LogDebug("HandleRequest: {0} in group {1}, {2} is recovering, notifying others to resume.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + } + else + { + // Client, that was buffering, resumed playback but did not update others in time + delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond; + delayTicks = delayTicks < context.DefaultPing ? context.DefaultPing : delayTicks; + + context.LastActivity = currentTime.AddTicks(delayTicks); + + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + _logger.LogDebug("HandleRequest: {0} in group {1}, {2} resumed playback but did not update others in time.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + } + + // Change state + var playingState = new PlayingGroupState(_logger); + context.SetState(playingState); + playingState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + } + else + { + // Check that session is really ready, tollerate half second difference to account for player imperfections + if (Math.Abs(context.PositionTicks - requestTicks) > TimeSpan.FromSeconds(0.5).Ticks) + { + // 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.LogDebug("HandleRequest: {0} in group {1}, {2} was seeking to wrong position, correcting.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + return; + } else { + // Session is ready + context.SetBuffering(session, false); + } + + if (!context.IsBuffering()) + { + // Group is ready, returning to previous state + var pausedState = new PausedGroupState(_logger); + context.SetState(pausedState); + + if (InitialState.Equals(GroupState.Playing)) + { + // Group went from playing to waiting state and a pause request occured while waiting + var pauserequest = new PauseGroupRequest(); + pausedState.HandleRequest(context, GetGroupState(), pauserequest, session, cancellationToken); + } + else if (InitialState.Equals(GroupState.Paused)) + { + pausedState.HandleRequest(context, GetGroupState(), request, session, cancellationToken); + } + + _logger.LogDebug("HandleRequest: {0} in group {1}, {2} is ready, returning to previous state.", request.GetRequestType(), context.GroupId.ToString(), session.Id.ToString()); + } + } + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, NextTrackGroupRequest request, 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("HandleRequest: {0} in group {1}, client provided the wrong playlist id.", request.GetRequestType(), context.GroupId.ToString()); + return; + } + + var newItem = context.NextItemInQueue(); + if (newItem) + { + // Send playing-queue update + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NextTrack); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events before sending Play command + context.SetAllBuffering(true); + } + else + { + // Return to old state + ISyncPlayState newState; + switch (prevState) + { + case GroupState.Playing: + newState = new PlayingGroupState(_logger); + break; + case GroupState.Paused: + newState = new PausedGroupState(_logger); + break; + default: + newState = new IdleGroupState(_logger); + break; + } + + context.SetState(newState); + + _logger.LogDebug("HandleRequest: {0} in group {1}, no next track available.", request.GetRequestType(), context.GroupId.ToString()); + } + } + + /// <inheritdoc /> + public override void HandleRequest(ISyncPlayStateContext context, GroupState prevState, PreviousTrackGroupRequest request, 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("HandleRequest: {0} in group {1}, client provided the wrong playlist id.", request.GetRequestType(), context.GroupId.ToString()); + return; + } + + var newItem = context.PreviousItemInQueue(); + if (newItem) + { + // Send playing-queue update + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.PreviousTrack); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events before sending Play command + context.SetAllBuffering(true); + } + else + { + // Return to old state + ISyncPlayState newState; + switch (prevState) + { + case GroupState.Playing: + newState = new PlayingGroupState(_logger); + break; + case GroupState.Paused: + newState = new PausedGroupState(_logger); + break; + default: + newState = new IdleGroupState(_logger); + break; + } + + context.SetState(newState); + + _logger.LogDebug("HandleRequest: {0} in group {1}, no previous track available.", request.GetRequestType(), context.GroupId.ToString()); + } + } + } +} diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs deleted file mode 100644 index 225be7430..000000000 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs +++ /dev/null @@ -1,282 +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; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.SyncPlay -{ - /// <summary> - /// Class SyncPlayController. - /// </summary> - /// <remarks> - /// Class is not thread-safe, external locking is required when accessing methods. - /// </remarks> - public class SyncPlayController : ISyncPlayController, ISyncPlayStateContext - { - /// <summary> - /// The session manager. - /// </summary> - private readonly ISessionManager _sessionManager; - - /// <summary> - /// The SyncPlay manager. - /// </summary> - private readonly ISyncPlayManager _syncPlayManager; - - /// <summary> - /// The logger. - /// </summary> - private readonly ILogger _logger; - - /// <summary> - /// The group to manage. - /// </summary> - private readonly GroupInfo _group = new GroupInfo(); - - /// <summary> - /// Internal group state. - /// </summary> - /// <value>The group's state.</value> - private ISyncPlayState State = new PausedGroupState(); - - /// <inheritdoc /> - public GroupInfo GetGroup() - { - return _group; - } - - /// <inheritdoc /> - public void SetState(ISyncPlayState state) - { - _logger.LogInformation("SetState: {0} -> {1}.", State.GetGroupState(), state.GetGroupState()); - this.State = state; - } - - /// <inheritdoc /> - public Guid GetGroupId() => _group.GroupId; - - /// <inheritdoc /> - public Guid GetPlayingItemId() => _group.PlayingItem.Id; - - /// <inheritdoc /> - public bool IsGroupEmpty() => _group.IsEmpty(); - - /// <summary> - /// Initializes a new instance of the <see cref="SyncPlayController" /> class. - /// </summary> - /// <param name="sessionManager">The session manager.</param> - /// <param name="syncPlayManager">The SyncPlay manager.</param> - public SyncPlayController( - ISessionManager sessionManager, - ISyncPlayManager syncPlayManager, - ILogger logger) - { - _sessionManager = sessionManager; - _syncPlayManager = syncPlayManager; - _logger = logger; - } - - /// <summary> - /// Filters sessions of this group. - /// </summary> - /// <param name="from">The current session.</param> - /// <param name="type">The filtering type.</param> - /// <value>The array of sessions matching the filter.</value> - private SessionInfo[] FilterSessions(SessionInfo from, SyncPlayBroadcastType type) - { - switch (type) - { - case SyncPlayBroadcastType.CurrentSession: - return new SessionInfo[] { from }; - case SyncPlayBroadcastType.AllGroup: - return _group.Participants.Values.Select( - session => session.Session).ToArray(); - case SyncPlayBroadcastType.AllExceptCurrentSession: - return _group.Participants.Values.Select( - session => session.Session).Where( - session => !session.Id.Equals(from.Id)).ToArray(); - case SyncPlayBroadcastType.AllReady: - return _group.Participants.Values.Where( - session => !session.IsBuffering).Select( - session => session.Session).ToArray(); - default: - return Array.Empty<SessionInfo>(); - } - } - - /// <inheritdoc /> - public Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken) - { - IEnumerable<Task> GetTasks() - { - foreach (var session in FilterSessions(from, type)) - { - yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id, message, cancellationToken); - } - } - - return Task.WhenAll(GetTasks()); - } - - /// <inheritdoc /> - public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken) - { - IEnumerable<Task> GetTasks() - { - foreach (var session in FilterSessions(from, type)) - { - yield return _sessionManager.SendSyncPlayCommand(session.Id, message, cancellationToken); - } - } - - return Task.WhenAll(GetTasks()); - } - - /// <inheritdoc /> - public SendCommand NewSyncPlayCommand(SendCommandType type) - { - return new SendCommand() - { - GroupId = _group.GroupId.ToString(), - Command = type, - PositionTicks = _group.PositionTicks, - When = DateToUTCString(_group.LastActivity), - EmittedAt = DateToUTCString(DateTime.UtcNow) - }; - } - - /// <inheritdoc /> - public GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data) - { - return new GroupUpdate<T>() - { - GroupId = _group.GroupId.ToString(), - Type = type, - Data = data - }; - } - - /// <inheritdoc /> - public string DateToUTCString(DateTime _date) - { - return _date.ToUniversalTime().ToString("o"); - } - - /// <inheritdoc /> - public 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; - } - - /// <inheritdoc /> - public void CreateGroup(SessionInfo session, CancellationToken cancellationToken) - { - _group.AddSession(session); - _syncPlayManager.AddSessionToGroup(session, this); - - State = new PausedGroupState(); - - _group.PlayingItem = session.FullNowPlayingItem; - // TODO: looks like new groups should mantain playstate (and not force to pause) - // _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, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); - // TODO: looks like new groups should mantain playstate (and not force to pause) - var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, SyncPlayBroadcastType.CurrentSession, pauseCommand, cancellationToken); - } - - /// <inheritdoc /> - public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) - { - if (session.NowPlayingItem?.Id == _group.PlayingItem.Id) - { - _group.AddSession(session); - _syncPlayManager.AddSessionToGroup(session, this); - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow)); - SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); - - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); - SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - - // Syncing will happen client-side - if (State.GetGroupState().Equals(GroupState.Playing)) - { - var playCommand = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, SyncPlayBroadcastType.CurrentSession, playCommand, cancellationToken); - } - else - { - var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, SyncPlayBroadcastType.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, SyncPlayBroadcastType.CurrentSession, update, cancellationToken); - } - } - - /// <inheritdoc /> - public void SessionLeave(SessionInfo session, CancellationToken cancellationToken) - { - _group.RemoveSession(session); - _syncPlayManager.RemoveSessionFromGroup(session, this); - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, _group.PositionTicks); - SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); - - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName); - SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - } - - /// <inheritdoc /> - public void HandleRequest(SessionInfo session, IPlaybackGroupRequest 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("HandleRequest: {0}:{1}.", request.GetType(), State.GetGroupState()); - _ = request.Apply(this, State, session, cancellationToken); - // TODO: do something with returned value - } - - /// <inheritdoc /> - public GroupInfoDto GetInfo() - { - return new GroupInfoDto() - { - 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 b85f3c149..a8e30a9ec 100644 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs +++ b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs @@ -1,9 +1,7 @@ using System; 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; @@ -41,14 +39,14 @@ namespace Emby.Server.Implementations.SyncPlay /// <summary> /// The map between sessions and groups. /// </summary> - private readonly Dictionary<string, ISyncPlayController> _sessionToGroupMap = - new Dictionary<string, ISyncPlayController>(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary<string, ISyncPlayGroupController> _sessionToGroupMap = + new Dictionary<string, ISyncPlayGroupController>(StringComparer.OrdinalIgnoreCase); /// <summary> /// The groups. /// </summary> - private readonly Dictionary<Guid, ISyncPlayController> _groups = - new Dictionary<Guid, ISyncPlayController>(); + private readonly Dictionary<Guid, ISyncPlayGroupController> _groups = + new Dictionary<Guid, ISyncPlayGroupController>(); /// <summary> /// Lock used for accesing any group. @@ -75,7 +73,9 @@ namespace Emby.Server.Implementations.SyncPlay _sessionManager = sessionManager; _libraryManager = libraryManager; + _sessionManager.SessionStarted += OnSessionManagerSessionStarted; _sessionManager.SessionEnded += OnSessionManagerSessionEnded; + _sessionManager.PlaybackStart += OnSessionManagerPlaybackStart; _sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped; } @@ -83,7 +83,7 @@ namespace Emby.Server.Implementations.SyncPlay /// Gets all groups. /// </summary> /// <value>All groups.</value> - public IEnumerable<ISyncPlayController> Groups => _groups.Values; + public IEnumerable<ISyncPlayGroupController> Groups => _groups.Values; /// <inheritdoc /> public void Dispose() @@ -103,13 +103,15 @@ namespace Emby.Server.Implementations.SyncPlay return; } + _sessionManager.SessionStarted -= OnSessionManagerSessionStarted; _sessionManager.SessionEnded -= OnSessionManagerSessionEnded; + _sessionManager.PlaybackStart -= OnSessionManagerPlaybackStart; _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped; _disposed = true; } - private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e) + private void OnSessionManagerSessionStarted(object sender, SessionEventArgs e) { var session = e.SessionInfo; if (!IsSessionInGroup(session)) @@ -117,52 +119,60 @@ namespace Emby.Server.Implementations.SyncPlay return; } - LeaveGroup(session, CancellationToken.None); + var groupId = GetSessionGroup(session) ?? Guid.Empty; + var request = new JoinGroupRequest() + { + GroupId = groupId + }; + JoinGroup(session, groupId, request, CancellationToken.None); } - private void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e) + private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e) { - var session = e.Session; + var session = e.SessionInfo; if (!IsSessionInGroup(session)) { return; } - LeaveGroup(session, CancellationToken.None); + // TODO: probably remove this event, not used at the moment } - private bool IsSessionInGroup(SessionInfo session) + private void OnSessionManagerPlaybackStart(object sender, PlaybackProgressEventArgs e) { - return _sessionToGroupMap.ContainsKey(session.Id); + var session = e.Session; + if (!IsSessionInGroup(session)) + { + return; + } + + // TODO: probably remove this event, not used at the moment } - private bool HasAccessToItem(User user, Guid itemId) + private void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e) { - var item = _libraryManager.GetItemById(itemId); - - // Check ParentalRating access - var hasParentalRatingAccess = !user.MaxParentalAgeRating.HasValue - || item.InheritedParentalRatingValue <= user.MaxParentalAgeRating; - - if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess) + var session = e.Session; + if (!IsSessionInGroup(session)) { - var collections = _libraryManager.GetCollectionFolders(item).Select( - folder => folder.Id.ToString("N", CultureInfo.InvariantCulture)); - - return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any(); + return; } - return hasParentalRatingAccess; + // TODO: probably remove this event, not used at the moment + } + + private bool IsSessionInGroup(SessionInfo session) + { + return _sessionToGroupMap.ContainsKey(session.Id); } private Guid? GetSessionGroup(SessionInfo session) { _sessionToGroupMap.TryGetValue(session.Id, out var group); - return group?.GetGroupId(); + return group?.GroupId; } /// <inheritdoc /> - public void NewGroup(SessionInfo session, CancellationToken cancellationToken) + public void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) { var user = _userManager.GetUserById(session.UserId); @@ -174,8 +184,7 @@ namespace Emby.Server.Implementations.SyncPlay { Type = GroupUpdateType.CreateGroupDenied }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } @@ -186,10 +195,10 @@ namespace Emby.Server.Implementations.SyncPlay LeaveGroup(session, cancellationToken); } - var group = new SyncPlayController(_sessionManager, this, _logger); - _groups[group.GetGroupId()] = group; + var group = new SyncPlayGroupController(_logger, _userManager, _sessionManager, _libraryManager, this); + _groups[group.GroupId] = group; - group.CreateGroup(session, cancellationToken); + group.CreateGroup(session, request, cancellationToken); } } @@ -206,14 +215,13 @@ namespace Emby.Server.Implementations.SyncPlay { Type = GroupUpdateType.JoinGroupDenied }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } lock (_groupsLock) { - ISyncPlayController group; + ISyncPlayGroupController group; _groups.TryGetValue(groupId, out group); if (group == null) @@ -224,20 +232,20 @@ namespace Emby.Server.Implementations.SyncPlay { Type = GroupUpdateType.GroupDoesNotExist }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } - if (!HasAccessToItem(user, group.GetPlayingItemId())) + if (!group.HasAccessToPlayQueue(user)) { - _logger.LogWarning("JoinGroup: {0} does not have access to {1}.", session.Id, group.GetPlayingItemId()); + _logger.LogWarning("JoinGroup: {0} does not have access to some content from the playing queue of group {1}.", session.Id, group.GroupId.ToString()); var error = new GroupUpdate<string>() { - GroupId = group.GetGroupId().ToString(), + GroupId = group.GroupId.ToString(), Type = GroupUpdateType.LibraryAccessDenied }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } @@ -245,6 +253,7 @@ namespace Emby.Server.Implementations.SyncPlay { if (GetSessionGroup(session).Equals(groupId)) { + group.SessionRestore(session, request, cancellationToken); return; } @@ -271,7 +280,7 @@ namespace Emby.Server.Implementations.SyncPlay { Type = GroupUpdateType.NotInGroup }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } @@ -279,14 +288,14 @@ namespace Emby.Server.Implementations.SyncPlay if (group.IsGroupEmpty()) { - _logger.LogInformation("LeaveGroup: removing empty group {0}.", group.GetGroupId()); - _groups.Remove(group.GetGroupId(), out _); + _logger.LogInformation("LeaveGroup: removing empty group {0}.", group.GroupId); + _groups.Remove(group.GroupId, out _); } } } /// <inheritdoc /> - public List<GroupInfoDto> ListGroups(SessionInfo session, Guid filterItemId) + public List<GroupInfoDto> ListGroups(SessionInfo session) { var user = _userManager.GetUserById(session.UserId); @@ -295,20 +304,9 @@ namespace Emby.Server.Implementations.SyncPlay return new List<GroupInfoDto>(); } - // 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(); - } + return _groups.Values.Where( + group => group.HasAccessToPlayQueue(user)).Select( + group => group.GetInfo()).ToList(); } /// <inheritdoc /> @@ -324,8 +322,7 @@ namespace Emby.Server.Implementations.SyncPlay { Type = GroupUpdateType.JoinGroupDenied }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } @@ -341,7 +338,7 @@ namespace Emby.Server.Implementations.SyncPlay { Type = GroupUpdateType.NotInGroup }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } @@ -350,7 +347,7 @@ namespace Emby.Server.Implementations.SyncPlay } /// <inheritdoc /> - public void AddSessionToGroup(SessionInfo session, ISyncPlayController group) + public void AddSessionToGroup(SessionInfo session, ISyncPlayGroupController group) { if (IsSessionInGroup(session)) { @@ -361,7 +358,7 @@ namespace Emby.Server.Implementations.SyncPlay } /// <inheritdoc /> - public void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group) + public void RemoveSessionFromGroup(SessionInfo session, ISyncPlayGroupController group) { if (!IsSessionInGroup(session)) { @@ -369,7 +366,7 @@ namespace Emby.Server.Implementations.SyncPlay } _sessionToGroupMap.Remove(session.Id, out var tempGroup); - if (!tempGroup.GetGroupId().Equals(group.GetGroupId())) + if (!tempGroup.GroupId.Equals(group.GroupId)) { throw new InvalidOperationException("Session was in wrong group!"); } |
