using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.SyncPlay; using MediaBrowser.Model.SyncPlay; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.SyncPlay { /// /// Class SyncPlayGroupController. /// /// /// Class is not thread-safe, external locking is required when accessing methods. /// public class SyncPlayGroupController : ISyncPlayGroupController, ISyncPlayStateContext { /// /// Gets the default ping value used for sessions. /// public long DefaultPing { get; } = 500; /// /// The logger. /// private readonly ILogger _logger; /// /// The user manager. /// private readonly IUserManager _userManager; /// /// The session manager. /// private readonly ISessionManager _sessionManager; /// /// The library manager. /// private readonly ILibraryManager _libraryManager; /// /// The SyncPlay manager. /// private readonly ISyncPlayManager _syncPlayManager; /// /// Internal group state. /// /// The group's state. private ISyncPlayState State; /// /// Gets the group identifier. /// /// The group identifier. public Guid GroupId { get; } = Guid.NewGuid(); /// /// Gets the group name. /// /// The group name. public string GroupName { get; private set; } /// /// Gets the group identifier. /// /// The group identifier. public PlayQueueManager PlayQueue { get; } = new PlayQueueManager(); /// /// Gets or sets the runtime ticks of current playing item. /// /// The runtime ticks of current playing item. public long RunTimeTicks { get; private set; } /// /// Gets or sets the position ticks. /// /// The position ticks. public long PositionTicks { get; set; } /// /// Gets or sets the last activity. /// /// The last activity. public DateTime LastActivity { get; set; } /// /// Gets the participants. /// /// The participants, or members of the group. public Dictionary Participants { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); /// /// Initializes a new instance of the class. /// /// The logger. /// The user manager. /// The session manager. /// The library manager. /// The SyncPlay manager. public SyncPlayGroupController( ILogger logger, IUserManager userManager, ISessionManager sessionManager, ILibraryManager libraryManager, ISyncPlayManager syncPlayManager) { _logger = logger; _userManager = userManager; _sessionManager = sessionManager; _libraryManager = libraryManager; _syncPlayManager = syncPlayManager; State = new IdleGroupState(_logger); } /// /// Checks if a session is in this group. /// /// The session id to check. /// true if the session is in this group; false otherwise. private bool ContainsSession(string sessionId) { return Participants.ContainsKey(sessionId); } /// /// Adds the session to the group. /// /// The session. private void AddSession(SessionInfo session) { Participants.TryAdd( session.Id, new GroupMember { Session = session, Ping = DefaultPing, IsBuffering = false }); } /// /// Removes the session from the group. /// /// The session. private void RemoveSession(SessionInfo session) { Participants.Remove(session.Id); } /// /// Filters sessions of this group. /// /// The current session. /// The filtering type. /// The array of sessions matching the filter. private SessionInfo[] FilterSessions(SessionInfo from, SyncPlayBroadcastType type) { switch (type) { case SyncPlayBroadcastType.CurrentSession: return new SessionInfo[] { from }; case SyncPlayBroadcastType.AllGroup: return Participants.Values.Select( session => session.Session).ToArray(); case SyncPlayBroadcastType.AllExceptCurrentSession: return Participants.Values.Select( session => session.Session).Where( session => !session.Id.Equals(from.Id)).ToArray(); case SyncPlayBroadcastType.AllReady: return Participants.Values.Where( session => !session.IsBuffering).Select( session => session.Session).ToArray(); default: return Array.Empty(); } } private bool HasAccessToItem(User user, BaseItem item) { var collections = _libraryManager.GetCollectionFolders(item) .Select(folder => folder.Id.ToString("N", CultureInfo.InvariantCulture)); return collections.Intersect(user.GetPreference(PreferenceKind.EnabledFolders)).Any(); } private bool HasAccessToQueue(User user, Guid[] queue) { if (queue == null || queue.Length == 0) { return true; } var items = queue.ToList() .Select(item => _libraryManager.GetItemById(item)); // Find the highest rating value, which becomes the required minimum for the user. var MinParentalRatingAccessRequired = items .Select(item => item.InheritedParentalRatingValue) .Min(); // Check ParentalRating access, user must have the minimum required access level. var hasParentalRatingAccess = !user.MaxParentalAgeRating.HasValue || MinParentalRatingAccessRequired <= user.MaxParentalAgeRating; // Check that user has access to all required folders. if (!user.HasPermission(PermissionKind.EnableAllFolders) && hasParentalRatingAccess) { // Get list of items that are not accessible. var blockedItems = items.Where(item => !HasAccessToItem(user, item)); // We need the user to be able to access all items. return !blockedItems.Any(); } return hasParentalRatingAccess; } private bool AllUsersHaveAccessToQueue(Guid[] queue) { if (queue == null || queue.Length == 0) { return true; } // Get list of users. var users = Participants.Values .Select(participant => _userManager.GetUserById(participant.Session.UserId)); // Find problematic users. var usersWithNoAccess = users.Where(user => !HasAccessToQueue(user, queue)); // All users must be able to access the queue. return !usersWithNoAccess.Any(); } /// public bool IsGroupEmpty() => Participants.Count == 0; /// public void CreateGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) { GroupName = request.GroupName; AddSession(session); _syncPlayManager.AddSessionToGroup(session, this); var sessionIsPlayingAnItem = session.FullNowPlayingItem != null; RestartCurrentItem(); if (sessionIsPlayingAnItem) { var playlist = session.NowPlayingQueue.Select(item => item.Id).ToArray(); PlayQueue.SetPlaylist(playlist); PlayQueue.SetPlayingItemById(session.FullNowPlayingItem.Id); RunTimeTicks = session.FullNowPlayingItem.RunTimeTicks ?? 0; PositionTicks = session.PlayState.PositionTicks ?? 0; // Mantain playstate. var waitingState = new WaitingGroupState(_logger); waitingState.ResumePlaying = !session.PlayState.IsPaused; SetState(waitingState); } var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); State.SessionJoined(this, State.GetGroupState(), session, cancellationToken); _logger.LogInformation("InitGroup: {0} created group {1}.", session.Id.ToString(), GroupId.ToString()); } /// public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) { AddSession(session); _syncPlayManager.AddSessionToGroup(session, this); var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); State.SessionJoined(this, State.GetGroupState(), session, cancellationToken); _logger.LogInformation("SessionJoin: {0} joined group {1}.", session.Id.ToString(), GroupId.ToString()); } /// public void SessionRestore(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) { var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); State.SessionJoined(this, State.GetGroupState(), session, cancellationToken); _logger.LogInformation("SessionRestore: {0} re-joined group {1}.", session.Id.ToString(), GroupId.ToString()); } /// public void SessionLeave(SessionInfo session, CancellationToken cancellationToken) { State.SessionLeaving(this, State.GetGroupState(), session, cancellationToken); RemoveSession(session); _syncPlayManager.RemoveSessionFromGroup(session, this); var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, GroupId.ToString()); SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName); SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); _logger.LogInformation("SessionLeave: {0} left group {1}.", session.Id.ToString(), GroupId.ToString()); } /// public void HandleRequest(SessionInfo session, IPlaybackGroupRequest request, CancellationToken cancellationToken) { // The server's job is to maintain a consistent state for clients to reference // and notify clients of state changes. The actual syncing of media playback // happens client side. Clients are aware of the server's time and use it to sync. _logger.LogInformation("HandleRequest: {0} requested {1}, group {2} in {3} state.", session.Id.ToString(), request.GetRequestType(), GroupId.ToString(), State.GetGroupState()); request.Apply(this, State, session, cancellationToken); } /// public GroupInfoDto GetInfo() { return new GroupInfoDto() { GroupId = GroupId.ToString(), GroupName = GroupName, State = State.GetGroupState(), Participants = Participants.Values.Select(session => session.Session.UserName).Distinct().ToList(), LastUpdatedAt = DateToUTCString(DateTime.UtcNow) }; } /// public bool HasAccessToPlayQueue(User user) { var items = PlayQueue.GetPlaylist().Select(item => item.ItemId).ToArray(); return HasAccessToQueue(user, items); } /// public void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait) { if (!ContainsSession(session.Id)) { return; } Participants[session.Id].IgnoreGroupWait = ignoreGroupWait; } /// public void SetState(ISyncPlayState state) { _logger.LogInformation("SetState: {0} switching from {1} to {2}.", GroupId.ToString(), State.GetGroupState(), state.GetGroupState()); this.State = state; } /// public Task SendGroupUpdate(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate message, CancellationToken cancellationToken) { IEnumerable GetTasks() { foreach (var session in FilterSessions(from, type)) { yield return _sessionManager.SendSyncPlayGroupUpdate(session, message, cancellationToken); } } return Task.WhenAll(GetTasks()); } /// public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken) { IEnumerable GetTasks() { foreach (var session in FilterSessions(from, type)) { yield return _sessionManager.SendSyncPlayCommand(session, message, cancellationToken); } } return Task.WhenAll(GetTasks()); } /// public SendCommand NewSyncPlayCommand(SendCommandType type) { return new SendCommand() { GroupId = GroupId.ToString(), PlaylistItemId = PlayQueue.GetPlayingItemPlaylistId(), PositionTicks = PositionTicks, Command = type, When = DateToUTCString(LastActivity), EmittedAt = DateToUTCString(DateTime.UtcNow) }; } /// public GroupUpdate NewSyncPlayGroupUpdate(GroupUpdateType type, T data) { return new GroupUpdate() { GroupId = GroupId.ToString(), Type = type, Data = data }; } /// public string DateToUTCString(DateTime dateTime) { return dateTime.ToUniversalTime().ToString("o"); } /// public long SanitizePositionTicks(long? positionTicks) { var ticks = positionTicks ?? 0; ticks = ticks >= 0 ? ticks : 0; ticks = ticks > RunTimeTicks ? RunTimeTicks : ticks; return ticks; } /// public void UpdatePing(SessionInfo session, long ping) { if (Participants.TryGetValue(session.Id, out GroupMember value)) { value.Ping = ping; } } /// public long GetHighestPing() { long max = long.MinValue; foreach (var session in Participants.Values) { max = Math.Max(max, session.Ping); } return max; } /// public void SetBuffering(SessionInfo session, bool isBuffering) { if (Participants.TryGetValue(session.Id, out GroupMember value)) { value.IsBuffering = isBuffering; } } /// public void SetAllBuffering(bool isBuffering) { foreach (var session in Participants.Values) { session.IsBuffering = isBuffering; } } /// public bool IsBuffering() { foreach (var session in Participants.Values) { if (session.IsBuffering && !session.IgnoreGroupWait) { return true; } } return false; } /// public bool SetPlayQueue(Guid[] playQueue, int playingItemPosition, long startPositionTicks) { // Ignore on empty queue or invalid item position. if (playQueue.Length < 1 || playingItemPosition >= playQueue.Length || playingItemPosition < 0) { return false; } // Check is participants can access the new playing queue. if (!AllUsersHaveAccessToQueue(playQueue)) { return false; } PlayQueue.SetPlaylist(playQueue); PlayQueue.SetPlayingItemByIndex(playingItemPosition); var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); RunTimeTicks = item.RunTimeTicks ?? 0; PositionTicks = startPositionTicks; LastActivity = DateTime.UtcNow; return true; } /// 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; } /// public bool RemoveFromPlayQueue(string[] playlistItemIds) { var playingItemRemoved = PlayQueue.RemoveFromPlaylist(playlistItemIds); if (playingItemRemoved) { var itemId = PlayQueue.GetPlayingItemId(); if (!itemId.Equals(Guid.Empty)) { var item = _libraryManager.GetItemById(itemId); RunTimeTicks = item.RunTimeTicks ?? 0; } else { RunTimeTicks = 0; } RestartCurrentItem(); } return playingItemRemoved; } /// public bool MoveItemInPlayQueue(string playlistItemId, int newIndex) { return PlayQueue.MovePlaylistItem(playlistItemId, newIndex); } /// public bool AddToPlayQueue(Guid[] newItems, string mode) { // Ignore on empty list. if (newItems.Length < 1) { return false; } // Check is participants can access the new playing queue. if (!AllUsersHaveAccessToQueue(newItems)) { return false; } if (mode.Equals("next")) { PlayQueue.QueueNext(newItems); } else { PlayQueue.Queue(newItems); } return true; } /// public void RestartCurrentItem() { PositionTicks = 0; LastActivity = DateTime.UtcNow; } /// public bool NextItemInQueue() { var update = PlayQueue.Next(); if (update) { var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); RunTimeTicks = item.RunTimeTicks ?? 0; RestartCurrentItem(); return true; } else { return false; } } /// public bool PreviousItemInQueue() { var update = PlayQueue.Previous(); if (update) { var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); RunTimeTicks = item.RunTimeTicks ?? 0; RestartCurrentItem(); return true; } else { return false; } } /// public void SetRepeatMode(string mode) { PlayQueue.SetRepeatMode(mode); } /// public void SetShuffleMode(string mode) { PlayQueue.SetShuffleMode(mode); } /// public PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason) { var startPositionTicks = PositionTicks; if (State.GetGroupState().Equals(GroupState.Playing)) { var currentTime = DateTime.UtcNow; var elapsedTime = currentTime - LastActivity; // Event may happen during the delay added to account for latency. startPositionTicks += elapsedTime.Ticks > 0 ? elapsedTime.Ticks : 0; } return new PlayQueueUpdate() { Reason = reason, LastUpdate = DateToUTCString(PlayQueue.LastChange), Playlist = PlayQueue.GetPlaylist(), PlayingItemIndex = PlayQueue.PlayingItemIndex, StartPositionTicks = startPositionTicks, ShuffleMode = PlayQueue.ShuffleMode, RepeatMode = PlayQueue.RepeatMode }; } } }