diff options
59 files changed, 4909 insertions, 1092 deletions
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index edc8b0864..a63db6ed7 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -79,6 +79,7 @@ - [Nickbert7](https://github.com/Nickbert7) - [nvllsvm](https://github.com/nvllsvm) - [nyanmisaka](https://github.com/nyanmisaka) + - [OancaAndrei](https://github.com/OancaAndrei) - [oddstr13](https://github.com/oddstr13) - [orryverducci](https://github.com/orryverducci) - [petermcneil](https://github.com/petermcneil) diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index afddfa856..b3965fcca 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -1181,18 +1181,16 @@ namespace Emby.Server.Implementations.Session } /// <inheritdoc /> - public async Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken) + public async Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken) { CheckDisposed(); - var session = GetSessionToRemoteControl(sessionId); await SendMessageToSession(session, SessionMessageType.SyncPlayCommand, command, cancellationToken).ConfigureAwait(false); } /// <inheritdoc /> - public async Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken) + public async Task SendSyncPlayGroupUpdate<T>(SessionInfo session, GroupUpdate<T> command, CancellationToken cancellationToken) { CheckDisposed(); - var session = GetSessionToRemoteControl(sessionId); await SendMessageToSession(session, SessionMessageType.SyncPlayGroupUpdate, command, cancellationToken).ConfigureAwait(false); } diff --git a/Emby.Server.Implementations/SyncPlay/GroupController.cs b/Emby.Server.Implementations/SyncPlay/GroupController.cs new file mode 100644 index 000000000..31df8404b --- /dev/null +++ b/Emby.Server.Implementations/SyncPlay/GroupController.cs @@ -0,0 +1,668 @@ +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.Controller.SyncPlay.GroupStates; +using MediaBrowser.Controller.SyncPlay.Queue; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.SyncPlay +{ + /// <summary> + /// Class GroupController. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public class GroupController : IGroupController, IGroupStateContext + { + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<GroupController> _logger; + + /// <summary> + /// The logger factory. + /// </summary> + private readonly ILoggerFactory _loggerFactory; + + /// <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 participants, or members of the group. + /// </summary> + private readonly Dictionary<string, GroupMember> _participants = + new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase); + + /// <summary> + /// The internal group state. + /// </summary> + private IGroupState _state; + + /// <summary> + /// Initializes a new instance of the <see cref="GroupController" /> class. + /// </summary> + /// <param name="loggerFactory">The logger factory.</param> + /// <param name="userManager">The user manager.</param> + /// <param name="sessionManager">The session manager.</param> + /// <param name="libraryManager">The library manager.</param> + public GroupController( + ILoggerFactory loggerFactory, + IUserManager userManager, + ISessionManager sessionManager, + ILibraryManager libraryManager) + { + _loggerFactory = loggerFactory; + _userManager = userManager; + _sessionManager = sessionManager; + _libraryManager = libraryManager; + _logger = loggerFactory.CreateLogger<GroupController>(); + + _state = new IdleGroupState(loggerFactory); + } + + /// <summary> + /// Gets the default ping value used for sessions. + /// </summary> + /// <value>The default ping.</value> + public long DefaultPing { get; } = 500; + + /// <summary> + /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds. + /// </summary> + /// <value>The maximum time offset error.</value> + public long TimeSyncOffset { get; } = 2000; + + /// <summary> + /// Gets the maximum offset error accepted for position reported by clients, in milliseconds. + /// </summary> + /// <value>The maximum offset error.</value> + public long MaxPlaybackOffset { get; } = 500; + + /// <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 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> + /// 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) + { + 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 list of sessions matching the filter.</returns> + private IEnumerable<SessionInfo> FilterSessions(SessionInfo from, SyncPlayBroadcastType type) + { + return type switch + { + SyncPlayBroadcastType.CurrentSession => new SessionInfo[] { from }, + SyncPlayBroadcastType.AllGroup => _participants + .Values + .Select(session => session.Session), + SyncPlayBroadcastType.AllExceptCurrentSession => _participants + .Values + .Select(session => session.Session) + .Where(session => !session.Id.Equals(from.Id, StringComparison.OrdinalIgnoreCase)), + SyncPlayBroadcastType.AllReady => _participants + .Values + .Where(session => !session.IsBuffering) + .Select(session => session.Session), + _ => Enumerable.Empty<SessionInfo>() + }; + } + + /// <summary> + /// Checks if a given user can access a given item, that is, the user has access to a folder where the item is stored. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="item">The item.</param> + /// <returns><c>true</c> if the user can access the item, <c>false</c> otherwise.</returns> + 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(); + } + + /// <summary> + /// Checks if a given user can access all items of a given queue, that is, + /// the user has the required minimum parental access and has access to all required folders. + /// </summary> + /// <param name="user">The user.</param> + /// <param name="queue">The queue.</param> + /// <returns><c>true</c> if the user can access all the items in the queue, <c>false</c> otherwise.</returns> + private bool HasAccessToQueue(User user, IReadOnlyList<Guid> queue) + { + // Check if queue is empty. + if (queue == null || queue.Count == 0) + { + return true; + } + + foreach (var itemId in queue) + { + var item = _libraryManager.GetItemById(itemId); + if (user.MaxParentalAgeRating.HasValue && item.InheritedParentalRatingValue > user.MaxParentalAgeRating) + { + return false; + } + + if (!user.HasPermission(PermissionKind.EnableAllFolders) && !HasAccessToItem(user, item)) + { + return false; + } + } + + return true; + } + + private bool AllUsersHaveAccessToQueue(IReadOnlyList<Guid> queue) + { + // Check if queue is empty. + if (queue == null || queue.Count == 0) + { + return true; + } + + // Get list of users. + var users = _participants + .Values + .Select(participant => _userManager.GetUserById(participant.Session.UserId)); + + // Find problematic users. + var usersWithNoAccess = users.Where(user => !HasAccessToQueue(user, queue)); + + // All users must be able to access the queue. + return !usersWithNoAccess.Any(); + } + + /// <inheritdoc /> + public bool IsGroupEmpty() => _participants.Count == 0; + + /// <inheritdoc /> + public void CreateGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) + { + GroupName = request.GroupName; + AddSession(session); + + var sessionIsPlayingAnItem = session.FullNowPlayingItem != null; + + RestartCurrentItem(); + + if (sessionIsPlayingAnItem) + { + var playlist = session.NowPlayingQueue.Select(item => item.Id).ToList(); + PlayQueue.Reset(); + PlayQueue.SetPlaylist(playlist); + PlayQueue.SetPlayingItemById(session.FullNowPlayingItem.Id); + RunTimeTicks = session.FullNowPlayingItem.RunTimeTicks ?? 0; + PositionTicks = session.PlayState.PositionTicks ?? 0; + + // Maintain playstate. + var waitingState = new WaitingGroupState(_loggerFactory) + { + ResumePlaying = !session.PlayState.IsPaused + }; + SetState(waitingState); + } + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + _state.SessionJoined(this, _state.Type, session, cancellationToken); + + _logger.LogInformation("Session {SessionId} created group {GroupId}.", session.Id, GroupId.ToString()); + } + + /// <inheritdoc /> + public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) + { + AddSession(session); + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); + SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); + + _state.SessionJoined(this, _state.Type, session, cancellationToken); + + _logger.LogInformation("Session {SessionId} joined group {GroupId}.", session.Id, GroupId.ToString()); + } + + /// <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.Type, session, cancellationToken); + + _logger.LogInformation("Session {SessionId} re-joined group {GroupId}.", session.Id, GroupId.ToString()); + } + + /// <inheritdoc /> + public void SessionLeave(SessionInfo session, CancellationToken cancellationToken) + { + _state.SessionLeaving(this, _state.Type, session, cancellationToken); + + RemoveSession(session); + + var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, GroupId.ToString()); + SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + + var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName); + SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); + + _logger.LogInformation("Session {SessionId} left group {GroupId}.", session.Id, GroupId.ToString()); + } + + /// <inheritdoc /> + public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken) + { + // The server's job is to maintain a consistent state for clients to reference + // and notify clients of state changes. The actual syncing of media playback + // happens client side. Clients are aware of the server's time and use it to sync. + _logger.LogInformation("Session {SessionId} requested {RequestType} in group {GroupId} that is {StateType}.", session.Id, request.Type, GroupId.ToString(), _state.Type); + request.Apply(this, _state, session, cancellationToken); + } + + /// <inheritdoc /> + public GroupInfoDto GetInfo() + { + var participants = _participants.Values.Select(session => session.Session.UserName).Distinct().ToList(); + return new GroupInfoDto(GroupId, GroupName, _state.Type, participants, DateTime.UtcNow); + } + + /// <inheritdoc /> + public bool HasAccessToPlayQueue(User user) + { + var items = PlayQueue.GetPlaylist().Select(item => item.ItemId).ToList(); + return HasAccessToQueue(user, items); + } + + /// <inheritdoc /> + public void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait) + { + if (_participants.TryGetValue(session.Id, out GroupMember value)) + { + value.IgnoreGroupWait = ignoreGroupWait; + } + } + + /// <inheritdoc /> + public void SetState(IGroupState state) + { + _logger.LogInformation("Group {GroupId} switching from {FromStateType} to {ToStateType}.", GroupId.ToString(), _state.Type, state.Type); + 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, + PlayQueue.GetPlayingItemPlaylistId(), + LastActivity, + type, + PositionTicks, + DateTime.UtcNow); + } + + /// <inheritdoc /> + public GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data) + { + return new GroupUpdate<T>(GroupId, type, data); + } + + /// <inheritdoc /> + public long SanitizePositionTicks(long? positionTicks) + { + var ticks = positionTicks ?? 0; + return Math.Clamp(ticks, 0, RunTimeTicks); + } + + /// <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(IReadOnlyList<Guid> playQueue, int playingItemPosition, long startPositionTicks) + { + // Ignore on empty queue or invalid item position. + if (playQueue.Count == 0 || playingItemPosition >= playQueue.Count || playingItemPosition < 0) + { + return false; + } + + // Check if participants can access the new playing queue. + if (!AllUsersHaveAccessToQueue(playQueue)) + { + return false; + } + + PlayQueue.Reset(); + PlayQueue.SetPlaylist(playQueue); + PlayQueue.SetPlayingItemByIndex(playingItemPosition); + var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); + RunTimeTicks = item.RunTimeTicks ?? 0; + PositionTicks = startPositionTicks; + LastActivity = DateTime.UtcNow; + + return true; + } + + /// <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(IReadOnlyList<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(IReadOnlyList<Guid> newItems, GroupQueueMode mode) + { + // Ignore on empty list. + if (newItems.Count == 0) + { + return false; + } + + // Check if participants can access the new playing queue. + if (!AllUsersHaveAccessToQueue(newItems)) + { + return false; + } + + if (mode.Equals(GroupQueueMode.QueueNext)) + { + PlayQueue.QueueNext(newItems); + } + else + { + PlayQueue.Queue(newItems); + } + + return true; + } + + /// <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(GroupRepeatMode mode) + { + PlayQueue.SetRepeatMode(mode); + } + + /// <inheritdoc /> + public void SetShuffleMode(GroupShuffleMode mode) + { + PlayQueue.SetShuffleMode(mode); + } + + /// <inheritdoc /> + public PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason) + { + var startPositionTicks = PositionTicks; + + if (_state.Type.Equals(GroupStateType.Playing)) + { + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - LastActivity; + // Elapsed time is negative if event happens + // during the delay added to account for latency. + // In this phase clients haven't started the playback yet. + // In other words, LastActivity is in the future, + // when playback unpause is supposed to happen. + // Adjust ticks only if playback actually started. + startPositionTicks += Math.Max(elapsedTime.Ticks, 0); + } + + return new PlayQueueUpdate( + reason, + PlayQueue.LastChange, + PlayQueue.GetPlaylist(), + PlayQueue.PlayingItemIndex, + startPositionTicks, + PlayQueue.ShuffleMode, + PlayQueue.RepeatMode); + } + } +} diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs deleted file mode 100644 index 538479512..000000000 --- a/Emby.Server.Implementations/SyncPlay/SyncPlayController.cs +++ /dev/null @@ -1,514 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Session; -using MediaBrowser.Controller.SyncPlay; -using MediaBrowser.Model.Session; -using MediaBrowser.Model.SyncPlay; - -namespace Emby.Server.Implementations.SyncPlay -{ - /// <summary> - /// Class SyncPlayController. - /// </summary> - /// <remarks> - /// Class is not thread-safe, external locking is required when accessing methods. - /// </remarks> - public class SyncPlayController : ISyncPlayController - { - /// <summary> - /// Used to filter the sessions of a group. - /// </summary> - private enum BroadcastType - { - /// <summary> - /// All sessions will receive the message. - /// </summary> - AllGroup = 0, - - /// <summary> - /// Only the specified session will receive the message. - /// </summary> - CurrentSession = 1, - - /// <summary> - /// All sessions, except the current one, will receive the message. - /// </summary> - AllExceptCurrentSession = 2, - - /// <summary> - /// Only sessions that are not buffering will receive the message. - /// </summary> - AllReady = 3 - } - - /// <summary> - /// The session manager. - /// </summary> - private readonly ISessionManager _sessionManager; - - /// <summary> - /// The SyncPlay manager. - /// </summary> - private readonly ISyncPlayManager _syncPlayManager; - - /// <summary> - /// The group to manage. - /// </summary> - private readonly GroupInfo _group = new GroupInfo(); - - /// <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) - { - _sessionManager = sessionManager; - _syncPlayManager = syncPlayManager; - } - - /// <inheritdoc /> - public Guid GetGroupId() => _group.GroupId; - - /// <inheritdoc /> - public Guid GetPlayingItemId() => _group.PlayingItem.Id; - - /// <inheritdoc /> - public bool IsGroupEmpty() => _group.IsEmpty(); - - /// <summary> - /// Converts DateTime to UTC string. - /// </summary> - /// <param name="date">The date to convert.</param> - /// <value>The UTC string.</value> - private string DateToUTCString(DateTime date) - { - return date.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture); - } - - /// <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 IEnumerable<SessionInfo> FilterSessions(SessionInfo from, BroadcastType type) - { - switch (type) - { - case BroadcastType.CurrentSession: - return new SessionInfo[] { from }; - case BroadcastType.AllGroup: - return _group.Participants.Values - .Select(session => session.Session); - case BroadcastType.AllExceptCurrentSession: - return _group.Participants.Values - .Select(session => session.Session) - .Where(session => !session.Id.Equals(from.Id, StringComparison.Ordinal)); - case BroadcastType.AllReady: - return _group.Participants.Values - .Where(session => !session.IsBuffering) - .Select(session => session.Session); - default: - return Array.Empty<SessionInfo>(); - } - } - - /// <summary> - /// Sends a GroupUpdate message to the interested sessions. - /// </summary> - /// <param name="from">The current session.</param> - /// <param name="type">The filtering type.</param> - /// <param name="message">The message to send.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <value>The task.</value> - private Task SendGroupUpdate<T>(SessionInfo from, BroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken) - { - IEnumerable<Task> GetTasks() - { - foreach (var session in FilterSessions(from, type)) - { - yield return _sessionManager.SendSyncPlayGroupUpdate(session.Id, message, cancellationToken); - } - } - - return Task.WhenAll(GetTasks()); - } - - /// <summary> - /// Sends a playback command to the interested sessions. - /// </summary> - /// <param name="from">The current session.</param> - /// <param name="type">The filtering type.</param> - /// <param name="message">The message to send.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <value>The task.</value> - private Task SendCommand(SessionInfo from, BroadcastType type, SendCommand message, CancellationToken cancellationToken) - { - IEnumerable<Task> GetTasks() - { - foreach (var session in FilterSessions(from, type)) - { - yield return _sessionManager.SendSyncPlayCommand(session.Id, message, cancellationToken); - } - } - - return Task.WhenAll(GetTasks()); - } - - /// <summary> - /// Builds a new playback command with some default values. - /// </summary> - /// <param name="type">The command type.</param> - /// <value>The SendCommand.</value> - private SendCommand NewSyncPlayCommand(SendCommandType type) - { - return new SendCommand() - { - GroupId = _group.GroupId.ToString(), - Command = type, - PositionTicks = _group.PositionTicks, - When = DateToUTCString(_group.LastActivity), - EmittedAt = DateToUTCString(DateTime.UtcNow) - }; - } - - /// <summary> - /// Builds a new group update message. - /// </summary> - /// <param name="type">The update type.</param> - /// <param name="data">The data to send.</param> - /// <value>The GroupUpdate.</value> - private GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data) - { - return new GroupUpdate<T>() - { - GroupId = _group.GroupId.ToString(), - Type = type, - Data = data - }; - } - - /// <inheritdoc /> - public void CreateGroup(SessionInfo session, CancellationToken cancellationToken) - { - _group.AddSession(session); - _syncPlayManager.AddSessionToGroup(session, this); - - _group.PlayingItem = session.FullNowPlayingItem; - _group.IsPaused = session.PlayState.IsPaused; - _group.PositionTicks = session.PlayState.PositionTicks ?? 0; - _group.LastActivity = DateTime.UtcNow; - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, DateToUTCString(DateTime.UtcNow)); - SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken); - } - - /// <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, BroadcastType.CurrentSession, updateSession, cancellationToken); - - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); - SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - - // Syncing will happen client-side - if (!_group.IsPaused) - { - var playCommand = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.CurrentSession, playCommand, cancellationToken); - } - else - { - var pauseCommand = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.CurrentSession, pauseCommand, cancellationToken); - } - } - else - { - var playRequest = new PlayRequest - { - ItemIds = new Guid[] { _group.PlayingItem.Id }, - StartPositionTicks = _group.PositionTicks - }; - var update = NewSyncPlayGroupUpdate(GroupUpdateType.PrepareSession, playRequest); - SendGroupUpdate(session, BroadcastType.CurrentSession, update, cancellationToken); - } - } - - /// <inheritdoc /> - public void SessionLeave(SessionInfo session, CancellationToken cancellationToken) - { - _group.RemoveSession(session); - _syncPlayManager.RemoveSessionFromGroup(session, this); - - var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, _group.PositionTicks); - SendGroupUpdate(session, BroadcastType.CurrentSession, updateSession, cancellationToken); - - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName); - SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - } - - /// <inheritdoc /> - public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - // The server's job is to maintain a consistent state for clients to reference - // and notify clients of state changes. The actual syncing of media playback - // happens client side. Clients are aware of the server's time and use it to sync. - switch (request.Type) - { - case PlaybackRequestType.Play: - HandlePlayRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Pause: - HandlePauseRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Seek: - HandleSeekRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Buffer: - HandleBufferingRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Ready: - HandleBufferingDoneRequest(session, request, cancellationToken); - break; - case PlaybackRequestType.Ping: - HandlePingUpdateRequest(session, request); - break; - } - } - - /// <summary> - /// Handles a play action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The play action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandlePlayRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (_group.IsPaused) - { - // Pick a suitable time that accounts for latency - var delay = Math.Max(_group.GetHighestPing() * 2, GroupInfo.DefaultPing); - - // Unpause group and set starting point in future - // Clients will start playback at LastActivity (datetime) from PositionTicks (playback position) - // The added delay does not guarantee, of course, that the command will be received in time - // Playback synchronization will mainly happen client side - _group.IsPaused = false; - _group.LastActivity = DateTime.UtcNow.AddMilliseconds( - delay); - - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - else - { - // Client got lost, sending current state - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// <summary> - /// Handles a pause action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The pause action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandlePauseRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (!_group.IsPaused) - { - // Pause group and compute the media playback position - _group.IsPaused = true; - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - _group.LastActivity; - _group.LastActivity = currentTime; - - // Seek only if playback actually started - // Pause request may be issued during the delay added to account for latency - _group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; - - var command = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - else - { - // Client got lost, sending current state - var command = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// <summary> - /// Handles a seek action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The seek action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandleSeekRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - // Sanitize PositionTicks - var ticks = SanitizePositionTicks(request.PositionTicks); - - // Pause and seek - _group.IsPaused = true; - _group.PositionTicks = ticks; - _group.LastActivity = DateTime.UtcNow; - - var command = NewSyncPlayCommand(SendCommandType.Seek); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - - /// <summary> - /// Handles a buffering action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The buffering action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandleBufferingRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (!_group.IsPaused) - { - // Pause group and compute the media playback position - _group.IsPaused = true; - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - _group.LastActivity; - _group.LastActivity = currentTime; - _group.PositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; - - _group.SetBuffering(session, true); - - // Send pause command to all non-buffering sessions - var command = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.AllReady, command, cancellationToken); - - var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.GroupWait, session.UserName); - SendGroupUpdate(session, BroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); - } - else - { - // Client got lost, sending current state - var command = NewSyncPlayCommand(SendCommandType.Pause); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// <summary> - /// Handles a buffering-done action requested by a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The buffering-done action.</param> - /// <param name="cancellationToken">The cancellation token.</param> - private void HandleBufferingDoneRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) - { - if (_group.IsPaused) - { - _group.SetBuffering(session, false); - - var requestTicks = SanitizePositionTicks(request.PositionTicks); - - var when = request.When ?? DateTime.UtcNow; - var currentTime = DateTime.UtcNow; - var elapsedTime = currentTime - when; - var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime; - var delay = _group.PositionTicks - clientPosition.Ticks; - - if (_group.IsBuffering()) - { - // Others are still buffering, tell this client to pause when ready - var command = NewSyncPlayCommand(SendCommandType.Pause); - var pauseAtTime = currentTime.AddMilliseconds(delay); - command.When = DateToUTCString(pauseAtTime); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - else - { - // Let other clients resume as soon as the buffering client catches up - _group.IsPaused = false; - - if (delay > _group.GetHighestPing() * 2) - { - // Client that was buffering is recovering, notifying others to resume - _group.LastActivity = currentTime.AddMilliseconds( - delay); - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.AllExceptCurrentSession, command, cancellationToken); - } - else - { - // Client, that was buffering, resumed playback but did not update others in time - delay = Math.Max(_group.GetHighestPing() * 2, GroupInfo.DefaultPing); - - _group.LastActivity = currentTime.AddMilliseconds( - delay); - - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.AllGroup, command, cancellationToken); - } - } - } - else - { - // Group was not waiting, make sure client has latest state - var command = NewSyncPlayCommand(SendCommandType.Play); - SendCommand(session, BroadcastType.CurrentSession, command, cancellationToken); - } - } - - /// <summary> - /// Sanitizes the PositionTicks, considers the current playing item when available. - /// </summary> - /// <param name="positionTicks">The PositionTicks.</param> - /// <value>The sanitized PositionTicks.</value> - private long SanitizePositionTicks(long? positionTicks) - { - var ticks = positionTicks ?? 0; - ticks = ticks >= 0 ? ticks : 0; - if (_group.PlayingItem != null) - { - var runTimeTicks = _group.PlayingItem.RunTimeTicks ?? 0; - ticks = ticks > runTimeTicks ? runTimeTicks : ticks; - } - - return ticks; - } - - /// <summary> - /// Updates ping of a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="request">The update.</param> - private void HandlePingUpdateRequest(SessionInfo session, PlaybackRequest request) - { - // Collected pings are used to account for network latency when unpausing playback - _group.UpdatePing(session, request.Ping ?? GroupInfo.DefaultPing); - } - - /// <inheritdoc /> - public GroupInfoView GetInfo() - { - return new GroupInfoView() - { - GroupId = GetGroupId().ToString(), - PlayingItemName = _group.PlayingItem.Name, - PlayingItemId = _group.PlayingItem.Id.ToString(), - PositionTicks = _group.PositionTicks, - Participants = _group.Participants.Values.Select(session => session.Session.UserName).Distinct().ToList() - }; - } - } -} diff --git a/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs b/Emby.Server.Implementations/SyncPlay/SyncPlayManager.cs index 7c4e00311..be94c3982 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; @@ -24,6 +22,11 @@ namespace Emby.Server.Implementations.SyncPlay private readonly ILogger<SyncPlayManager> _logger; /// <summary> + /// The logger factory. + /// </summary> + private readonly ILoggerFactory _loggerFactory; + + /// <summary> /// The user manager. /// </summary> private readonly IUserManager _userManager; @@ -41,50 +44,48 @@ 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, IGroupController> _sessionToGroupMap = + new Dictionary<string, IGroupController>(StringComparer.OrdinalIgnoreCase); /// <summary> /// The groups. /// </summary> - private readonly Dictionary<Guid, ISyncPlayController> _groups = - new Dictionary<Guid, ISyncPlayController>(); + private readonly Dictionary<Guid, IGroupController> _groups = + new Dictionary<Guid, IGroupController>(); /// <summary> /// Lock used for accessing any group. /// </summary> private readonly object _groupsLock = new object(); + /// <summary> + /// Lock used for accessing the session-to-group map. + /// </summary> + private readonly object _mapsLock = new object(); + private bool _disposed = false; /// <summary> /// Initializes a new instance of the <see cref="SyncPlayManager" /> class. /// </summary> - /// <param name="logger">The logger.</param> + /// <param name="loggerFactory">The logger factory.</param> /// <param name="userManager">The user manager.</param> /// <param name="sessionManager">The session manager.</param> /// <param name="libraryManager">The library manager.</param> public SyncPlayManager( - ILogger<SyncPlayManager> logger, + ILoggerFactory loggerFactory, IUserManager userManager, ISessionManager sessionManager, ILibraryManager libraryManager) { - _logger = logger; + _loggerFactory = loggerFactory; _userManager = userManager; _sessionManager = sessionManager; _libraryManager = libraryManager; - - _sessionManager.SessionEnded += OnSessionManagerSessionEnded; - _sessionManager.PlaybackStopped += OnSessionManagerPlaybackStopped; + _logger = loggerFactory.CreateLogger<SyncPlayManager>(); + _sessionManager.SessionStarted += OnSessionManagerSessionStarted; } - /// <summary> - /// Gets all groups. - /// </summary> - /// <value>All groups.</value> - public IEnumerable<ISyncPlayController> Groups => _groups.Values; - /// <inheritdoc /> public void Dispose() { @@ -92,287 +93,363 @@ namespace Emby.Server.Implementations.SyncPlay GC.SuppressFinalize(this); } - /// <summary> - /// Releases unmanaged and optionally managed resources. - /// </summary> - /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool disposing) + /// <inheritdoc /> + public void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) { - if (_disposed) + // TODO: create abstract class for GroupRequests to avoid explicit request type here. + if (!IsRequestValid(session, GroupRequestType.NewGroup, request)) { return; } - _sessionManager.SessionEnded -= OnSessionManagerSessionEnded; - _sessionManager.PlaybackStopped -= OnSessionManagerPlaybackStopped; + // Locking required to access list of groups. + lock (_groupsLock) + { + // Locking required as session-to-group map will be edited. + // Locking the group is not required as it is not visible yet. + lock (_mapsLock) + { + if (IsSessionInGroup(session)) + { + LeaveGroup(session, cancellationToken); + } - _disposed = true; - } + var group = new GroupController(_loggerFactory, _userManager, _sessionManager, _libraryManager); + _groups[group.GroupId] = group; - private void OnSessionManagerSessionEnded(object sender, SessionEventArgs e) - { - var session = e.SessionInfo; - if (!IsSessionInGroup(session)) - { - return; + AddSessionToGroup(session, group); + group.CreateGroup(session, request, cancellationToken); + } } - - LeaveGroup(session, CancellationToken.None); } - private void OnSessionManagerPlaybackStopped(object sender, PlaybackStopEventArgs e) + /// <inheritdoc /> + public void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken) { - var session = e.Session; - if (!IsSessionInGroup(session)) + // TODO: create abstract class for GroupRequests to avoid explicit request type here. + if (!IsRequestValid(session, GroupRequestType.JoinGroup, request)) { return; } - LeaveGroup(session, CancellationToken.None); - } - - private bool IsSessionInGroup(SessionInfo session) - { - return _sessionToGroupMap.ContainsKey(session.Id); - } - - private bool HasAccessToItem(User user, Guid itemId) - { - var item = _libraryManager.GetItemById(itemId); - - // Check ParentalRating access - var hasParentalRatingAccess = !user.MaxParentalAgeRating.HasValue - || item.InheritedParentalRatingValue <= user.MaxParentalAgeRating; + var user = _userManager.GetUserById(session.UserId); - if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess) + // Locking required to access list of groups. + lock (_groupsLock) { - var collections = _libraryManager.GetCollectionFolders(item).Select( - folder => folder.Id.ToString("N", CultureInfo.InvariantCulture)); + _groups.TryGetValue(groupId, out IGroupController group); - return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any(); - } + if (group == null) + { + _logger.LogWarning("Session {SessionId} tried to join group {GroupId} that does not exist.", session.Id, groupId); - return hasParentalRatingAccess; - } + var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.GroupDoesNotExist, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + return; + } - private Guid? GetSessionGroup(SessionInfo session) - { - _sessionToGroupMap.TryGetValue(session.Id, out var group); - return group?.GetGroupId(); + // Locking required as session-to-group map will be edited. + lock (_mapsLock) + { + // Group lock required to let other requests end first. + lock (group) + { + if (!group.HasAccessToPlayQueue(user)) + { + _logger.LogWarning("Session {SessionId} tried to join group {GroupId} but does not have access to some content of the playing queue.", session.Id, group.GroupId.ToString()); + + var error = new GroupUpdate<string>(group.GroupId, GroupUpdateType.LibraryAccessDenied, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + return; + } + + if (IsSessionInGroup(session)) + { + if (FindJoinedGroupId(session).Equals(groupId)) + { + group.SessionRestore(session, request, cancellationToken); + return; + } + + LeaveGroup(session, cancellationToken); + } + + AddSessionToGroup(session, group); + group.SessionJoin(session, request, cancellationToken); + } + } + } } /// <inheritdoc /> - public void NewGroup(SessionInfo session, CancellationToken cancellationToken) + public void LeaveGroup(SessionInfo session, CancellationToken cancellationToken) { - var user = _userManager.GetUserById(session.UserId); - - if (user.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups) + // TODO: create abstract class for GroupRequests to avoid explicit request type here. + if (!IsRequestValid(session, GroupRequestType.LeaveGroup)) { - _logger.LogWarning("NewGroup: {0} does not have permission to create groups.", session.Id); - - var error = new GroupUpdate<string> - { - Type = GroupUpdateType.CreateGroupDenied - }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); return; } + // Locking required to access list of groups. lock (_groupsLock) { - if (IsSessionInGroup(session)) + // Locking required as session-to-group map will be edited. + lock (_mapsLock) { - LeaveGroup(session, cancellationToken); - } + var group = FindJoinedGroup(session); + if (group == null) + { + _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id); - var group = new SyncPlayController(_sessionManager, this); - _groups[group.GetGroupId()] = group; + var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + return; + } - group.CreateGroup(session, cancellationToken); + // Group lock required to let other requests end first. + lock (group) + { + RemoveSessionFromGroup(session, group); + group.SessionLeave(session, cancellationToken); + + if (group.IsGroupEmpty()) + { + _logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId); + _groups.Remove(group.GroupId, out _); + } + } + } } } /// <inheritdoc /> - public void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken) + public List<GroupInfoDto> ListGroups(SessionInfo session) { - var user = _userManager.GetUserById(session.UserId); - - if (user.SyncPlayAccess == SyncPlayAccess.None) + // TODO: create abstract class for GroupRequests to avoid explicit request type here. + if (!IsRequestValid(session, GroupRequestType.ListGroups)) { - _logger.LogWarning("JoinGroup: {0} does not have access to SyncPlay.", session.Id); - - var error = new GroupUpdate<string>() - { - Type = GroupUpdateType.JoinGroupDenied - }; - - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; + return new List<GroupInfoDto>(); } + var user = _userManager.GetUserById(session.UserId); + List<GroupInfoDto> list = new List<GroupInfoDto>(); + + // Locking required to access list of groups. lock (_groupsLock) { - ISyncPlayController group; - _groups.TryGetValue(groupId, out group); - - if (group == null) - { - _logger.LogWarning("JoinGroup: {0} tried to join group {0} that does not exist.", session.Id, groupId); - - var error = new GroupUpdate<string>() - { - Type = GroupUpdateType.GroupDoesNotExist - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; - } - - if (!HasAccessToItem(user, group.GetPlayingItemId())) - { - _logger.LogWarning("JoinGroup: {0} does not have access to {1}.", session.Id, group.GetPlayingItemId()); - - var error = new GroupUpdate<string>() - { - GroupId = group.GetGroupId().ToString(), - Type = GroupUpdateType.LibraryAccessDenied - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; - } - - if (IsSessionInGroup(session)) + foreach (var group in _groups.Values) { - if (GetSessionGroup(session).Equals(groupId)) + // Locking required as group is not thread-safe. + lock (group) { - return; + if (group.HasAccessToPlayQueue(user)) + { + list.Add(group.GetInfo()); + } } - - LeaveGroup(session, cancellationToken); } - - group.SessionJoin(session, request, cancellationToken); } + + return list; } /// <inheritdoc /> - public void LeaveGroup(SessionInfo session, CancellationToken cancellationToken) + public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken) { - // TODO: determine what happens to users that are in a group and get their permissions revoked - lock (_groupsLock) + // TODO: create abstract class for GroupRequests to avoid explicit request type here. + if (!IsRequestValid(session, GroupRequestType.Playback, request)) { - _sessionToGroupMap.TryGetValue(session.Id, out var group); + return; + } - if (group == null) - { - _logger.LogWarning("LeaveGroup: {0} does not belong to any group.", session.Id); + var group = FindJoinedGroup(session); + if (group == null) + { + _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id); - var error = new GroupUpdate<string>() - { - Type = GroupUpdateType.NotInGroup - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; - } + var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + return; + } - group.SessionLeave(session, cancellationToken); + // Group lock required as GroupController is not thread-safe. + lock (group) + { + group.HandleRequest(session, request, cancellationToken); + } + } - if (group.IsGroupEmpty()) - { - _logger.LogInformation("LeaveGroup: removing empty group {0}.", group.GetGroupId()); - _groups.Remove(group.GetGroupId(), out _); - } + /// <summary> + /// Releases unmanaged and optionally managed resources. + /// </summary> + /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> + protected virtual void Dispose(bool disposing) + { + if (_disposed) + { + return; } + + _sessionManager.SessionStarted -= OnSessionManagerSessionStarted; + _disposed = true; } - /// <inheritdoc /> - public List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId) + private void OnSessionManagerSessionStarted(object sender, SessionEventArgs e) { - var user = _userManager.GetUserById(session.UserId); + var session = e.SessionInfo; - if (user.SyncPlayAccess == SyncPlayAccess.None) + Guid groupId = FindJoinedGroupId(session); + if (groupId.Equals(Guid.Empty)) { - return new List<GroupInfoView>(); + return; } - // Filter by item if requested - if (!filterItemId.Equals(Guid.Empty)) + var request = new JoinGroupRequest(groupId); + JoinGroup(session, groupId, request, CancellationToken.None); + } + + /// <summary> + /// Checks if a given session has joined a group. + /// </summary> + /// <param name="session">The session.</param> + /// <returns><c>true</c> if the session has joined a group, <c>false</c> otherwise.</returns> + private bool IsSessionInGroup(SessionInfo session) + { + lock (_mapsLock) { - return _groups.Values.Where( - group => group.GetPlayingItemId().Equals(filterItemId) && HasAccessToItem(user, group.GetPlayingItemId())).Select( - group => group.GetInfo()).ToList(); + return _sessionToGroupMap.ContainsKey(session.Id); } - else + } + + /// <summary> + /// Gets the group joined by the given session, if any. + /// </summary> + /// <param name="session">The session.</param> + /// <returns>The group.</returns> + private IGroupController FindJoinedGroup(SessionInfo session) + { + lock (_mapsLock) { - // Otherwise show all available groups - return _groups.Values.Where( - group => HasAccessToItem(user, group.GetPlayingItemId())).Select( - group => group.GetInfo()).ToList(); + _sessionToGroupMap.TryGetValue(session.Id, out var group); + return group; } } - /// <inheritdoc /> - public void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken) + /// <summary> + /// Gets the group identifier joined by the given session, if any. + /// </summary> + /// <param name="session">The session.</param> + /// <returns>The group identifier if the session has joined a group, an empty identifier otherwise.</returns> + private Guid FindJoinedGroupId(SessionInfo session) { - var user = _userManager.GetUserById(session.UserId); + return FindJoinedGroup(session)?.GroupId ?? Guid.Empty; + } - if (user.SyncPlayAccess == SyncPlayAccess.None) + /// <summary> + /// Maps a session to a group. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="group">The group.</param> + /// <exception cref="InvalidOperationException">Thrown when the user is in another group already.</exception> + private void AddSessionToGroup(SessionInfo session, IGroupController group) + { + if (session == null) { - _logger.LogWarning("HandleRequest: {0} does not have access to SyncPlay.", session.Id); + throw new InvalidOperationException("Session is null!"); + } - var error = new GroupUpdate<string>() + lock (_mapsLock) + { + if (IsSessionInGroup(session)) { - Type = GroupUpdateType.JoinGroupDenied - }; + throw new InvalidOperationException("Session in other group already!"); + } - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; + _sessionToGroupMap[session.Id] = group ?? throw new InvalidOperationException("Group is null!"); } + } - lock (_groupsLock) + /// <summary> + /// Unmaps a session from a group. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="group">The group.</param> + /// <exception cref="InvalidOperationException">Thrown when the user is not found in the specified group.</exception> + private void RemoveSessionFromGroup(SessionInfo session, IGroupController group) + { + if (session == null) { - _sessionToGroupMap.TryGetValue(session.Id, out var group); + throw new InvalidOperationException("Session is null!"); + } - if (group == null) - { - _logger.LogWarning("HandleRequest: {0} does not belong to any group.", session.Id); + if (group == null) + { + throw new InvalidOperationException("Group is null!"); + } - var error = new GroupUpdate<string>() - { - Type = GroupUpdateType.NotInGroup - }; - _sessionManager.SendSyncPlayGroupUpdate(session.Id, error, CancellationToken.None); - return; + lock (_mapsLock) + { + if (!IsSessionInGroup(session)) + { + throw new InvalidOperationException("Session not in any group!"); } - group.HandleRequest(session, request, cancellationToken); + _sessionToGroupMap.Remove(session.Id, out var tempGroup); + if (!tempGroup.GroupId.Equals(group.GroupId)) + { + throw new InvalidOperationException("Session was in wrong group!"); + } } } - /// <inheritdoc /> - public void AddSessionToGroup(SessionInfo session, ISyncPlayController group) + /// <summary> + /// Checks if a given session is allowed to make a given request. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="requestType">The request type.</param> + /// <param name="request">The request.</param> + /// <param name="checkRequest">Whether to check if request is null.</param> + /// <returns><c>true</c> if the request is valid, <c>false</c> otherwise. Will return <c>false</c> also when session is null.</returns> + private bool IsRequestValid<T>(SessionInfo session, GroupRequestType requestType, T request, bool checkRequest = true) { - if (IsSessionInGroup(session)) + if (session == null || (request == null && checkRequest)) { - throw new InvalidOperationException("Session in other group already!"); + return false; } - _sessionToGroupMap[session.Id] = group; - } + var user = _userManager.GetUserById(session.UserId); - /// <inheritdoc /> - public void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group) - { - if (!IsSessionInGroup(session)) + if (user.SyncPlayAccess == SyncPlayAccess.None) { - throw new InvalidOperationException("Session not in any group!"); + _logger.LogWarning("Session {SessionId} requested {RequestType} but does not have access to SyncPlay.", session.Id, requestType); + + // TODO: rename to a more generic error. Next PR will fix this. + var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.JoinGroupDenied, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + return false; } - _sessionToGroupMap.Remove(session.Id, out var tempGroup); - if (!tempGroup.GetGroupId().Equals(group.GetGroupId())) + if (requestType.Equals(GroupRequestType.NewGroup) && user.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups) { - throw new InvalidOperationException("Session was in wrong group!"); + _logger.LogWarning("Session {SessionId} does not have permission to create groups.", session.Id); + + var error = new GroupUpdate<string>(Guid.Empty, GroupUpdateType.CreateGroupDenied, string.Empty); + _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); + return false; } + + return true; + } + + /// <summary> + /// Checks if a given session is allowed to make a given type of request. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="requestType">The request type.</param> + /// <returns><c>true</c> if the request is valid, <c>false</c> otherwise. Will return <c>false</c> also when session is null.</returns> + private bool IsRequestValid(SessionInfo session, GroupRequestType requestType) + { + return IsRequestValid(session, requestType, session, false); } } } diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs index 346431e60..530cce1ea 100644 --- a/Jellyfin.Api/Controllers/SyncPlayController.cs +++ b/Jellyfin.Api/Controllers/SyncPlayController.cs @@ -7,6 +7,7 @@ using Jellyfin.Api.Helpers; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.SyncPlay; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; using MediaBrowser.Model.SyncPlay; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -43,14 +44,17 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Create a new SyncPlay group. /// </summary> + /// <param name="groupName">The name of the new group.</param> /// <response code="204">New group created.</response> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("New")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayCreateGroup() + public ActionResult SyncPlayCreateGroup( + [FromQuery, Required] string groupName) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - _syncPlayManager.NewGroup(currentSession, CancellationToken.None); + var newGroupRequest = new NewGroupRequest(groupName); + _syncPlayManager.NewGroup(currentSession, newGroupRequest, CancellationToken.None); return NoContent(); } @@ -62,15 +66,11 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Join")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayJoinGroup([FromQuery, Required] Guid groupId) + public ActionResult SyncPlayJoinGroup( + [FromQuery, Required] Guid groupId) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - - var joinRequest = new JoinGroupRequest() - { - GroupId = groupId - }; - + var joinRequest = new JoinGroupRequest(groupId); _syncPlayManager.JoinGroup(currentSession, groupId, joinRequest, CancellationToken.None); return NoContent(); } @@ -92,31 +92,120 @@ namespace Jellyfin.Api.Controllers /// <summary> /// Gets all SyncPlay groups. /// </summary> - /// <param name="filterItemId">Optional. Filter by item id.</param> /// <response code="200">Groups returned.</response> /// <returns>An <see cref="IEnumerable{GroupInfoView}"/> containing the available SyncPlay groups.</returns> [HttpGet("List")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult<IEnumerable<GroupInfoView>> SyncPlayGetGroups([FromQuery] Guid? filterItemId) + public ActionResult<IEnumerable<GroupInfoDto>> SyncPlayGetGroups() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - return Ok(_syncPlayManager.ListGroups(currentSession, filterItemId.HasValue ? filterItemId.Value : Guid.Empty)); + return Ok(_syncPlayManager.ListGroups(currentSession)); } /// <summary> /// Request play in SyncPlay group. /// </summary> + /// <param name="playingQueue">The playing queue. Item ids in the playing queue, comma delimited.</param> + /// <param name="playingItemPosition">The playing item position from the queue.</param> + /// <param name="startPositionTicks">The start position ticks.</param> /// <response code="204">Play request sent to all group members.</response> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Play")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayPlay() + public ActionResult SyncPlayPlay( + [FromQuery, Required] Guid[] playingQueue, + [FromQuery, Required] int playingItemPosition, + [FromQuery, Required] long startPositionTicks) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Play - }; + var syncPlayRequest = new PlayGroupRequest(playingQueue, playingItemPosition, startPositionTicks); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request to change playlist item in SyncPlay group. + /// </summary> + /// <param name="playlistItemId">The playlist id of the item.</param> + /// <response code="204">Queue update request sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetPlaylistItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlaySetPlaylistItem( + [FromQuery, Required] string playlistItemId) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new SetPlaylistItemGroupRequest(playlistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request to remove items from the playlist in SyncPlay group. + /// </summary> + /// <param name="playlistItemIds">The playlist ids of the items to remove.</param> + /// <response code="204">Queue update request sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("RemoveFromPlaylist")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayRemoveFromPlaylist( + [FromQuery, Required] string[] playlistItemIds) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new RemoveFromPlaylistGroupRequest(playlistItemIds); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request to move an item in the playlist in SyncPlay group. + /// </summary> + /// <param name="playlistItemId">The playlist id of the item to move.</param> + /// <param name="newIndex">The new position.</param> + /// <response code="204">Queue update request sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("MovePlaylistItem")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayMovePlaylistItem( + [FromQuery, Required] string playlistItemId, + [FromQuery, Required] int newIndex) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new MovePlaylistItemGroupRequest(playlistItemId, newIndex); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request to queue items to the playlist of a SyncPlay group. + /// </summary> + /// <param name="itemIds">The items to add.</param> + /// <param name="mode">The mode in which to enqueue the items.</param> + /// <response code="204">Queue update request sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Queue")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayQueue( + [FromQuery, Required] Guid[] itemIds, + [FromQuery, Required] GroupQueueMode mode) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new QueueGroupRequest(itemIds, mode); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request unpause in SyncPlay group. + /// </summary> + /// <response code="204">Unpause request sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Unpause")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayUnpause() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new UnpauseGroupRequest(); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -131,10 +220,22 @@ namespace Jellyfin.Api.Controllers public ActionResult SyncPlayPause() { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Pause - }; + var syncPlayRequest = new PauseGroupRequest(); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request stop in SyncPlay group. + /// </summary> + /// <response code="204">Stop request sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("Stop")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayStop() + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new StopGroupRequest(); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -147,14 +248,11 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Seek")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlaySeek([FromQuery] long positionTicks) + public ActionResult SyncPlaySeek( + [FromQuery, Required] long positionTicks) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Seek, - PositionTicks = positionTicks - }; + var syncPlayRequest = new SeekGroupRequest(positionTicks); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -164,20 +262,116 @@ namespace Jellyfin.Api.Controllers /// </summary> /// <param name="when">When the request has been made by the client.</param> /// <param name="positionTicks">The playback position in ticks.</param> + /// <param name="isPlaying">Whether the client's playback is playing or not.</param> + /// <param name="playlistItemId">The playlist item id.</param> /// <param name="bufferingDone">Whether the buffering is done.</param> /// <response code="204">Buffering request sent to all group members.</response> /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Buffering")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayBuffering([FromQuery] DateTime when, [FromQuery] long positionTicks, [FromQuery] bool bufferingDone) + public ActionResult SyncPlayBuffering( + [FromQuery, Required] DateTime when, + [FromQuery, Required] long positionTicks, + [FromQuery, Required] bool isPlaying, + [FromQuery, Required] string playlistItemId, + [FromQuery, Required] bool bufferingDone) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() + IGroupPlaybackRequest syncPlayRequest; + if (!bufferingDone) { - Type = bufferingDone ? PlaybackRequestType.Ready : PlaybackRequestType.Buffer, - When = when, - PositionTicks = positionTicks - }; + syncPlayRequest = new BufferGroupRequest(when, positionTicks, isPlaying, playlistItemId); + } + else + { + syncPlayRequest = new ReadyGroupRequest(when, positionTicks, isPlaying, playlistItemId); + } + + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request SyncPlay group to ignore member during group-wait. + /// </summary> + /// <param name="ignoreWait">Whether to ignore the member.</param> + /// <response code="204">Member state updated.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetIgnoreWait")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlaySetIgnoreWait( + [FromQuery, Required] bool ignoreWait) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new IgnoreWaitGroupRequest(ignoreWait); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request next track in SyncPlay group. + /// </summary> + /// <param name="playlistItemId">The playing item id.</param> + /// <response code="204">Next track request sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("NextTrack")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayNextTrack( + [FromQuery, Required] string playlistItemId) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new NextTrackGroupRequest(playlistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request previous track in SyncPlay group. + /// </summary> + /// <param name="playlistItemId">The playing item id.</param> + /// <response code="204">Previous track request sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("PreviousTrack")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlayPreviousTrack( + [FromQuery, Required] string playlistItemId) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new PreviousTrackGroupRequest(playlistItemId); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request to set repeat mode in SyncPlay group. + /// </summary> + /// <param name="mode">The repeat mode.</param> + /// <response code="204">Play queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetRepeatMode")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlaySetRepeatMode( + [FromQuery, Required] GroupRepeatMode mode) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new SetRepeatModeGroupRequest(mode); + _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); + return NoContent(); + } + + /// <summary> + /// Request to set shuffle mode in SyncPlay group. + /// </summary> + /// <param name="mode">The shuffle mode.</param> + /// <response code="204">Play queue update sent to all group members.</response> + /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> + [HttpPost("SetShuffleMode")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public ActionResult SyncPlaySetShuffleMode( + [FromQuery, Required] GroupShuffleMode mode) + { + var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); + var syncPlayRequest = new SetShuffleModeGroupRequest(mode); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } @@ -190,14 +384,11 @@ namespace Jellyfin.Api.Controllers /// <returns>A <see cref="NoContentResult"/> indicating success.</returns> [HttpPost("Ping")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public ActionResult SyncPlayPing([FromQuery] double ping) + public ActionResult SyncPlayPing( + [FromQuery, Required] double ping) { var currentSession = RequestHelpers.GetSession(_sessionManager, _authorizationContext, Request); - var syncPlayRequest = new PlaybackRequest() - { - Type = PlaybackRequestType.Ping, - Ping = Convert.ToInt64(ping) - }; + var syncPlayRequest = new PingGroupRequest(Convert.ToInt64(ping)); _syncPlayManager.HandleRequest(currentSession, syncPlayRequest, CancellationToken.None); return NoContent(); } diff --git a/Jellyfin.Api/Controllers/TimeSyncController.cs b/Jellyfin.Api/Controllers/TimeSyncController.cs index 27c7186fc..c730ac12b 100644 --- a/Jellyfin.Api/Controllers/TimeSyncController.cs +++ b/Jellyfin.Api/Controllers/TimeSyncController.cs @@ -13,7 +13,7 @@ namespace Jellyfin.Api.Controllers public class TimeSyncController : BaseJellyfinApiController { /// <summary> - /// Gets the current utc time. + /// Gets the current UTC time. /// </summary> /// <response code="200">Time returned.</response> /// <returns>An <see cref="UtcTimeResponse"/> to sync the client and server time.</returns> @@ -22,18 +22,14 @@ namespace Jellyfin.Api.Controllers public ActionResult<UtcTimeResponse> GetUtcTime() { // Important to keep the following line at the beginning - var requestReceptionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo); + var requestReceptionTime = DateTime.UtcNow.ToUniversalTime(); - var response = new UtcTimeResponse(); - response.RequestReceptionTime = requestReceptionTime; - - // Important to keep the following two lines at the end - var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime().ToString("o", DateTimeFormatInfo.InvariantInfo); - response.ResponseTransmissionTime = responseTransmissionTime; + // Important to keep the following line at the end + var responseTransmissionTime = DateTime.UtcNow.ToUniversalTime(); // Implementing NTP on such a high level results in this useless // information being sent. On the other hand it enables future additions. - return response; + return new UtcTimeResponse(requestReceptionTime, responseTransmissionTime); } } } diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index 04c3004ee..9ad8557ce 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -143,22 +143,22 @@ namespace MediaBrowser.Controller.Session Task SendPlayCommand(string controllingSessionId, string sessionId, PlayRequest command, CancellationToken cancellationToken); /// <summary> - /// Sends the SyncPlayCommand. + /// Sends a SyncPlayCommand to a session. /// </summary> - /// <param name="sessionId">The session id.</param> + /// <param name="session">The session.</param> /// <param name="command">The command.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - Task SendSyncPlayCommand(string sessionId, SendCommand command, CancellationToken cancellationToken); + Task SendSyncPlayCommand(SessionInfo session, SendCommand command, CancellationToken cancellationToken); /// <summary> - /// Sends the SyncPlayGroupUpdate. + /// Sends a SyncPlayGroupUpdate to a session. /// </summary> - /// <param name="sessionId">The session id.</param> + /// <param name="session">The session.</param> /// <param name="command">The group update.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> - Task SendSyncPlayGroupUpdate<T>(string sessionId, GroupUpdate<T> command, CancellationToken cancellationToken); + Task SendSyncPlayGroupUpdate<T>(SessionInfo session, GroupUpdate<T> command, CancellationToken cancellationToken); /// <summary> /// Sends the browse command. diff --git a/MediaBrowser.Controller/SyncPlay/GroupInfo.cs b/MediaBrowser.Controller/SyncPlay/GroupInfo.cs deleted file mode 100644 index a1cada25c..000000000 --- a/MediaBrowser.Controller/SyncPlay/GroupInfo.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System; -using System.Collections.Generic; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Session; - -namespace MediaBrowser.Controller.SyncPlay -{ - /// <summary> - /// Class GroupInfo. - /// </summary> - /// <remarks> - /// Class is not thread-safe, external locking is required when accessing methods. - /// </remarks> - public class GroupInfo - { - /// <summary> - /// The default ping value used for sessions. - /// </summary> - public const long DefaultPing = 500; - - /// <summary> - /// Gets the group identifier. - /// </summary> - /// <value>The group identifier.</value> - public Guid GroupId { get; } = Guid.NewGuid(); - - /// <summary> - /// Gets or sets the playing item. - /// </summary> - /// <value>The playing item.</value> - public BaseItem PlayingItem { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether playback is paused. - /// </summary> - /// <value>Playback is paused.</value> - public bool IsPaused { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether there are position ticks. - /// </summary> - /// <value>The position ticks.</value> - public long PositionTicks { get; set; } - - /// <summary> - /// Gets or sets the last activity. - /// </summary> - /// <value>The last activity.</value> - public DateTime LastActivity { get; set; } - - /// <summary> - /// Gets the participants. - /// </summary> - /// <value>The participants, or members of the group.</value> - public Dictionary<string, GroupMember> Participants { get; } = - new Dictionary<string, GroupMember>(StringComparer.OrdinalIgnoreCase); - - /// <summary> - /// Checks if a session is in this group. - /// </summary> - /// <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> - public bool ContainsSession(string sessionId) - { - return Participants.ContainsKey(sessionId); - } - - /// <summary> - /// Adds the session to the group. - /// </summary> - /// <param name="session">The session.</param> - public void AddSession(SessionInfo session) - { - 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> - public void RemoveSession(SessionInfo session) - { - Participants.Remove(session.Id); - } - - /// <summary> - /// Updates the ping of a session. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="ping">The ping.</param> - public void UpdatePing(SessionInfo session, long ping) - { - if (Participants.TryGetValue(session.Id, out GroupMember value)) - { - value.Ping = ping; - } - } - - /// <summary> - /// Gets the highest ping in the group. - /// </summary> - /// <returns>The highest ping in the group.</returns> - public long GetHighestPing() - { - long max = long.MinValue; - foreach (var session in Participants.Values) - { - max = Math.Max(max, session.Ping); - } - - return max; - } - - /// <summary> - /// Sets the session's buffering state. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="isBuffering">The state.</param> - public void SetBuffering(SessionInfo session, bool isBuffering) - { - if (Participants.TryGetValue(session.Id, out GroupMember value)) - { - value.IsBuffering = isBuffering; - } - } - - /// <summary> - /// Gets the group buffering state. - /// </summary> - /// <returns><c>true</c> if there is a session buffering in the group; <c>false</c> otherwise.</returns> - public bool IsBuffering() - { - foreach (var session in Participants.Values) - { - if (session.IsBuffering) - { - return true; - } - } - - return false; - } - - /// <summary> - /// Checks if the group is empty. - /// </summary> - /// <returns><c>true</c> if the group is empty; <c>false</c> otherwise.</returns> - public bool IsEmpty() - { - return Participants.Count == 0; - } - } -} diff --git a/MediaBrowser.Controller/SyncPlay/GroupMember.cs b/MediaBrowser.Controller/SyncPlay/GroupMember.cs index cde6f8e8c..5fb982e85 100644 --- a/MediaBrowser.Controller/SyncPlay/GroupMember.cs +++ b/MediaBrowser.Controller/SyncPlay/GroupMember.cs @@ -8,21 +8,36 @@ namespace MediaBrowser.Controller.SyncPlay public class GroupMember { /// <summary> - /// Gets or sets a value indicating whether this member is buffering. + /// Initializes a new instance of the <see cref="GroupMember"/> class. /// </summary> - /// <value><c>true</c> if member is buffering; <c>false</c> otherwise.</value> - public bool IsBuffering { get; set; } + /// <param name="session">The session.</param> + public GroupMember(SessionInfo session) + { + Session = session; + } /// <summary> - /// Gets or sets the session. + /// Gets the session. /// </summary> /// <value>The session.</value> - public SessionInfo Session { get; set; } + public SessionInfo Session { get; } /// <summary> - /// Gets or sets the ping. + /// Gets or sets the ping, in milliseconds. /// </summary> /// <value>The ping.</value> public long Ping { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this member is buffering. + /// </summary> + /// <value><c>true</c> if member is buffering; <c>false</c> otherwise.</value> + public bool IsBuffering { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether this member is following group playback. + /// </summary> + /// <value><c>true</c> to ignore member on group wait; <c>false</c> if they're following group playback.</value> + public bool IgnoreGroupWait { get; set; } } } diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs new file mode 100644 index 000000000..057488d6b --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/AbstractGroupState.cs @@ -0,0 +1,222 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// <summary> + /// Class AbstractGroupState. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public abstract class AbstractGroupState : IGroupState + { + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<AbstractGroupState> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="AbstractGroupState"/> class. + /// </summary> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + protected AbstractGroupState(ILoggerFactory loggerFactory) + { + LoggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger<AbstractGroupState>(); + } + + /// <inheritdoc /> + public abstract GroupStateType Type { get; } + + /// <summary> + /// Gets the logger factory. + /// </summary> + protected ILoggerFactory LoggerFactory { get; } + + /// <inheritdoc /> + public abstract void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <inheritdoc /> + public abstract void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType prevState, IGroupPlaybackRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType prevState, SetPlaylistItemGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType 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 && !context.PlayQueue.IsItemPlaying()) + { + _logger.LogDebug("Play queue in group {GroupId} is now empty.", context.GroupId.ToString()); + + IGroupState idleState = new IdleGroupState(LoggerFactory); + context.SetState(idleState); + var stopRequest = new StopGroupRequest(); + idleState.HandleRequest(context, Type, stopRequest, session, cancellationToken); + } + } + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType prevState, MovePlaylistItemGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + var result = context.MoveItemInPlayQueue(request.PlaylistItemId, request.NewIndex); + + if (!result) + { + _logger.LogError("Unable to move item in group {GroupId}.", context.GroupId.ToString()); + return; + } + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.MoveItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType prevState, QueueGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + var result = context.AddToPlayQueue(request.ItemIds, request.Mode); + + if (!result) + { + _logger.LogError("Unable to add items to play queue in group {GroupId}.", context.GroupId.ToString()); + return; + } + + var reason = request.Mode switch + { + GroupQueueMode.QueueNext => PlayQueueUpdateReason.QueueNext, + _ => PlayQueueUpdateReason.Queue + }; + var playQueueUpdate = context.GetPlayQueueUpdate(reason); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + UnhandledRequest(request); + } + + /// <inheritdoc /> + public virtual void HandleRequest(IGroupStateContext context, GroupStateType 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(IGroupStateContext context, GroupStateType 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(IGroupStateContext context, GroupStateType 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(IGroupStateContext context, GroupStateType prevState, IgnoreWaitGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + context.SetIgnoreGroupWait(session, request.IgnoreWait); + } + + /// <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(IGroupStateContext context, IGroupPlaybackRequest reason, SessionInfo session, CancellationToken cancellationToken) + { + // Notify relevant state change event. + var stateUpdate = new GroupStateUpdate(Type, reason.Type); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.StateUpdate, stateUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + } + + private void UnhandledRequest(IGroupPlaybackRequest request) + { + _logger.LogWarning("Unhandled request of type {RequestType} in {StateType} state.", request.Type, Type); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs new file mode 100644 index 000000000..7730a298c --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/IdleGroupState.cs @@ -0,0 +1,126 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// <summary> + /// Class IdleGroupState. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public class IdleGroupState : AbstractGroupState + { + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<IdleGroupState> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="IdleGroupState"/> class. + /// </summary> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + public IdleGroupState(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _logger = LoggerFactory.CreateLogger<IdleGroupState>(); + } + + /// <inheritdoc /> + public override GroupStateType Type { get; } = GroupStateType.Idle; + + /// <inheritdoc /> + public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Do nothing. + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + SendStopCommand(context, prevState, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + + private void SendStopCommand(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + var command = context.NewSyncPlayCommand(SendCommandType.Stop); + if (!prevState.Equals(Type)) + { + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + } + else + { + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs new file mode 100644 index 000000000..90411f61b --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/PausedGroupState.cs @@ -0,0 +1,165 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// <summary> + /// Class PausedGroupState. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public class PausedGroupState : AbstractGroupState + { + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<PausedGroupState> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="PausedGroupState"/> class. + /// </summary> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + public PausedGroupState(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _logger = LoggerFactory.CreateLogger<PausedGroupState>(); + } + + /// <inheritdoc /> + public override GroupStateType Type { get; } = GroupStateType.Paused; + + /// <inheritdoc /> + public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Wait for session to be ready. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.SessionJoined(context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Do nothing. + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var playingState = new PlayingGroupState(LoggerFactory); + context.SetState(playingState); + playingState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + if (!prevState.Equals(Type)) + { + // Pause group and compute the media playback position. + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - context.LastActivity; + context.LastActivity = currentTime; + // Elapsed time is negative if event happens + // during the delay added to account for latency. + // In this phase clients haven't started the playback yet. + // In other words, LastActivity is in the future, + // when playback unpause is supposed to happen. + // Seek only if playback actually started. + context.PositionTicks += Math.Max(elapsedTime.Ticks, 0); + + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + else + { + // Client got lost, sending current state. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var idleState = new IdleGroupState(LoggerFactory); + context.SetState(idleState); + idleState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + if (prevState.Equals(Type)) + { + // Client got lost, sending current state. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + else if (prevState.Equals(GroupStateType.Waiting)) + { + // Sending current state to all clients. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs new file mode 100644 index 000000000..aab87d9c3 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/PlayingGroupState.cs @@ -0,0 +1,168 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// <summary> + /// Class PlayingGroupState. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public class PlayingGroupState : AbstractGroupState + { + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<PlayingGroupState> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="PlayingGroupState"/> class. + /// </summary> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + public PlayingGroupState(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _logger = LoggerFactory.CreateLogger<PlayingGroupState>(); + } + + /// <inheritdoc /> + public override GroupStateType Type { get; } = GroupStateType.Playing; + + /// <summary> + /// Gets or sets a value indicating whether requests for buffering should be ignored. + /// </summary> + public bool IgnoreBuffering { get; set; } + + /// <inheritdoc /> + public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Wait for session to be ready. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.SessionJoined(context, Type, session, cancellationToken); + } + + /// <inheritdoc /> + public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Do nothing. + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + if (!prevState.Equals(Type)) + { + // Pick a suitable time that accounts for latency. + var delayMillis = Math.Max(context.GetHighestPing() * 2, context.DefaultPing); + + // Unpause group and set starting point in future. + // Clients will start playback at LastActivity (datetime) from PositionTicks (playback position). + // The added delay does not guarantee, of course, that the command will be received in time. + // Playback synchronization will mainly happen client side. + context.LastActivity = DateTime.UtcNow.AddMilliseconds(delayMillis); + + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + else + { + // Client got lost, sending current state. + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var pausedState = new PausedGroupState(LoggerFactory); + context.SetState(pausedState); + pausedState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var idleState = new IdleGroupState(LoggerFactory); + context.SetState(idleState); + idleState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + if (IgnoreBuffering) + { + return; + } + + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + if (prevState.Equals(Type)) + { + // Group was not waiting, make sure client has latest state. + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + else if (prevState.Equals(GroupStateType.Waiting)) + { + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Change state. + var waitingState = new WaitingGroupState(LoggerFactory); + context.SetState(waitingState); + waitingState.HandleRequest(context, Type, request, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs new file mode 100644 index 000000000..fefb8067f --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/GroupStates/WaitingGroupState.cs @@ -0,0 +1,680 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; +using Microsoft.Extensions.Logging; + +namespace MediaBrowser.Controller.SyncPlay.GroupStates +{ + /// <summary> + /// Class WaitingGroupState. + /// </summary> + /// <remarks> + /// Class is not thread-safe, external locking is required when accessing methods. + /// </remarks> + public class WaitingGroupState : AbstractGroupState + { + /// <summary> + /// The logger. + /// </summary> + private readonly ILogger<WaitingGroupState> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="WaitingGroupState"/> class. + /// </summary> + /// <param name="loggerFactory">Instance of the <see cref="ILoggerFactory"/> interface.</param> + public WaitingGroupState(ILoggerFactory loggerFactory) + : base(loggerFactory) + { + _logger = LoggerFactory.CreateLogger<WaitingGroupState>(); + } + + /// <inheritdoc /> + public override GroupStateType Type { get; } = GroupStateType.Waiting; + + /// <summary> + /// Gets or sets a value indicating whether playback should resume when group is ready. + /// </summary> + public bool ResumePlaying { get; set; } = false; + + /// <summary> + /// Gets or sets a value indicating whether the initial state has been set. + /// </summary> + private bool InitialStateSet { get; set; } = false; + + /// <summary> + /// Gets or sets the group state before the first ever event. + /// </summary> + private GroupStateType InitialState { get; set; } + + /// <inheritdoc /> + public override void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupStateType.Playing)) + { + ResumePlaying = true; + // Pause group and compute the media playback position. + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - context.LastActivity; + context.LastActivity = currentTime; + // Elapsed time is negative if event happens + // during the delay added to account for latency. + // In this phase clients haven't started the playback yet. + // In other words, LastActivity is in the future, + // when playback unpause is supposed to happen. + // Seek only if playback actually started. + context.PositionTicks += Math.Max(elapsedTime.Ticks, 0); + } + + // Prepare new session. + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken); + + context.SetBuffering(session, true); + + // Send pause command to all non-buffering sessions. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken); + } + + /// <inheritdoc /> + public override void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + context.SetBuffering(session, false); + + if (!context.IsBuffering()) + { + if (ResumePlaying) + { + _logger.LogDebug("Session {SessionId} left group {GroupId}, notifying others to resume.", session.Id, context.GroupId.ToString()); + + // Client, that was buffering, left the group. + var playingState = new PlayingGroupState(LoggerFactory); + context.SetState(playingState); + var unpauseRequest = new UnpauseGroupRequest(); + playingState.HandleRequest(context, Type, unpauseRequest, session, cancellationToken); + } + else + { + _logger.LogDebug("Session {SessionId} left group {GroupId}, returning to previous state.", session.Id, context.GroupId.ToString()); + + // Group is ready, returning to previous state. + var pausedState = new PausedGroupState(LoggerFactory); + context.SetState(pausedState); + } + } + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType 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("Unable to set playing queue in group {GroupId}.", context.GroupId.ToString()); + + // Ignore request and return to previous state. + IGroupState newState = prevState switch { + GroupStateType.Playing => new PlayingGroupState(LoggerFactory), + GroupStateType.Paused => new PausedGroupState(LoggerFactory), + _ => new IdleGroupState(LoggerFactory) + }; + + context.SetState(newState); + return; + } + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + + _logger.LogDebug("Session {SessionId} set a new play queue in group {GroupId}.", session.Id, context.GroupId.ToString()); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType 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. + context.SetAllBuffering(true); + } + else + { + // Return to old state. + IGroupState newState = prevState switch + { + GroupStateType.Playing => new PlayingGroupState(LoggerFactory), + GroupStateType.Paused => new PausedGroupState(LoggerFactory), + _ => new IdleGroupState(LoggerFactory) + }; + + context.SetState(newState); + + _logger.LogDebug("Unable to change current playing item in group {GroupId}.", context.GroupId.ToString()); + } + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupStateType.Idle)) + { + ResumePlaying = true; + context.RestartCurrentItem(); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.NewPlaylist); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.AllGroup, update, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + + _logger.LogDebug("Group {GroupId} is waiting for all ready events.", context.GroupId.ToString()); + } + else + { + if (ResumePlaying) + { + _logger.LogDebug("Forcing the playback to start in group {GroupId}. Group-wait is disabled until next state change.", context.GroupId.ToString()); + + // An Unpause request is forcing the playback to start, ignoring sessions that are not ready. + context.SetAllBuffering(false); + + // Change state. + var playingState = new PlayingGroupState(LoggerFactory) + { + IgnoreBuffering = true + }; + context.SetState(playingState); + playingState.HandleRequest(context, Type, 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(IGroupStateContext context, GroupStateType 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(IGroupStateContext context, GroupStateType prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + // Change state. + var idleState = new IdleGroupState(LoggerFactory); + context.SetState(idleState); + idleState.HandleRequest(context, Type, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + // Save state if first event. + if (!InitialStateSet) + { + InitialState = prevState; + InitialStateSet = true; + } + + if (prevState.Equals(GroupStateType.Playing)) + { + ResumePlaying = true; + } + else if (prevState.Equals(GroupStateType.Paused)) + { + ResumePlaying = false; + } + + // Sanitize PositionTicks. + var ticks = context.SanitizePositionTicks(request.PositionTicks); + + // Seek. + context.PositionTicks = ticks; + context.LastActivity = DateTime.UtcNow; + + var command = context.NewSyncPlayCommand(SendCommandType.Seek); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + // Reset status of sessions and await for all Ready events. + context.SetAllBuffering(true); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType 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(), StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Session {SessionId} reported wrong playlist item in group {GroupId}.", session.Id, context.GroupId.ToString()); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); + var updateSession = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); + context.SetBuffering(session, true); + + return; + } + + if (prevState.Equals(GroupStateType.Playing)) + { + // Resume playback when all ready. + ResumePlaying = true; + + context.SetBuffering(session, true); + + // Pause group and compute the media playback position. + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime - context.LastActivity; + context.LastActivity = currentTime; + // Elapsed time is negative if event happens + // during the delay added to account for latency. + // In this phase clients haven't started the playback yet. + // In other words, LastActivity is in the future, + // when playback unpause is supposed to happen. + // Seek only if playback actually started. + context.PositionTicks += Math.Max(elapsedTime.Ticks, 0); + + // Send pause command to all non-buffering sessions. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.AllReady, command, cancellationToken); + } + else if (prevState.Equals(GroupStateType.Paused)) + { + // Don't resume playback when all ready. + ResumePlaying = false; + + context.SetBuffering(session, true); + + // Send pause command to buffering session. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + else if (prevState.Equals(GroupStateType.Waiting)) + { + // Another session is now buffering. + context.SetBuffering(session, true); + + if (!ResumePlaying) + { + // Force update for this session that should be paused. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + } + } + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType 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(), StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Session {SessionId} reported wrong playlist item in group {GroupId}.", session.Id, context.GroupId.ToString()); + + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.SetCurrentItem); + var update = context.NewSyncPlayGroupUpdate(GroupUpdateType.PlayQueue, playQueueUpdate); + context.SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, update, cancellationToken); + context.SetBuffering(session, true); + + return; + } + + // Compute elapsed time between the client reported time and now. + // Elapsed time is used to estimate the client position when playback is unpaused. + // Ideally, the request is received and handled without major delays. + // However, to avoid waiting indefinitely when a client is not reporting a correct time, + // the elapsed time is ignored after a certain threshold. + var currentTime = DateTime.UtcNow; + var elapsedTime = currentTime.Subtract(request.When); + var timeSyncThresholdTicks = TimeSpan.FromMilliseconds(context.TimeSyncOffset).Ticks; + if (Math.Abs(elapsedTime.Ticks) > timeSyncThresholdTicks) + { + _logger.LogWarning("Session {SessionId} is not time syncing properly. Ignoring elapsed time.", session.Id); + + elapsedTime = TimeSpan.Zero; + } + + // Ignore elapsed time if client is paused. + if (!request.IsPlaying) + { + elapsedTime = TimeSpan.Zero; + } + + var requestTicks = context.SanitizePositionTicks(request.PositionTicks); + var clientPosition = TimeSpan.FromTicks(requestTicks) + elapsedTime; + var delayTicks = context.PositionTicks - clientPosition.Ticks; + var maxPlaybackOffsetTicks = TimeSpan.FromMilliseconds(context.MaxPlaybackOffset).Ticks; + + _logger.LogDebug("Session {SessionId} is at {PositionTicks} (delay of {Delay} seconds) in group {GroupId}.", session.Id, clientPosition, TimeSpan.FromTicks(delayTicks).TotalSeconds, context.GroupId.ToString()); + + if (ResumePlaying) + { + // Handle case where session reported as ready but in reality + // it has no clue of the real position nor the playback state. + if (!request.IsPlaying && Math.Abs(delayTicks) > maxPlaybackOffsetTicks) + { + // Session not ready at all. + context.SetBuffering(session, true); + + // Correcting session's position. + var command = context.NewSyncPlayCommand(SendCommandType.Seek); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + + _logger.LogWarning("Session {SessionId} got lost in time, correcting.", session.Id); + return; + } + + // Session is ready. + context.SetBuffering(session, false); + + if (context.IsBuffering()) + { + // Others are still buffering, tell this client to pause when ready. + var command = context.NewSyncPlayCommand(SendCommandType.Pause); + command.When = currentTime.AddTicks(delayTicks); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + + _logger.LogInformation("Session {SessionId} will pause when ready in {Delay} seconds. Group {GroupId} is waiting for all ready events.", session.Id, TimeSpan.FromTicks(delayTicks).TotalSeconds, context.GroupId.ToString()); + } + else + { + // If all ready, then start playback. + // Let other clients resume as soon as the buffering client catches up. + if (delayTicks > context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond) + { + // Client that was buffering is recovering, notifying others to resume. + context.LastActivity = currentTime.AddTicks(delayTicks); + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + var filter = SyncPlayBroadcastType.AllExceptCurrentSession; + if (!request.IsPlaying) + { + filter = SyncPlayBroadcastType.AllGroup; + } + + context.SendCommand(session, filter, command, cancellationToken); + + _logger.LogInformation("Session {SessionId} is recovering, group {GroupId} will resume in {Delay} seconds.", session.Id, context.GroupId.ToString(), TimeSpan.FromTicks(delayTicks).TotalSeconds); + } + else + { + // Client, that was buffering, resumed playback but did not update others in time. + delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond; + delayTicks = Math.Max(delayTicks, context.DefaultPing); + + context.LastActivity = currentTime.AddTicks(delayTicks); + + var command = context.NewSyncPlayCommand(SendCommandType.Unpause); + context.SendCommand(session, SyncPlayBroadcastType.AllGroup, command, cancellationToken); + + _logger.LogWarning("Session {SessionId} resumed playback, group {GroupId} has {Delay} seconds to recover.", session.Id, context.GroupId.ToString(), TimeSpan.FromTicks(delayTicks).TotalSeconds); + } + + // Change state. + var playingState = new PlayingGroupState(LoggerFactory); + context.SetState(playingState); + playingState.HandleRequest(context, Type, request, session, cancellationToken); + } + } + else + { + // Check that session is really ready, tolerate player imperfections under a certain threshold. + if (Math.Abs(context.PositionTicks - requestTicks) > maxPlaybackOffsetTicks) + { + // Session still not ready. + context.SetBuffering(session, true); + // Session is seeking to wrong position, correcting. + var command = context.NewSyncPlayCommand(SendCommandType.Seek); + context.SendCommand(session, SyncPlayBroadcastType.CurrentSession, command, cancellationToken); + + // Notify relevant state change event. + SendGroupStateUpdate(context, request, session, cancellationToken); + + _logger.LogWarning("Session {SessionId} is seeking to wrong position, correcting.", session.Id); + return; + } + else + { + // Session is ready. + context.SetBuffering(session, false); + } + + if (!context.IsBuffering()) + { + _logger.LogDebug("Session {SessionId} is ready, group {GroupId} is ready.", session.Id, context.GroupId.ToString()); + + // Group is ready, returning to previous state. + var pausedState = new PausedGroupState(LoggerFactory); + context.SetState(pausedState); + + if (InitialState.Equals(GroupStateType.Playing)) + { + // Group went from playing to waiting state and a pause request occured while waiting. + var pauserequest = new PauseGroupRequest(); + pausedState.HandleRequest(context, Type, pauserequest, session, cancellationToken); + } + else if (InitialState.Equals(GroupStateType.Paused)) + { + pausedState.HandleRequest(context, Type, request, session, cancellationToken); + } + } + } + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType 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(), StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Session {SessionId} provided the wrong playlist item for group {GroupId}.", session.Id, context.GroupId.ToString()); + return; + } + + var newItem = context.NextItemInQueue(); + if (newItem) + { + // Send playing-queue update. + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.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. + context.SetAllBuffering(true); + } + else + { + // Return to old state. + IGroupState newState = prevState switch + { + GroupStateType.Playing => new PlayingGroupState(LoggerFactory), + GroupStateType.Paused => new PausedGroupState(LoggerFactory), + _ => new IdleGroupState(LoggerFactory) + }; + + context.SetState(newState); + + _logger.LogDebug("No next track available in group {GroupId}.", context.GroupId.ToString()); + } + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType 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(), StringComparison.OrdinalIgnoreCase)) + { + _logger.LogDebug("Session {SessionId} provided the wrong playlist item for group {GroupId}.", session.Id, context.GroupId.ToString()); + return; + } + + var newItem = context.PreviousItemInQueue(); + if (newItem) + { + // Send playing-queue update. + var playQueueUpdate = context.GetPlayQueueUpdate(PlayQueueUpdateReason.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. + context.SetAllBuffering(true); + } + else + { + // Return to old state. + IGroupState newState = prevState switch + { + GroupStateType.Playing => new PlayingGroupState(LoggerFactory), + GroupStateType.Paused => new PausedGroupState(LoggerFactory), + _ => new IdleGroupState(LoggerFactory) + }; + + context.SetState(newState); + + _logger.LogDebug("No previous track available in group {GroupId}.", context.GroupId.ToString()); + } + } + + /// <inheritdoc /> + public override void HandleRequest(IGroupStateContext context, GroupStateType prevState, IgnoreWaitGroupRequest request, SessionInfo session, CancellationToken cancellationToken) + { + context.SetIgnoreGroupWait(session, request.IgnoreWait); + + if (!context.IsBuffering()) + { + _logger.LogDebug("Ignoring session {SessionId}, group {GroupId} is ready.", session.Id, context.GroupId.ToString()); + + if (ResumePlaying) + { + // Client, that was buffering, stopped following playback. + var playingState = new PlayingGroupState(LoggerFactory); + context.SetState(playingState); + var unpauseRequest = new UnpauseGroupRequest(); + playingState.HandleRequest(context, Type, unpauseRequest, session, cancellationToken); + } + else + { + // Group is ready, returning to previous state. + var pausedState = new PausedGroupState(LoggerFactory); + context.SetState(pausedState); + } + } + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs b/MediaBrowser.Controller/SyncPlay/IGroupController.cs index 60d17fe36..aa8bb9eae 100644 --- a/MediaBrowser.Controller/SyncPlay/ISyncPlayController.cs +++ b/MediaBrowser.Controller/SyncPlay/IGroupController.cs @@ -1,39 +1,42 @@ using System; using System.Threading; +using Jellyfin.Data.Entities; using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.Queue; using MediaBrowser.Model.SyncPlay; namespace MediaBrowser.Controller.SyncPlay { /// <summary> - /// Interface ISyncPlayController. + /// Interface IGroupController. /// </summary> - public interface ISyncPlayController + public interface IGroupController { /// <summary> - /// Gets the group id. + /// Gets the group identifier. /// </summary> - /// <value>The group id.</value> - Guid GetGroupId(); + /// <value>The group identifier.</value> + Guid GroupId { get; } /// <summary> - /// Gets the playing item id. + /// Gets the play queue. /// </summary> - /// <value>The playing item id.</value> - Guid GetPlayingItemId(); + /// <value>The play queue.</value> + PlayQueueManager PlayQueue { get; } /// <summary> /// Checks if the group is empty. /// </summary> - /// <value>If the group is empty.</value> + /// <returns><c>true</c> if the group is empty, <c>false</c> otherwise.</returns> bool IsGroupEmpty(); /// <summary> /// Initializes the group with the session's info. /// </summary> /// <param name="session">The session.</param> + /// <param name="request">The request.</param> /// <param name="cancellationToken">The cancellation token.</param> - void CreateGroup(SessionInfo session, CancellationToken cancellationToken); + void CreateGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken); /// <summary> /// Adds the session to the group. @@ -44,6 +47,14 @@ namespace MediaBrowser.Controller.SyncPlay void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken); /// <summary> + /// Restores the state of a session that already joined the group. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="request">The request.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void SessionRestore(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken); + + /// <summary> /// Removes the session from the group. /// </summary> /// <param name="session">The session.</param> @@ -56,12 +67,19 @@ namespace MediaBrowser.Controller.SyncPlay /// <param name="session">The session.</param> /// <param name="request">The requested action.</param> /// <param name="cancellationToken">The cancellation token.</param> - void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken); + void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken); /// <summary> /// Gets the info about the group for the clients. /// </summary> - /// <value>The group info for the clients.</value> - GroupInfoView GetInfo(); + /// <returns>The group info for the clients.</returns> + GroupInfoDto GetInfo(); + + /// <summary> + /// Checks if a user has access to all content in the play queue. + /// </summary> + /// <param name="user">The user.</param> + /// <returns><c>true</c> if the user can access the play queue; <c>false</c> otherwise.</returns> + bool HasAccessToPlayQueue(User user); } } diff --git a/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs b/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs new file mode 100644 index 000000000..3b195e98c --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/IGroupPlaybackRequest.cs @@ -0,0 +1,27 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// <summary> + /// Interface IGroupPlaybackRequest. + /// </summary> + public interface IGroupPlaybackRequest + { + /// <summary> + /// Gets the playback request type. + /// </summary> + /// <returns>The playback request type.</returns> + PlaybackRequestType Type { get; } + + /// <summary> + /// Applies the request to a group. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="state">The current state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Controller/SyncPlay/IGroupState.cs b/MediaBrowser.Controller/SyncPlay/IGroupState.cs new file mode 100644 index 000000000..0028823b4 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/IGroupState.cs @@ -0,0 +1,217 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.PlaybackRequests; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// <summary> + /// Interface IGroupState. + /// </summary> + public interface IGroupState + { + /// <summary> + /// Gets the group state type. + /// </summary> + /// <value>The group state type.</value> + GroupStateType Type { get; } + + /// <summary> + /// Handles a session that joined the group. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void SessionJoined(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a session that is leaving the group. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void SessionLeaving(IGroupStateContext context, GroupStateType prevState, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Generic handler. Context's state can change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The generic request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, IGroupPlaybackRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a play request from a session. Context's state can change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The play request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, PlayGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a set-playlist-item request from a session. Context's state can change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The set-playlist-item request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, SetPlaylistItemGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a remove-items request from a session. Context's state can change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The remove-items request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, RemoveFromPlaylistGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a move-playlist-item request from a session. Context's state should not change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The move-playlist-item request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, MovePlaylistItemGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a queue request from a session. Context's state should not change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The queue request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, QueueGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles an unpause request from a session. Context's state can change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The unpause request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, UnpauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a pause request from a session. Context's state can change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The pause request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, PauseGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a stop request from a session. Context's state can change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The stop request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, StopGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a seek request from a session. Context's state can change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The seek request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, SeekGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a buffer request from a session. Context's state can change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The buffer request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, BufferGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a ready request from a session. Context's state can change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The ready request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, ReadyGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a next-track request from a session. Context's state can change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The next-track request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, NextTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a previous-track request from a session. Context's state can change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The previous-track request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, PreviousTrackGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a set-repeat-mode request from a session. Context's state should not change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The repeat-mode request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, SetRepeatModeGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a set-shuffle-mode request from a session. Context's state should not change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The shuffle-mode request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, SetShuffleModeGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Updates the ping of a session. Context's state should not change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The ping request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, PingGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + + /// <summary> + /// Handles a ignore-wait request from a session. Context's state can change. + /// </summary> + /// <param name="context">The context of the state.</param> + /// <param name="prevState">The previous state.</param> + /// <param name="request">The ignore-wait request.</param> + /// <param name="session">The session.</param> + /// <param name="cancellationToken">The cancellation token.</param> + void HandleRequest(IGroupStateContext context, GroupStateType prevState, IgnoreWaitGroupRequest request, SessionInfo session, CancellationToken cancellationToken); + } +} diff --git a/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs new file mode 100644 index 000000000..13f1b2316 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/IGroupStateContext.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.SyncPlay.Queue; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay +{ + /// <summary> + /// Interface IGroupStateContext. + /// </summary> + public interface IGroupStateContext + { + /// <summary> + /// Gets the default ping value used for sessions, in milliseconds. + /// </summary> + /// <value>The default ping value used for sessions, in milliseconds.</value> + long DefaultPing { get; } + + /// <summary> + /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds. + /// </summary> + /// <value>The maximum offset error accepted, in milliseconds.</value> + long TimeSyncOffset { get; } + + /// <summary> + /// Gets the maximum offset error accepted for position reported by clients, in milliseconds. + /// </summary> + /// <value>The maximum offset error accepted, in milliseconds.</value> + long MaxPlaybackOffset { get; } + + /// <summary> + /// Gets the group identifier. + /// </summary> + /// <value>The group identifier.</value> + Guid GroupId { get; } + + /// <summary> + /// Gets or sets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + long PositionTicks { get; set; } + + /// <summary> + /// Gets or sets the last activity. + /// </summary> + /// <value>The last activity.</value> + DateTime LastActivity { get; set; } + + /// <summary> + /// Gets the play queue. + /// </summary> + /// <value>The play queue.</value> + PlayQueueManager PlayQueue { get; } + + /// <summary> + /// Sets a new state. + /// </summary> + /// <param name="state">The new state.</param> + void SetState(IGroupState state); + + /// <summary> + /// Sends a GroupUpdate message to the interested sessions. + /// </summary> + /// <typeparam name="T">The type of the data of the message.</typeparam> + /// <param name="from">The current session.</param> + /// <param name="type">The filtering type.</param> + /// <param name="message">The message to send.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The task.</returns> + Task SendGroupUpdate<T>(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate<T> message, CancellationToken cancellationToken); + + /// <summary> + /// Sends a playback command to the interested sessions. + /// </summary> + /// <param name="from">The current session.</param> + /// <param name="type">The filtering type.</param> + /// <param name="message">The message to send.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The task.</returns> + Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken); + + /// <summary> + /// Builds a new playback command with some default values. + /// </summary> + /// <param name="type">The command type.</param> + /// <returns>The command.</returns> + SendCommand NewSyncPlayCommand(SendCommandType type); + + /// <summary> + /// Builds a new group update message. + /// </summary> + /// <typeparam name="T">The type of the data of the message.</typeparam> + /// <param name="type">The update type.</param> + /// <param name="data">The data to send.</param> + /// <returns>The group update.</returns> + GroupUpdate<T> NewSyncPlayGroupUpdate<T>(GroupUpdateType type, T data); + + /// <summary> + /// Sanitizes the PositionTicks, considers the current playing item when available. + /// </summary> + /// <param name="positionTicks">The PositionTicks.</param> + /// <returns>The sanitized position ticks.</returns> + long SanitizePositionTicks(long? positionTicks); + + /// <summary> + /// Updates the ping of a session, in milliseconds. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="ping">The ping, in milliseconds.</param> + void UpdatePing(SessionInfo session, long ping); + + /// <summary> + /// Gets the highest ping in the group, in milliseconds. + /// </summary> + /// <returns>The highest ping in the group.</returns> + long GetHighestPing(); + + /// <summary> + /// Sets the session's buffering state. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="isBuffering">The state.</param> + void SetBuffering(SessionInfo session, bool isBuffering); + + /// <summary> + /// Sets the buffering state of all the sessions. + /// </summary> + /// <param name="isBuffering">The state.</param> + void SetAllBuffering(bool isBuffering); + + /// <summary> + /// Gets the group buffering state. + /// </summary> + /// <returns><c>true</c> if there is a session buffering in the group; <c>false</c> otherwise.</returns> + bool IsBuffering(); + + /// <summary> + /// Sets the session's group wait state. + /// </summary> + /// <param name="session">The session.</param> + /// <param name="ignoreGroupWait">The state.</param> + void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait); + + /// <summary> + /// Sets a new play queue. + /// </summary> + /// <param name="playQueue">The new play queue.</param> + /// <param name="playingItemPosition">The playing item position in the play queue.</param> + /// <param name="startPositionTicks">The start position ticks.</param> + /// <returns><c>true</c> if the play queue has been changed; <c>false</c> if something went wrong.</returns> + bool SetPlayQueue(IReadOnlyList<Guid> playQueue, int playingItemPosition, long startPositionTicks); + + /// <summary> + /// Sets the playing item. + /// </summary> + /// <param name="playlistItemId">The new playing item identifier.</param> + /// <returns><c>true</c> if the play queue has been changed; <c>false</c> if something went wrong.</returns> + bool SetPlayingItem(string playlistItemId); + + /// <summary> + /// Removes items from the play queue. + /// </summary> + /// <param name="playlistItemIds">The items to remove.</param> + /// <returns><c>true</c> if playing item got removed; <c>false</c> otherwise.</returns> + bool RemoveFromPlayQueue(IReadOnlyList<string> playlistItemIds); + + /// <summary> + /// Moves an item in the play queue. + /// </summary> + /// <param name="playlistItemId">The playlist identifier of the item to move.</param> + /// <param name="newIndex">The new position.</param> + /// <returns><c>true</c> if item has been moved; <c>false</c> if something went wrong.</returns> + bool MoveItemInPlayQueue(string playlistItemId, int newIndex); + + /// <summary> + /// Updates the play queue. + /// </summary> + /// <param name="newItems">The new items to add to the play queue.</param> + /// <param name="mode">The mode with which the items will be added.</param> + /// <returns><c>true</c> if the play queue has been changed; <c>false</c> if something went wrong.</returns> + bool AddToPlayQueue(IReadOnlyList<Guid> newItems, GroupQueueMode mode); + + /// <summary> + /// Restarts current item in play queue. + /// </summary> + void RestartCurrentItem(); + + /// <summary> + /// Picks next item in play queue. + /// </summary> + /// <returns><c>true</c> if the item changed; <c>false</c> otherwise.</returns> + bool NextItemInQueue(); + + /// <summary> + /// Picks previous item in play queue. + /// </summary> + /// <returns><c>true</c> if the item changed; <c>false</c> otherwise.</returns> + bool PreviousItemInQueue(); + + /// <summary> + /// Sets the repeat mode. + /// </summary> + /// <param name="mode">The new mode.</param> + void SetRepeatMode(GroupRepeatMode mode); + + /// <summary> + /// Sets the shuffle mode. + /// </summary> + /// <param name="mode">The new mode.</param> + void SetShuffleMode(GroupShuffleMode mode); + + /// <summary> + /// Creates a play queue update. + /// </summary> + /// <param name="reason">The reason for the update.</param> + /// <returns>The play queue update.</returns> + PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason); + } +} diff --git a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs index 006fb687b..a98001682 100644 --- a/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs +++ b/MediaBrowser.Controller/SyncPlay/ISyncPlayManager.cs @@ -15,14 +15,15 @@ namespace MediaBrowser.Controller.SyncPlay /// Creates a new group. /// </summary> /// <param name="session">The session that's creating the group.</param> + /// <param name="request">The request.</param> /// <param name="cancellationToken">The cancellation token.</param> - void NewGroup(SessionInfo session, CancellationToken cancellationToken); + void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken); /// <summary> /// Adds the session to a group. /// </summary> /// <param name="session">The session.</param> - /// <param name="groupId">The group id.</param> + /// <param name="groupId">The group identifier.</param> /// <param name="request">The request.</param> /// <param name="cancellationToken">The cancellation token.</param> void JoinGroup(SessionInfo session, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken); @@ -38,9 +39,8 @@ namespace MediaBrowser.Controller.SyncPlay /// Gets list of available groups for a session. /// </summary> /// <param name="session">The session.</param> - /// <param name="filterItemId">The item id to filter by.</param> - /// <value>The list of available groups.</value> - List<GroupInfoView> ListGroups(SessionInfo session, Guid filterItemId); + /// <returns>The list of available groups.</returns> + List<GroupInfoDto> ListGroups(SessionInfo session); /// <summary> /// Handle a request by a session in a group. @@ -48,22 +48,6 @@ namespace MediaBrowser.Controller.SyncPlay /// <param name="session">The session.</param> /// <param name="request">The request.</param> /// <param name="cancellationToken">The cancellation token.</param> - void HandleRequest(SessionInfo session, PlaybackRequest request, CancellationToken cancellationToken); - - /// <summary> - /// Maps a session to a group. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="group">The group.</param> - /// <exception cref="InvalidOperationException"></exception> - void AddSessionToGroup(SessionInfo session, ISyncPlayController group); - - /// <summary> - /// Unmaps a session from a group. - /// </summary> - /// <param name="session">The session.</param> - /// <param name="group">The group.</param> - /// <exception cref="InvalidOperationException"></exception> - void RemoveSessionFromGroup(SessionInfo session, ISyncPlayController group); + void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs new file mode 100644 index 000000000..a12ab96b7 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/BufferGroupRequest.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class BufferGroupRequest. + /// </summary> + public class BufferGroupRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="BufferGroupRequest"/> class. + /// </summary> + /// <param name="when">When the request has been made, as reported by the client.</param> + /// <param name="positionTicks">The position ticks.</param> + /// <param name="isPlaying">Whether the client playback is unpaused.</param> + /// <param name="playlistItemId">The playlist item identifier of the playing item.</param> + public BufferGroupRequest(DateTime when, long positionTicks, bool isPlaying, string playlistItemId) + { + When = when; + PositionTicks = positionTicks; + IsPlaying = isPlaying; + PlaylistItemId = playlistItemId; + } + + /// <summary> + /// Gets when the request has been made by the client. + /// </summary> + /// <value>The date of the request.</value> + public DateTime When { get; } + + /// <summary> + /// Gets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; } + + /// <summary> + /// Gets a value indicating whether the client playback is unpaused. + /// </summary> + /// <value>The client playback status.</value> + public bool IsPlaying { get; } + + /// <summary> + /// Gets the playlist item identifier of the playing item. + /// </summary> + /// <value>The playlist item identifier.</value> + public string PlaylistItemId { get; } + + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.Buffer; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs new file mode 100644 index 000000000..25034cb10 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/IgnoreWaitGroupRequest.cs @@ -0,0 +1,36 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class IgnoreWaitGroupRequest. + /// </summary> + public class IgnoreWaitGroupRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="IgnoreWaitGroupRequest"/> class. + /// </summary> + /// <param name="ignoreWait">Whether the client should be ignored.</param> + public IgnoreWaitGroupRequest(bool ignoreWait) + { + IgnoreWait = ignoreWait; + } + + /// <summary> + /// Gets a value indicating whether the client should be ignored. + /// </summary> + /// <value>The client group-wait status.</value> + public bool IgnoreWait { get; } + + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.IgnoreWait; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs new file mode 100644 index 000000000..a12eff8b8 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/MovePlaylistItemGroupRequest.cs @@ -0,0 +1,44 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class MovePlaylistItemGroupRequest. + /// </summary> + public class MovePlaylistItemGroupRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="MovePlaylistItemGroupRequest"/> class. + /// </summary> + /// <param name="playlistItemId">The playlist identifier of the item.</param> + /// <param name="newIndex">The new position.</param> + public MovePlaylistItemGroupRequest(string playlistItemId, int newIndex) + { + PlaylistItemId = playlistItemId; + NewIndex = newIndex; + } + + /// <summary> + /// Gets the playlist identifier of the item. + /// </summary> + /// <value>The playlist identifier of the item.</value> + public string PlaylistItemId { get; } + + /// <summary> + /// Gets the new position. + /// </summary> + /// <value>The new position.</value> + public int NewIndex { get; } + + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.MovePlaylistItem; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextTrackGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextTrackGroupRequest.cs new file mode 100644 index 000000000..f87bbc556 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/NextTrackGroupRequest.cs @@ -0,0 +1,36 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class NextTrackGroupRequest. + /// </summary> + public class NextTrackGroupRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="NextTrackGroupRequest"/> class. + /// </summary> + /// <param name="playlistItemId">The playing item identifier.</param> + public NextTrackGroupRequest(string playlistItemId) + { + PlaylistItemId = playlistItemId; + } + + /// <summary> + /// Gets the playing item identifier. + /// </summary> + /// <value>The playing item identifier.</value> + public string PlaylistItemId { get; } + + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.NextTrack; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs new file mode 100644 index 000000000..0dcd1423f --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PauseGroupRequest.cs @@ -0,0 +1,21 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class PauseGroupRequest. + /// </summary> + public class PauseGroupRequest : IGroupPlaybackRequest + { + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.Pause; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs new file mode 100644 index 000000000..2528bb3e7 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PingGroupRequest.cs @@ -0,0 +1,36 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class PingGroupRequest. + /// </summary> + public class PingGroupRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="PingGroupRequest"/> class. + /// </summary> + /// <param name="ping">The ping time.</param> + public PingGroupRequest(long ping) + { + Ping = ping; + } + + /// <summary> + /// Gets the ping time. + /// </summary> + /// <value>The ping time.</value> + public long Ping { get; } + + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.Ping; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs new file mode 100644 index 000000000..7d27f6151 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PlayGroupRequest.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class PlayGroupRequest. + /// </summary> + public class PlayGroupRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="PlayGroupRequest"/> class. + /// </summary> + /// <param name="playingQueue">The playing queue.</param> + /// <param name="playingItemPosition">The playing item position.</param> + /// <param name="startPositionTicks">The start position ticks.</param> + public PlayGroupRequest(Guid[] playingQueue, int playingItemPosition, long startPositionTicks) + { + PlayingQueue = playingQueue ?? Array.Empty<Guid>(); + PlayingItemPosition = playingItemPosition; + StartPositionTicks = startPositionTicks; + } + + /// <summary> + /// Gets the playing queue. + /// </summary> + /// <value>The playing queue.</value> + public IReadOnlyList<Guid> PlayingQueue { get; } + + /// <summary> + /// Gets the position of the playing item in the queue. + /// </summary> + /// <value>The playing item position.</value> + public int PlayingItemPosition { get; } + + /// <summary> + /// Gets the start position ticks. + /// </summary> + /// <value>The start position ticks.</value> + public long StartPositionTicks { get; } + + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.Play; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousTrackGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousTrackGroupRequest.cs new file mode 100644 index 000000000..206fef331 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/PreviousTrackGroupRequest.cs @@ -0,0 +1,36 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class PreviousTrackGroupRequest. + /// </summary> + public class PreviousTrackGroupRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="PreviousTrackGroupRequest"/> class. + /// </summary> + /// <param name="playlistItemId">The playing item identifier.</param> + public PreviousTrackGroupRequest(string playlistItemId) + { + PlaylistItemId = playlistItemId; + } + + /// <summary> + /// Gets the playing item identifier. + /// </summary> + /// <value>The playing item identifier.</value> + public string PlaylistItemId { get; } + + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.PreviousTrack; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs new file mode 100644 index 000000000..106daecc8 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/QueueGroupRequest.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class QueueGroupRequest. + /// </summary> + public class QueueGroupRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="QueueGroupRequest"/> class. + /// </summary> + /// <param name="items">The items to add to the queue.</param> + /// <param name="mode">The enqueue mode.</param> + public QueueGroupRequest(Guid[] items, GroupQueueMode mode) + { + ItemIds = items ?? Array.Empty<Guid>(); + Mode = mode; + } + + /// <summary> + /// Gets the items to enqueue. + /// </summary> + /// <value>The items to enqueue.</value> + public IReadOnlyList<Guid> ItemIds { get; } + + /// <summary> + /// Gets the mode in which to add the new items. + /// </summary> + /// <value>The enqueue mode.</value> + public GroupQueueMode Mode { get; } + + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.Queue; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs new file mode 100644 index 000000000..a2b3553ce --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/ReadyGroupRequest.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class ReadyGroupRequest. + /// </summary> + public class ReadyGroupRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="ReadyGroupRequest"/> class. + /// </summary> + /// <param name="when">When the request has been made, as reported by the client.</param> + /// <param name="positionTicks">The position ticks.</param> + /// <param name="isPlaying">Whether the client playback is unpaused.</param> + /// <param name="playlistItemId">The playlist item identifier of the playing item.</param> + public ReadyGroupRequest(DateTime when, long positionTicks, bool isPlaying, string playlistItemId) + { + When = when; + PositionTicks = positionTicks; + IsPlaying = isPlaying; + PlaylistItemId = playlistItemId; + } + + /// <summary> + /// Gets when the request has been made by the client. + /// </summary> + /// <value>The date of the request.</value> + public DateTime When { get; } + + /// <summary> + /// Gets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; } + + /// <summary> + /// Gets a value indicating whether the client playback is unpaused. + /// </summary> + /// <value>The client playback status.</value> + public bool IsPlaying { get; } + + /// <summary> + /// Gets the playlist item identifier of the playing item. + /// </summary> + /// <value>The playlist item identifier.</value> + public string PlaylistItemId { get; } + + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.Ready; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs new file mode 100644 index 000000000..1e892d819 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/RemoveFromPlaylistGroupRequest.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class RemoveFromPlaylistGroupRequest. + /// </summary> + public class RemoveFromPlaylistGroupRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="RemoveFromPlaylistGroupRequest"/> class. + /// </summary> + /// <param name="items">The playlist ids of the items to remove.</param> + public RemoveFromPlaylistGroupRequest(string[] items) + { + PlaylistItemIds = items ?? Array.Empty<string>(); + } + + /// <summary> + /// Gets the playlist identifiers ot the items. + /// </summary> + /// <value>The playlist identifiers ot the items.</value> + public IReadOnlyList<string> PlaylistItemIds { get; } + + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.RemoveFromPlaylist; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs new file mode 100644 index 000000000..f7bfc1978 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SeekGroupRequest.cs @@ -0,0 +1,36 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class SeekGroupRequest. + /// </summary> + public class SeekGroupRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="SeekGroupRequest"/> class. + /// </summary> + /// <param name="positionTicks">The position ticks.</param> + public SeekGroupRequest(long positionTicks) + { + PositionTicks = positionTicks; + } + + /// <summary> + /// Gets the position ticks. + /// </summary> + /// <value>The position ticks.</value> + public long PositionTicks { get; } + + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.Seek; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs new file mode 100644 index 000000000..2ca33c1cc --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetPlaylistItemGroupRequest.cs @@ -0,0 +1,36 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class SetPlaylistItemGroupRequest. + /// </summary> + public class SetPlaylistItemGroupRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="SetPlaylistItemGroupRequest"/> class. + /// </summary> + /// <param name="playlistItemId">The playlist identifier of the item.</param> + public SetPlaylistItemGroupRequest(string playlistItemId) + { + PlaylistItemId = playlistItemId; + } + + /// <summary> + /// Gets the playlist identifier of the playing item. + /// </summary> + /// <value>The playlist identifier of the playing item.</value> + public string PlaylistItemId { get; } + + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.SetPlaylistItem; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs new file mode 100644 index 000000000..cd4505e4d --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetRepeatModeGroupRequest.cs @@ -0,0 +1,36 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class SetRepeatModeGroupRequest. + /// </summary> + public class SetRepeatModeGroupRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="SetRepeatModeGroupRequest"/> class. + /// </summary> + /// <param name="mode">The repeat mode.</param> + public SetRepeatModeGroupRequest(GroupRepeatMode mode) + { + Mode = mode; + } + + /// <summary> + /// Gets the repeat mode. + /// </summary> + /// <value>The repeat mode.</value> + public GroupRepeatMode Mode { get; } + + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.SetRepeatMode; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs new file mode 100644 index 000000000..4530a34c0 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/SetShuffleModeGroupRequest.cs @@ -0,0 +1,36 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class SetShuffleModeGroupRequest. + /// </summary> + public class SetShuffleModeGroupRequest : IGroupPlaybackRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="SetShuffleModeGroupRequest"/> class. + /// </summary> + /// <param name="mode">The shuffle mode.</param> + public SetShuffleModeGroupRequest(GroupShuffleMode mode) + { + Mode = mode; + } + + /// <summary> + /// Gets the shuffle mode. + /// </summary> + /// <value>The shuffle mode.</value> + public GroupShuffleMode Mode { get; } + + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.SetShuffleMode; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs new file mode 100644 index 000000000..ec01cd110 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/StopGroupRequest.cs @@ -0,0 +1,21 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class StopGroupRequest. + /// </summary> + public class StopGroupRequest : IGroupPlaybackRequest + { + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.Stop; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs new file mode 100644 index 000000000..bdf4fd476 --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/PlaybackRequests/UnpauseGroupRequest.cs @@ -0,0 +1,21 @@ +using System.Threading; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.PlaybackRequests +{ + /// <summary> + /// Class UnpauseGroupRequest. + /// </summary> + public class UnpauseGroupRequest : IGroupPlaybackRequest + { + /// <inheritdoc /> + public PlaybackRequestType Type { get; } = PlaybackRequestType.Unpause; + + /// <inheritdoc /> + public void Apply(IGroupStateContext context, IGroupState state, SessionInfo session, CancellationToken cancellationToken) + { + state.HandleRequest(context, state.Type, this, session, cancellationToken); + } + } +} diff --git a/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs new file mode 100644 index 000000000..bfb9d1e4c --- /dev/null +++ b/MediaBrowser.Controller/SyncPlay/Queue/PlayQueueManager.cs @@ -0,0 +1,593 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MediaBrowser.Model.SyncPlay; + +namespace MediaBrowser.Controller.SyncPlay.Queue +{ + /// <summary> + /// Class PlayQueueManager. + /// </summary> + public class PlayQueueManager + { + /// <summary> + /// Placeholder index for when no item is playing. + /// </summary> + /// <value>The no-playing item index.</value> + private const int NoPlayingItemIndex = -1; + + /// <summary> + /// Random number generator used to shuffle lists. + /// </summary> + /// <value>The random number generator.</value> + private readonly Random _randomNumberGenerator = new Random(); + + /// <summary> + /// Initializes a new instance of the <see cref="PlayQueueManager" /> class. + /// </summary> + public PlayQueueManager() + { + Reset(); + } + + /// <summary> + /// Gets the playing item index. + /// </summary> + /// <value>The playing item index.</value> + public int PlayingItemIndex { get; private set; } + + /// <summary> + /// Gets the last time the queue has been changed. + /// </summary> + /// <value>The last time the queue has been changed.</value> + public DateTime LastChange { get; private set; } + + /// <summary> + /// Gets the shuffle mode. + /// </summary> + /// <value>The shuffle mode.</value> + public GroupShuffleMode ShuffleMode { get; private set; } = GroupShuffleMode.Sorted; + + /// <summary> + /// Gets the repeat mode. + /// </summary> + /// <value>The repeat mode.</value> + public GroupRepeatMode RepeatMode { get; private set; } = GroupRepeatMode.RepeatNone; + + /// <summary> + /// Gets or sets the sorted playlist. + /// </summary> + /// <value>The sorted playlist, or play queue of the group.</value> + private List<QueueItem> SortedPlaylist { get; set; } = new List<QueueItem>(); + + /// <summary> + /// Gets or sets the shuffled playlist. + /// </summary> + /// <value>The shuffled playlist, or play queue of the group.</value> + private List<QueueItem> ShuffledPlaylist { get; set; } = new List<QueueItem>(); + + /// <summary> + /// Gets or sets the progressive identifier counter. + /// </summary> + /// <value>The progressive identifier.</value> + private int ProgressiveId { get; set; } + + /// <summary> + /// Checks if an item is playing. + /// </summary> + /// <returns><c>true</c> if an item is playing; <c>false</c> otherwise.</returns> + public bool IsItemPlaying() + { + return PlayingItemIndex != NoPlayingItemIndex; + } + + /// <summary> + /// Gets the current playlist considering the shuffle mode. + /// </summary> + /// <returns>The playlist.</returns> + public IReadOnlyList<QueueItem> GetPlaylist() + { + return GetPlaylistInternal(); + } + + /// <summary> + /// Sets a new playlist. Playing item is reset. + /// </summary> + /// <param name="items">The new items of the playlist.</param> + public void SetPlaylist(IReadOnlyList<Guid> items) + { + SortedPlaylist.Clear(); + ShuffledPlaylist.Clear(); + + SortedPlaylist = CreateQueueItemsFromArray(items); + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + ShuffledPlaylist = new List<QueueItem>(SortedPlaylist); + Shuffle(ShuffledPlaylist); + } + + PlayingItemIndex = NoPlayingItemIndex; + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Appends new items to the playlist. The specified order is mantained. + /// </summary> + /// <param name="items">The items to add to the playlist.</param> + public void Queue(IReadOnlyList<Guid> items) + { + var newItems = CreateQueueItemsFromArray(items); + + SortedPlaylist.AddRange(newItems); + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + ShuffledPlaylist.AddRange(newItems); + } + + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Shuffles the playlist. Shuffle mode is changed. The playlist gets re-shuffled if already shuffled. + /// </summary> + public void ShufflePlaylist() + { + if (PlayingItemIndex == NoPlayingItemIndex) + { + ShuffledPlaylist = new List<QueueItem>(SortedPlaylist); + Shuffle(ShuffledPlaylist); + } + else if (ShuffleMode.Equals(GroupShuffleMode.Sorted)) + { + // First time shuffle. + var playingItem = SortedPlaylist[PlayingItemIndex]; + ShuffledPlaylist = new List<QueueItem>(SortedPlaylist); + ShuffledPlaylist.RemoveAt(PlayingItemIndex); + Shuffle(ShuffledPlaylist); + ShuffledPlaylist.Insert(0, playingItem); + PlayingItemIndex = 0; + } + else + { + // Re-shuffle playlist. + var playingItem = ShuffledPlaylist[PlayingItemIndex]; + ShuffledPlaylist.RemoveAt(PlayingItemIndex); + Shuffle(ShuffledPlaylist); + ShuffledPlaylist.Insert(0, playingItem); + PlayingItemIndex = 0; + } + + ShuffleMode = GroupShuffleMode.Shuffle; + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Resets the playlist to sorted mode. Shuffle mode is changed. + /// </summary> + public void RestoreSortedPlaylist() + { + if (PlayingItemIndex != NoPlayingItemIndex) + { + var playingItem = ShuffledPlaylist[PlayingItemIndex]; + PlayingItemIndex = SortedPlaylist.IndexOf(playingItem); + } + + ShuffledPlaylist.Clear(); + + ShuffleMode = GroupShuffleMode.Sorted; + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Clears the playlist. Shuffle mode is preserved. + /// </summary> + /// <param name="clearPlayingItem">Whether to remove the playing item as well.</param> + public void ClearPlaylist(bool clearPlayingItem) + { + var playingItem = GetPlayingItem(); + SortedPlaylist.Clear(); + ShuffledPlaylist.Clear(); + LastChange = DateTime.UtcNow; + + if (!clearPlayingItem && playingItem != null) + { + SortedPlaylist.Add(playingItem); + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + ShuffledPlaylist.Add(playingItem); + } + + PlayingItemIndex = 0; + } + else + { + PlayingItemIndex = NoPlayingItemIndex; + } + } + + /// <summary> + /// Adds new items to the playlist right after the playing item. The specified order is mantained. + /// </summary> + /// <param name="items">The items to add to the playlist.</param> + public void QueueNext(IReadOnlyList<Guid> items) + { + var newItems = CreateQueueItemsFromArray(items); + + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + var playingItem = GetPlayingItem(); + var sortedPlayingItemIndex = SortedPlaylist.IndexOf(playingItem); + // Append items to sorted and shuffled playlist as they are. + SortedPlaylist.InsertRange(sortedPlayingItemIndex + 1, newItems); + ShuffledPlaylist.InsertRange(PlayingItemIndex + 1, newItems); + } + else + { + SortedPlaylist.InsertRange(PlayingItemIndex + 1, newItems); + } + + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Gets playlist identifier of the playing item, if any. + /// </summary> + /// <returns>The playlist identifier of the playing item.</returns> + public string GetPlayingItemPlaylistId() + { + var playingItem = GetPlayingItem(); + return playingItem?.PlaylistItemId; + } + + /// <summary> + /// Gets the playing item identifier, if any. + /// </summary> + /// <returns>The playing item identifier.</returns> + public Guid GetPlayingItemId() + { + var playingItem = GetPlayingItem(); + return playingItem?.ItemId ?? Guid.Empty; + } + + /// <summary> + /// Sets the playing item using its identifier. If not in the playlist, the playing item is reset. + /// </summary> + /// <param name="itemId">The new playing item identifier.</param> + public void SetPlayingItemById(Guid itemId) + { + var playlist = GetPlaylistInternal(); + PlayingItemIndex = playlist.FindIndex(item => item.ItemId.Equals(itemId)); + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Sets the playing item using its playlist identifier. If not in the playlist, the playing item is reset. + /// </summary> + /// <param name="playlistItemId">The new playing item identifier.</param> + /// <returns><c>true</c> if playing item has been set; <c>false</c> if item is not in the playlist.</returns> + public bool SetPlayingItemByPlaylistId(string playlistItemId) + { + var playlist = GetPlaylistInternal(); + PlayingItemIndex = playlist.FindIndex(item => item.PlaylistItemId.Equals(playlistItemId, StringComparison.OrdinalIgnoreCase)); + LastChange = DateTime.UtcNow; + + return PlayingItemIndex != NoPlayingItemIndex; + } + + /// <summary> + /// Sets the playing item using its position. If not in range, the playing item is reset. + /// </summary> + /// <param name="playlistIndex">The new playing item index.</param> + public void SetPlayingItemByIndex(int playlistIndex) + { + var playlist = GetPlaylistInternal(); + if (playlistIndex < 0 || playlistIndex > playlist.Count) + { + PlayingItemIndex = NoPlayingItemIndex; + } + else + { + PlayingItemIndex = playlistIndex; + } + + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Removes items from the playlist. If not removed, the playing item is preserved. + /// </summary> + /// <param name="playlistItemIds">The items to remove.</param> + /// <returns><c>true</c> if playing item got removed; <c>false</c> otherwise.</returns> + public bool RemoveFromPlaylist(IReadOnlyList<string> playlistItemIds) + { + var playingItem = GetPlayingItem(); + + SortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId)); + ShuffledPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId)); + + LastChange = DateTime.UtcNow; + + if (playingItem != null) + { + if (playlistItemIds.Contains(playingItem.PlaylistItemId)) + { + // Playing item has been removed, picking previous item. + PlayingItemIndex--; + if (PlayingItemIndex < 0) + { + // Was first element, picking next if available. + // Default to no playing item otherwise. + PlayingItemIndex = SortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex; + } + + return true; + } + else + { + // Restoring playing item. + SetPlayingItemByPlaylistId(playingItem.PlaylistItemId); + return false; + } + } + else + { + return false; + } + } + + /// <summary> + /// Moves an item in the playlist to another position. + /// </summary> + /// <param name="playlistItemId">The item to move.</param> + /// <param name="newIndex">The new position.</param> + /// <returns><c>true</c> if the item has been moved; <c>false</c> otherwise.</returns> + public bool MovePlaylistItem(string playlistItemId, int newIndex) + { + var playlist = GetPlaylistInternal(); + var playingItem = GetPlayingItem(); + + var oldIndex = playlist.FindIndex(item => item.PlaylistItemId.Equals(playlistItemId, StringComparison.OrdinalIgnoreCase)); + if (oldIndex < 0) + { + return false; + } + + var queueItem = playlist[oldIndex]; + playlist.RemoveAt(oldIndex); + newIndex = Math.Clamp(newIndex, 0, playlist.Count); + playlist.Insert(newIndex, queueItem); + + LastChange = DateTime.UtcNow; + PlayingItemIndex = playlist.IndexOf(playingItem); + return true; + } + + /// <summary> + /// Resets the playlist to its initial state. + /// </summary> + public void Reset() + { + ProgressiveId = 0; + SortedPlaylist.Clear(); + ShuffledPlaylist.Clear(); + PlayingItemIndex = NoPlayingItemIndex; + ShuffleMode = GroupShuffleMode.Sorted; + RepeatMode = GroupRepeatMode.RepeatNone; + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Sets the repeat mode. + /// </summary> + /// <param name="mode">The new mode.</param> + public void SetRepeatMode(GroupRepeatMode mode) + { + RepeatMode = mode; + LastChange = DateTime.UtcNow; + } + + /// <summary> + /// Sets the shuffle mode. + /// </summary> + /// <param name="mode">The new mode.</param> + public void SetShuffleMode(GroupShuffleMode mode) + { + if (mode.Equals(GroupShuffleMode.Shuffle)) + { + ShufflePlaylist(); + } + else + { + RestoreSortedPlaylist(); + } + } + + /// <summary> + /// Toggles the shuffle mode between sorted and shuffled. + /// </summary> + public void ToggleShuffleMode() + { + if (ShuffleMode.Equals(GroupShuffleMode.Sorted)) + { + ShufflePlaylist(); + } + else + { + RestoreSortedPlaylist(); + } + } + + /// <summary> + /// Gets the next item in the playlist considering repeat mode and shuffle mode. + /// </summary> + /// <returns>The next item in the playlist.</returns> + public QueueItem GetNextItemPlaylistId() + { + int newIndex; + var playlist = GetPlaylistInternal(); + + switch (RepeatMode) + { + case GroupRepeatMode.RepeatOne: + newIndex = PlayingItemIndex; + break; + case GroupRepeatMode.RepeatAll: + newIndex = PlayingItemIndex + 1; + if (newIndex >= playlist.Count) + { + newIndex = 0; + } + + break; + default: + newIndex = PlayingItemIndex + 1; + break; + } + + if (newIndex < 0 || newIndex >= playlist.Count) + { + return null; + } + + return playlist[newIndex]; + } + + /// <summary> + /// Sets the next item in the queue as playing item. + /// </summary> + /// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns> + public bool Next() + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatOne)) + { + LastChange = DateTime.UtcNow; + return true; + } + + PlayingItemIndex++; + if (PlayingItemIndex >= SortedPlaylist.Count) + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatAll)) + { + PlayingItemIndex = 0; + } + else + { + PlayingItemIndex = SortedPlaylist.Count - 1; + return false; + } + } + + LastChange = DateTime.UtcNow; + return true; + } + + /// <summary> + /// Sets the previous item in the queue as playing item. + /// </summary> + /// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns> + public bool Previous() + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatOne)) + { + LastChange = DateTime.UtcNow; + return true; + } + + PlayingItemIndex--; + if (PlayingItemIndex < 0) + { + if (RepeatMode.Equals(GroupRepeatMode.RepeatAll)) + { + PlayingItemIndex = SortedPlaylist.Count - 1; + } + else + { + PlayingItemIndex = 0; + return false; + } + } + + LastChange = DateTime.UtcNow; + return true; + } + + /// <summary> + /// Shuffles a given list. + /// </summary> + /// <param name="list">The list to shuffle.</param> + private void Shuffle<T>(IList<T> list) + { + int n = list.Count; + while (n > 1) + { + n--; + int k = _randomNumberGenerator.Next(n + 1); + T value = list[k]; + list[k] = list[n]; + list[n] = value; + } + } + + /// <summary> + /// Gets the next available identifier. + /// </summary> + /// <returns>The next available identifier.</returns> + private int GetNextProgressiveId() + { + return ProgressiveId++; + } + + /// <summary> + /// Creates a list from the array of items. Each item is given an unique playlist identifier. + /// </summary> + /// <returns>The list of queue items.</returns> + private List<QueueItem> CreateQueueItemsFromArray(IReadOnlyList<Guid> items) + { + var list = new List<QueueItem>(); + foreach (var item in items) + { + var queueItem = new QueueItem(item, "syncPlayItem" + GetNextProgressiveId()); + list.Add(queueItem); + } + + return list; + } + + /// <summary> + /// Gets the current playlist considering the shuffle mode. + /// </summary> + /// <returns>The playlist.</returns> + private List<QueueItem> GetPlaylistInternal() + { + if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + return ShuffledPlaylist; + } + else + { + return SortedPlaylist; + } + } + + /// <summary> + /// Gets the current playing item, depending on the shuffle mode. + /// </summary> + /// <returns>The playing item.</returns> + private QueueItem GetPlayingItem() + { + if (PlayingItemIndex == NoPlayingItemIndex) + { + return null; + } + else if (ShuffleMode.Equals(GroupShuffleMode.Shuffle)) + { + return ShuffledPlaylist[PlayingItemIndex]; + } + else + { + return SortedPlaylist[PlayingItemIndex]; + } + } + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs b/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs new file mode 100644 index 000000000..8c0960b83 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupInfoDto.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; + +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Class GroupInfoDto. + /// </summary> + public class GroupInfoDto + { + /// <summary> + /// Initializes a new instance of the <see cref="GroupInfoDto"/> class. + /// </summary> + /// <param name="groupId">The group identifier.</param> + /// <param name="groupName">The group name.</param> + /// <param name="state">The group state.</param> + /// <param name="participants">The participants.</param> + /// <param name="lastUpdatedAt">The date when this DTO has been created.</param> + public GroupInfoDto(Guid groupId, string groupName, GroupStateType state, IReadOnlyList<string> participants, DateTime lastUpdatedAt) + { + GroupId = groupId; + GroupName = groupName; + State = state; + Participants = participants; + LastUpdatedAt = lastUpdatedAt; + } + + /// <summary> + /// Gets the group identifier. + /// </summary> + /// <value>The group identifier.</value> + public Guid GroupId { get; } + + /// <summary> + /// Gets the group name. + /// </summary> + /// <value>The group name.</value> + public string GroupName { get; } + + /// <summary> + /// Gets the group state. + /// </summary> + /// <value>The group state.</value> + public GroupStateType State { get; } + + /// <summary> + /// Gets the participants. + /// </summary> + /// <value>The participants.</value> + public IReadOnlyList<string> Participants { get; } + + /// <summary> + /// Gets the date when this DTO has been created. + /// </summary> + /// <value>The date when this DTO has been created.</value> + public DateTime LastUpdatedAt { get; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupInfoView.cs b/MediaBrowser.Model/SyncPlay/GroupInfoView.cs deleted file mode 100644 index f4c685998..000000000 --- a/MediaBrowser.Model/SyncPlay/GroupInfoView.cs +++ /dev/null @@ -1,42 +0,0 @@ -#nullable disable - -using System.Collections.Generic; - -namespace MediaBrowser.Model.SyncPlay -{ - /// <summary> - /// Class GroupInfoView. - /// </summary> - public class GroupInfoView - { - /// <summary> - /// Gets or sets the group identifier. - /// </summary> - /// <value>The group identifier.</value> - public string GroupId { get; set; } - - /// <summary> - /// Gets or sets the playing item id. - /// </summary> - /// <value>The playing item id.</value> - public string PlayingItemId { get; set; } - - /// <summary> - /// Gets or sets the playing item name. - /// </summary> - /// <value>The playing item name.</value> - public string PlayingItemName { get; set; } - - /// <summary> - /// Gets or sets the position ticks. - /// </summary> - /// <value>The position ticks.</value> - public long PositionTicks { get; set; } - - /// <summary> - /// Gets or sets the participants. - /// </summary> - /// <value>The participants.</value> - public IReadOnlyList<string> Participants { get; set; } - } -} diff --git a/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs b/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs new file mode 100644 index 000000000..5c9c2627b --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupQueueMode.cs @@ -0,0 +1,18 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Enum GroupQueueMode. + /// </summary> + public enum GroupQueueMode + { + /// <summary> + /// Insert items at the end of the queue. + /// </summary> + Queue = 0, + + /// <summary> + /// Insert items after the currently playing item. + /// </summary> + QueueNext = 1 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs b/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs new file mode 100644 index 000000000..4895e57b7 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupRepeatMode.cs @@ -0,0 +1,23 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Enum GroupRepeatMode. + /// </summary> + public enum GroupRepeatMode + { + /// <summary> + /// Repeat one item only. + /// </summary> + RepeatOne = 0, + + /// <summary> + /// Cycle the playlist. + /// </summary> + RepeatAll = 1, + + /// <summary> + /// Do not repeat. + /// </summary> + RepeatNone = 2 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupRequestType.cs b/MediaBrowser.Model/SyncPlay/GroupRequestType.cs new file mode 100644 index 000000000..75c071236 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupRequestType.cs @@ -0,0 +1,33 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Enum GroupRequestType. + /// </summary> + public enum GroupRequestType + { + /// <summary> + /// A user is requesting to create a new group. + /// </summary> + NewGroup = 0, + + /// <summary> + /// A user is requesting to join a group. + /// </summary> + JoinGroup = 1, + + /// <summary> + /// A user is requesting to leave a group. + /// </summary> + LeaveGroup = 2, + + /// <summary> + /// A user is requesting the list of available groups. + /// </summary> + ListGroups = 3, + + /// <summary> + /// A user is sending a playback command to a group. + /// </summary> + Playback = 4 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs b/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs new file mode 100644 index 000000000..de860883c --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupShuffleMode.cs @@ -0,0 +1,18 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Enum GroupShuffleMode. + /// </summary> + public enum GroupShuffleMode + { + /// <summary> + /// Sorted playlist. + /// </summary> + Sorted = 0, + + /// <summary> + /// Shuffled playlist. + /// </summary> + Shuffle = 1 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupStateType.cs b/MediaBrowser.Model/SyncPlay/GroupStateType.cs new file mode 100644 index 000000000..7aa454f92 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupStateType.cs @@ -0,0 +1,28 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Enum GroupState. + /// </summary> + public enum GroupStateType + { + /// <summary> + /// The group is in idle state. No media is playing. + /// </summary> + Idle = 0, + + /// <summary> + /// The group is in wating state. Playback is paused. Will start playing when users are ready. + /// </summary> + Waiting = 1, + + /// <summary> + /// The group is in paused state. Playback is paused. Will resume on play command. + /// </summary> + Paused = 2, + + /// <summary> + /// The group is in playing state. Playback is advancing. + /// </summary> + Playing = 3 + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs new file mode 100644 index 000000000..7f7deb86b --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/GroupStateUpdate.cs @@ -0,0 +1,31 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Class GroupStateUpdate. + /// </summary> + public class GroupStateUpdate + { + /// <summary> + /// Initializes a new instance of the <see cref="GroupStateUpdate"/> class. + /// </summary> + /// <param name="state">The state of the group.</param> + /// <param name="reason">The reason of the state change.</param> + public GroupStateUpdate(GroupStateType state, PlaybackRequestType reason) + { + State = state; + Reason = reason; + } + + /// <summary> + /// Gets the state of the group. + /// </summary> + /// <value>The state of the group.</value> + public GroupStateType State { get; } + + /// <summary> + /// Gets the reason of the state change. + /// </summary> + /// <value>The reason of the state change.</value> + public PlaybackRequestType Reason { get; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs index 8c7208211..6f159d653 100644 --- a/MediaBrowser.Model/SyncPlay/GroupUpdate.cs +++ b/MediaBrowser.Model/SyncPlay/GroupUpdate.cs @@ -1,28 +1,42 @@ -#nullable disable +using System; namespace MediaBrowser.Model.SyncPlay { /// <summary> /// Class GroupUpdate. /// </summary> + /// <typeparam name="T">The type of the data of the message.</typeparam> public class GroupUpdate<T> { /// <summary> - /// Gets or sets the group identifier. + /// Initializes a new instance of the <see cref="GroupUpdate{T}"/> class. + /// </summary> + /// <param name="groupId">The group identifier.</param> + /// <param name="type">The update type.</param> + /// <param name="data">The update data.</param> + public GroupUpdate(Guid groupId, GroupUpdateType type, T data) + { + GroupId = groupId; + Type = type; + Data = data; + } + + /// <summary> + /// Gets the group identifier. /// </summary> /// <value>The group identifier.</value> - public string GroupId { get; set; } + public Guid GroupId { get; } /// <summary> - /// Gets or sets the update type. + /// Gets the update type. /// </summary> /// <value>The update type.</value> - public GroupUpdateType Type { get; set; } + public GroupUpdateType Type { get; } /// <summary> - /// Gets or sets the data. + /// Gets the update data. /// </summary> - /// <value>The data.</value> - public T Data { get; set; } + /// <value>The update data.</value> + public T Data { get; } } } diff --git a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs index c749f7b13..907d1defe 100644 --- a/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs +++ b/MediaBrowser.Model/SyncPlay/GroupUpdateType.cs @@ -26,14 +26,14 @@ namespace MediaBrowser.Model.SyncPlay GroupLeft, /// <summary> - /// The group-wait update. Tells members of the group that a user is buffering. + /// The group-state update. Tells members of the group that the state changed. /// </summary> - GroupWait, + StateUpdate, /// <summary> - /// The prepare-session update. Tells a user to load some content. + /// The play-queue update. Tells a user the playing queue of the group. /// </summary> - PrepareSession, + PlayQueue, /// <summary> /// The not-in-group error. Tells a user that they don't belong to a group. diff --git a/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs b/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs index 0c77a6132..7402c4ce2 100644 --- a/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs +++ b/MediaBrowser.Model/SyncPlay/JoinGroupRequest.cs @@ -8,9 +8,18 @@ namespace MediaBrowser.Model.SyncPlay public class JoinGroupRequest { /// <summary> - /// Gets or sets the Group id. + /// Initializes a new instance of the <see cref="JoinGroupRequest"/> class. /// </summary> - /// <value>The Group id to join.</value> - public Guid GroupId { get; set; } + /// <param name="groupId">The identifier of the group to join.</param> + public JoinGroupRequest(Guid groupId) + { + GroupId = groupId; + } + + /// <summary> + /// Gets the group identifier. + /// </summary> + /// <value>The identifier of the group to join.</value> + public Guid GroupId { get; } } } diff --git a/MediaBrowser.Model/SyncPlay/NewGroupRequest.cs b/MediaBrowser.Model/SyncPlay/NewGroupRequest.cs new file mode 100644 index 000000000..ba4bd3ef1 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/NewGroupRequest.cs @@ -0,0 +1,23 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Class NewGroupRequest. + /// </summary> + public class NewGroupRequest + { + /// <summary> + /// Initializes a new instance of the <see cref="NewGroupRequest"/> class. + /// </summary> + /// <param name="groupName">The name of the new group.</param> + public NewGroupRequest(string groupName) + { + GroupName = groupName; + } + + /// <summary> + /// Gets the group name. + /// </summary> + /// <value>The name of the new group.</value> + public string GroupName { get; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs new file mode 100644 index 000000000..a851229f7 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/PlayQueueUpdate.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; + +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Class PlayQueueUpdate. + /// </summary> + public class PlayQueueUpdate + { + /// <summary> + /// Initializes a new instance of the <see cref="PlayQueueUpdate"/> class. + /// </summary> + /// <param name="reason">The reason for the update.</param> + /// <param name="lastUpdate">The UTC time of the last change to the playing queue.</param> + /// <param name="playlist">The playlist.</param> + /// <param name="playingItemIndex">The playing item index in the playlist.</param> + /// <param name="startPositionTicks">The start position ticks.</param> + /// <param name="shuffleMode">The shuffle mode.</param> + /// <param name="repeatMode">The repeat mode.</param> + public PlayQueueUpdate(PlayQueueUpdateReason reason, DateTime lastUpdate, IReadOnlyList<QueueItem> playlist, int playingItemIndex, long startPositionTicks, GroupShuffleMode shuffleMode, GroupRepeatMode repeatMode) + { + Reason = reason; + LastUpdate = lastUpdate; + Playlist = playlist; + PlayingItemIndex = playingItemIndex; + StartPositionTicks = startPositionTicks; + ShuffleMode = shuffleMode; + RepeatMode = repeatMode; + } + + /// <summary> + /// Gets the request type that originated this update. + /// </summary> + /// <value>The reason for the update.</value> + public PlayQueueUpdateReason Reason { get; } + + /// <summary> + /// Gets the UTC time of the last change to the playing queue. + /// </summary> + /// <value>The UTC time of the last change to the playing queue.</value> + public DateTime LastUpdate { get; } + + /// <summary> + /// Gets the playlist. + /// </summary> + /// <value>The playlist.</value> + public IReadOnlyList<QueueItem> Playlist { get; } + + /// <summary> + /// Gets the playing item index in the playlist. + /// </summary> + /// <value>The playing item index in the playlist.</value> + public int PlayingItemIndex { get; } + + /// <summary> + /// Gets the start position ticks. + /// </summary> + /// <value>The start position ticks.</value> + public long StartPositionTicks { get; } + + /// <summary> + /// Gets the shuffle mode. + /// </summary> + /// <value>The shuffle mode.</value> + public GroupShuffleMode ShuffleMode { get; } + + /// <summary> + /// Gets the repeat mode. + /// </summary> + /// <value>The repeat mode.</value> + public GroupRepeatMode RepeatMode { get; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs b/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs new file mode 100644 index 000000000..e78940fe6 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/PlayQueueUpdateReason.cs @@ -0,0 +1,58 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Enum PlayQueueUpdateReason. + /// </summary> + public enum PlayQueueUpdateReason + { + /// <summary> + /// A user is requesting to play a new playlist. + /// </summary> + NewPlaylist = 0, + + /// <summary> + /// A user is changing the playing item. + /// </summary> + SetCurrentItem = 1, + + /// <summary> + /// A user is removing items from the playlist. + /// </summary> + RemoveItems = 2, + + /// <summary> + /// A user is moving an item in the playlist. + /// </summary> + MoveItem = 3, + + /// <summary> + /// A user is adding items the queue. + /// </summary> + Queue = 4, + + /// <summary> + /// A user is adding items to the queue, after the currently playing item. + /// </summary> + QueueNext = 5, + + /// <summary> + /// A user is requesting the next item in queue. + /// </summary> + NextTrack = 6, + + /// <summary> + /// A user is requesting the previous item in queue. + /// </summary> + PreviousTrack = 7, + + /// <summary> + /// A user is changing repeat mode. + /// </summary> + RepeatMode = 8, + + /// <summary> + /// A user is changing shuffle mode. + /// </summary> + ShuffleMode = 9 + } +} diff --git a/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs b/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs deleted file mode 100644 index 9de23194e..000000000 --- a/MediaBrowser.Model/SyncPlay/PlaybackRequest.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; - -namespace MediaBrowser.Model.SyncPlay -{ - /// <summary> - /// Class PlaybackRequest. - /// </summary> - public class PlaybackRequest - { - /// <summary> - /// Gets or sets the request type. - /// </summary> - /// <value>The request type.</value> - public PlaybackRequestType Type { get; set; } - - /// <summary> - /// Gets or sets when the request has been made by the client. - /// </summary> - /// <value>The date of the request.</value> - public DateTime? When { get; set; } - - /// <summary> - /// Gets or sets the position ticks. - /// </summary> - /// <value>The position ticks.</value> - public long? PositionTicks { get; set; } - - /// <summary> - /// Gets or sets the ping time. - /// </summary> - /// <value>The ping time.</value> - public long? Ping { get; set; } - } -} diff --git a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs index e89efeed8..4809dc44f 100644 --- a/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs +++ b/MediaBrowser.Model/SyncPlay/PlaybackRequestType.cs @@ -6,33 +6,88 @@ namespace MediaBrowser.Model.SyncPlay public enum PlaybackRequestType { /// <summary> - /// A user is requesting a play command for the group. + /// A user is setting a new playlist. /// </summary> Play = 0, /// <summary> + /// A user is changing the playlist item. + /// </summary> + SetPlaylistItem = 1, + + /// <summary> + /// A user is removing items from the playlist. + /// </summary> + RemoveFromPlaylist = 2, + + /// <summary> + /// A user is moving an item in the playlist. + /// </summary> + MovePlaylistItem = 3, + + /// <summary> + /// A user is adding items to the playlist. + /// </summary> + Queue = 4, + + /// <summary> + /// A user is requesting an unpause command for the group. + /// </summary> + Unpause = 5, + + /// <summary> /// A user is requesting a pause command for the group. /// </summary> - Pause = 1, + Pause = 6, /// <summary> - /// A user is requesting a seek command for the group. + /// A user is requesting a stop command for the group. /// </summary> - Seek = 2, + Stop = 7, /// <summary> + /// A user is requesting a seek command for the group. + /// </summary> + Seek = 8, + + /// <summary> /// A user is signaling that playback is buffering. /// </summary> - Buffer = 3, + Buffer = 9, /// <summary> /// A user is signaling that playback resumed. /// </summary> - Ready = 4, + Ready = 10, + + /// <summary> + /// A user is requesting next track in playlist. + /// </summary> + NextTrack = 11, + + /// <summary> + /// A user is requesting previous track in playlist. + /// </summary> + PreviousTrack = 12, + + /// <summary> + /// A user is setting the repeat mode. + /// </summary> + SetRepeatMode = 13, + + /// <summary> + /// A user is setting the shuffle mode. + /// </summary> + SetShuffleMode = 14, + + /// <summary> + /// A user is reporting their ping. + /// </summary> + Ping = 15, /// <summary> - /// A user is reporting its ping. + /// A user is requesting to be ignored on group wait. /// </summary> - Ping = 5 + IgnoreWait = 16 } } diff --git a/MediaBrowser.Model/SyncPlay/QueueItem.cs b/MediaBrowser.Model/SyncPlay/QueueItem.cs new file mode 100644 index 000000000..9c4d3a4ce --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/QueueItem.cs @@ -0,0 +1,33 @@ +using System; + +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Class QueueItem. + /// </summary> + public class QueueItem + { + /// <summary> + /// Initializes a new instance of the <see cref="QueueItem"/> class. + /// </summary> + /// <param name="itemId">The item identifier.</param> + /// <param name="playlistItemId">The playlist identifier of the item.</param> + public QueueItem(Guid itemId, string playlistItemId) + { + ItemId = itemId; + PlaylistItemId = playlistItemId; + } + + /// <summary> + /// Gets the item identifier. + /// </summary> + /// <value>The item identifier.</value> + public Guid ItemId { get; } + + /// <summary> + /// Gets the playlist identifier of the item. + /// </summary> + /// <value>The playlist identifier of the item.</value> + public string PlaylistItemId { get; } + } +} diff --git a/MediaBrowser.Model/SyncPlay/SendCommand.cs b/MediaBrowser.Model/SyncPlay/SendCommand.cs index 0f0be0152..ab4c64130 100644 --- a/MediaBrowser.Model/SyncPlay/SendCommand.cs +++ b/MediaBrowser.Model/SyncPlay/SendCommand.cs @@ -1,4 +1,4 @@ -#nullable disable +using System; namespace MediaBrowser.Model.SyncPlay { @@ -8,33 +8,58 @@ namespace MediaBrowser.Model.SyncPlay public class SendCommand { /// <summary> - /// Gets or sets the group identifier. + /// Initializes a new instance of the <see cref="SendCommand"/> class. + /// </summary> + /// <param name="groupId">The group identifier.</param> + /// <param name="playlistItemId">The playlist identifier of the playing item.</param> + /// <param name="when">The UTC time when to execute the command.</param> + /// <param name="command">The command.</param> + /// <param name="positionTicks">The position ticks, for commands that require it.</param> + /// <param name="emittedAt">The UTC time when this command has been emitted.</param> + public SendCommand(Guid groupId, string playlistItemId, DateTime when, SendCommandType command, long? positionTicks, DateTime emittedAt) + { + GroupId = groupId; + PlaylistItemId = playlistItemId; + When = when; + Command = command; + PositionTicks = positionTicks; + EmittedAt = emittedAt; + } + + /// <summary> + /// Gets the group identifier. /// </summary> /// <value>The group identifier.</value> - public string GroupId { get; set; } + public Guid GroupId { get; } + + /// <summary> + /// Gets the playlist identifier of the playing item. + /// </summary> + /// <value>The playlist identifier of the playing item.</value> + public string PlaylistItemId { get; } /// <summary> /// Gets or sets the UTC time when to execute the command. /// </summary> /// <value>The UTC time when to execute the command.</value> - public string When { get; set; } + public DateTime When { get; set; } /// <summary> - /// Gets or sets the position ticks. + /// Gets the position ticks. /// </summary> /// <value>The position ticks.</value> - public long? PositionTicks { get; set; } + public long? PositionTicks { get; } /// <summary> - /// Gets or sets the command. + /// Gets the command. /// </summary> /// <value>The command.</value> - public SendCommandType Command { get; set; } + public SendCommandType Command { get; } /// <summary> - /// Gets or sets the UTC time when this command has been emitted. + /// Gets the UTC time when this command has been emitted. /// </summary> /// <value>The UTC time when this command has been emitted.</value> - public string EmittedAt { get; set; } + public DateTime EmittedAt { get; } } } diff --git a/MediaBrowser.Model/SyncPlay/SendCommandType.cs b/MediaBrowser.Model/SyncPlay/SendCommandType.cs index 86dec9e90..e6b17c60a 100644 --- a/MediaBrowser.Model/SyncPlay/SendCommandType.cs +++ b/MediaBrowser.Model/SyncPlay/SendCommandType.cs @@ -6,9 +6,9 @@ namespace MediaBrowser.Model.SyncPlay public enum SendCommandType { /// <summary> - /// The play command. Instructs users to start playback. + /// The unpause command. Instructs users to unpause playback. /// </summary> - Play = 0, + Unpause = 0, /// <summary> /// The pause command. Instructs users to pause playback. @@ -16,8 +16,13 @@ namespace MediaBrowser.Model.SyncPlay Pause = 1, /// <summary> + /// The stop command. Instructs users to stop playback. + /// </summary> + Stop = 2, + + /// <summary> /// The seek command. Instructs users to seek to a specified time. /// </summary> - Seek = 2 + Seek = 3 } } diff --git a/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs b/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs new file mode 100644 index 000000000..29dbb11b3 --- /dev/null +++ b/MediaBrowser.Model/SyncPlay/SyncPlayBroadcastType.cs @@ -0,0 +1,28 @@ +namespace MediaBrowser.Model.SyncPlay +{ + /// <summary> + /// Used to filter the sessions of a group. + /// </summary> + public enum SyncPlayBroadcastType + { + /// <summary> + /// All sessions will receive the message. + /// </summary> + AllGroup = 0, + + /// <summary> + /// Only the specified session will receive the message. + /// </summary> + CurrentSession = 1, + + /// <summary> + /// All sessions, except the current one, will receive the message. + /// </summary> + AllExceptCurrentSession = 2, + + /// <summary> + /// Only sessions that are not buffering will receive the message. + /// </summary> + AllReady = 3 + } +} diff --git a/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs b/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs index 8ec5eaab3..219e7b1e0 100644 --- a/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs +++ b/MediaBrowser.Model/SyncPlay/UtcTimeResponse.cs @@ -1,4 +1,4 @@ -#nullable disable +using System; namespace MediaBrowser.Model.SyncPlay { @@ -8,15 +8,26 @@ namespace MediaBrowser.Model.SyncPlay public class UtcTimeResponse { /// <summary> - /// Gets or sets the UTC time when request has been received. + /// Initializes a new instance of the <see cref="UtcTimeResponse"/> class. + /// </summary> + /// <param name="requestReceptionTime">The UTC time when request has been received.</param> + /// <param name="responseTransmissionTime">The UTC time when response has been sent.</param> + public UtcTimeResponse(DateTime requestReceptionTime, DateTime responseTransmissionTime) + { + RequestReceptionTime = requestReceptionTime; + ResponseTransmissionTime = responseTransmissionTime; + } + + /// <summary> + /// Gets the UTC time when request has been received. /// </summary> /// <value>The UTC time when request has been received.</value> - public string RequestReceptionTime { get; set; } + public DateTime RequestReceptionTime { get; } /// <summary> - /// Gets or sets the UTC time when response has been sent. + /// Gets the UTC time when response has been sent. /// </summary> /// <value>The UTC time when response has been sent.</value> - public string ResponseTransmissionTime { get; set; } + public DateTime ResponseTransmissionTime { get; } } } |
