using MediaBrowser.Common.Events; using MediaBrowser.Common.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Logging; using MediaBrowser.Model.Session; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace MediaBrowser.Server.Implementations.Session { /// /// Class SessionManager /// public class SessionManager : ISessionManager { /// /// The _user data repository /// private readonly IUserDataManager _userDataRepository; /// /// The _user repository /// private readonly IUserRepository _userRepository; /// /// The _logger /// private readonly ILogger _logger; private readonly ILibraryManager _libraryManager; /// /// Gets or sets the configuration manager. /// /// The configuration manager. private readonly IServerConfigurationManager _configurationManager; /// /// The _active connections /// private readonly ConcurrentDictionary _activeConnections = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); /// /// Occurs when [playback start]. /// public event EventHandler PlaybackStart; /// /// Occurs when [playback progress]. /// public event EventHandler PlaybackProgress; /// /// Occurs when [playback stopped]. /// public event EventHandler PlaybackStopped; /// /// Initializes a new instance of the class. /// /// The user data repository. /// The configuration manager. /// The logger. /// The user repository. public SessionManager(IUserDataManager userDataRepository, IServerConfigurationManager configurationManager, ILogger logger, IUserRepository userRepository) { _userDataRepository = userDataRepository; _configurationManager = configurationManager; _logger = logger; _userRepository = userRepository; } /// /// Gets all connections. /// /// All connections. public IEnumerable Sessions { get { return _activeConnections.Values.OrderByDescending(c => c.LastActivityDate).ToList(); } } /// /// Logs the user activity. /// /// Type of the client. /// The app version. /// The device id. /// Name of the device. /// The user. /// Task. /// /// user public async Task LogSessionActivity(string clientType, string appVersion, string deviceId, string deviceName, User user) { if (string.IsNullOrEmpty(clientType)) { throw new ArgumentNullException("clientType"); } if (string.IsNullOrEmpty(appVersion)) { throw new ArgumentNullException("appVersion"); } if (string.IsNullOrEmpty(deviceId)) { throw new ArgumentNullException("deviceId"); } if (string.IsNullOrEmpty(deviceName)) { throw new ArgumentNullException("deviceName"); } if (user != null && user.Configuration.IsDisabled) { throw new UnauthorizedAccessException(string.Format("The {0} account is currently disabled. Please consult with your administrator.", user.Name)); } var activityDate = DateTime.UtcNow; var session = GetSessionInfo(clientType, appVersion, deviceId, deviceName, user); session.LastActivityDate = activityDate; if (user == null) { return session; } var lastActivityDate = user.LastActivityDate; user.LastActivityDate = activityDate; // Don't log in the db anymore frequently than 10 seconds if (lastActivityDate.HasValue && (activityDate - lastActivityDate.Value).TotalSeconds < 10) { return session; } // Save this directly. No need to fire off all the events for this. await _userRepository.SaveUser(user, CancellationToken.None).ConfigureAwait(false); return session; } /// /// Updates the now playing item id. /// /// The session. /// The item. /// if set to true [is paused]. /// The current position ticks. private void UpdateNowPlayingItem(SessionInfo session, BaseItem item, bool isPaused, bool isMuted, long? currentPositionTicks = null) { session.IsMuted = isMuted; session.IsPaused = isPaused; session.NowPlayingPositionTicks = currentPositionTicks; session.NowPlayingItem = item; session.LastActivityDate = DateTime.UtcNow; } /// /// Removes the now playing item id. /// /// The session. /// The item. private void RemoveNowPlayingItem(SessionInfo session, BaseItem item) { if (item == null) { throw new ArgumentNullException("item"); } if (session.NowPlayingItem != null && session.NowPlayingItem.Id == item.Id) { session.NowPlayingItem = null; session.NowPlayingPositionTicks = null; session.IsPaused = false; } } /// /// Gets the connection. /// /// Type of the client. /// The app version. /// The device id. /// Name of the device. /// The user. /// SessionInfo. private SessionInfo GetSessionInfo(string clientType, string appVersion, string deviceId, string deviceName, User user) { var key = clientType + deviceId + appVersion; var connection = _activeConnections.GetOrAdd(key, keyName => new SessionInfo { Client = clientType, DeviceId = deviceId, ApplicationVersion = appVersion, Id = Guid.NewGuid() }); connection.DeviceName = deviceName; connection.User = user; return connection; } /// /// Used to report that playback has started for an item /// /// The info. /// Task. /// info public async Task OnPlaybackStart(PlaybackInfo info) { if (info == null) { throw new ArgumentNullException("info"); } if (info.SessionId == Guid.Empty) { throw new ArgumentNullException("info"); } var session = Sessions.First(i => i.Id.Equals(info.SessionId)); var item = info.Item; UpdateNowPlayingItem(session, item, false, false); session.CanSeek = info.CanSeek; session.QueueableMediaTypes = info.QueueableMediaTypes; var key = item.GetUserDataKey(); var user = session.User; var data = _userDataRepository.GetUserData(user.Id, key); data.PlayCount++; data.LastPlayedDate = DateTime.UtcNow; if (!(item is Video)) { data.Played = true; } await _userDataRepository.SaveUserData(user.Id, info.Item, data, UserDataSaveReason.PlaybackStart, CancellationToken.None).ConfigureAwait(false); // Nothing to save here // Fire events to inform plugins EventHelper.QueueEventIfNotNull(PlaybackStart, this, new PlaybackProgressEventArgs { Item = item, User = user }, _logger); } /// /// Used to report playback progress for an item /// /// The info. /// Task. /// /// positionTicks public async Task OnPlaybackProgress(PlaybackProgressInfo info) { if (info == null) { throw new ArgumentNullException("info"); } if (info.PositionTicks.HasValue && info.PositionTicks.Value < 0) { throw new ArgumentOutOfRangeException("positionTicks"); } var session = Sessions.First(i => i.Id.Equals(info.SessionId)); UpdateNowPlayingItem(session, info.Item, info.IsPaused, info.IsMuted, info.PositionTicks); var key = info.Item.GetUserDataKey(); var user = session.User; if (info.PositionTicks.HasValue) { var data = _userDataRepository.GetUserData(user.Id, key); UpdatePlayState(info.Item, data, info.PositionTicks.Value); await _userDataRepository.SaveUserData(user.Id, info.Item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None).ConfigureAwait(false); } EventHelper.QueueEventIfNotNull(PlaybackProgress, this, new PlaybackProgressEventArgs { Item = info.Item, User = user, PlaybackPositionTicks = info.PositionTicks }, _logger); } /// /// Used to report that playback has ended for an item /// /// The info. /// Task. /// info /// positionTicks public async Task OnPlaybackStopped(PlaybackStopInfo info) { if (info == null) { throw new ArgumentNullException("info"); } if (info.Item == null) { throw new ArgumentException("PlaybackStopInfo.Item cannot be null"); } if (info.SessionId == Guid.Empty) { throw new ArgumentException("PlaybackStopInfo.SessionId cannot be Guid.Empty"); } if (info.PositionTicks.HasValue && info.PositionTicks.Value < 0) { throw new ArgumentOutOfRangeException("positionTicks"); } var session = Sessions.First(i => i.Id.Equals(info.SessionId)); RemoveNowPlayingItem(session, info.Item); var key = info.Item.GetUserDataKey(); var user = session.User; var data = _userDataRepository.GetUserData(user.Id, key); if (info.PositionTicks.HasValue) { UpdatePlayState(info.Item, data, info.PositionTicks.Value); } else { // If the client isn't able to report this, then we'll just have to make an assumption data.PlayCount++; data.Played = true; data.PlaybackPositionTicks = 0; } await _userDataRepository.SaveUserData(user.Id, info.Item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None).ConfigureAwait(false); EventHelper.QueueEventIfNotNull(PlaybackStopped, this, new PlaybackProgressEventArgs { Item = info.Item, User = user, PlaybackPositionTicks = info.PositionTicks }, _logger); } /// /// Updates playstate position for an item but does not save /// /// The item /// User data for the item /// The current playback position private void UpdatePlayState(BaseItem item, UserItemData data, long positionTicks) { var hasRuntime = item.RunTimeTicks.HasValue && item.RunTimeTicks > 0; // If a position has been reported, and if we know the duration if (positionTicks > 0 && hasRuntime) { var pctIn = Decimal.Divide(positionTicks, item.RunTimeTicks.Value) * 100; // Don't track in very beginning if (pctIn < _configurationManager.Configuration.MinResumePct) { positionTicks = 0; } // If we're at the end, assume completed else if (pctIn > _configurationManager.Configuration.MaxResumePct || positionTicks >= item.RunTimeTicks.Value) { positionTicks = 0; data.Played = true; } else { // Enforce MinResumeDuration var durationSeconds = TimeSpan.FromTicks(item.RunTimeTicks.Value).TotalSeconds; if (durationSeconds < _configurationManager.Configuration.MinResumeDurationSeconds) { positionTicks = 0; data.Played = true; } } } else if (!hasRuntime) { // If we don't know the runtime we'll just have to assume it was fully played data.Played = true; positionTicks = 0; } if (item is Audio) { positionTicks = 0; } data.PlaybackPositionTicks = positionTicks; } /// /// Gets the session for remote control. /// /// The session id. /// SessionInfo. /// private SessionInfo GetSessionForRemoteControl(Guid sessionId) { var session = Sessions.First(i => i.Id.Equals(sessionId)); if (session == null) { throw new ResourceNotFoundException(string.Format("Session {0} not found.", sessionId)); } if (!session.SupportsRemoteControl) { throw new ArgumentException(string.Format("Session {0} does not support remote control.", session.Id)); } return session; } /// /// Sends the system command. /// /// The session id. /// The command. /// The cancellation token. /// Task. public Task SendSystemCommand(Guid sessionId, SystemCommand command, CancellationToken cancellationToken) { var session = GetSessionForRemoteControl(sessionId); return session.SessionController.SendSystemCommand(command, cancellationToken); } /// /// Sends the message command. /// /// The session id. /// The command. /// The cancellation token. /// Task. public Task SendMessageCommand(Guid sessionId, MessageCommand command, CancellationToken cancellationToken) { var session = GetSessionForRemoteControl(sessionId); return session.SessionController.SendMessageCommand(command, cancellationToken); } /// /// Sends the play command. /// /// The session id. /// The command. /// The cancellation token. /// Task. public Task SendPlayCommand(Guid sessionId, PlayRequest command, CancellationToken cancellationToken) { var session = GetSessionForRemoteControl(sessionId); var items = command.ItemIds.Select(i => _libraryManager.GetItemById(new Guid(i))) .ToList(); if (items.Any(i => i.LocationType == LocationType.Virtual)) { throw new ArgumentException("Virtual items are not playable."); } if (command.PlayCommand != PlayCommand.PlayNow) { if (items.Any(i => !session.QueueableMediaTypes.Contains(i.MediaType, StringComparer.OrdinalIgnoreCase))) { throw new ArgumentException(string.Format("Session {0} is unable to queue the requested media type.", session.Id)); } } return session.SessionController.SendPlayCommand(command, cancellationToken); } /// /// Sends the browse command. /// /// The session id. /// The command. /// The cancellation token. /// Task. public Task SendBrowseCommand(Guid sessionId, BrowseRequest command, CancellationToken cancellationToken) { var session = GetSessionForRemoteControl(sessionId); return session.SessionController.SendBrowseCommand(command, cancellationToken); } /// /// Sends the playstate command. /// /// The session id. /// The command. /// The cancellation token. /// Task. public Task SendPlaystateCommand(Guid sessionId, PlaystateRequest command, CancellationToken cancellationToken) { var session = GetSessionForRemoteControl(sessionId); if (command.Command == PlaystateCommand.Seek && !session.CanSeek) { throw new ArgumentException(string.Format("Session {0} is unable to seek.", session.Id)); } return session.SessionController.SendPlaystateCommand(command, cancellationToken); } /// /// Sends the restart required message. /// /// The cancellation token. /// Task. public Task SendRestartRequiredNotification(CancellationToken cancellationToken) { var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList(); var tasks = sessions.Select(session => Task.Run(async () => { try { await session.SessionController.SendRestartRequiredNotification(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger.ErrorException("Error in SendRestartRequiredNotification.", ex); } })); return Task.WhenAll(tasks); } /// /// Sends the server shutdown notification. /// /// The cancellation token. /// Task. public Task SendServerShutdownNotification(CancellationToken cancellationToken) { var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList(); var tasks = sessions.Select(session => Task.Run(async () => { try { await session.SessionController.SendServerShutdownNotification(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger.ErrorException("Error in SendServerShutdownNotification.", ex); } })); return Task.WhenAll(tasks); } /// /// Sends the server restart notification. /// /// The cancellation token. /// Task. public Task SendServerRestartNotification(CancellationToken cancellationToken) { var sessions = Sessions.Where(i => i.IsActive && i.SessionController != null).ToList(); var tasks = sessions.Select(session => Task.Run(async () => { try { await session.SessionController.SendServerRestartNotification(cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger.ErrorException("Error in SendServerRestartNotification.", ex); } })); return Task.WhenAll(tasks); } } }