using System; using System.Collections.Generic; using System.Threading; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Session; using MediaBrowser.Controller.SyncPlay; using MediaBrowser.Controller.SyncPlay.Requests; using MediaBrowser.Model.SyncPlay; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.SyncPlay { /// /// Class SyncPlayManager. /// public class SyncPlayManager : ISyncPlayManager, IDisposable { /// /// 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 map between sessions and groups. /// private readonly Dictionary _sessionToGroupMap = new Dictionary(StringComparer.OrdinalIgnoreCase); /// /// The groups. /// private readonly Dictionary _groups = new Dictionary(); /// /// Lock used for accessing any group. /// /// /// Always lock before and before locking on any . /// private readonly object _groupsLock = new object(); /// /// Lock used for accessing the session-to-group map. /// /// /// Always lock after and before locking on any . /// private readonly object _mapsLock = new object(); private bool _disposed = false; /// /// Initializes a new instance of the class. /// /// The logger factory. /// The user manager. /// The session manager. /// The library manager. public SyncPlayManager( ILoggerFactory loggerFactory, IUserManager userManager, ISessionManager sessionManager, ILibraryManager libraryManager) { _loggerFactory = loggerFactory; _userManager = userManager; _sessionManager = sessionManager; _libraryManager = libraryManager; _logger = loggerFactory.CreateLogger(); _sessionManager.SessionStarted += OnSessionManagerSessionStarted; } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// public void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) { // 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)) { var leaveGroupRequest = new LeaveGroupRequest(); LeaveGroup(session, leaveGroupRequest, cancellationToken); } var group = new GroupController(_loggerFactory, _userManager, _sessionManager, _libraryManager); _groups[group.GroupId] = group; AddSessionToGroup(session, group); group.CreateGroup(session, request, cancellationToken); } } } /// public void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) { var user = _userManager.GetUserById(session.UserId); // Locking required to access list of groups. lock (_groupsLock) { _groups.TryGetValue(request.GroupId, out IGroupController group); if (group == null) { _logger.LogWarning("Session {SessionId} tried to join group {GroupId} that does not exist.", session.Id, request.GroupId); var error = new GroupUpdate(Guid.Empty, GroupUpdateType.GroupDoesNotExist, string.Empty); _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } // 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(group.GroupId, GroupUpdateType.LibraryAccessDenied, string.Empty); _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } if (IsSessionInGroup(session)) { if (FindJoinedGroupId(session).Equals(request.GroupId)) { group.SessionRestore(session, request, cancellationToken); return; } var leaveGroupRequest = new LeaveGroupRequest(); LeaveGroup(session, leaveGroupRequest, cancellationToken); } AddSessionToGroup(session, group); group.SessionJoin(session, request, cancellationToken); } } } } /// public void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken) { // Locking required to access list of groups. lock (_groupsLock) { // Locking required as session-to-group map will be edited. lock (_mapsLock) { var group = FindJoinedGroup(session); if (group == null) { _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id); var error = new GroupUpdate(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty); _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } // Group lock required to let other requests end first. lock (group) { RemoveSessionFromGroup(session, group); group.SessionLeave(session, request, cancellationToken); if (group.IsGroupEmpty()) { _logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId); _groups.Remove(group.GroupId, out _); } } } } } /// public List ListGroups(SessionInfo session, ListGroupsRequest request) { var user = _userManager.GetUserById(session.UserId); List list = new List(); // Locking required to access list of groups. lock (_groupsLock) { foreach (var group in _groups.Values) { // Locking required as group is not thread-safe. lock (group) { if (group.HasAccessToPlayQueue(user)) { list.Add(group.GetInfo()); } } } } return list; } /// public void HandleRequest(SessionInfo session, IGroupPlaybackRequest request, CancellationToken cancellationToken) { IGroupController group; lock (_mapsLock) { group = FindJoinedGroup(session); } if (group == null) { _logger.LogWarning("Session {SessionId} does not belong to any group.", session.Id); var error = new GroupUpdate(Guid.Empty, GroupUpdateType.NotInGroup, string.Empty); _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return; } // Group lock required as GroupController is not thread-safe. lock (group) { group.HandleRequest(session, request, cancellationToken); } } /// /// Releases unmanaged and optionally managed resources. /// /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected virtual void Dispose(bool disposing) { if (_disposed) { return; } _sessionManager.SessionStarted -= OnSessionManagerSessionStarted; _disposed = true; } private void OnSessionManagerSessionStarted(object sender, SessionEventArgs e) { var session = e.SessionInfo; Guid groupId = Guid.Empty; lock (_mapsLock) { groupId = FindJoinedGroupId(session); } if (groupId.Equals(Guid.Empty)) { return; } var request = new JoinGroupRequest(groupId); JoinGroup(session, request, CancellationToken.None); } /// /// Checks if a given session has joined a group. /// /// /// Method is not thread-safe, external locking on is required. /// /// The session. /// true if the session has joined a group, false otherwise. private bool IsSessionInGroup(SessionInfo session) { return _sessionToGroupMap.ContainsKey(session.Id); } /// /// Gets the group joined by the given session, if any. /// /// /// Method is not thread-safe, external locking on is required. /// /// The session. /// The group. private IGroupController FindJoinedGroup(SessionInfo session) { _sessionToGroupMap.TryGetValue(session.Id, out var group); return group; } /// /// Gets the group identifier joined by the given session, if any. /// /// /// Method is not thread-safe, external locking on is required. /// /// The session. /// The group identifier if the session has joined a group, an empty identifier otherwise. private Guid FindJoinedGroupId(SessionInfo session) { return FindJoinedGroup(session)?.GroupId ?? Guid.Empty; } /// /// Maps a session to a group. /// /// /// Method is not thread-safe, external locking on is required. /// /// The session. /// The group. /// Thrown when the user is in another group already. private void AddSessionToGroup(SessionInfo session, IGroupController group) { if (session == null) { throw new InvalidOperationException("Session is null!"); } if (IsSessionInGroup(session)) { throw new InvalidOperationException("Session in other group already!"); } _sessionToGroupMap[session.Id] = group ?? throw new InvalidOperationException("Group is null!"); } /// /// Unmaps a session from a group. /// /// /// Method is not thread-safe, external locking on is required. /// /// The session. /// The group. /// Thrown when the user is not found in the specified group. private void RemoveSessionFromGroup(SessionInfo session, IGroupController group) { if (session == null) { throw new InvalidOperationException("Session is null!"); } if (group == null) { throw new InvalidOperationException("Group is null!"); } if (!IsSessionInGroup(session)) { throw new InvalidOperationException("Session not in any group!"); } _sessionToGroupMap.Remove(session.Id, out var tempGroup); if (!tempGroup.GroupId.Equals(group.GroupId)) { throw new InvalidOperationException("Session was in wrong group!"); } } } }