From 53450bd514eec97d58eb18b8a01feab36475826b Mon Sep 17 00:00:00 2001 From: Luke Pulverenti Date: Sat, 6 Jul 2013 17:23:32 -0400 Subject: [PATCH] added a notifications service --- MediaBrowser.Api/MediaBrowser.Api.csproj | 1 + MediaBrowser.Api/NotificationsService.cs | 218 ++++++++ MediaBrowser.Api/SystemService.cs | 2 +- .../BaseApplicationHost.cs | 18 - .../ScheduledTasks/ScheduledTaskWorker.cs | 15 +- .../Updates/InstallationManager.cs | 27 +- MediaBrowser.Common/IApplicationHost.cs | 5 - .../MediaBrowser.Common.csproj | 1 + .../Updates/IInstallationManager.cs | 8 +- .../Updates/InstallationEventArgs.cs | 17 + .../MediaBrowser.Controller.csproj | 2 + .../Notifications/INotificationsRepository.cs | 76 +++ .../NotificationUpdateEventArgs.cs | 17 + .../MediaBrowser.Model.net35.csproj | 13 + MediaBrowser.Model/MediaBrowser.Model.csproj | 5 + .../Notifications/Notification.cs | 33 ++ .../Notifications/NotificationLevel.cs | 10 + .../Notifications/NotificationQuery.cs | 15 + .../Notifications/NotificationResult.cs | 9 + .../Notifications/NotificationsSummary.cs | 9 + MediaBrowser.Model/Tasks/TaskResult.cs | 6 + .../EntryPoints/Notifications/Notifier.cs | 173 +++++++ .../Notifications/WebSocketNotifier.cs | 63 +++ .../EntryPoints/WebSocketEvents.cs | 62 +-- ...MediaBrowser.Server.Implementations.csproj | 3 + .../Persistence/SqliteChapterRepository.cs | 26 +- .../Persistence/SqliteItemRepository.cs | 8 +- .../SqliteNotificationsRepository.cs | 490 ++++++++++++++++++ .../ScheduledTasks/PluginUpdateTask.cs | 5 +- .../ApplicationHost.cs | 53 ++ .../MediaBrowser.ServerApplication.csproj | 7 + .../packages.config | 1 + .../Api/DashboardService.cs | 2 + MediaBrowser.WebDashboard/ApiClient.js | 57 +- .../MediaBrowser.WebDashboard.csproj | 9 + MediaBrowser.WebDashboard/packages.config | 2 +- 36 files changed, 1361 insertions(+), 107 deletions(-) create mode 100644 MediaBrowser.Api/NotificationsService.cs create mode 100644 MediaBrowser.Common/Updates/InstallationEventArgs.cs create mode 100644 MediaBrowser.Controller/Notifications/INotificationsRepository.cs create mode 100644 MediaBrowser.Controller/Notifications/NotificationUpdateEventArgs.cs create mode 100644 MediaBrowser.Model/Notifications/Notification.cs create mode 100644 MediaBrowser.Model/Notifications/NotificationLevel.cs create mode 100644 MediaBrowser.Model/Notifications/NotificationQuery.cs create mode 100644 MediaBrowser.Model/Notifications/NotificationResult.cs create mode 100644 MediaBrowser.Model/Notifications/NotificationsSummary.cs create mode 100644 MediaBrowser.Server.Implementations/EntryPoints/Notifications/Notifier.cs create mode 100644 MediaBrowser.Server.Implementations/EntryPoints/Notifications/WebSocketNotifier.cs create mode 100644 MediaBrowser.Server.Implementations/Persistence/SqliteNotificationsRepository.cs diff --git a/MediaBrowser.Api/MediaBrowser.Api.csproj b/MediaBrowser.Api/MediaBrowser.Api.csproj index d22fa5c8ae..6bef8b5415 100644 --- a/MediaBrowser.Api/MediaBrowser.Api.csproj +++ b/MediaBrowser.Api/MediaBrowser.Api.csproj @@ -81,6 +81,7 @@ + diff --git a/MediaBrowser.Api/NotificationsService.cs b/MediaBrowser.Api/NotificationsService.cs new file mode 100644 index 0000000000..6b39b3c487 --- /dev/null +++ b/MediaBrowser.Api/NotificationsService.cs @@ -0,0 +1,218 @@ +using MediaBrowser.Controller.Notifications; +using MediaBrowser.Model.Notifications; +using ServiceStack.ServiceHost; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Api +{ + [Route("/Notifications/{UserId}", "GET")] + [Api(Description = "Gets notifications")] + public class GetNotifications : IReturn + { + [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] + public Guid UserId { get; set; } + + [ApiMember(Name = "IsRead", Description = "An optional filter by IsRead", IsRequired = false, DataType = "bool", ParameterType = "query", Verb = "GET")] + public bool? IsRead { get; set; } + + [ApiMember(Name = "StartIndex", Description = "Optional. The record index to start at. All items with a lower index will be dropped from the results.", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] + public int? StartIndex { get; set; } + + [ApiMember(Name = "Limit", Description = "Optional. The maximum number of records to return", IsRequired = false, DataType = "int", ParameterType = "query", Verb = "GET")] + public int? Limit { get; set; } + } + + [Route("/Notifications/{UserId}/Summary", "GET")] + [Api(Description = "Gets a notification summary for a user")] + public class GetNotificationsSummary : IReturn + { + [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] + public Guid UserId { get; set; } + } + + [Route("/Notifications/{UserId}", "POST")] + [Api(Description = "Adds a notifications")] + public class AddNotification : IReturn + { + [ApiMember(Name = "Id", Description = "The Id of the new notification. If unspecified one will be provided.", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] + public Guid? Id { get; set; } + + [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] + public Guid UserId { get; set; } + + [ApiMember(Name = "Name", Description = "The notification's name", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] + public string Name { get; set; } + + [ApiMember(Name = "Description", Description = "The notification's description", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] + public string Description { get; set; } + + [ApiMember(Name = "Url", Description = "The notification's info url", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] + public string Url { get; set; } + + [ApiMember(Name = "Category", Description = "The notification's category", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] + public string Category { get; set; } + + [ApiMember(Name = "RelatedId", Description = "The notification's related id (item)", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] + public string RelatedId { get; set; } + + [ApiMember(Name = "Level", Description = "The notification level", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] + public NotificationLevel Level { get; set; } + } + + [Route("/Notifications/{UserId}/{Id}", "POST")] + [Api(Description = "Updates a notifications")] + public class UpdateNotification : IReturnVoid + { + [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "GET")] + public Guid UserId { get; set; } + + [ApiMember(Name = "Id", Description = "Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] + public Guid Id { get; set; } + + [ApiMember(Name = "Name", Description = "The notification's name", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] + public string Name { get; set; } + + [ApiMember(Name = "Description", Description = "The notification's description", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST")] + public string Description { get; set; } + + [ApiMember(Name = "Url", Description = "The notification's info url", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] + public string Url { get; set; } + + [ApiMember(Name = "Category", Description = "The notification's category", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] + public string Category { get; set; } + + [ApiMember(Name = "RelatedId", Description = "The notification's related id (item)", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] + public string RelatedId { get; set; } + + [ApiMember(Name = "Level", Description = "The notification level", IsRequired = false, DataType = "string", ParameterType = "query", Verb = "POST")] + public NotificationLevel Level { get; set; } + } + + [Route("/Notifications/{UserId}/Read", "POST")] + [Api(Description = "Marks notifications as read")] + public class MarkRead : IReturnVoid + { + [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] + public Guid UserId { get; set; } + + [ApiMember(Name = "Ids", Description = "A list of notification ids, comma delimited", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)] + public string Ids { get; set; } + } + + [Route("/Notifications/{UserId}/Unread", "POST")] + [Api(Description = "Marks notifications as unread")] + public class MarkUnread : IReturnVoid + { + [ApiMember(Name = "UserId", Description = "User Id", IsRequired = true, DataType = "string", ParameterType = "path", Verb = "POST")] + public Guid UserId { get; set; } + + [ApiMember(Name = "Ids", Description = "A list of notification ids, comma delimited", IsRequired = true, DataType = "string", ParameterType = "query", Verb = "POST", AllowMultiple = true)] + public string Ids { get; set; } + } + + public class NotificationsService : BaseApiService + { + private readonly INotificationsRepository _notificationsRepo; + + public NotificationsService(INotificationsRepository notificationsRepo) + { + _notificationsRepo = notificationsRepo; + } + + public object Post(AddNotification request) + { + var task = AddNotification(request); + + return ToOptimizedResult(task.Result); + } + + public void Post(UpdateNotification request) + { + var task = UpdateNotification(request); + + Task.WaitAll(task); + } + + public object Get(GetNotificationsSummary request) + { + var result = _notificationsRepo.GetNotificationsSummary(request.UserId); + + return result; + } + + private async Task AddNotification(AddNotification request) + { + var notification = new Notification + { + Id = request.Id ?? Guid.NewGuid(), + Date = DateTime.UtcNow, + Description = request.Description, + Level = request.Level, + Name = request.Name, + Url = request.Url, + UserId = request.UserId, + Category = request.Category, + RelatedId = request.RelatedId + }; + + await _notificationsRepo.AddNotification(notification, CancellationToken.None).ConfigureAwait(false); + + return notification; + } + + private Task UpdateNotification(UpdateNotification request) + { + var notification = _notificationsRepo.GetNotification(request.Id, request.UserId); + + notification.Description = request.Description; + notification.Level = request.Level; + notification.Url = request.Url; + + notification.Date = DateTime.UtcNow; + + notification.RelatedId = request.RelatedId; + notification.Category = request.Category; + + notification.Name = request.Name; + + return _notificationsRepo.UpdateNotification(notification, CancellationToken.None); + } + + public void Post(MarkRead request) + { + var task = MarkRead(request.Ids, request.UserId, true); + + Task.WaitAll(task); + } + + public void Post(MarkUnread request) + { + var task = MarkRead(request.Ids, request.UserId, false); + + Task.WaitAll(task); + } + + private Task MarkRead(string idList, Guid userId, bool read) + { + var ids = idList.Split(',').Select(i => new Guid(i)); + + return _notificationsRepo.MarkRead(ids, userId, read, CancellationToken.None); + } + + public object Get(GetNotifications request) + { + var result = _notificationsRepo.GetNotifications(new NotificationQuery + { + IsRead = request.IsRead, + Limit = request.Limit, + StartIndex = request.StartIndex, + UserId = request.UserId + }); + + return ToOptimizedResult(result); + } + } +} diff --git a/MediaBrowser.Api/SystemService.cs b/MediaBrowser.Api/SystemService.cs index 69f99a963e..3360e5e9e6 100644 --- a/MediaBrowser.Api/SystemService.cs +++ b/MediaBrowser.Api/SystemService.cs @@ -134,7 +134,7 @@ namespace MediaBrowser.Api Task.Run(async () => { await Task.Delay(100); - _appHost.PerformPendingRestart(); + _appHost.Restart(); }); } diff --git a/MediaBrowser.Common.Implementations/BaseApplicationHost.cs b/MediaBrowser.Common.Implementations/BaseApplicationHost.cs index 46c84ff7d7..197142590a 100644 --- a/MediaBrowser.Common.Implementations/BaseApplicationHost.cs +++ b/MediaBrowser.Common.Implementations/BaseApplicationHost.cs @@ -512,24 +512,6 @@ namespace MediaBrowser.Common.Implementations Plugins = list; } - /// - /// Performs the pending restart. - /// - /// Task. - public void PerformPendingRestart() - { - if (HasPendingRestart) - { - Logger.Info("Restarting the application"); - - Restart(); - } - else - { - Logger.Info("PerformPendingRestart - not needed"); - } - } - /// /// Notifies that the kernel that a change has been made that requires a restart /// diff --git a/MediaBrowser.Common.Implementations/ScheduledTasks/ScheduledTaskWorker.cs b/MediaBrowser.Common.Implementations/ScheduledTasks/ScheduledTaskWorker.cs index 3367182499..8947bdcc03 100644 --- a/MediaBrowser.Common.Implementations/ScheduledTasks/ScheduledTaskWorker.cs +++ b/MediaBrowser.Common.Implementations/ScheduledTasks/ScheduledTaskWorker.cs @@ -343,6 +343,8 @@ namespace MediaBrowser.Common.Implementations.ScheduledTasks TaskCompletionStatus status; CurrentExecutionStartTime = DateTime.UtcNow; + Exception failureException = null; + try { await ExecuteTask(CurrentCancellationTokenSource.Token, progress).ConfigureAwait(false); @@ -357,6 +359,8 @@ namespace MediaBrowser.Common.Implementations.ScheduledTasks { Logger.ErrorException("Error", ex); + failureException = ex; + status = TaskCompletionStatus.Failed; } @@ -368,7 +372,7 @@ namespace MediaBrowser.Common.Implementations.ScheduledTasks CurrentCancellationTokenSource = null; CurrentProgress = null; - OnTaskCompleted(startTime, endTime, status); + OnTaskCompleted(startTime, endTime, status, failureException); } /// @@ -517,7 +521,7 @@ namespace MediaBrowser.Common.Implementations.ScheduledTasks /// The start time. /// The end time. /// The status. - private void OnTaskCompleted(DateTime startTime, DateTime endTime, TaskCompletionStatus status) + private void OnTaskCompleted(DateTime startTime, DateTime endTime, TaskCompletionStatus status, Exception ex) { var elapsedTime = endTime - startTime; @@ -532,6 +536,11 @@ namespace MediaBrowser.Common.Implementations.ScheduledTasks Id = Id }; + if (ex != null) + { + result.ErrorMessage = ex.Message; + } + JsonSerializer.SerializeToFile(result, GetHistoryFilePath(true)); LastExecutionResult = result; @@ -560,7 +569,7 @@ namespace MediaBrowser.Common.Implementations.ScheduledTasks if (State == TaskState.Running) { - OnTaskCompleted(CurrentExecutionStartTime, DateTime.UtcNow, TaskCompletionStatus.Aborted); + OnTaskCompleted(CurrentExecutionStartTime, DateTime.UtcNow, TaskCompletionStatus.Aborted, null); } if (CurrentCancellationTokenSource != null) diff --git a/MediaBrowser.Common.Implementations/Updates/InstallationManager.cs b/MediaBrowser.Common.Implementations/Updates/InstallationManager.cs index ba2cd7baa0..e7af4004b5 100644 --- a/MediaBrowser.Common.Implementations/Updates/InstallationManager.cs +++ b/MediaBrowser.Common.Implementations/Updates/InstallationManager.cs @@ -21,10 +21,10 @@ namespace MediaBrowser.Common.Implementations.Updates /// public class InstallationManager : IInstallationManager { - public event EventHandler> PackageInstalling; - public event EventHandler> PackageInstallationCompleted; - public event EventHandler> PackageInstallationFailed; - public event EventHandler> PackageInstallationCancelled; + public event EventHandler PackageInstalling; + public event EventHandler PackageInstallationCompleted; + public event EventHandler PackageInstallationFailed; + public event EventHandler PackageInstallationCancelled; /// /// The current installations @@ -384,7 +384,13 @@ namespace MediaBrowser.Common.Implementations.Updates var linkedToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, innerCancellationTokenSource.Token).Token; - EventHelper.QueueEventIfNotNull(PackageInstalling, this, new GenericEventArgs { Argument = installationInfo }, _logger); + var installationEventArgs = new InstallationEventArgs + { + InstallationInfo = installationInfo, + PackageVersionInfo = package + }; + + EventHelper.QueueEventIfNotNull(PackageInstalling, this, installationEventArgs, _logger); try { @@ -397,7 +403,7 @@ namespace MediaBrowser.Common.Implementations.Updates CompletedInstallations.Add(installationInfo); - EventHelper.QueueEventIfNotNull(PackageInstallationCompleted, this, new GenericEventArgs { Argument = installationInfo }, _logger); + EventHelper.QueueEventIfNotNull(PackageInstallationCompleted, this, installationEventArgs, _logger); } catch (OperationCanceledException) { @@ -408,7 +414,7 @@ namespace MediaBrowser.Common.Implementations.Updates _logger.Info("Package installation cancelled: {0} {1}", package.name, package.versionStr); - EventHelper.QueueEventIfNotNull(PackageInstallationCancelled, this, new GenericEventArgs { Argument = installationInfo }, _logger); + EventHelper.QueueEventIfNotNull(PackageInstallationCancelled, this, installationEventArgs, _logger); throw; } @@ -421,7 +427,12 @@ namespace MediaBrowser.Common.Implementations.Updates CurrentInstallations.Remove(tuple); } - EventHelper.QueueEventIfNotNull(PackageInstallationFailed, this, new GenericEventArgs { Argument = installationInfo }, _logger); + EventHelper.QueueEventIfNotNull(PackageInstallationFailed, this, new InstallationFailedEventArgs + { + InstallationInfo = installationInfo, + Exception = ex + + }, _logger); throw; } diff --git a/MediaBrowser.Common/IApplicationHost.cs b/MediaBrowser.Common/IApplicationHost.cs index 69c8b4f976..1ff28d924f 100644 --- a/MediaBrowser.Common/IApplicationHost.cs +++ b/MediaBrowser.Common/IApplicationHost.cs @@ -18,11 +18,6 @@ namespace MediaBrowser.Common /// event EventHandler> ApplicationUpdated; - /// - /// Performs the pending restart. - /// - void PerformPendingRestart(); - /// /// Gets or sets a value indicating whether this instance has pending kernel reload. /// diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index f4acca25da..1b58368422 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -107,6 +107,7 @@ + diff --git a/MediaBrowser.Common/Updates/IInstallationManager.cs b/MediaBrowser.Common/Updates/IInstallationManager.cs index 72b581325c..31259353f8 100644 --- a/MediaBrowser.Common/Updates/IInstallationManager.cs +++ b/MediaBrowser.Common/Updates/IInstallationManager.cs @@ -11,10 +11,10 @@ namespace MediaBrowser.Common.Updates { public interface IInstallationManager : IDisposable { - event EventHandler> PackageInstalling; - event EventHandler> PackageInstallationCompleted; - event EventHandler> PackageInstallationFailed; - event EventHandler> PackageInstallationCancelled; + event EventHandler PackageInstalling; + event EventHandler PackageInstallationCompleted; + event EventHandler PackageInstallationFailed; + event EventHandler PackageInstallationCancelled; /// /// The current installations diff --git a/MediaBrowser.Common/Updates/InstallationEventArgs.cs b/MediaBrowser.Common/Updates/InstallationEventArgs.cs new file mode 100644 index 0000000000..2c3a805de1 --- /dev/null +++ b/MediaBrowser.Common/Updates/InstallationEventArgs.cs @@ -0,0 +1,17 @@ +using MediaBrowser.Model.Updates; +using System; + +namespace MediaBrowser.Common.Updates +{ + public class InstallationEventArgs + { + public InstallationInfo InstallationInfo { get; set; } + + public PackageVersionInfo PackageVersionInfo { get; set; } + } + + public class InstallationFailedEventArgs : InstallationEventArgs + { + public Exception Exception { get; set; } + } +} diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj index 6910722d68..3d2b46712f 100644 --- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj +++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj @@ -85,6 +85,8 @@ + + diff --git a/MediaBrowser.Controller/Notifications/INotificationsRepository.cs b/MediaBrowser.Controller/Notifications/INotificationsRepository.cs new file mode 100644 index 0000000000..8790b54f46 --- /dev/null +++ b/MediaBrowser.Controller/Notifications/INotificationsRepository.cs @@ -0,0 +1,76 @@ +using System.Threading; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Model.Notifications; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace MediaBrowser.Controller.Notifications +{ + /// + /// Interface INotificationsRepository + /// + public interface INotificationsRepository + { + /// + /// Occurs when [notification added]. + /// + event EventHandler NotificationAdded; + /// + /// Occurs when [notification updated]. + /// + event EventHandler NotificationUpdated; + /// + /// Occurs when [notifications marked read]. + /// + event EventHandler NotificationsMarkedRead; + + /// + /// Gets the notifications. + /// + /// The query. + /// NotificationResult. + NotificationResult GetNotifications(NotificationQuery query); + + /// + /// Gets the notification. + /// + /// The id. + /// The user id. + /// Notification. + Notification GetNotification(Guid id, Guid userId); + + /// + /// Adds the notification. + /// + /// The notification. + /// The cancellation token. + /// Task. + Task AddNotification(Notification notification, CancellationToken cancellationToken); + + /// + /// Updates the notification. + /// + /// The notification. + /// The cancellation token. + /// Task. + Task UpdateNotification(Notification notification, CancellationToken cancellationToken); + + /// + /// Marks the read. + /// + /// The notification id list. + /// The user id. + /// if set to true [is read]. + /// The cancellation token. + /// Task. + Task MarkRead(IEnumerable notificationIdList, Guid userId, bool isRead, CancellationToken cancellationToken); + + /// + /// Gets the notifications summary. + /// + /// The user id. + /// NotificationsSummary. + NotificationsSummary GetNotificationsSummary(Guid userId); + } +} diff --git a/MediaBrowser.Controller/Notifications/NotificationUpdateEventArgs.cs b/MediaBrowser.Controller/Notifications/NotificationUpdateEventArgs.cs new file mode 100644 index 0000000000..e156ca26a3 --- /dev/null +++ b/MediaBrowser.Controller/Notifications/NotificationUpdateEventArgs.cs @@ -0,0 +1,17 @@ +using MediaBrowser.Model.Notifications; +using System; + +namespace MediaBrowser.Controller.Notifications +{ + public class NotificationUpdateEventArgs : EventArgs + { + public Notification Notification { get; set; } + } + + public class NotificationReadEventArgs : EventArgs + { + public Guid[] IdList { get; set; } + public Guid UserId { get; set; } + public bool IsRead { get; set; } + } +} diff --git a/MediaBrowser.Model.net35/MediaBrowser.Model.net35.csproj b/MediaBrowser.Model.net35/MediaBrowser.Model.net35.csproj index 92cef12c92..78ac127141 100644 --- a/MediaBrowser.Model.net35/MediaBrowser.Model.net35.csproj +++ b/MediaBrowser.Model.net35/MediaBrowser.Model.net35.csproj @@ -214,6 +214,18 @@ Net\WebSocketState.cs + + Notifications\Notification.cs + + + Notifications\NotificationLevel.cs + + + Notifications\NotificationQuery.cs + + + Notifications\NotificationResult.cs + Plugins\BasePluginConfiguration.cs @@ -333,6 +345,7 @@ + if $(ConfigurationName) == Release ( diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index b3e837dcaf..b59e54bcfb 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -59,6 +59,11 @@ + + + + + diff --git a/MediaBrowser.Model/Notifications/Notification.cs b/MediaBrowser.Model/Notifications/Notification.cs new file mode 100644 index 0000000000..14f55b6e17 --- /dev/null +++ b/MediaBrowser.Model/Notifications/Notification.cs @@ -0,0 +1,33 @@ +using System; + +namespace MediaBrowser.Model.Notifications +{ + public class Notification + { + public Guid Id { get; set; } + + public Guid UserId { get; set; } + + public DateTime Date { get; set; } + + public bool IsRead { get; set; } + + public string Name { get; set; } + + public string Description { get; set; } + + public string Url { get; set; } + + public string Category { get; set; } + + public string RelatedId { get; set; } + + public NotificationLevel Level { get; set; } + + public Notification() + { + Id = Guid.NewGuid(); + Date = DateTime.UtcNow; + } + } +} diff --git a/MediaBrowser.Model/Notifications/NotificationLevel.cs b/MediaBrowser.Model/Notifications/NotificationLevel.cs new file mode 100644 index 0000000000..24946e0718 --- /dev/null +++ b/MediaBrowser.Model/Notifications/NotificationLevel.cs @@ -0,0 +1,10 @@ + +namespace MediaBrowser.Model.Notifications +{ + public enum NotificationLevel + { + Normal, + Warning, + Error + } +} diff --git a/MediaBrowser.Model/Notifications/NotificationQuery.cs b/MediaBrowser.Model/Notifications/NotificationQuery.cs new file mode 100644 index 0000000000..39a7326a6d --- /dev/null +++ b/MediaBrowser.Model/Notifications/NotificationQuery.cs @@ -0,0 +1,15 @@ +using System; + +namespace MediaBrowser.Model.Notifications +{ + public class NotificationQuery + { + public Guid? UserId { get; set; } + + public bool? IsRead { get; set; } + + public int? StartIndex { get; set; } + + public int? Limit { get; set; } + } +} diff --git a/MediaBrowser.Model/Notifications/NotificationResult.cs b/MediaBrowser.Model/Notifications/NotificationResult.cs new file mode 100644 index 0000000000..a98fe4edb0 --- /dev/null +++ b/MediaBrowser.Model/Notifications/NotificationResult.cs @@ -0,0 +1,9 @@ + +namespace MediaBrowser.Model.Notifications +{ + public class NotificationResult + { + public Notification[] Notifications { get; set; } + public int TotalRecordCount { get; set; } + } +} diff --git a/MediaBrowser.Model/Notifications/NotificationsSummary.cs b/MediaBrowser.Model/Notifications/NotificationsSummary.cs new file mode 100644 index 0000000000..87dd51a5f0 --- /dev/null +++ b/MediaBrowser.Model/Notifications/NotificationsSummary.cs @@ -0,0 +1,9 @@ + +namespace MediaBrowser.Model.Notifications +{ + public class NotificationsSummary + { + public int UnreadCount { get; set; } + public NotificationLevel MaxUnreadNotificationLevel { get; set; } + } +} diff --git a/MediaBrowser.Model/Tasks/TaskResult.cs b/MediaBrowser.Model/Tasks/TaskResult.cs index 46d2c86e7d..c04d2f2fe6 100644 --- a/MediaBrowser.Model/Tasks/TaskResult.cs +++ b/MediaBrowser.Model/Tasks/TaskResult.cs @@ -36,5 +36,11 @@ namespace MediaBrowser.Model.Tasks /// /// The id. public Guid Id { get; set; } + + /// + /// Gets or sets the error message. + /// + /// The error message. + public string ErrorMessage { get; set; } } } diff --git a/MediaBrowser.Server.Implementations/EntryPoints/Notifications/Notifier.cs b/MediaBrowser.Server.Implementations/EntryPoints/Notifications/Notifier.cs new file mode 100644 index 0000000000..65567d3a88 --- /dev/null +++ b/MediaBrowser.Server.Implementations/EntryPoints/Notifications/Notifier.cs @@ -0,0 +1,173 @@ +using MediaBrowser.Common.Events; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Common.ScheduledTasks; +using MediaBrowser.Common.Updates; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Notifications; +using MediaBrowser.Controller.Plugins; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Notifications; +using System; +using System.Linq; +using System.Threading; +using MediaBrowser.Model.Tasks; + +namespace MediaBrowser.Server.Implementations.EntryPoints.Notifications +{ + /// + /// Creates notifications for various system events + /// + public class Notifications : IServerEntryPoint + { + private readonly INotificationsRepository _notificationsRepo; + private readonly IInstallationManager _installationManager; + private readonly IUserManager _userManager; + private readonly ILogger _logger; + + private readonly ITaskManager _taskManager; + + public Notifications(IInstallationManager installationManager, INotificationsRepository notificationsRepo, IUserManager userManager, ILogger logger, ITaskManager taskManager) + { + _installationManager = installationManager; + _notificationsRepo = notificationsRepo; + _userManager = userManager; + _logger = logger; + _taskManager = taskManager; + } + + public void Run() + { + _installationManager.PackageInstallationCompleted += _installationManager_PackageInstallationCompleted; + _installationManager.PackageInstallationFailed += _installationManager_PackageInstallationFailed; + _installationManager.PluginUninstalled += _installationManager_PluginUninstalled; + + _taskManager.TaskCompleted += _taskManager_TaskCompleted; + } + + async void _taskManager_TaskCompleted(object sender, GenericEventArgs e) + { + var result = e.Argument; + + if (result.Status == TaskCompletionStatus.Failed) + { + foreach (var user in _userManager + .Users + .Where(i => i.Configuration.IsAdministrator) + .ToList()) + { + var notification = new Notification + { + UserId = user.Id, + Category = "ScheduledTaskFailed", + Name = result.Name + " failed", + RelatedId = result.Name, + Description = result.ErrorMessage, + Level = NotificationLevel.Error + }; + + try + { + await _notificationsRepo.AddNotification(notification, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error adding notification", ex); + } + } + } + } + + async void _installationManager_PluginUninstalled(object sender, GenericEventArgs e) + { + var plugin = e.Argument; + + foreach (var user in _userManager + .Users + .Where(i => i.Configuration.IsAdministrator) + .ToList()) + { + var notification = new Notification + { + UserId = user.Id, + Category = "PluginUninstalled", + Name = plugin.Name + " has been uninstalled", + RelatedId = plugin.Id.ToString() + }; + + try + { + await _notificationsRepo.AddNotification(notification, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error adding notification", ex); + } + } + } + + async void _installationManager_PackageInstallationCompleted(object sender, InstallationEventArgs e) + { + var installationInfo = e.InstallationInfo; + + foreach (var user in _userManager + .Users + .Where(i => i.Configuration.IsAdministrator) + .ToList()) + { + var notification = new Notification + { + UserId = user.Id, + Category = "PackageInstallationCompleted", + Name = installationInfo.Name + " " + installationInfo.Version + " was installed", + RelatedId = installationInfo.Name, + Description = e.PackageVersionInfo.description + }; + + try + { + await _notificationsRepo.AddNotification(notification, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error adding notification", ex); + } + } + } + + async void _installationManager_PackageInstallationFailed(object sender, InstallationFailedEventArgs e) + { + var installationInfo = e.InstallationInfo; + + foreach (var user in _userManager + .Users + .Where(i => i.Configuration.IsAdministrator) + .ToList()) + { + var notification = new Notification + { + UserId = user.Id, + Category = "PackageInstallationFailed", + Level = NotificationLevel.Error, + Name = installationInfo.Name + " " + installationInfo.Version + " installation failed", + RelatedId = installationInfo.Name, + Description = e.Exception.Message + }; + + try + { + await _notificationsRepo.AddNotification(notification, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.ErrorException("Error adding notification", ex); + } + } + } + + public void Dispose() + { + _installationManager.PackageInstallationCompleted -= _installationManager_PackageInstallationCompleted; + _installationManager.PackageInstallationFailed -= _installationManager_PackageInstallationFailed; + } + } +} diff --git a/MediaBrowser.Server.Implementations/EntryPoints/Notifications/WebSocketNotifier.cs b/MediaBrowser.Server.Implementations/EntryPoints/Notifications/WebSocketNotifier.cs new file mode 100644 index 0000000000..2264cc5244 --- /dev/null +++ b/MediaBrowser.Server.Implementations/EntryPoints/Notifications/WebSocketNotifier.cs @@ -0,0 +1,63 @@ +using MediaBrowser.Common.Net; +using MediaBrowser.Controller.Notifications; +using MediaBrowser.Controller.Plugins; +using System.Linq; + +namespace MediaBrowser.Server.Implementations.EntryPoints.Notifications +{ + /// + /// Sends out messages anytime a notification is added or udpated + /// + public class WebSocketNotifier : IServerEntryPoint + { + private readonly INotificationsRepository _notificationsRepo; + + private readonly IServerManager _serverManager; + + public WebSocketNotifier(INotificationsRepository notificationsRepo, IServerManager serverManager) + { + _notificationsRepo = notificationsRepo; + _serverManager = serverManager; + } + + public void Run() + { + _notificationsRepo.NotificationAdded += _notificationsRepo_NotificationAdded; + _notificationsRepo.NotificationUpdated += _notificationsRepo_NotificationUpdated; + + _notificationsRepo.NotificationsMarkedRead += _notificationsRepo_NotificationsMarkedRead; + } + + void _notificationsRepo_NotificationsMarkedRead(object sender, NotificationReadEventArgs e) + { + var list = e.IdList.Select(i => i.ToString("N")).ToList(); + + list.Add(e.UserId.ToString("N")); + list.Add(e.IsRead.ToString().ToLower()); + + var msg = string.Join("|", list.ToArray()); + + _serverManager.SendWebSocketMessage("NotificationsMarkedRead", msg); + } + + void _notificationsRepo_NotificationUpdated(object sender, NotificationUpdateEventArgs e) + { + var msg = e.Notification.UserId + "|" + e.Notification.Id; + + _serverManager.SendWebSocketMessage("NotificationUpdated", msg); + } + + void _notificationsRepo_NotificationAdded(object sender, NotificationUpdateEventArgs e) + { + var msg = e.Notification.UserId + "|" + e.Notification.Id; + + _serverManager.SendWebSocketMessage("NotificationAdded", msg); + } + + public void Dispose() + { + _notificationsRepo.NotificationAdded -= _notificationsRepo_NotificationAdded; + _notificationsRepo.NotificationUpdated -= _notificationsRepo_NotificationUpdated; + } + } +} diff --git a/MediaBrowser.Server.Implementations/EntryPoints/WebSocketEvents.cs b/MediaBrowser.Server.Implementations/EntryPoints/WebSocketEvents.cs index b4bffc077d..6325944f17 100644 --- a/MediaBrowser.Server.Implementations/EntryPoints/WebSocketEvents.cs +++ b/MediaBrowser.Server.Implementations/EntryPoints/WebSocketEvents.cs @@ -73,64 +73,44 @@ namespace MediaBrowser.Server.Implementations.EntryPoints _appHost.HasPendingRestartChanged += kernel_HasPendingRestartChanged; _installationManager.PluginUninstalled += InstallationManager_PluginUninstalled; - _installationManager.PackageInstalling += installationManager_PackageInstalling; - _installationManager.PackageInstallationCancelled += installationManager_PackageInstallationCancelled; - _installationManager.PackageInstallationCompleted += installationManager_PackageInstallationCompleted; - _installationManager.PackageInstallationFailed += installationManager_PackageInstallationFailed; + _installationManager.PackageInstalling += _installationManager_PackageInstalling; + _installationManager.PackageInstallationCancelled += _installationManager_PackageInstallationCancelled; + _installationManager.PackageInstallationCompleted += _installationManager_PackageInstallationCompleted; + _installationManager.PackageInstallationFailed += _installationManager_PackageInstallationFailed; _taskManager.TaskExecuting += _taskManager_TaskExecuting; _taskManager.TaskCompleted += _taskManager_TaskCompleted; } - void _taskManager_TaskCompleted(object sender, GenericEventArgs e) + void _installationManager_PackageInstalling(object sender, InstallationEventArgs e) { - _serverManager.SendWebSocketMessage("ScheduledTaskEnded", e.Argument); + _serverManager.SendWebSocketMessage("PackageInstalling", e.InstallationInfo); } - void _taskManager_TaskExecuting(object sender, EventArgs e) + void _installationManager_PackageInstallationCancelled(object sender, InstallationEventArgs e) { - var task = (IScheduledTask)sender; - _serverManager.SendWebSocketMessage("ScheduledTaskStarted", task.Name); + _serverManager.SendWebSocketMessage("PackageInstallationCancelled", e.InstallationInfo); } - /// - /// Installations the manager_ package installation failed. - /// - /// The sender. - /// The e. - void installationManager_PackageInstallationFailed(object sender, GenericEventArgs e) + void _installationManager_PackageInstallationCompleted(object sender, InstallationEventArgs e) { - _serverManager.SendWebSocketMessage("PackageInstallationFailed", e.Argument); + _serverManager.SendWebSocketMessage("PackageInstallationCompleted", e.InstallationInfo); } - /// - /// Installations the manager_ package installation completed. - /// - /// The sender. - /// The e. - void installationManager_PackageInstallationCompleted(object sender, GenericEventArgs e) + void _installationManager_PackageInstallationFailed(object sender, InstallationFailedEventArgs e) { - _serverManager.SendWebSocketMessage("PackageInstallationCompleted", e.Argument); + _serverManager.SendWebSocketMessage("PackageInstallationFailed", e.InstallationInfo); } - /// - /// Installations the manager_ package installation cancelled. - /// - /// The sender. - /// The e. - void installationManager_PackageInstallationCancelled(object sender, GenericEventArgs e) + void _taskManager_TaskCompleted(object sender, GenericEventArgs e) { - _serverManager.SendWebSocketMessage("PackageInstallationCancelled", e.Argument); + _serverManager.SendWebSocketMessage("ScheduledTaskEnded", e.Argument); } - /// - /// Installations the manager_ package installing. - /// - /// The sender. - /// The e. - void installationManager_PackageInstalling(object sender, GenericEventArgs e) + void _taskManager_TaskExecuting(object sender, EventArgs e) { - _serverManager.SendWebSocketMessage("PackageInstalling", e.Argument); + var task = (IScheduledTask)sender; + _serverManager.SendWebSocketMessage("ScheduledTaskStarted", task.Name); } /// @@ -195,10 +175,10 @@ namespace MediaBrowser.Server.Implementations.EntryPoints _userManager.UserUpdated -= userManager_UserUpdated; _installationManager.PluginUninstalled -= InstallationManager_PluginUninstalled; - _installationManager.PackageInstalling -= installationManager_PackageInstalling; - _installationManager.PackageInstallationCancelled -= installationManager_PackageInstallationCancelled; - _installationManager.PackageInstallationCompleted -= installationManager_PackageInstallationCompleted; - _installationManager.PackageInstallationFailed -= installationManager_PackageInstallationFailed; + _installationManager.PackageInstalling -= _installationManager_PackageInstalling; + _installationManager.PackageInstallationCancelled -= _installationManager_PackageInstallationCancelled; + _installationManager.PackageInstallationCompleted -= _installationManager_PackageInstallationCompleted; + _installationManager.PackageInstallationFailed -= _installationManager_PackageInstallationFailed; _appHost.HasPendingRestartChanged -= kernel_HasPendingRestartChanged; } diff --git a/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj b/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj index 34dc8ff52b..625db7e4b1 100644 --- a/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj +++ b/MediaBrowser.Server.Implementations/MediaBrowser.Server.Implementations.csproj @@ -111,6 +111,8 @@ + + @@ -144,6 +146,7 @@ + diff --git a/MediaBrowser.Server.Implementations/Persistence/SqliteChapterRepository.cs b/MediaBrowser.Server.Implementations/Persistence/SqliteChapterRepository.cs index c472241e39..7db8a5a6f4 100644 --- a/MediaBrowser.Server.Implementations/Persistence/SqliteChapterRepository.cs +++ b/MediaBrowser.Server.Implementations/Persistence/SqliteChapterRepository.cs @@ -1,10 +1,8 @@ -using MediaBrowser.Common.Configuration; -using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.Logging; using System; using System.Collections.Generic; using System.Data; -using System.IO; using System.Threading; using System.Threading.Tasks; @@ -16,30 +14,20 @@ namespace MediaBrowser.Server.Implementations.Persistence private readonly ILogger _logger; - /// - /// The _app paths - /// - private readonly IApplicationPaths _appPaths; - private IDbCommand _deleteChaptersCommand; private IDbCommand _saveChapterCommand; /// /// Initializes a new instance of the class. /// - /// The app paths. + /// The connection. /// The log manager. /// appPaths /// or /// jsonSerializer - public SqliteChapterRepository(IApplicationPaths appPaths, ILogManager logManager) + public SqliteChapterRepository(IDbConnection connection, ILogManager logManager) { - if (appPaths == null) - { - throw new ArgumentNullException("appPaths"); - } - - _appPaths = appPaths; + _connection = connection; _logger = logManager.GetLogger(GetType().Name); } @@ -48,12 +36,8 @@ namespace MediaBrowser.Server.Implementations.Persistence /// Opens the connection to the database /// /// Task. - public async Task Initialize() + public void Initialize() { - var dbFile = Path.Combine(_appPaths.DataPath, "chapters.db"); - - _connection = await SqliteExtensions.ConnectToDb(dbFile).ConfigureAwait(false); - string[] queries = { "create table if not exists chapters (ItemId GUID, ChapterIndex INT, StartPositionTicks BIGINT, Name TEXT, ImagePath TEXT, PRIMARY KEY (ItemId, ChapterIndex))", diff --git a/MediaBrowser.Server.Implementations/Persistence/SqliteItemRepository.cs b/MediaBrowser.Server.Implementations/Persistence/SqliteItemRepository.cs index e081a78e30..a90835c6af 100644 --- a/MediaBrowser.Server.Implementations/Persistence/SqliteItemRepository.cs +++ b/MediaBrowser.Server.Implementations/Persistence/SqliteItemRepository.cs @@ -90,7 +90,11 @@ namespace MediaBrowser.Server.Implementations.Persistence _logger = logManager.GetLogger(GetType().Name); - _chapterRepository = new SqliteChapterRepository(appPaths, logManager); + var chapterDbFile = Path.Combine(_appPaths.DataPath, "chapters.db"); + + var chapterConnection = SqliteExtensions.ConnectToDb(chapterDbFile).Result; + + _chapterRepository = new SqliteChapterRepository(chapterConnection, logManager); } /// @@ -119,7 +123,7 @@ namespace MediaBrowser.Server.Implementations.Persistence PrepareStatements(); - await _chapterRepository.Initialize().ConfigureAwait(false); + _chapterRepository.Initialize(); } /// diff --git a/MediaBrowser.Server.Implementations/Persistence/SqliteNotificationsRepository.cs b/MediaBrowser.Server.Implementations/Persistence/SqliteNotificationsRepository.cs new file mode 100644 index 0000000000..7b15467e09 --- /dev/null +++ b/MediaBrowser.Server.Implementations/Persistence/SqliteNotificationsRepository.cs @@ -0,0 +1,490 @@ +using MediaBrowser.Controller.Notifications; +using MediaBrowser.Model.Logging; +using MediaBrowser.Model.Notifications; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MediaBrowser.Server.Implementations.Persistence +{ + public class SqliteNotificationsRepository : INotificationsRepository + { + private readonly IDbConnection _connection; + private readonly ILogger _logger; + + private readonly SemaphoreSlim _writeLock = new SemaphoreSlim(1, 1); + + public SqliteNotificationsRepository(IDbConnection connection, ILogManager logManager) + { + _connection = connection; + + _logger = logManager.GetLogger(GetType().Name); + } + + public event EventHandler NotificationAdded; + public event EventHandler NotificationsMarkedRead; + public event EventHandler NotificationUpdated; + + private IDbCommand _replaceNotificationCommand; + private IDbCommand _markReadCommand; + + public void Initialize() + { + string[] queries = { + + "create table if not exists Notifications (Id GUID NOT NULL, UserId GUID NOT NULL, Date DATETIME NOT NULL, Name TEXT NOT NULL, Description TEXT, Url TEXT, Level TEXT NOT NULL, IsRead BOOLEAN NOT NULL, Category TEXT NOT NULL, RelatedId TEXT, PRIMARY KEY (Id, UserId))", + "create index if not exists idx_Notifications on Notifications(Id, UserId)", + + //pragmas + "pragma temp_store = memory" + }; + + _connection.RunQueries(queries, _logger); + + PrepareStatements(); + } + + private void PrepareStatements() + { + _replaceNotificationCommand = _connection.CreateCommand(); + _replaceNotificationCommand.CommandText = "replace into Notifications (Id, UserId, Date, Name, Description, Url, Level, IsRead, Category, RelatedId) values (@Id, @UserId, @Date, @Name, @Description, @Url, @Level, @IsRead, @Category, @RelatedId)"; + + _replaceNotificationCommand.Parameters.Add(_replaceNotificationCommand, "@Id"); + _replaceNotificationCommand.Parameters.Add(_replaceNotificationCommand, "@UserId"); + _replaceNotificationCommand.Parameters.Add(_replaceNotificationCommand, "@Date"); + _replaceNotificationCommand.Parameters.Add(_replaceNotificationCommand, "@Name"); + _replaceNotificationCommand.Parameters.Add(_replaceNotificationCommand, "@Description"); + _replaceNotificationCommand.Parameters.Add(_replaceNotificationCommand, "@Url"); + _replaceNotificationCommand.Parameters.Add(_replaceNotificationCommand, "@Level"); + _replaceNotificationCommand.Parameters.Add(_replaceNotificationCommand, "@IsRead"); + _replaceNotificationCommand.Parameters.Add(_replaceNotificationCommand, "@Category"); + _replaceNotificationCommand.Parameters.Add(_replaceNotificationCommand, "@RelatedId"); + + _markReadCommand = _connection.CreateCommand(); + _markReadCommand.CommandText = "update Notifications set IsRead=@IsRead where Id=@Id and UserId=@UserId"; + + _markReadCommand.Parameters.Add(_replaceNotificationCommand, "@UserId"); + _markReadCommand.Parameters.Add(_replaceNotificationCommand, "@IsRead"); + _markReadCommand.Parameters.Add(_replaceNotificationCommand, "@Id"); + } + + /// + /// Gets the notifications. + /// + /// The query. + /// NotificationResult. + public NotificationResult GetNotifications(NotificationQuery query) + { + var whereClause = string.Empty; + + var result = new NotificationResult(); + + using (var cmd = _connection.CreateCommand()) + { + if (query.IsRead.HasValue || query.UserId.HasValue) + { + var clauses = new List(); + + if (query.IsRead.HasValue) + { + clauses.Add("IsRead=@IsRead"); + cmd.Parameters.Add(cmd, "@IsRead", DbType.Boolean).Value = query.IsRead.Value; + } + + if (query.UserId.HasValue) + { + clauses.Add("UserId=@UserId"); + cmd.Parameters.Add(cmd, "@UserId", DbType.Guid).Value = query.UserId.Value; + } + + whereClause = " where " + string.Join(" And ", clauses.ToArray()); + } + + cmd.CommandText = string.Format("select count(Id) from Notifications{0};select Id,UserId,Date,Name,Description,Url,Level,IsRead,Category,RelatedId from Notifications{0} order by IsRead asc, Date desc", whereClause); + + using (var reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess)) + { + if (reader.Read()) + { + result.TotalRecordCount = reader.GetInt32(0); + } + + if (reader.NextResult()) + { + var notifications = GetNotifications(reader); + + if (query.StartIndex.HasValue) + { + notifications = notifications.Skip(query.StartIndex.Value); + } + + if (query.Limit.HasValue) + { + notifications = notifications.Take(query.Limit.Value); + } + + result.Notifications = notifications.ToArray(); + } + } + + return result; + } + } + + public NotificationsSummary GetNotificationsSummary(Guid userId) + { + var result = new NotificationsSummary(); + + using (var cmd = _connection.CreateCommand()) + { + cmd.CommandText = "select Level from Notifications where UserId=@UserId and IsRead=@IsRead"; + + cmd.Parameters.Add(cmd, "@UserId", DbType.Guid).Value = userId; + cmd.Parameters.Add(cmd, "@IsRead", DbType.Boolean).Value = false; + + using (var reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess)) + { + var levels = new List(); + + while (reader.Read()) + { + levels.Add(GetLevel(reader, 0)); + } + + result.UnreadCount = levels.Count; + + if (levels.Count > 0) + { + result.MaxUnreadNotificationLevel = levels.Max(); + } + } + + return result; + } + } + + /// + /// Gets the notifications. + /// + /// The reader. + /// IEnumerable{Notification}. + private IEnumerable GetNotifications(IDataReader reader) + { + while (reader.Read()) + { + yield return GetNotification(reader); + } + } + + private Notification GetNotification(IDataReader reader) + { + var notification = new Notification + { + Id = reader.GetGuid(0), + UserId = reader.GetGuid(1), + Date = reader.GetDateTime(2).ToUniversalTime(), + Name = reader.GetString(3) + }; + + if (!reader.IsDBNull(4)) + { + notification.Description = reader.GetString(4); + } + + if (!reader.IsDBNull(5)) + { + notification.Url = reader.GetString(5); + } + + notification.Level = GetLevel(reader, 6); + notification.IsRead = reader.GetBoolean(7); + + notification.Category = reader.GetString(8); + + if (!reader.IsDBNull(9)) + { + notification.RelatedId = reader.GetString(9); + } + + return notification; + } + + /// + /// Gets the notification. + /// + /// The id. + /// The user id. + /// Notification. + /// + /// id + /// or + /// userId + /// + public Notification GetNotification(Guid id, Guid userId) + { + if (id == Guid.Empty) + { + throw new ArgumentNullException("id"); + } + if (userId == Guid.Empty) + { + throw new ArgumentNullException("userId"); + } + + using (var cmd = _connection.CreateCommand()) + { + cmd.CommandText = "select Id,UserId,Date,Name,Description,Url,Level,IsRead,Category,RelatedId where Id=@Id And UserId = @UserId"; + + cmd.Parameters.Add(cmd, "@Id", DbType.Guid).Value = id; + cmd.Parameters.Add(cmd, "@UserId", DbType.Guid).Value = userId; + + using (var reader = cmd.ExecuteReader(CommandBehavior.SequentialAccess | CommandBehavior.SingleResult | CommandBehavior.SingleRow)) + { + if (reader.Read()) + { + return GetNotification(reader); + } + } + return null; + } + } + + /// + /// Gets the level. + /// + /// The reader. + /// The index. + /// NotificationLevel. + private NotificationLevel GetLevel(IDataReader reader, int index) + { + NotificationLevel level; + + var val = reader.GetString(index); + + Enum.TryParse(val, true, out level); + + return level; + } + + /// + /// Adds the notification. + /// + /// The notification. + /// The cancellation token. + /// Task. + public async Task AddNotification(Notification notification, CancellationToken cancellationToken) + { + await ReplaceNotification(notification, cancellationToken).ConfigureAwait(false); + + if (NotificationAdded != null) + { + try + { + NotificationAdded(this, new NotificationUpdateEventArgs + { + Notification = notification + }); + } + catch (Exception ex) + { + _logger.ErrorException("Error in NotificationAdded event handler", ex); + } + } + } + + /// + /// Updates the notification. + /// + /// The notification. + /// The cancellation token. + /// Task. + public async Task UpdateNotification(Notification notification, CancellationToken cancellationToken) + { + await ReplaceNotification(notification, cancellationToken).ConfigureAwait(false); + + if (NotificationUpdated != null) + { + try + { + NotificationUpdated(this, new NotificationUpdateEventArgs + { + Notification = notification + }); + } + catch (Exception ex) + { + _logger.ErrorException("Error in NotificationUpdated event handler", ex); + } + } + } + + /// + /// Replaces the notification. + /// + /// The notification. + /// The cancellation token. + /// Task. + private async Task ReplaceNotification(Notification notification, CancellationToken cancellationToken) + { + if (notification.Id == Guid.Empty) + { + throw new ArgumentException("The notification must have an id"); + } + if (notification.UserId == Guid.Empty) + { + throw new ArgumentException("The notification must have a user id"); + } + + cancellationToken.ThrowIfCancellationRequested(); + + await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + IDbTransaction transaction = null; + + try + { + transaction = _connection.BeginTransaction(); + + _replaceNotificationCommand.GetParameter(0).Value = notification.Id; + _replaceNotificationCommand.GetParameter(1).Value = notification.UserId; + _replaceNotificationCommand.GetParameter(2).Value = notification.Date.ToUniversalTime(); + _replaceNotificationCommand.GetParameter(3).Value = notification.Name; + _replaceNotificationCommand.GetParameter(4).Value = notification.Description; + _replaceNotificationCommand.GetParameter(5).Value = notification.Url; + _replaceNotificationCommand.GetParameter(6).Value = notification.Level.ToString(); + _replaceNotificationCommand.GetParameter(7).Value = notification.IsRead; + _replaceNotificationCommand.GetParameter(8).Value = notification.Category; + _replaceNotificationCommand.GetParameter(9).Value = notification.RelatedId; + + _replaceNotificationCommand.Transaction = transaction; + + _replaceNotificationCommand.ExecuteNonQuery(); + + transaction.Commit(); + } + catch (OperationCanceledException) + { + if (transaction != null) + { + transaction.Rollback(); + } + + throw; + } + catch (Exception e) + { + _logger.ErrorException("Failed to save notification:", e); + + if (transaction != null) + { + transaction.Rollback(); + } + + throw; + } + finally + { + if (transaction != null) + { + transaction.Dispose(); + } + + _writeLock.Release(); + } + } + + /// + /// Marks the read. + /// + /// The notification id list. + /// The user id. + /// if set to true [is read]. + /// The cancellation token. + /// Task. + public async Task MarkRead(IEnumerable notificationIdList, Guid userId, bool isRead, CancellationToken cancellationToken) + { + var idArray = notificationIdList.ToArray(); + + await MarkReadInternal(idArray, userId, isRead, cancellationToken).ConfigureAwait(false); + + if (NotificationsMarkedRead != null) + { + try + { + NotificationsMarkedRead(this, new NotificationReadEventArgs + { + IdList = idArray.ToArray(), + IsRead = isRead, + UserId = userId + }); + } + catch (Exception ex) + { + _logger.ErrorException("Error in NotificationsMarkedRead event handler", ex); + } + } + } + + private async Task MarkReadInternal(IEnumerable notificationIdList, Guid userId, bool isRead, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + IDbTransaction transaction = null; + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + transaction = _connection.BeginTransaction(); + + _markReadCommand.GetParameter(0).Value = userId; + _markReadCommand.GetParameter(1).Value = isRead; + + foreach (var id in notificationIdList) + { + _markReadCommand.GetParameter(2).Value = id; + + _markReadCommand.Transaction = transaction; + + _markReadCommand.ExecuteNonQuery(); + } + + transaction.Commit(); + } + catch (OperationCanceledException) + { + if (transaction != null) + { + transaction.Rollback(); + } + + throw; + } + catch (Exception e) + { + _logger.ErrorException("Failed to save notification:", e); + + if (transaction != null) + { + transaction.Rollback(); + } + + throw; + } + finally + { + if (transaction != null) + { + transaction.Dispose(); + } + + _writeLock.Release(); + } + } + } +} diff --git a/MediaBrowser.Server.Implementations/ScheduledTasks/PluginUpdateTask.cs b/MediaBrowser.Server.Implementations/ScheduledTasks/PluginUpdateTask.cs index 48b8771082..547c870eb7 100644 --- a/MediaBrowser.Server.Implementations/ScheduledTasks/PluginUpdateTask.cs +++ b/MediaBrowser.Server.Implementations/ScheduledTasks/PluginUpdateTask.cs @@ -1,6 +1,5 @@ using MediaBrowser.Common.ScheduledTasks; using MediaBrowser.Common.Updates; -using MediaBrowser.Controller; using MediaBrowser.Model.Logging; using MediaBrowser.Model.Net; using System; @@ -46,7 +45,9 @@ namespace MediaBrowser.Server.Implementations.ScheduledTasks // 1:30am new DailyTrigger { TimeOfDay = TimeSpan.FromHours(1.5) }, - new IntervalTrigger { Interval = TimeSpan.FromHours(2)} + new IntervalTrigger { Interval = TimeSpan.FromHours(3)}, + + new StartupTrigger() }; } diff --git a/MediaBrowser.ServerApplication/ApplicationHost.cs b/MediaBrowser.ServerApplication/ApplicationHost.cs index fd56a5aa6e..274a491bee 100644 --- a/MediaBrowser.ServerApplication/ApplicationHost.cs +++ b/MediaBrowser.ServerApplication/ApplicationHost.cs @@ -16,6 +16,7 @@ using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Localization; using MediaBrowser.Controller.MediaInfo; +using MediaBrowser.Controller.Notifications; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Providers; @@ -44,6 +45,7 @@ using MediaBrowser.ServerApplication.Implementations; using MediaBrowser.WebDashboard.Api; using System; using System.Collections.Generic; +using System.Data.SQLite; using System.Diagnostics; using System.IO; using System.Linq; @@ -166,6 +168,7 @@ namespace MediaBrowser.ServerApplication private IUserRepository UserRepository { get; set; } internal IDisplayPreferencesRepository DisplayPreferencesRepository { get; set; } private IItemRepository ItemRepository { get; set; } + private INotificationsRepository NotificationsRepository { get; set; } /// /// The full path to our startmenu shortcut @@ -284,6 +287,8 @@ namespace MediaBrowser.ServerApplication var userdataTask = Task.Run(async () => await ConfigureUserDataRepositories().ConfigureAwait(false)); var userTask = Task.Run(async () => await ConfigureUserRepositories().ConfigureAwait(false)); + await ConfigureNotificationsRepository().ConfigureAwait(false); + await Task.WhenAll(itemsTask, userTask, displayPreferencesTask, userdataTask).ConfigureAwait(false); SetKernelProperties(); @@ -304,6 +309,25 @@ namespace MediaBrowser.ServerApplication ); } + /// + /// Configures the repositories. + /// + /// Task. + private async Task ConfigureNotificationsRepository() + { + var dbFile = Path.Combine(ApplicationPaths.DataPath, "notifications.db"); + + var connection = await ConnectToDb(dbFile).ConfigureAwait(false); + + var repo = new SqliteNotificationsRepository(connection, LogManager); + + repo.Initialize(); + + NotificationsRepository = repo; + + RegisterSingleInstance(NotificationsRepository); + } + /// /// Configures the repositories. /// @@ -342,6 +366,35 @@ namespace MediaBrowser.ServerApplication await UserRepository.Initialize().ConfigureAwait(false); ((UserManager)UserManager).UserRepository = UserRepository; + } + + /// + /// Connects to db. + /// + /// The db path. + /// Task{IDbConnection}. + /// dbPath + private static async Task ConnectToDb(string dbPath) + { + if (string.IsNullOrEmpty(dbPath)) + { + throw new ArgumentNullException("dbPath"); + } + + var connectionstr = new SQLiteConnectionStringBuilder + { + PageSize = 4096, + CacheSize = 4096, + SyncMode = SynchronizationModes.Off, + DataSource = dbPath, + JournalMode = SQLiteJournalModeEnum.Wal + }; + + var connection = new SQLiteConnection(connectionstr.ConnectionString); + + await connection.OpenAsync().ConfigureAwait(false); + + return connection; } /// diff --git a/MediaBrowser.ServerApplication/MediaBrowser.ServerApplication.csproj b/MediaBrowser.ServerApplication/MediaBrowser.ServerApplication.csproj index e9169c28ef..21fa8a27e7 100644 --- a/MediaBrowser.ServerApplication/MediaBrowser.ServerApplication.csproj +++ b/MediaBrowser.ServerApplication/MediaBrowser.ServerApplication.csproj @@ -172,6 +172,13 @@ + + False + ..\packages\System.Data.SQLite.x86.1.0.86.0\lib\net45\System.Data.SQLite.dll + + + ..\packages\System.Data.SQLite.x86.1.0.86.0\lib\net45\System.Data.SQLite.Linq.dll + diff --git a/MediaBrowser.ServerApplication/packages.config b/MediaBrowser.ServerApplication/packages.config index d994c6e0ff..fa7f6219be 100644 --- a/MediaBrowser.ServerApplication/packages.config +++ b/MediaBrowser.ServerApplication/packages.config @@ -12,4 +12,5 @@ + \ No newline at end of file diff --git a/MediaBrowser.WebDashboard/Api/DashboardService.cs b/MediaBrowser.WebDashboard/Api/DashboardService.cs index 33f7f30ab8..0281ee6adc 100644 --- a/MediaBrowser.WebDashboard/Api/DashboardService.cs +++ b/MediaBrowser.WebDashboard/Api/DashboardService.cs @@ -468,6 +468,7 @@ namespace MediaBrowser.WebDashboard.Api "musicgenres.js", "musicrecommended.js", "musicvideos.js", + "notifications.js", "playlist.js", "plugincatalogpage.js", "pluginspage.js", @@ -524,6 +525,7 @@ namespace MediaBrowser.WebDashboard.Api "detailtable.css", "posteritem.css", "tileitem.css", + "notifications.css", "search.css", "pluginupdates.css", "remotecontrol.css", diff --git a/MediaBrowser.WebDashboard/ApiClient.js b/MediaBrowser.WebDashboard/ApiClient.js index ebea5b9860..a90d2dcf8b 100644 --- a/MediaBrowser.WebDashboard/ApiClient.js +++ b/MediaBrowser.WebDashboard/ApiClient.js @@ -243,6 +243,61 @@ MediaBrowser.ApiClient = function ($, navigator, JSON, WebSocket, setTimeout) { }); }; + self.getNotificationSummary = function (userId) { + + if (!userId) { + throw new Error("null userId"); + } + + var url = self.getUrl("Notifications/" + userId + "/Summary"); + + return self.ajax({ + type: "GET", + url: url, + dataType: "json" + }); + }; + + self.getNotifications = function (userId, options) { + + if (!userId) { + throw new Error("null userId"); + } + + var url = self.getUrl("Notifications/" + userId, options || {}); + + return self.ajax({ + type: "GET", + url: url, + dataType: "json" + }); + }; + + self.markNotificationsRead = function (userId, idList, isRead) { + + if (!userId) { + throw new Error("null userId"); + } + + if (!idList || !idList.length) { + throw new Error("null idList"); + } + + var suffix = isRead ? "Read" : "Unread"; + + var params = { + UserId: userId, + Ids: idList.join(',') + }; + + var url = self.getUrl("Notifications/" + userId + "/" + suffix, params); + + return self.ajax({ + type: "POST", + url: url + }); + }; + /** * Gets the current server status */ @@ -1937,7 +1992,7 @@ MediaBrowser.ApiClient = function ($, navigator, JSON, WebSocket, setTimeout) { if (!item) { throw new Error("null item"); } - + var url = self.getUrl("Artists/" + self.encodeName(item.Name)); return self.ajax({ diff --git a/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj b/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj index 36c8923bd2..62dd59cc19 100644 --- a/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj +++ b/MediaBrowser.WebDashboard/MediaBrowser.WebDashboard.csproj @@ -84,6 +84,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest @@ -354,6 +357,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest @@ -375,6 +381,9 @@ PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/MediaBrowser.WebDashboard/packages.config b/MediaBrowser.WebDashboard/packages.config index ef87d534e7..439f8cb979 100644 --- a/MediaBrowser.WebDashboard/packages.config +++ b/MediaBrowser.WebDashboard/packages.config @@ -1,6 +1,6 @@  - + \ No newline at end of file