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 { /// /// Class GroupController. /// /// /// Class is not thread-safe, external locking is required when accessing methods. /// public class GroupController : IGroupController, IGroupStateContext { /// /// 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 IGroupState _state; /// /// Initializes a new instance of the class. /// /// The logger. /// The user manager. /// The session manager. /// The library manager. /// The SyncPlay manager. public GroupController( ILogger logger, IUserManager userManager, ISessionManager sessionManager, ILibraryManager libraryManager, ISyncPlayManager syncPlayManager) { _logger = logger; _userManager = userManager; _sessionManager = sessionManager; _libraryManager = libraryManager; _syncPlayManager = syncPlayManager; _state = new IdleGroupState(_logger); } /// /// Gets the default ping value used for sessions. /// public long DefaultPing { get; } = 500; /// /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds. /// public long TimeSyncOffset { get; } = 2000; /// /// Gets the maximum offset error accepted for position reported by clients, in milliseconds. /// public long MaxPlaybackOffset { get; } = 500; /// /// Gets the group identifier. /// /// The group identifier. public Guid GroupId { get; } = Guid.NewGuid(); /// /// Gets the group name. /// /// The group name. public string GroupName { get; private set; } /// /// Gets the group identifier. /// /// The group identifier. public PlayQueueManager PlayQueue { get; } = new PlayQueueManager(); /// /// Gets the runtime ticks of current playing item. /// /// The runtime ticks of current playing item. public long RunTimeTicks { get; private set; } /// /// Gets or sets the position ticks. /// /// The position ticks. public long PositionTicks { get; set; } /// /// Gets or sets the last activity. /// /// The last activity. public DateTime LastActivity { get; set; } /// /// Gets the participants. /// /// The participants, or members of the group. public Dictionary Participants { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); /// /// 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 IEnumerable FilterSessions(SessionInfo from, SyncPlayBroadcastType type) { return type switch { SyncPlayBroadcastType.CurrentSession => new SessionInfo[] { from }, SyncPlayBroadcastType.AllGroup => Participants .Values .Select(session => session.Session), SyncPlayBroadcastType.AllExceptCurrentSession => Participants .Values .Select(session => session.Session) .Where(session => !session.Id.Equals(from.Id, StringComparison.OrdinalIgnoreCase)), SyncPlayBroadcastType.AllReady => Participants .Values .Where(session => !session.IsBuffering) .Select(session => session.Session), _ => Enumerable.Empty() }; } /// /// Checks if a given user can access a given item, that is, the user has access to a folder where the item is stored. /// /// The user. /// The item. /// true if the user can access the item, false otherwise. 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(); } /// /// Checks if a given user can access all items of a given queue, that is, /// the user has the required minimum parental access and has access to all required folders. /// /// The user. /// The queue. /// true if the user can access all the items in the queue, false otherwise. private bool HasAccessToQueue(User user, IEnumerable queue) { // Check if queue is empty. if (!queue?.Any() ?? true) { 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(IEnumerable queue) { // Check if queue is empty. if (!queue?.Any() ?? true) { 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); PlayQueue.Reset(); 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) { 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("InitGroup: {0} created group {1}.", session.Id, 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.Type, session, cancellationToken); _logger.LogInformation("SessionJoin: {0} joined group {1}.", session.Id, 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.Type, session, cancellationToken); _logger.LogInformation("SessionRestore: {0} re-joined group {1}.", session.Id, GroupId.ToString()); } /// public void SessionLeave(SessionInfo session, CancellationToken cancellationToken) { _state.SessionLeaving(this, _state.Type, 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, GroupId.ToString()); } /// 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("HandleRequest: {0} requested {1}, group {2} in {3} state.", session.Id, request.Type, GroupId.ToString(), _state.Type); request.Apply(this, _state, session, cancellationToken); } /// public GroupInfoDto GetInfo() { return new GroupInfoDto() { GroupId = GroupId.ToString(), GroupName = GroupName, State = _state.Type, Participants = Participants.Values.Select(session => session.Session.UserName).Distinct().ToList(), LastUpdatedAt = DateTime.UtcNow }; } /// public bool HasAccessToPlayQueue(User user) { var items = PlayQueue.GetPlaylist().Select(item => item.ItemId); return HasAccessToQueue(user, items); } /// public void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait) { if (Participants.TryGetValue(session.Id, out GroupMember value)) { value.IgnoreGroupWait = ignoreGroupWait; } } /// public void SetState(IGroupState state) { _logger.LogInformation("SetState: {0} switching from {1} to {2}.", GroupId.ToString(), _state.Type, state.Type); this._state = state; } /// public Task SendGroupUpdate(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate message, CancellationToken cancellationToken) { IEnumerable GetTasks() { foreach (var session in FilterSessions(from, type)) { yield return _sessionManager.SendSyncPlayGroupUpdate(session, message, cancellationToken); } } return Task.WhenAll(GetTasks()); } /// public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken) { IEnumerable GetTasks() { foreach (var session in FilterSessions(from, type)) { yield return _sessionManager.SendSyncPlayCommand(session, message, cancellationToken); } } return Task.WhenAll(GetTasks()); } /// public SendCommand NewSyncPlayCommand(SendCommandType type) { return new SendCommand() { GroupId = GroupId.ToString(), PlaylistItemId = PlayQueue.GetPlayingItemPlaylistId(), PositionTicks = PositionTicks, Command = type, When = LastActivity, EmittedAt = DateTime.UtcNow }; } /// public GroupUpdate NewSyncPlayGroupUpdate(GroupUpdateType type, T data) { return new GroupUpdate() { GroupId = GroupId.ToString(), Type = type, Data = data }; } /// public long SanitizePositionTicks(long? positionTicks) { var ticks = positionTicks ?? 0; return Math.Clamp(ticks, 0, RunTimeTicks); } /// public void UpdatePing(SessionInfo session, long ping) { if (Participants.TryGetValue(session.Id, out GroupMember value)) { value.Ping = ping; } } /// public long GetHighestPing() { long max = long.MinValue; foreach (var session in Participants.Values) { max = Math.Max(max, session.Ping); } return max; } /// public void SetBuffering(SessionInfo session, bool isBuffering) { if (Participants.TryGetValue(session.Id, out GroupMember value)) { value.IsBuffering = isBuffering; } } /// public void SetAllBuffering(bool isBuffering) { foreach (var session in Participants.Values) { session.IsBuffering = isBuffering; } } /// public bool IsBuffering() { foreach (var session in Participants.Values) { if (session.IsBuffering && !session.IgnoreGroupWait) { return true; } } return false; } /// public bool SetPlayQueue(IEnumerable playQueue, int playingItemPosition, long startPositionTicks) { // Ignore on empty queue or invalid item position. if (!playQueue.Any() || playingItemPosition >= playQueue.Count() || playingItemPosition < 0) { return false; } // Check if participants can access the new playing queue. if (!AllUsersHaveAccessToQueue(playQueue)) { return false; } PlayQueue.Reset(); PlayQueue.SetPlaylist(playQueue); PlayQueue.SetPlayingItemByIndex(playingItemPosition); var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); RunTimeTicks = item.RunTimeTicks ?? 0; PositionTicks = startPositionTicks; LastActivity = DateTime.UtcNow; return true; } /// public bool SetPlayingItem(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(IEnumerable 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(IEnumerable newItems, GroupQueueMode mode) { // Ignore on empty list. if (!newItems.Any()) { return false; } // Check if participants can access the new playing queue. if (!AllUsersHaveAccessToQueue(newItems)) { return false; } if (mode.Equals(GroupQueueMode.QueueNext)) { PlayQueue.QueueNext(newItems); } else { PlayQueue.Queue(newItems); } return true; } /// public void RestartCurrentItem() { PositionTicks = 0; LastActivity = DateTime.UtcNow; } /// public bool NextItemInQueue() { var update = PlayQueue.Next(); if (update) { var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); RunTimeTicks = item.RunTimeTicks ?? 0; RestartCurrentItem(); return true; } else { return false; } } /// public bool PreviousItemInQueue() { var update = PlayQueue.Previous(); if (update) { var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); RunTimeTicks = item.RunTimeTicks ?? 0; RestartCurrentItem(); return true; } else { return false; } } /// public void SetRepeatMode(GroupRepeatMode mode) { PlayQueue.SetRepeatMode(mode); } /// public void SetShuffleMode(GroupShuffleMode mode) { PlayQueue.SetShuffleMode(mode); } /// public PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason) { var startPositionTicks = PositionTicks; if (_state.Type.Equals(GroupStateType.Playing)) { var currentTime = DateTime.UtcNow; var elapsedTime = currentTime - LastActivity; // Elapsed time is negative if event happens // during the delay added to account for latency. // In this phase clients haven't started the playback yet. // In other words, LastActivity is in the future, // when playback unpause is supposed to happen. // Adjust ticks only if playback actually started. startPositionTicks += Math.Max(elapsedTime.Ticks, 0); } return new PlayQueueUpdate() { Reason = reason, LastUpdate = PlayQueue.LastChange, Playlist = PlayQueue.GetPlaylist(), PlayingItemIndex = PlayQueue.PlayingItemIndex, StartPositionTicks = startPositionTicks, ShuffleMode = PlayQueue.ShuffleMode, RepeatMode = PlayQueue.RepeatMode }; } } }