#nullable disable using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.SyncPlay; using MediaBrowser.Controller.SyncPlay.GroupStates; using MediaBrowser.Controller.SyncPlay.Queue; using MediaBrowser.Controller.SyncPlay.Requests; using MediaBrowser.Model.SyncPlay; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.SyncPlay { /// /// Class Group. /// /// /// Class is not thread-safe, external locking is required when accessing methods. /// public class Group : IGroupStateContext { /// /// The logger. /// private readonly ILogger _logger; /// /// The logger factory. /// private readonly ILoggerFactory _loggerFactory; /// /// The user manager. /// private readonly IUserManager _userManager; /// /// The session manager. /// private readonly ISessionManager _sessionManager; /// /// The library manager. /// private readonly ILibraryManager _libraryManager; /// /// The participants, or members of the group. /// private readonly Dictionary _participants = new Dictionary(StringComparer.OrdinalIgnoreCase); /// /// The internal group state. /// private IGroupState _state; /// /// Initializes a new instance of the class. /// /// The logger factory. /// The user manager. /// The session manager. /// The library manager. public Group( ILoggerFactory loggerFactory, IUserManager userManager, ISessionManager sessionManager, ILibraryManager libraryManager) { _loggerFactory = loggerFactory; _userManager = userManager; _sessionManager = sessionManager; _libraryManager = libraryManager; _logger = loggerFactory.CreateLogger(); _state = new IdleGroupState(loggerFactory); } /// /// Gets the default ping value used for sessions. /// /// The default ping. public long DefaultPing { get; } = 500; /// /// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds. /// /// The maximum time offset error. public long TimeSyncOffset { get; } = 2000; /// /// Gets the maximum offset error accepted for position reported by clients, in milliseconds. /// /// The maximum offset error. public long MaxPlaybackOffset { get; } = 500; /// /// Gets the group identifier. /// /// The group identifier. public Guid GroupId { get; } = Guid.NewGuid(); /// /// Gets the group name. /// /// The group name. public string GroupName { get; private set; } /// /// Gets the group identifier. /// /// The group identifier. public PlayQueueManager PlayQueue { get; } = new PlayQueueManager(); /// /// Gets the runtime ticks of current playing item. /// /// The runtime ticks of current playing item. public long RunTimeTicks { get; private set; } /// /// Gets or sets the position ticks. /// /// The position ticks. public long PositionTicks { get; set; } /// /// Gets or sets the last activity. /// /// The last activity. public DateTime LastActivity { get; set; } /// /// Adds the session to the group. /// /// The session. private void AddSession(SessionInfo session) { _participants.TryAdd( session.Id, new GroupMember(session) { Ping = DefaultPing, IsBuffering = false }); } /// /// Removes the session from the group. /// /// The session. private void RemoveSession(SessionInfo session) { _participants.Remove(session.Id); } /// /// Filters sessions of this group. /// /// The current session identifier. /// The filtering type. /// The list of sessions matching the filter. private IEnumerable FilterSessions(string fromId, SyncPlayBroadcastType type) { return type switch { SyncPlayBroadcastType.CurrentSession => new string[] { fromId }, SyncPlayBroadcastType.AllGroup => _participants .Values .Select(member => member.SessionId), SyncPlayBroadcastType.AllExceptCurrentSession => _participants .Values .Select(member => member.SessionId) .Where(sessionId => !sessionId.Equals(fromId, StringComparison.OrdinalIgnoreCase)), SyncPlayBroadcastType.AllReady => _participants .Values .Where(member => !member.IsBuffering) .Select(member => member.SessionId), _ => Enumerable.Empty() }; } /// /// Checks if a given user can access all items of a given queue, that is, /// the user has the required minimum parental access and has access to all required folders. /// /// The user. /// The queue. /// true if the user can access all the items in the queue, false otherwise. private bool HasAccessToQueue(User user, IReadOnlyList queue) { // Check if queue is empty. if (queue == null || queue.Count == 0) { return true; } foreach (var itemId in queue) { var item = _libraryManager.GetItemById(itemId); if (!item.IsVisibleStandalone(user)) { return false; } } return true; } private bool AllUsersHaveAccessToQueue(IReadOnlyList queue) { // Check if queue is empty. if (queue == null || queue.Count == 0) { return true; } // Get list of users. var users = _participants .Values .Select(participant => _userManager.GetUserById(participant.UserId)); // Find problematic users. var usersWithNoAccess = users.Where(user => !HasAccessToQueue(user, queue)); // All users must be able to access the queue. return !usersWithNoAccess.Any(); } /// /// Checks if the group is empty. /// /// true if the group is empty, false otherwise. public bool IsGroupEmpty() => _participants.Count == 0; /// /// Initializes the group with the session's info. /// /// The session. /// The request. /// The cancellation token. public void CreateGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) { GroupName = request.GroupName; AddSession(session); var sessionIsPlayingAnItem = session.FullNowPlayingItem != null; RestartCurrentItem(); if (sessionIsPlayingAnItem) { var playlist = session.NowPlayingQueue.Select(item => item.Id).ToList(); PlayQueue.Reset(); PlayQueue.SetPlaylist(playlist); PlayQueue.SetPlayingItemById(session.FullNowPlayingItem.Id); RunTimeTicks = session.FullNowPlayingItem.RunTimeTicks ?? 0; PositionTicks = session.PlayState.PositionTicks ?? 0; // Maintain playstate. var waitingState = new WaitingGroupState(_loggerFactory) { ResumePlaying = !session.PlayState.IsPaused }; SetState(waitingState); } var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); _state.SessionJoined(this, _state.Type, session, cancellationToken); _logger.LogInformation("Session {SessionId} created group {GroupId}.", session.Id, GroupId.ToString()); } /// /// Adds the session to the group. /// /// The session. /// The request. /// The cancellation token. public void SessionJoin(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) { AddSession(session); var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupJoined, GetInfo()); SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserJoined, session.UserName); SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); _state.SessionJoined(this, _state.Type, session, cancellationToken); _logger.LogInformation("Session {SessionId} joined group {GroupId}.", session.Id, GroupId.ToString()); } /// /// Removes the session from the group. /// /// The session. /// The request. /// The cancellation token. public void SessionLeave(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken) { _state.SessionLeaving(this, _state.Type, session, cancellationToken); RemoveSession(session); var updateSession = NewSyncPlayGroupUpdate(GroupUpdateType.GroupLeft, GroupId.ToString()); SendGroupUpdate(session, SyncPlayBroadcastType.CurrentSession, updateSession, cancellationToken); var updateOthers = NewSyncPlayGroupUpdate(GroupUpdateType.UserLeft, session.UserName); SendGroupUpdate(session, SyncPlayBroadcastType.AllExceptCurrentSession, updateOthers, cancellationToken); _logger.LogInformation("Session {SessionId} left group {GroupId}.", session.Id, GroupId.ToString()); } /// /// Handles the requested action by the session. /// /// The session. /// The requested action. /// The cancellation token. public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken) { // The server's job is to maintain a consistent state for clients to reference // and notify clients of state changes. The actual syncing of media playback // happens client side. Clients are aware of the server's time and use it to sync. _logger.LogInformation("Session {SessionId} requested {RequestType} in group {GroupId} that is {StateType}.", session.Id, request.Action, GroupId.ToString(), _state.Type); // Apply requested changes to this group given its current state. // Every request has a slightly different outcome depending on the group's state. // There are currently four different group states that accomplish different goals: // - Idle: in this state no media is playing and clients should be idle (playback is stopped). // - Waiting: in this state the group is waiting for all the clients to be ready to start the playback, // that is, they've either finished loading the media for the first time or they've finished buffering. // Once all clients report to be ready the group's state can change to Playing or Paused. // - Playing: clients have some media loaded and playback is unpaused. // - Paused: clients have some media loaded but playback is currently paused. request.Apply(this, _state, session, cancellationToken); } /// /// Gets the info about the group for the clients. /// /// The group info for the clients. public GroupInfoDto GetInfo() { var participants = _participants.Values.Select(session => session.UserName).Distinct().ToList(); return new GroupInfoDto(GroupId, GroupName, _state.Type, participants, DateTime.UtcNow); } /// /// Checks if a user has access to all content in the play queue. /// /// The user. /// true if the user can access the play queue; false otherwise. public bool HasAccessToPlayQueue(User user) { var items = PlayQueue.GetPlaylist().Select(item => item.ItemId).ToList(); return HasAccessToQueue(user, items); } /// public void SetIgnoreGroupWait(SessionInfo session, bool ignoreGroupWait) { if (_participants.TryGetValue(session.Id, out GroupMember value)) { value.IgnoreGroupWait = ignoreGroupWait; } } /// public void SetState(IGroupState state) { _logger.LogInformation("Group {GroupId} switching from {FromStateType} to {ToStateType}.", GroupId.ToString(), _state.Type, state.Type); this._state = state; } /// public Task SendGroupUpdate(SessionInfo from, SyncPlayBroadcastType type, GroupUpdate message, CancellationToken cancellationToken) { IEnumerable GetTasks() { foreach (var sessionId in FilterSessions(from.Id, type)) { yield return _sessionManager.SendSyncPlayGroupUpdate(sessionId, message, cancellationToken); } } return Task.WhenAll(GetTasks()); } /// public Task SendCommand(SessionInfo from, SyncPlayBroadcastType type, SendCommand message, CancellationToken cancellationToken) { IEnumerable GetTasks() { foreach (var sessionId in FilterSessions(from.Id, type)) { yield return _sessionManager.SendSyncPlayCommand(sessionId, message, cancellationToken); } } return Task.WhenAll(GetTasks()); } /// public SendCommand NewSyncPlayCommand(SendCommandType type) { return new SendCommand( GroupId, PlayQueue.GetPlayingItemPlaylistId(), LastActivity, type, PositionTicks, DateTime.UtcNow); } /// public GroupUpdate NewSyncPlayGroupUpdate(GroupUpdateType type, T data) { return new GroupUpdate(GroupId, type, data); } /// public long SanitizePositionTicks(long? positionTicks) { var ticks = positionTicks ?? 0; return Math.Clamp(ticks, 0, RunTimeTicks); } /// public void UpdatePing(SessionInfo session, long ping) { if (_participants.TryGetValue(session.Id, out GroupMember value)) { value.Ping = ping; } } /// public long GetHighestPing() { long max = long.MinValue; foreach (var session in _participants.Values) { max = Math.Max(max, session.Ping); } return max; } /// public void SetBuffering(SessionInfo session, bool isBuffering) { if (_participants.TryGetValue(session.Id, out GroupMember value)) { value.IsBuffering = isBuffering; } } /// public void SetAllBuffering(bool isBuffering) { foreach (var session in _participants.Values) { session.IsBuffering = isBuffering; } } /// public bool IsBuffering() { foreach (var session in _participants.Values) { if (session.IsBuffering && !session.IgnoreGroupWait) { return true; } } return false; } /// public bool SetPlayQueue(IReadOnlyList playQueue, int playingItemPosition, long startPositionTicks) { // Ignore on empty queue or invalid item position. if (playQueue.Count == 0 || playingItemPosition >= playQueue.Count || playingItemPosition < 0) { return false; } // Check if participants can access the new playing queue. if (!AllUsersHaveAccessToQueue(playQueue)) { return false; } PlayQueue.Reset(); PlayQueue.SetPlaylist(playQueue); PlayQueue.SetPlayingItemByIndex(playingItemPosition); var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); RunTimeTicks = item.RunTimeTicks ?? 0; PositionTicks = startPositionTicks; LastActivity = DateTime.UtcNow; return true; } /// public bool SetPlayingItem(Guid playlistItemId) { var itemFound = PlayQueue.SetPlayingItemByPlaylistId(playlistItemId); if (itemFound) { var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); RunTimeTicks = item.RunTimeTicks ?? 0; } else { RunTimeTicks = 0; } RestartCurrentItem(); return itemFound; } /// public void ClearPlayQueue(bool clearPlayingItem) { PlayQueue.ClearPlaylist(clearPlayingItem); if (clearPlayingItem) { RestartCurrentItem(); } } /// public bool RemoveFromPlayQueue(IReadOnlyList playlistItemIds) { var playingItemRemoved = PlayQueue.RemoveFromPlaylist(playlistItemIds); if (playingItemRemoved) { var itemId = PlayQueue.GetPlayingItemId(); if (!itemId.Equals(default)) { var item = _libraryManager.GetItemById(itemId); RunTimeTicks = item.RunTimeTicks ?? 0; } else { RunTimeTicks = 0; } RestartCurrentItem(); } return playingItemRemoved; } /// public bool MoveItemInPlayQueue(Guid playlistItemId, int newIndex) { return PlayQueue.MovePlaylistItem(playlistItemId, newIndex); } /// public bool AddToPlayQueue(IReadOnlyList newItems, GroupQueueMode mode) { // Ignore on empty list. if (newItems.Count == 0) { return false; } // Check if participants can access the new playing queue. if (!AllUsersHaveAccessToQueue(newItems)) { return false; } if (mode.Equals(GroupQueueMode.QueueNext)) { PlayQueue.QueueNext(newItems); } else { PlayQueue.Queue(newItems); } return true; } /// public void RestartCurrentItem() { PositionTicks = 0; LastActivity = DateTime.UtcNow; } /// public bool NextItemInQueue() { var update = PlayQueue.Next(); if (update) { var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); RunTimeTicks = item.RunTimeTicks ?? 0; RestartCurrentItem(); return true; } else { return false; } } /// public bool PreviousItemInQueue() { var update = PlayQueue.Previous(); if (update) { var item = _libraryManager.GetItemById(PlayQueue.GetPlayingItemId()); RunTimeTicks = item.RunTimeTicks ?? 0; RestartCurrentItem(); return true; } else { return false; } } /// public void SetRepeatMode(GroupRepeatMode mode) { PlayQueue.SetRepeatMode(mode); } /// public void SetShuffleMode(GroupShuffleMode mode) { PlayQueue.SetShuffleMode(mode); } /// public PlayQueueUpdate GetPlayQueueUpdate(PlayQueueUpdateReason reason) { var startPositionTicks = PositionTicks; var isPlaying = _state.Type.Equals(GroupStateType.Playing); if (isPlaying) { 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, isPlaying, PlayQueue.ShuffleMode, PlayQueue.RepeatMode); } } }