using System; using System.Collections.Generic; using System.Linq; using System.Threading; using Jellyfin.Data.Enums; 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 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. /// private readonly object _groupsLock = new object(); /// /// Lock used for accessing the session-to-group map. /// 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) { // TODO: create abstract class for GroupRequests to avoid explicit request type here. if (!IsRequestValid(session, GroupRequestType.NewGroup, request)) { return; } // 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)) { LeaveGroup(session, 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, Guid groupId, JoinGroupRequest request, CancellationToken cancellationToken) { // TODO: create abstract class for GroupRequests to avoid explicit request type here. if (!IsRequestValid(session, GroupRequestType.JoinGroup, request)) { return; } var user = _userManager.GetUserById(session.UserId); // Locking required to access list of groups. lock (_groupsLock) { _groups.TryGetValue(groupId, out IGroupController group); if (group == null) { _logger.LogWarning("Session {SessionId} tried to join group {GroupId} that does not exist.", session.Id, 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(groupId)) { group.SessionRestore(session, request, cancellationToken); return; } LeaveGroup(session, cancellationToken); } AddSessionToGroup(session, group); group.SessionJoin(session, request, cancellationToken); } } } } /// public void LeaveGroup(SessionInfo session, CancellationToken cancellationToken) { // TODO: create abstract class for GroupRequests to avoid explicit request type here. if (!IsRequestValid(session, GroupRequestType.LeaveGroup)) { return; } // 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, cancellationToken); if (group.IsGroupEmpty()) { _logger.LogInformation("Group {GroupId} is empty, removing it.", group.GroupId); _groups.Remove(group.GroupId, out _); } } } } } /// public List ListGroups(SessionInfo session) { // TODO: create abstract class for GroupRequests to avoid explicit request type here. if (!IsRequestValid(session, GroupRequestType.ListGroups)) { return new List(); } 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) { // TODO: create abstract class for GroupRequests to avoid explicit request type here. if (!IsRequestValid(session, GroupRequestType.Playback, request)) { return; } 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 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 = FindJoinedGroupId(session); if (groupId.Equals(Guid.Empty)) { return; } var request = new JoinGroupRequest(groupId); JoinGroup(session, groupId, request, CancellationToken.None); } /// /// Checks if a given session has joined a group. /// /// The session. /// true if the session has joined a group, false otherwise. private bool IsSessionInGroup(SessionInfo session) { lock (_mapsLock) { return _sessionToGroupMap.ContainsKey(session.Id); } } /// /// Gets the group joined by the given session, if any. /// /// The session. /// The group. private IGroupController FindJoinedGroup(SessionInfo session) { lock (_mapsLock) { _sessionToGroupMap.TryGetValue(session.Id, out var group); return group; } } /// /// Gets the group identifier joined by the given session, if any. /// /// 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. /// /// 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!"); } lock (_mapsLock) { 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. /// /// 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!"); } lock (_mapsLock) { 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!"); } } } /// /// Checks if a given session is allowed to make a given request. /// /// The session. /// The request type. /// The request. /// Whether to check if request is null. /// true if the request is valid, false otherwise. Will return false also when session is null. private bool IsRequestValid(SessionInfo session, GroupRequestType requestType, T request, bool checkRequest = true) { if (session == null || (request == null && checkRequest)) { return false; } var user = _userManager.GetUserById(session.UserId); if (user.SyncPlayAccess == SyncPlayAccess.None) { _logger.LogWarning("Session {SessionId} requested {RequestType} but does not have access to SyncPlay.", session.Id, requestType); // TODO: rename to a more generic error. Next PR will fix this. var error = new GroupUpdate(Guid.Empty, GroupUpdateType.JoinGroupDenied, string.Empty); _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return false; } if (requestType.Equals(GroupRequestType.NewGroup) && user.SyncPlayAccess != SyncPlayAccess.CreateAndJoinGroups) { _logger.LogWarning("Session {SessionId} does not have permission to create groups.", session.Id); var error = new GroupUpdate(Guid.Empty, GroupUpdateType.CreateGroupDenied, string.Empty); _sessionManager.SendSyncPlayGroupUpdate(session, error, CancellationToken.None); return false; } return true; } /// /// Checks if a given session is allowed to make a given type of request. /// /// The session. /// The request type. /// true if the request is valid, false otherwise. Will return false also when session is null. private bool IsRequestValid(SessionInfo session, GroupRequestType requestType) { return IsRequestValid(session, requestType, session, false); } } }