#nullable disable using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading; 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 users and counter of active sessions. /// private readonly ConcurrentDictionary _activeUsers = new ConcurrentDictionary(); /// /// The map between sessions and groups. /// private readonly ConcurrentDictionary _sessionToGroupMap = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); /// /// The groups. /// private readonly ConcurrentDictionary _groups = new ConcurrentDictionary(); /// /// Lock used for accessing multiple groups at once. /// /// /// This lock has priority on locks made on . /// private readonly object _groupsLock = 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.SessionEnded += OnSessionEnded; } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// public void NewGroup(SessionInfo session, NewGroupRequest request, CancellationToken cancellationToken) { if (session is null) { throw new InvalidOperationException("Session is null!"); } if (request is null) { throw new InvalidOperationException("Request is null!"); } // Locking required to access list of groups. lock (_groupsLock) { // Make sure that session has not joined another group. if (_sessionToGroupMap.ContainsKey(session.Id)) { var leaveGroupRequest = new LeaveGroupRequest(); LeaveGroup(session, leaveGroupRequest, cancellationToken); } var group = new Group(_loggerFactory, _userManager, _sessionManager, _libraryManager); _groups[group.GroupId] = group; if (!_sessionToGroupMap.TryAdd(session.Id, group)) { throw new InvalidOperationException("Could not add session to group!"); } UpdateSessionsCounter(session.UserId, 1); group.CreateGroup(session, request, cancellationToken); } } /// public void JoinGroup(SessionInfo session, JoinGroupRequest request, CancellationToken cancellationToken) { if (session is null) { throw new InvalidOperationException("Session is null!"); } if (request is null) { throw new InvalidOperationException("Request is null!"); } var user = _userManager.GetUserById(session.UserId); // Locking required to access list of groups. lock (_groupsLock) { _groups.TryGetValue(request.GroupId, out Group group); if (group is 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.Id, error, CancellationToken.None); return; } // 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.Id, error, CancellationToken.None); return; } if (_sessionToGroupMap.TryGetValue(session.Id, out var existingGroup)) { if (existingGroup.GroupId.Equals(request.GroupId)) { // Restore session. UpdateSessionsCounter(session.UserId, 1); group.SessionJoin(session, request, cancellationToken); return; } var leaveGroupRequest = new LeaveGroupRequest(); LeaveGroup(session, leaveGroupRequest, cancellationToken); } if (!_sessionToGroupMap.TryAdd(session.Id, group)) { throw new InvalidOperationException("Could not add session to group!"); } UpdateSessionsCounter(session.UserId, 1); group.SessionJoin(session, request, cancellationToken); } } } /// public void LeaveGroup(SessionInfo session, LeaveGroupRequest request, CancellationToken cancellationToken) { if (session is null) { throw new InvalidOperationException("Session is null!"); } if (request is null) { throw new InvalidOperationException("Request is null!"); } // Locking required to access list of groups. lock (_groupsLock) { if (_sessionToGroupMap.TryGetValue(session.Id, out var group)) { // Group lock required to let other requests end first. lock (group) { if (_sessionToGroupMap.TryRemove(session.Id, out var tempGroup)) { if (!tempGroup.GroupId.Equals(group.GroupId)) { throw new InvalidOperationException("Session was in wrong group!"); } } else { throw new InvalidOperationException("Could not remove session from group!"); } UpdateSessionsCounter(session.UserId, -1); group.SessionLeave(session, request, cancellationToken); if (group.IsGroupEmpty()) { _logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId); _groups.Remove(group.GroupId, out _); } } } else { _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.Id, error, CancellationToken.None); } } } /// public List ListGroups(SessionInfo session, ListGroupsRequest request) { if (session is null) { throw new InvalidOperationException("Session is null!"); } if (request is null) { throw new InvalidOperationException("Request is null!"); } var user = _userManager.GetUserById(session.UserId); List list = new List(); lock (_groupsLock) { foreach (var (_, group) in _groups) { // 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) { if (session is null) { throw new InvalidOperationException("Session is null!"); } if (request is null) { throw new InvalidOperationException("Request is null!"); } if (_sessionToGroupMap.TryGetValue(session.Id, out var group)) { // Group lock required as Group is not thread-safe. lock (group) { // Make sure that session still belongs to this group. if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId)) { // Drop request. return; } // Drop request if group is empty. if (group.IsGroupEmpty()) { return; } // Apply requested changes to group. group.HandleRequest(session, request, cancellationToken); } } else { _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.Id, error, CancellationToken.None); } } /// public bool IsUserActive(Guid userId) { if (_activeUsers.TryGetValue(userId, out var sessionsCounter)) { return sessionsCounter > 0; } else { return false; } } /// /// 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.SessionEnded -= OnSessionEnded; _disposed = true; } private void OnSessionEnded(object sender, SessionEventArgs e) { var session = e.SessionInfo; if (_sessionToGroupMap.TryGetValue(session.Id, out _)) { var leaveGroupRequest = new LeaveGroupRequest(); LeaveGroup(session, leaveGroupRequest, CancellationToken.None); } } private void UpdateSessionsCounter(Guid userId, int toAdd) { // Update sessions counter. var newSessionsCounter = _activeUsers.AddOrUpdate( userId, 1, (_, sessionsCounter) => sessionsCounter + toAdd); // Should never happen. if (newSessionsCounter < 0) { throw new InvalidOperationException("Sessions counter is negative!"); } // Clean record if user has no more active sessions. if (newSessionsCounter == 0) { _activeUsers.TryRemove(new KeyValuePair(userId, newSessionsCounter)); } } } }