diff --git a/API.Benchmark/ParseScannedFilesBenchmarks.cs b/API.Benchmark/ParseScannedFilesBenchmarks.cs index a180d566f..7c244a5d4 100644 --- a/API.Benchmark/ParseScannedFilesBenchmarks.cs +++ b/API.Benchmark/ParseScannedFilesBenchmarks.cs @@ -4,6 +4,7 @@ using API.Entities.Enums; using API.Parser; using API.Services; using API.Services.Tasks.Scanner; +using API.SignalR; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; using Microsoft.Extensions.Logging; @@ -28,7 +29,8 @@ namespace API.Benchmark _parseScannedFiles = new ParseScannedFiles( Substitute.For(), directoryService, - new ReadingItemService(_archiveService, new BookService(_bookLogger, directoryService, new ImageService(Substitute.For>(), directoryService)), Substitute.For(), directoryService)); + new ReadingItemService(_archiveService, new BookService(_bookLogger, directoryService, new ImageService(Substitute.For>(), directoryService)), Substitute.For(), directoryService), + Substitute.For()); } // [Benchmark] @@ -59,8 +61,7 @@ namespace API.Benchmark Title = "A Town Where You Live", Volumes = "1" }; - _parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new [] {libraryPath}, - out _, out _); + _parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new [] {libraryPath}, "Manga"); _parseScannedFiles.MergeName(p1); } } diff --git a/API.Tests/Services/BackupServiceTests.cs b/API.Tests/Services/BackupServiceTests.cs index 1af01632c..31896a38c 100644 --- a/API.Tests/Services/BackupServiceTests.cs +++ b/API.Tests/Services/BackupServiceTests.cs @@ -26,7 +26,7 @@ public class BackupServiceTests { private readonly ILogger _logger = Substitute.For>(); private readonly IUnitOfWork _unitOfWork; - private readonly IHubContext _messageHub = Substitute.For>(); + private readonly IEventHub _messageHub = Substitute.For(); private readonly IConfiguration _config; private readonly DbConnection _connection; diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 44cba64a4..1dd131112 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -26,7 +26,7 @@ public class CleanupServiceTests { private readonly ILogger _logger = Substitute.For>(); private readonly IUnitOfWork _unitOfWork; - private readonly IHubContext _messageHub = Substitute.For>(); + private readonly IEventHub _messageHub = Substitute.For(); private readonly DbConnection _connection; private readonly DataContext _context; diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index e3b0b498f..39f990bbf 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -11,6 +11,7 @@ using API.Entities.Enums; using API.Parser; using API.Services; using API.Services.Tasks.Scanner; +using API.SignalR; using API.Tests.Helpers; using AutoMapper; using Microsoft.Data.Sqlite; @@ -155,7 +156,7 @@ public class ParseScannedFilesTests var fileSystem = new MockFileSystem(); var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new DefaultParser(ds))); + new MockReadingItemService(new DefaultParser(ds)), Substitute.For()); var infos = new List() { @@ -200,7 +201,7 @@ public class ParseScannedFilesTests var fileSystem = new MockFileSystem(); var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new DefaultParser(ds))); + new MockReadingItemService(new DefaultParser(ds)), Substitute.For()); var infos = new List() { @@ -240,7 +241,7 @@ public class ParseScannedFilesTests #region MergeName [Fact] - public void MergeName_ShouldMergeMatchingFormatAndName() + public async Task MergeName_ShouldMergeMatchingFormatAndName() { var fileSystem = new MockFileSystem(); fileSystem.AddDirectory("C:/Data/"); @@ -250,10 +251,10 @@ public class ParseScannedFilesTests var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new DefaultParser(ds))); + new MockReadingItemService(new DefaultParser(ds)), Substitute.For()); - psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, out _, out _); + await psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, "libraryName"); Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("Accel World", "1", "0", "Accel World v1.cbz", false))); Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("accel_world", "1", "0", "Accel World v1.cbz", false))); @@ -261,7 +262,7 @@ public class ParseScannedFilesTests } [Fact] - public void MergeName_ShouldMerge_MismatchedFormatSameName() + public async Task MergeName_ShouldMerge_MismatchedFormatSameName() { var fileSystem = new MockFileSystem(); fileSystem.AddDirectory("C:/Data/"); @@ -271,10 +272,10 @@ public class ParseScannedFilesTests var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new DefaultParser(ds))); + new MockReadingItemService(new DefaultParser(ds)), Substitute.For()); - psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, out _, out _); + await psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, "libraryName"); Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("Accel World", "1", "0", "Accel World v1.epub", false))); Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("accel_world", "1", "0", "Accel World v1.epub", false))); @@ -285,7 +286,7 @@ public class ParseScannedFilesTests #region ScanLibrariesForSeries [Fact] - public void ScanLibrariesForSeries_ShouldFindFiles() + public async Task ScanLibrariesForSeries_ShouldFindFiles() { var fileSystem = new MockFileSystem(); fileSystem.AddDirectory("C:/Data/"); @@ -296,10 +297,10 @@ public class ParseScannedFilesTests var ds = new DirectoryService(Substitute.For>(), fileSystem); var psf = new ParseScannedFiles(Substitute.For>(), ds, - new MockReadingItemService(new DefaultParser(ds))); + new MockReadingItemService(new DefaultParser(ds)), Substitute.For()); - var parsedSeries = psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, out _, out _); + var parsedSeries = await psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, "libraryName"); Assert.Equal(3, parsedSeries.Values.Count); Assert.NotEmpty(parsedSeries.Keys.Where(p => p.Format == MangaFormat.Archive && p.Name.Equals("Accel World"))); diff --git a/API.Tests/Services/SiteThemeServiceTests.cs b/API.Tests/Services/SiteThemeServiceTests.cs index a9198f26f..3f3f18acf 100644 --- a/API.Tests/Services/SiteThemeServiceTests.cs +++ b/API.Tests/Services/SiteThemeServiceTests.cs @@ -26,7 +26,7 @@ namespace API.Tests.Services; public class SiteThemeServiceTests { private readonly ILogger _logger = Substitute.For>(); - private readonly IHubContext _messageHub = Substitute.For>(); + private readonly IEventHub _messageHub = Substitute.For(); private readonly DbConnection _connection; private readonly DataContext _context; diff --git a/API/API.csproj b/API/API.csproj index a95863aa7..edda546eb 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -48,6 +48,8 @@ + + diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index 89921d5f2..6abc22955 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -19,13 +19,13 @@ namespace API.Controllers public class CollectionController : BaseApiController { private readonly IUnitOfWork _unitOfWork; - private readonly IHubContext _messageHub; + private readonly IEventHub _eventHub; /// - public CollectionController(IUnitOfWork unitOfWork, IHubContext messageHub) + public CollectionController(IUnitOfWork unitOfWork, IEventHub eventHub) { _unitOfWork = unitOfWork; - _messageHub = messageHub; + _eventHub = eventHub; } /// @@ -156,7 +156,8 @@ namespace API.Controllers { tag.CoverImageLocked = false; tag.CoverImage = string.Empty; - await _messageHub.Clients.All.SendAsync(SignalREvents.CoverUpdate, MessageFactory.CoverUpdateEvent(tag.Id, "collectionTag")); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(tag.Id, "collectionTag"), false); _unitOfWork.CollectionTagRepository.Update(tag); } diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index bb84138b2..30d0abd0f 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -27,19 +27,19 @@ namespace API.Controllers private readonly IArchiveService _archiveService; private readonly IDirectoryService _directoryService; private readonly IDownloadService _downloadService; - private readonly IHubContext _messageHub; + private readonly IEventHub _eventHub; private readonly UserManager _userManager; private readonly ILogger _logger; private const string DefaultContentType = "application/octet-stream"; public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, - IDownloadService downloadService, IHubContext messageHub, UserManager userManager, ILogger logger) + IDownloadService downloadService, IEventHub eventHub, UserManager userManager, ILogger logger) { _unitOfWork = unitOfWork; _archiveService = archiveService; _directoryService = directoryService; _downloadService = downloadService; - _messageHub = messageHub; + _eventHub = eventHub; _userManager = userManager; _logger = logger; } @@ -119,30 +119,30 @@ namespace API.Controllers { try { - await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(User.GetUsername(), - Path.GetFileNameWithoutExtension(downloadName), 0F)); + Path.GetFileNameWithoutExtension(downloadName), 0F, "started")); if (files.Count == 1) { - await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(User.GetUsername(), - Path.GetFileNameWithoutExtension(downloadName), 1F)); + Path.GetFileNameWithoutExtension(downloadName), 1F, "ended")); return await GetFirstFileDownload(files); } var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), tempFolder); - await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(User.GetUsername(), - Path.GetFileNameWithoutExtension(downloadName), 1F)); + Path.GetFileNameWithoutExtension(downloadName), 1F, "ended")); return File(fileBytes, DefaultContentType, downloadName); } catch (Exception ex) { _logger.LogError(ex, "There was an exception when trying to download files"); - await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(User.GetUsername(), - Path.GetFileNameWithoutExtension(downloadName), 1F)); + Path.GetFileNameWithoutExtension(downloadName), 1F, "ended")); throw; } } @@ -181,11 +181,11 @@ namespace API.Controllers .Select(b => Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(bookmarkDirectory, $"{b.ChapterId}_{b.FileName}"))); var filename = $"{series.Name} - Bookmarks.zip"; - await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 0F)); var (fileBytes, _) = await _archiveService.CreateZipForDownload(files, $"download_{user.Id}_{series.Id}_bookmarks"); - await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 1F)); return File(fileBytes, DefaultContentType, filename); } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 397109c09..01606b5b7 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -26,14 +26,15 @@ namespace API.Controllers private readonly ILogger _logger; private readonly ITaskScheduler _taskScheduler; private readonly IUnitOfWork _unitOfWork; - private readonly IHubContext _messageHub; + private readonly IEventHub _eventHub; - public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IHubContext messageHub) + + public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IEventHub eventHub) { _logger = logger; _taskScheduler = taskScheduler; _unitOfWork = unitOfWork; - _messageHub = messageHub; + _eventHub = eventHub; } [HttpPost] @@ -93,8 +94,9 @@ namespace API.Controllers await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); await _unitOfWork.CommitAsync(); _taskScheduler.CleanupChapters(chapterIds); - await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved, - MessageFactory.SeriesRemovedEvent(seriesId, series.Name, series.LibraryId)); + + await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved, + MessageFactory.SeriesRemovedEvent(seriesId, series.Name, series.LibraryId), false); } return Ok(result); } @@ -378,9 +380,9 @@ namespace API.Controllers { foreach (var tag in updateSeriesMetadataDto.Tags) { - await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAddedToCollection, - MessageFactory.SeriesAddedToCollection(tag.Id, - updateSeriesMetadataDto.SeriesMetadata.SeriesId)); + await _eventHub.SendMessageAsync(MessageFactory.SeriesAddedToCollection, + MessageFactory.SeriesAddedToCollectionEvent(tag.Id, + updateSeriesMetadataDto.SeriesMetadata.SeriesId), false); } return Ok("Successfully updated"); } diff --git a/API/DTOs/UpdateUserRole.cs b/API/DTOs/UpdateUserRole.cs new file mode 100644 index 000000000..a37076d2c --- /dev/null +++ b/API/DTOs/UpdateUserRole.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using MediatR; + +namespace API.DTOs; + +public class UpdateUserRole : IRequest +{ + public string Username { get; init; } + public IList Roles { get; init; } +} diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 146647393..154c1b04a 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -3,6 +3,7 @@ using API.Data; using API.Helpers; using API.Services; using API.Services.Tasks; +using API.SignalR; using API.SignalR.Presence; using Kavita.Common; using Microsoft.AspNetCore.Hosting; @@ -41,11 +42,13 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSqLite(config, env); services.AddLogging(config); diff --git a/API/Parser/ParserInfo.cs b/API/Parser/ParserInfo.cs index cb55bd18e..07679ea25 100644 --- a/API/Parser/ParserInfo.cs +++ b/API/Parser/ParserInfo.cs @@ -80,6 +80,7 @@ namespace API.Parser /// /// Merges non empty/null properties from info2 into this entity. /// + /// This does not merge ComicInfo as they should always be the same /// public void Merge(ParserInfo info2) { diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 75513193d..590582eb5 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -5,8 +5,11 @@ using System.Linq; using System.Threading.Tasks; using API.Comparators; using API.Data; +using API.Data.Metadata; using API.Data.Repositories; +using API.Data.Scanner; using API.Entities; +using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.SignalR; @@ -35,18 +38,18 @@ public class MetadataService : IMetadataService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; - private readonly IHubContext _messageHub; + private readonly IEventHub _eventHub; private readonly ICacheHelper _cacheHelper; private readonly IReadingItemService _readingItemService; private readonly IDirectoryService _directoryService; private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); public MetadataService(IUnitOfWork unitOfWork, ILogger logger, - IHubContext messageHub, ICacheHelper cacheHelper, + IEventHub eventHub, ICacheHelper cacheHelper, IReadingItemService readingItemService, IDirectoryService directoryService) { _unitOfWork = unitOfWork; _logger = logger; - _messageHub = messageHub; + _eventHub = eventHub; _cacheHelper = cacheHelper; _readingItemService = readingItemService; _directoryService = directoryService; @@ -68,8 +71,8 @@ public class MetadataService : IMetadataService _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath); chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format); - await _messageHub.Clients.All.SendAsync(SignalREvents.CoverUpdate, MessageFactory.CoverUpdateEvent(chapter.Id, "chapter")); - + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(chapter.Id, "chapter"), false); return true; } @@ -98,7 +101,7 @@ public class MetadataService : IMetadataService if (firstChapter == null) return false; volume.CoverImage = firstChapter.CoverImage; - await _messageHub.Clients.All.SendAsync(SignalREvents.CoverUpdate, MessageFactory.CoverUpdateEvent(volume.Id, "volume")); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(volume.Id, "volume"), false); return true; } @@ -135,7 +138,7 @@ public class MetadataService : IMetadataService } } series.CoverImage = firstCover?.CoverImage ?? coverImage; - await _messageHub.Clients.All.SendAsync(SignalREvents.CoverUpdate, MessageFactory.CoverUpdateEvent(series.Id, "series")); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(series.Id, "series"), false); } @@ -200,8 +203,9 @@ public class MetadataService : IMetadataService var stopwatch = Stopwatch.StartNew(); var totalTime = 0L; _logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, - MessageFactory.RefreshMetadataProgressEvent(library.Id, 0F)); + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.CoverUpdateProgressEvent(library.Id, 0F, ProgressEventType.Started, $"Starting {library.Name}")); for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) { @@ -223,6 +227,12 @@ public class MetadataService : IMetadataService var seriesIndex = 0; foreach (var series in nonLibrarySeries) { + var index = chunk * seriesIndex; + var progress = Math.Max(0F, Math.Min(1F, index * 1F / chunkInfo.TotalSize)); + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.CoverUpdateProgressEvent(library.Id, progress, ProgressEventType.Updated, series.Name)); + try { await ProcessSeriesMetadataUpdate(series, forceUpdate); @@ -231,11 +241,6 @@ public class MetadataService : IMetadataService { _logger.LogError(ex, "[MetadataService] There was an exception during metadata refresh for {SeriesName}", series.Name); } - var index = chunk * seriesIndex; - var progress = Math.Max(0F, Math.Min(1F, index * 1F / chunkInfo.TotalSize)); - - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, - MessageFactory.RefreshMetadataProgressEvent(library.Id, progress)); seriesIndex++; } @@ -246,8 +251,8 @@ public class MetadataService : IMetadataService chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); } - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, - MessageFactory.RefreshMetadataProgressEvent(library.Id, 1F)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.CoverUpdateProgressEvent(library.Id, 1F, ProgressEventType.Ended, $"Complete")); await RemoveAbandonedMetadataKeys(); @@ -277,8 +282,8 @@ public class MetadataService : IMetadataService return; } - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, - MessageFactory.RefreshMetadataProgressEvent(libraryId, 0F)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.CoverUpdateProgressEvent(libraryId, 0F, ProgressEventType.Started, series.Name)); await ProcessSeriesMetadataUpdate(series, forceUpdate); @@ -288,11 +293,16 @@ public class MetadataService : IMetadataService await _unitOfWork.CommitAsync(); } - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, - MessageFactory.RefreshMetadataProgressEvent(libraryId, 1F)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.CoverUpdateProgressEvent(libraryId, 1F, ProgressEventType.Ended, series.Name)); await RemoveAbandonedMetadataKeys(); + if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) + { + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(series.Id, "series"), false); + } + _logger.LogInformation("[MetadataService] Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds); } } diff --git a/API/Services/Tasks/BackupService.cs b/API/Services/Tasks/BackupService.cs index 12f4f1083..d1fc8a0f9 100644 --- a/API/Services/Tasks/BackupService.cs +++ b/API/Services/Tasks/BackupService.cs @@ -31,17 +31,17 @@ public class BackupService : IBackupService private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IDirectoryService _directoryService; - private readonly IHubContext _messageHub; + private readonly IEventHub _eventHub; private readonly IList _backupFiles; public BackupService(ILogger logger, IUnitOfWork unitOfWork, - IDirectoryService directoryService, IConfiguration config, IHubContext messageHub) + IDirectoryService directoryService, IConfiguration config, IEventHub eventHub) { _unitOfWork = unitOfWork; _logger = logger; _directoryService = directoryService; - _messageHub = messageHub; + _eventHub = eventHub; var maxRollingFiles = config.GetMaxRollingFiles(); var loggingSection = config.GetLoggingFileName(); @@ -94,7 +94,7 @@ public class BackupService : IBackupService return; } - await SendProgress(0F); + await SendProgress(0F, "Started backup"); var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_"); var zipPath = _directoryService.FileSystem.Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip"); @@ -112,15 +112,15 @@ public class BackupService : IBackupService _directoryService.CopyFilesToDirectory( _backupFiles.Select(file => _directoryService.FileSystem.Path.Join(_directoryService.ConfigDirectory, file)).ToList(), tempDirectory); - await SendProgress(0.25F); + await SendProgress(0.25F, "Copying core files"); await CopyCoverImagesToBackupDirectory(tempDirectory); - await SendProgress(0.5F); + await SendProgress(0.5F, "Copying cover images"); await CopyBookmarksToBackupDirectory(tempDirectory); - await SendProgress(0.75F); + await SendProgress(0.75F, "Copying bookmarks"); try { @@ -133,7 +133,7 @@ public class BackupService : IBackupService _directoryService.ClearAndDeleteDirectory(tempDirectory); _logger.LogInformation("Database backup completed"); - await SendProgress(1F); + await SendProgress(1F, "Completed backup"); } private async Task CopyCoverImagesToBackupDirectory(string tempDirectory) @@ -189,10 +189,10 @@ public class BackupService : IBackupService } } - private async Task SendProgress(float progress) + private async Task SendProgress(float progress, string subtitle) { - await _messageHub.Clients.All.SendAsync(SignalREvents.BackupDatabaseProgress, - MessageFactory.BackupDatabaseProgressEvent(progress)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.BackupDatabaseProgressEvent(progress, subtitle)); } } diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index fbb87ecd5..ee0af81cb 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -28,16 +28,16 @@ namespace API.Services.Tasks { private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; - private readonly IHubContext _messageHub; + private readonly IEventHub _eventHub; private readonly IDirectoryService _directoryService; public CleanupService(ILogger logger, - IUnitOfWork unitOfWork, IHubContext messageHub, + IUnitOfWork unitOfWork, IEventHub eventHub, IDirectoryService directoryService) { _logger = logger; _unitOfWork = unitOfWork; - _messageHub = messageHub; + _eventHub = eventHub; _directoryService = directoryService; } @@ -49,25 +49,23 @@ namespace API.Services.Tasks public async Task Cleanup() { _logger.LogInformation("Starting Cleanup"); - await SendProgress(0F); + await SendProgress(0F, "Starting cleanup"); _logger.LogInformation("Cleaning temp directory"); _directoryService.ClearDirectory(_directoryService.TempDirectory); - await SendProgress(0.1F); + await SendProgress(0.1F, "Cleaning temp directory"); CleanupCacheDirectory(); - await SendProgress(0.25F); + await SendProgress(0.25F, "Cleaning old database backups"); _logger.LogInformation("Cleaning old database backups"); await CleanupBackups(); - await SendProgress(0.50F); + await SendProgress(0.50F, "Cleaning deleted cover images"); _logger.LogInformation("Cleaning deleted cover images"); await DeleteSeriesCoverImages(); - await SendProgress(0.6F); + await SendProgress(0.6F, "Cleaning deleted cover images"); await DeleteChapterCoverImages(); - await SendProgress(0.7F); + await SendProgress(0.7F, "Cleaning deleted cover images"); await DeleteTagCoverImages(); - await SendProgress(0.8F); - //_logger.LogInformation("Cleaning old bookmarks"); - //await CleanupBookmarks(); - await SendProgress(1F); + await SendProgress(0.8F, "Cleaning deleted cover images"); + await SendProgress(1F, "Cleanup finished"); _logger.LogInformation("Cleanup finished"); } @@ -82,10 +80,10 @@ namespace API.Services.Tasks await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); } - private async Task SendProgress(float progress) + private async Task SendProgress(float progress, string subtitle) { - await _messageHub.Clients.All.SendAsync(SignalREvents.CleanupProgress, - MessageFactory.CleanupProgressEvent(progress)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.CleanupProgressEvent(progress, subtitle)); } /// diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 50cb98da9..c78e137e7 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -4,10 +4,14 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading.Tasks; +using API.Data.Metadata; using API.Entities; using API.Entities.Enums; using API.Helpers; using API.Parser; +using API.SignalR; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; namespace API.Services.Tasks.Scanner @@ -26,6 +30,7 @@ namespace API.Services.Tasks.Scanner private readonly ILogger _logger; private readonly IDirectoryService _directoryService; private readonly IReadingItemService _readingItemService; + private readonly IEventHub _eventHub; private readonly DefaultParser _defaultParser; /// @@ -36,13 +41,14 @@ namespace API.Services.Tasks.Scanner /// Directory Service /// ReadingItemService Service for extracting information on a number of formats public ParseScannedFiles(ILogger logger, IDirectoryService directoryService, - IReadingItemService readingItemService) + IReadingItemService readingItemService, IEventHub eventHub) { _logger = logger; _directoryService = directoryService; _readingItemService = readingItemService; _scannedSeries = new ConcurrentDictionary>(); _defaultParser = new DefaultParser(_directoryService); + _eventHub = eventHub; } /// @@ -74,8 +80,6 @@ namespace API.Services.Tasks.Scanner /// Library type to determine parsing to perform private void ProcessFile(string path, string rootPath, LibraryType type) { - // TODO: Emit event with what is being processed. It can look like Kavita isn't doing anything during file scan - var info = _readingItemService.Parse(path, rootPath, type); if (info == null) { @@ -138,8 +142,6 @@ namespace API.Services.Tasks.Scanner NormalizedName = Parser.Parser.Normalize(info.Series) }; - - _scannedSeries.AddOrUpdate(existingKey, new List() {info}, (_, oldValue) => { oldValue ??= new List(); @@ -177,29 +179,28 @@ namespace API.Services.Tasks.Scanner /// /// Type of library. Used for selecting the correct file extensions to search for and parsing files /// The folders to scan. By default, this should be library.Folders, however it can be overwritten to restrict folders - /// Total files scanned - /// Time it took to scan and parse files /// - public Dictionary> ScanLibrariesForSeries(LibraryType libraryType, IEnumerable folders, out int totalFiles, - out long scanElapsedTime) + public async Task>> ScanLibrariesForSeries(LibraryType libraryType, IEnumerable folders, string libraryName) { - var sw = Stopwatch.StartNew(); - totalFiles = 0; + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("", libraryName, ProgressEventType.Started)); foreach (var folderPath in folders) { try { - totalFiles += _directoryService.TraverseTreeParallelForEach(folderPath, (f) => + async void Action(string f) { try { ProcessFile(f, folderPath, libraryType); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent(f, libraryName, ProgressEventType.Updated)); } catch (FileNotFoundException exception) { _logger.LogError(exception, "The file {Filename} could not be found", f); } - }, Parser.Parser.SupportedExtensions, _logger); + } + + _directoryService.TraverseTreeParallelForEach(folderPath, Action, Parser.Parser.SupportedExtensions, _logger); } catch (ArgumentException ex) { @@ -207,9 +208,7 @@ namespace API.Services.Tasks.Scanner } } - scanElapsedTime = sw.ElapsedMilliseconds; - _logger.LogInformation("Scanned {TotalFiles} files in {ElapsedScanTime} milliseconds", totalFiles, - scanElapsedTime); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("", libraryName, ProgressEventType.Ended)); return SeriesWithInfos(); } diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 86b819819..7d8fb19a2 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -17,7 +17,6 @@ using API.Parser; using API.Services.Tasks.Scanner; using API.SignalR; using Hangfire; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; namespace API.Services.Tasks; @@ -39,14 +38,14 @@ public class ScannerService : IScannerService private readonly ILogger _logger; private readonly IMetadataService _metadataService; private readonly ICacheService _cacheService; - private readonly IHubContext _messageHub; + private readonly IEventHub _eventHub; private readonly IFileService _fileService; private readonly IDirectoryService _directoryService; private readonly IReadingItemService _readingItemService; private readonly ICacheHelper _cacheHelper; public ScannerService(IUnitOfWork unitOfWork, ILogger logger, - IMetadataService metadataService, ICacheService cacheService, IHubContext messageHub, + IMetadataService metadataService, ICacheService cacheService, IEventHub eventHub, IFileService fileService, IDirectoryService directoryService, IReadingItemService readingItemService, ICacheHelper cacheHelper) { @@ -54,7 +53,7 @@ public class ScannerService : IScannerService _logger = logger; _metadataService = metadataService; _cacheService = cacheService; - _messageHub = messageHub; + _eventHub = eventHub; _fileService = fileService; _directoryService = directoryService; _readingItemService = readingItemService; @@ -72,8 +71,8 @@ public class ScannerService : IScannerService var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders); var folderPaths = library.Folders.Select(f => f.Path).ToList(); - // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are - if (folderPaths.Any(f => !_directoryService.IsDriveMounted(f))) + + if (!await CheckMounts(library.Folders.Select(f => f.Path).ToList())) { _logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); return; @@ -86,8 +85,9 @@ public class ScannerService : IScannerService var dirs = _directoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList()); _logger.LogInformation("Beginning file scan on {SeriesName}", series.Name); - var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService); - var parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles, out var scanElapsedTime); + var (totalFiles, scanElapsedTime, parsedSeries) = await ScanFiles(library, dirs.Keys); + + // Remove any parsedSeries keys that don't belong to our series. This can occur when users store 2 series in the same folder RemoveParsedInfosNotForSeries(parsedSeries, series); @@ -133,11 +133,11 @@ public class ScannerService : IScannerService } } + var (totalFiles2, scanElapsedTime2, parsedSeries2) = await ScanFiles(library, dirs.Keys); _logger.LogInformation("{SeriesName} has bad naming convention, forcing rescan at a higher directory", series.OriginalName); - scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService); - parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles2, out var scanElapsedTime2); totalFiles += totalFiles2; scanElapsedTime += scanElapsedTime2; + parsedSeries = parsedSeries2; RemoveParsedInfosNotForSeries(parsedSeries, series); } } @@ -148,9 +148,12 @@ public class ScannerService : IScannerService // Merge any series together that might have different ParsedSeries but belong to another group of ParsedSeries try { - UpdateSeries(series, parsedSeries, allPeople, allTags, allGenres, library.Type); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Started, series.Name)); + await UpdateSeries(series, parsedSeries, allPeople, allTags, allGenres, library.Type); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name)); await CommitAndSend(totalFiles, parsedSeries, sw, scanElapsedTime, series); + await RemoveAbandonedMetadataKeys(); } catch (Exception ex) { @@ -158,7 +161,8 @@ public class ScannerService : IScannerService await _unitOfWork.RollbackAsync(); } // Tell UI that this series is done - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(seriesId, series.Name), token); + await _eventHub.SendMessageAsync(MessageFactory.ScanSeries, + MessageFactory.ScanSeriesEvent(seriesId, series.Name)); await CleanupDbEntities(); BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, series.Id, false)); @@ -186,6 +190,64 @@ public class ScannerService : IScannerService } } + private async Task CheckMounts(IList folders) + { + // TODO: IF false, inform UI + // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are + if (folders.Any(f => !_directoryService.IsDriveMounted(f))) + { + _logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); + await _eventHub.SendMessageAsync("library.scan.error", new SignalRMessage() + { + Name = "library.scan.error", + Body = + new { + Message = + "Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted", + Details = "" + }, + Title = "Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted", + SubTitle = string.Join(", ", folders.Where(f => !_directoryService.IsDriveMounted(f))) + }); + + return false; + } + + // For Docker instances check if any of the folder roots are not available (ie disconnected volumes, etc) and fail if any of them are + if (folders.Any(f => _directoryService.IsDirectoryEmpty(f))) + { + // TODO: Food for thought, move this to throw an exception and let a middleware inform the UI to keep the code clean. (We can throw a custom exception which + // will always propagate to the UI) + // That way logging and UI informing is all in one place with full context + _logger.LogError("Some of the root folders for the library are empty. " + + "Either your mount has been disconnected or you are trying to delete all series in the library. " + + "Scan will be aborted. " + + "Check that your mount is connected or change the library's root folder and rescan"); + + // TODO: Use a factory method + await _eventHub.SendMessageAsync(MessageFactory.Error, new SignalRMessage() + { + Name = MessageFactory.Error, + Title = "Some of the root folders for the library are empty.", + SubTitle = "Either your mount has been disconnected or you are trying to delete all series in the library. " + + "Scan will be aborted. " + + "Check that your mount is connected or change the library's root folder and rescan", + Body = + new { + Title = + "Some of the root folders for the library are empty.", + SubTitle = "Either your mount has been disconnected or you are trying to delete all series in the library. " + + "Scan will be aborted. " + + "Check that your mount is connected or change the library's root folder and rescan" + } + }, true); + + return false; + } + + return true; + } + [DisableConcurrentExecution(timeoutInSeconds: 360)] [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] @@ -223,12 +285,11 @@ public class ScannerService : IScannerService return; } - // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are - if (library.Folders.Any(f => !_directoryService.IsDriveMounted(f.Path))) + if (!await CheckMounts(library.Folders.Select(f => f.Path).ToList())) { _logger.LogCritical("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); + // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, + // MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); return; } @@ -239,17 +300,19 @@ public class ScannerService : IScannerService "Either your mount has been disconnected or you are trying to delete all series in the library. " + "Scan will be aborted. " + "Check that your mount is connected or change the library's root folder and rescan"); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); + // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, + // MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); return; } _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(libraryId, 0)); + // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, + // MessageFactory.ScanLibraryProgressEvent(libraryId, 0F)); - var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService); - var series = scanner.ScanLibrariesForSeries(library.Type, library.Folders.Select(fp => fp.Path), out var totalFiles, out var scanElapsedTime); + + var (totalFiles, scanElapsedTime, series) = await ScanFiles(library, library.Folders.Select(fp => fp.Path)); + // var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService); + // var series = scanner.ScanLibrariesForSeries(library.Type, library.Folders.Select(fp => fp.Path), out var totalFiles, out var scanElapsedTime); _logger.LogInformation("[ScannerService] Finished file scan. Updating database"); foreach (var folderPath in library.Folders) @@ -276,11 +339,23 @@ public class ScannerService : IScannerService await CleanupDbEntities(); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); + // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, + // MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false)); } + private async Task>>> ScanFiles(Library library, IEnumerable dirs) + { + var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService, _eventHub); + var scanWatch = new Stopwatch(); + var parsedSeries = await scanner.ScanLibrariesForSeries(library.Type, dirs, library.Name); + var totalFiles = parsedSeries.Keys.Sum(key => parsedSeries[key].Count); + var scanElapsedTime = scanWatch.ElapsedMilliseconds; + _logger.LogInformation("Scanned {TotalFiles} files in {ElapsedScanTime} milliseconds", totalFiles, + scanElapsedTime); + return new Tuple>>(totalFiles, scanElapsedTime, parsedSeries); + } + /// /// Remove any user progress rows that no longer exist since scan library ran and deleted series/volumes/chapters /// @@ -350,10 +425,16 @@ public class ScannerService : IScannerService // Now, we only have to deal with series that exist on disk. Let's recalculate the volumes for each series var librarySeries = cleanedSeries.ToList(); - Parallel.ForEach(librarySeries, (series) => + + //var index = 0; + foreach (var series in librarySeries) { - UpdateSeries(series, parsedSeries, allPeople, allTags, allGenres, library.Type); - }); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Started, series.Name)); + await UpdateSeries(series, parsedSeries, allPeople, allTags, allGenres, library.Type); + // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, + // MessageFactory.ScanLibraryProgressEvent(library.Id, (1F * index) / librarySeries.Count)); + // index += 1; + } try { @@ -364,10 +445,10 @@ public class ScannerService : IScannerService _logger.LogCritical(ex, "[ScannerService] There was an issue writing to the DB. Chunk {ChunkNumber} did not save to DB. If debug mode, series to check will be printed", chunk); foreach (var series in nonLibrarySeries) { - _logger.LogDebug("[ScannerService] There may be a constraint issue with {SeriesName}", series.OriginalName); + _logger.LogCritical("[ScannerService] There may be a constraint issue with {SeriesName}", series.OriginalName); } - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryError, - MessageFactory.ScanLibraryError(library.Id)); + await _eventHub.SendMessageAsync(MessageFactory.ScanLibraryError, + MessageFactory.ScanLibraryErrorEvent(library.Id, library.Name)); continue; } _logger.LogInformation( @@ -377,17 +458,19 @@ public class ScannerService : IScannerService // Emit any series removed foreach (var missing in missingSeries) { - await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved, MessageFactory.SeriesRemovedEvent(missing.Id, missing.Name, library.Id)); + await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved, MessageFactory.SeriesRemovedEvent(missing.Id, missing.Name, library.Id)); } foreach (var series in librarySeries) { - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(series.Id, series.Name)); + // TODO: Do I need this? Shouldn't this be NotificationProgress + // This is something more like, the series has finished updating in the backend. It may or may not have been modified. + await _eventHub.SendMessageAsync(MessageFactory.ScanSeries, MessageFactory.ScanSeriesEvent(series.Id, series.Name)); } - var progress = Math.Max(0, Math.Min(1, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize)); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); + //var progress = Math.Max(0, Math.Min(1, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize)); + // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, + // MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); } @@ -435,7 +518,7 @@ public class ScannerService : IScannerService foreach(var series in newSeries) { _logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName); - UpdateSeries(series, parsedSeries, allPeople, allTags, allGenres, library.Type); + await UpdateSeries(series, parsedSeries, allPeople, allTags, allGenres, library.Type); _unitOfWork.SeriesRepository.Attach(series); try { @@ -445,7 +528,7 @@ public class ScannerService : IScannerService newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); // Inform UI of new series added - await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, library.Id)); + await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, library.Id)); } catch (Exception ex) { @@ -453,23 +536,28 @@ public class ScannerService : IScannerService series.Name, $"{series.Name}_{series.NormalizedName}_{series.LocalizedName}_{series.LibraryId}_{series.Format}"); } - var progress = Math.Max(0F, Math.Min(1F, i * 1F / newSeries.Count)); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); + //var progress = Math.Max(0F, Math.Min(1F, i * 1F / newSeries.Count)); + // await _eventHub.SendMessageAsync(SignalREvents.NotificationProgress, + // MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); i++; } + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended)); + _logger.LogInformation( "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); } - private void UpdateSeries(Series series, Dictionary> parsedSeries, + private async Task UpdateSeries(Series series, Dictionary> parsedSeries, ICollection allPeople, ICollection allTags, ICollection allGenres, LibraryType libraryType) { try { _logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName); + //await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DbUpdateProgressEvent(series, ProgressEventType.Started)); + //await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DbUpdateProgressEvent(series, ProgressEventType.Updated)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(series.Library.Name, ProgressEventType.Ended, series.Name)); // Get all associated ParsedInfos to the series. This includes infos that use a different filename that matches Series LocalizedName var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series); @@ -484,6 +572,8 @@ public class ScannerService : IScannerService } series.OriginalName ??= parsedInfos[0].Series; series.SortName ??= parsedInfos[0].SeriesSort; + //await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DbUpdateProgressEvent(series, ProgressEventType.Updated)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(series.Library.Name, ProgressEventType.Ended, series.Name)); UpdateSeriesMetadata(series, allPeople, allGenres, allTags, libraryType); } @@ -491,6 +581,8 @@ public class ScannerService : IScannerService { _logger.LogError(ex, "[ScannerService] There was an exception updating volumes for {SeriesName}", series.Name); } + //await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.DbUpdateProgressEvent(series, ProgressEventType.Ended)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(series.Library.Name, ProgressEventType.Ended, series.Name)); } public static IEnumerable FindSeriesNotOnDisk(IEnumerable existingSeries, Dictionary> parsedSeries) @@ -498,6 +590,13 @@ public class ScannerService : IScannerService return existingSeries.Where(es => !ParserInfoHelpers.SeriesHasMatchingParserInfoFormat(es, parsedSeries)); } + private async Task RemoveAbandonedMetadataKeys() + { + await _unitOfWork.TagRepository.RemoveAllTagNoLongerAssociated(); + await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); + await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); + } + private static void UpdateSeriesMetadata(Series series, ICollection allPeople, ICollection allGenres, ICollection allTags, LibraryType libraryType) { @@ -605,6 +704,7 @@ public class ScannerService : IScannerService _unitOfWork.VolumeRepository.Add(volume); } + // TODO: Here we can put a signalR update _logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); UpdateChapters(volume, infos); diff --git a/API/Services/Tasks/SiteThemeService.cs b/API/Services/Tasks/SiteThemeService.cs index e474adf06..4a579434f 100644 --- a/API/Services/Tasks/SiteThemeService.cs +++ b/API/Services/Tasks/SiteThemeService.cs @@ -22,13 +22,13 @@ public class SiteThemeService : ISiteThemeService { private readonly IDirectoryService _directoryService; private readonly IUnitOfWork _unitOfWork; - private readonly IHubContext _messageHub; + private readonly IEventHub _eventHub; - public SiteThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IHubContext messageHub) + public SiteThemeService(IDirectoryService directoryService, IUnitOfWork unitOfWork, IEventHub eventHub) { _directoryService = directoryService; _unitOfWork = unitOfWork; - _messageHub = messageHub; + _eventHub = eventHub; } /// @@ -59,8 +59,6 @@ public class SiteThemeService : ISiteThemeService .Where(name => !reservedNames.Contains(Parser.Parser.Normalize(name))).ToList(); var allThemes = (await _unitOfWork.SiteThemeRepository.GetThemes()).ToList(); - var totalThemesToIterate = themeFiles.Count; - var themeIteratedCount = 0; // First remove any files from allThemes that are User Defined and not on disk var userThemes = allThemes.Where(t => t.Provider == ThemeProvider.User).ToList(); @@ -68,15 +66,11 @@ public class SiteThemeService : ISiteThemeService { var filepath = Parser.Parser.NormalizePath( _directoryService.FileSystem.Path.Join(_directoryService.SiteThemeDirectory, userTheme.FileName)); - if (!_directoryService.FileSystem.File.Exists(filepath)) - { - // I need to do the removal different. I need to update all userpreferences to use DefaultTheme - allThemes.Remove(userTheme); - await RemoveTheme(userTheme); + if (_directoryService.FileSystem.File.Exists(filepath)) continue; - await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress, - MessageFactory.SiteThemeProgressEvent(1, totalThemesToIterate, userTheme.FileName, 0F)); - } + // I need to do the removal different. I need to update all user preferences to use DefaultTheme + allThemes.Remove(userTheme); + await RemoveTheme(userTheme); } // Add new custom themes @@ -85,11 +79,8 @@ public class SiteThemeService : ISiteThemeService { var themeName = Parser.Parser.Normalize(_directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile)); - if (allThemeNames.Contains(themeName)) - { - themeIteratedCount += 1; - continue; - } + if (allThemeNames.Contains(themeName)) continue; + _unitOfWork.SiteThemeRepository.Add(new SiteTheme() { Name = _directoryService.FileSystem.Path.GetFileNameWithoutExtension(themeFile), @@ -98,9 +89,9 @@ public class SiteThemeService : ISiteThemeService Provider = ThemeProvider.User, IsDefault = false, }); - await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress, - MessageFactory.SiteThemeProgressEvent(themeIteratedCount, totalThemesToIterate, themeName, themeIteratedCount / (totalThemesToIterate * 1.0f))); - themeIteratedCount += 1; + + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.SiteThemeProgressEvent(_directoryService.FileSystem.Path.GetFileName(themeFile), themeName, ProgressEventType.Updated)); } @@ -109,8 +100,8 @@ public class SiteThemeService : ISiteThemeService await _unitOfWork.CommitAsync(); } - await _messageHub.Clients.All.SendAsync(SignalREvents.SiteThemeProgress, - MessageFactory.SiteThemeProgressEvent(totalThemesToIterate, totalThemesToIterate, "", 1F)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.SiteThemeProgressEvent("", "", ProgressEventType.Ended)); } diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index 255d0b105..d72f487b4 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -53,7 +53,7 @@ public interface IVersionUpdaterService public class VersionUpdaterService : IVersionUpdaterService { private readonly ILogger _logger; - private readonly IHubContext _messageHub; + private readonly IEventHub _eventHub; private readonly IPresenceTracker _tracker; private readonly Markdown _markdown = new MarkdownDeep.Markdown(); #pragma warning disable S1075 @@ -61,10 +61,10 @@ public class VersionUpdaterService : IVersionUpdaterService private const string GithubAllReleasesUrl = "https://api.github.com/repos/Kareadita/Kavita/releases"; #pragma warning restore S1075 - public VersionUpdaterService(ILogger logger, IHubContext messageHub, IPresenceTracker tracker) + public VersionUpdaterService(ILogger logger, IEventHub eventHub, IPresenceTracker tracker) { _logger = logger; - _messageHub = messageHub; + _eventHub = eventHub; _tracker = tracker; FlurlHttp.ConfigureClient(GithubLatestReleasesUrl, cli => @@ -117,26 +117,22 @@ public class VersionUpdaterService : IVersionUpdaterService { if (update == null) return; - var admins = await _tracker.GetOnlineAdmins(); var updateVersion = new Version(update.CurrentVersion); if (BuildInfo.Version < updateVersion) { _logger.LogInformation("Server is out of date. Current: {CurrentVersion}. Available: {AvailableUpdate}", BuildInfo.Version, updateVersion); - await SendEvent(update, admins); + await _eventHub.SendMessageAsync(MessageFactory.UpdateAvailable, MessageFactory.UpdateVersionEvent(update), + true); } else if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development) { _logger.LogInformation("Server is up to date. Current: {CurrentVersion}", BuildInfo.Version); - await SendEvent(update, admins); + await _eventHub.SendMessageAsync(MessageFactory.UpdateAvailable, MessageFactory.UpdateVersionEvent(update), + true); } } - private async Task SendEvent(UpdateNotificationDto update, IReadOnlyList admins) - { - await _messageHub.Clients.Users(admins).SendAsync(SignalREvents.UpdateAvailable, MessageFactory.UpdateVersionEvent(update)); - } - private static async Task GetGithubRelease() { diff --git a/API/SignalR/EventHub.cs b/API/SignalR/EventHub.cs new file mode 100644 index 000000000..fa92d9ec7 --- /dev/null +++ b/API/SignalR/EventHub.cs @@ -0,0 +1,44 @@ +using System.Threading.Tasks; +using API.Data; +using API.SignalR.Presence; +using Microsoft.AspNetCore.SignalR; + +namespace API.SignalR; + +/// +/// Responsible for ushering events to the UI and allowing simple DI hook to send data +/// +public interface IEventHub +{ + Task SendMessageAsync(string method, SignalRMessage message, bool onlyAdmins = true); +} + +public class EventHub : IEventHub +{ + private readonly IHubContext _messageHub; + private readonly IPresenceTracker _presenceTracker; + private readonly IUnitOfWork _unitOfWork; + + public EventHub(IHubContext messageHub, IPresenceTracker presenceTracker, IUnitOfWork unitOfWork) + { + _messageHub = messageHub; + _presenceTracker = presenceTracker; + _unitOfWork = unitOfWork; + + // TODO: When sending a message, queue the message up and on re-connect, reply the queued messages. Queue messages expire on a rolling basis (rolling array) + } + + public async Task SendMessageAsync(string method, SignalRMessage message, bool onlyAdmins = true) + { + // TODO: If libraryId and NOT onlyAdmins, then perform RBS check before sending the event + + var users = _messageHub.Clients.All; + if (onlyAdmins) + { + var admins = await _presenceTracker.GetOnlineAdmins(); + _messageHub.Clients.Users(admins); + } + + await users.SendAsync(method, message); + } +} diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index c2f661973..e15d991a5 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -1,16 +1,90 @@ using System; +using System.Diagnostics; +using System.IO; using System.Threading; using API.DTOs.Update; +using API.Entities; namespace API.SignalR { public static class MessageFactory { + /// + /// An update is available for the Kavita instance + /// + public const string UpdateAvailable = "UpdateAvailable"; + /// + /// Used to tell when a scan series completes + /// + public const string ScanSeries = "ScanSeries"; + /// + /// Event sent out during Refresh Metadata for progress tracking + /// + private const string CoverUpdateProgress = "CoverUpdateProgress"; + /// + /// Series is added to server + /// + public const string SeriesAdded = "SeriesAdded"; + /// + /// Series is removed from server + /// + public const string SeriesRemoved = "SeriesRemoved"; + /// + /// When a user is connects/disconnects from server + /// + public const string OnlineUsers = "OnlineUsers"; + /// + /// When a series is added to a collection + /// + public const string SeriesAddedToCollection = "SeriesAddedToCollection"; + /// + /// When an error occurs during a scan library task + /// + public const string ScanLibraryError = "ScanLibraryError"; + /// + /// Event sent out during backing up the database + /// + private const string BackupDatabaseProgress = "BackupDatabaseProgress"; + /// + /// Event sent out during cleaning up temp and cache folders + /// + private const string CleanupProgress = "CleanupProgress"; + /// + /// Event sent out during downloading of files + /// + private const string DownloadProgress = "DownloadProgress"; + /// + /// A cover was updated + /// + public const string CoverUpdate = "CoverUpdate"; + /// + /// A custom site theme was removed or added + /// + private const string SiteThemeProgress = "SiteThemeProgress"; + /// + /// A type of event that has progress (determinate or indeterminate). + /// The underlying event will have a name to give details on how to handle. + /// + public const string NotificationProgress = "NotificationProgress"; + /// + /// Event sent out when Scan Loop is parsing a file + /// + private const string FileScanProgress = "FileScanProgress"; + /// + /// A generic error that can occur in background processing + /// + public const string Error = "Error"; + /// + /// When DB updates are occuring during a library/series scan + /// + private const string ScanProgress = "ScanProgress"; + + public static SignalRMessage ScanSeriesEvent(int seriesId, string seriesName) { return new SignalRMessage() { - Name = SignalREvents.ScanSeries, + Name = ScanSeries, Body = new { SeriesId = seriesId, @@ -23,7 +97,7 @@ namespace API.SignalR { return new SignalRMessage() { - Name = SignalREvents.SeriesAdded, + Name = SeriesAdded, Body = new { SeriesId = seriesId, @@ -37,7 +111,7 @@ namespace API.SignalR { return new SignalRMessage() { - Name = SignalREvents.SeriesRemoved, + Name = SeriesRemoved, Body = new { SeriesId = seriesId, @@ -47,11 +121,15 @@ namespace API.SignalR }; } - public static SignalRMessage ScanLibraryProgressEvent(int libraryId, float progress) + public static SignalRMessage CoverUpdateProgressEvent(int libraryId, float progress, string eventType, string subtitle = "") { return new SignalRMessage() { - Name = SignalREvents.ScanLibraryProgress, + Name = CoverUpdateProgress, + Title = "Refreshing Covers", + SubTitle = subtitle, + EventType = eventType, + Progress = ProgressType.Determinate, Body = new { LibraryId = libraryId, @@ -61,37 +139,40 @@ namespace API.SignalR }; } - public static SignalRMessage RefreshMetadataProgressEvent(int libraryId, float progress) + public static SignalRMessage BackupDatabaseProgressEvent(float progress, string subtitle = "") { return new SignalRMessage() { - Name = SignalREvents.RefreshMetadataProgress, - Body = new + Name = BackupDatabaseProgress, + Title = "Backing up Database", + SubTitle = subtitle, + EventType = progress switch { - LibraryId = libraryId, - Progress = progress, - EventTime = DateTime.Now - } - }; - } - - - public static SignalRMessage BackupDatabaseProgressEvent(float progress) - { - return new SignalRMessage() - { - Name = SignalREvents.BackupDatabaseProgress, + 0f => "started", + 1f => "ended", + _ => "updated" + }, + Progress = ProgressType.Determinate, Body = new { Progress = progress } }; } - public static SignalRMessage CleanupProgressEvent(float progress) + public static SignalRMessage CleanupProgressEvent(float progress, string subtitle = "") { return new SignalRMessage() { - Name = SignalREvents.CleanupProgress, + Name = CleanupProgress, + Title = "Performing Cleanup", + SubTitle = subtitle, + EventType = progress switch + { + 0f => "started", + 1f => "ended", + _ => "updated" + }, + Progress = ProgressType.Determinate, Body = new { Progress = progress @@ -100,21 +181,26 @@ namespace API.SignalR } - public static SignalRMessage UpdateVersionEvent(UpdateNotificationDto update) { return new SignalRMessage { - Name = SignalREvents.UpdateAvailable, + Name = UpdateAvailable, + Title = "Update Available", + SubTitle = update.UpdateTitle, + EventType = ProgressEventType.Single, + Progress = ProgressType.None, Body = update }; } - public static SignalRMessage SeriesAddedToCollection(int tagId, int seriesId) + public static SignalRMessage SeriesAddedToCollectionEvent(int tagId, int seriesId) { return new SignalRMessage { - Name = SignalREvents.UpdateAvailable, + Name = SeriesAddedToCollection, + Progress = ProgressType.None, + EventType = ProgressEventType.Single, Body = new { TagId = tagId, @@ -123,11 +209,15 @@ namespace API.SignalR }; } - public static SignalRMessage ScanLibraryError(int libraryId) + public static SignalRMessage ScanLibraryErrorEvent(int libraryId, string libraryName) { return new SignalRMessage { - Name = SignalREvents.ScanLibraryError, + Name = ScanLibraryError, + Title = "Error", + SubTitle = $"Error Scanning {libraryName}", + Progress = ProgressType.None, + EventType = ProgressEventType.Single, Body = new { LibraryId = libraryId, @@ -135,11 +225,15 @@ namespace API.SignalR }; } - public static SignalRMessage DownloadProgressEvent(string username, string downloadName, float progress) + public static SignalRMessage DownloadProgressEvent(string username, string downloadName, float progress, string eventType = "updated") { return new SignalRMessage() { - Name = SignalREvents.DownloadProgress, + Name = DownloadProgress, + Title = $"Downloading {downloadName}", + SubTitle = $"{username} is downloading {downloadName}", + EventType = eventType, + Progress = ProgressType.Determinate, Body = new { UserName = username, @@ -149,11 +243,73 @@ namespace API.SignalR }; } + /// + /// Represents a file being scanned by Kavita for processing and grouping + /// + /// Does not have a progress as it's unknown how many files there are. Instead sends -1 to represent indeterminate + /// + /// + /// + /// + public static SignalRMessage FileScanProgressEvent(string filename, string libraryName, string eventType) + { + return new SignalRMessage() + { + Name = FileScanProgress, + Title = $"Scanning {libraryName}", + SubTitle = Path.GetFileName(filename), + EventType = eventType, + Progress = ProgressType.Indeterminate, + Body = new + { + Title = $"Scanning {libraryName}", + Subtitle = filename, + Filename = filename, + EventTime = DateTime.Now, + } + }; + } + + public static SignalRMessage DbUpdateProgressEvent(Series series, string eventType) + { + // TODO: I want this as a detail of a Scanning Series and we can put more information like Volume or Chapter here + return new SignalRMessage() + { + Name = ScanProgress, + Title = $"Scanning {series.Library.Name}", + SubTitle = series.Name, + EventType = eventType, + Progress = ProgressType.Indeterminate, + Body = new + { + Title = "Updating Series", + SubTitle = series.Name + } + }; + } + + public static SignalRMessage LibraryScanProgressEvent(string libraryName, string eventType, string seriesName = "") + { + // TODO: I want this as a detail of a Scanning Series and we can put more information like Volume or Chapter here + return new SignalRMessage() + { + Name = ScanProgress, + Title = $"Scanning {libraryName}", + SubTitle = seriesName, + EventType = eventType, + Progress = ProgressType.Indeterminate, + Body = null + }; + } + public static SignalRMessage CoverUpdateEvent(int id, string entityType) { return new SignalRMessage() { - Name = SignalREvents.CoverUpdate, + Name = CoverUpdate, + Title = "Updating Cover", + //SubTitle = series.Name, // TODO: Refactor this + Progress = ProgressType.None, Body = new { Id = id, @@ -162,17 +318,18 @@ namespace API.SignalR }; } - public static SignalRMessage SiteThemeProgressEvent(int themeIteratedCount, int totalThemesToIterate, string themeName, float progress) + public static SignalRMessage SiteThemeProgressEvent(string subtitle, string themeName, string eventType) { return new SignalRMessage() { - Name = SignalREvents.SiteThemeProgress, + Name = SiteThemeProgress, + Title = "Scanning Site Theme", + SubTitle = subtitle, + EventType = eventType, + Progress = ProgressType.Indeterminate, Body = new { - TotalUpdates = totalThemesToIterate, - CurrentCount = themeIteratedCount, ThemeName = themeName, - Progress = progress } }; } diff --git a/API/SignalR/MessageHub.cs b/API/SignalR/MessageHub.cs index 2b3cd96cc..d4508db17 100644 --- a/API/SignalR/MessageHub.cs +++ b/API/SignalR/MessageHub.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using API.Data; using API.Extensions; using API.SignalR.Presence; using Microsoft.AspNetCore.Authorization; @@ -36,6 +37,7 @@ namespace API.SignalR public override async Task OnConnectedAsync() { + lock (Connections) { Connections.Add(Context.ConnectionId); @@ -44,7 +46,7 @@ namespace API.SignalR await _tracker.UserConnected(Context.User.GetUsername(), Context.ConnectionId); var currentUsers = await PresenceTracker.GetOnlineUsers(); - await Clients.All.SendAsync(SignalREvents.OnlineUsers, currentUsers); + await Clients.All.SendAsync(MessageFactory.OnlineUsers, currentUsers); await base.OnConnectedAsync(); @@ -60,7 +62,7 @@ namespace API.SignalR await _tracker.UserDisconnected(Context.User.GetUsername(), Context.ConnectionId); var currentUsers = await PresenceTracker.GetOnlineUsers(); - await Clients.All.SendAsync(SignalREvents.OnlineUsers, currentUsers); + await Clients.All.SendAsync(MessageFactory.OnlineUsers, currentUsers); await base.OnDisconnectedAsync(exception); diff --git a/API/SignalR/Presence/PresenceTracker.cs b/API/SignalR/Presence/PresenceTracker.cs index 73d6479ff..2d71b7302 100644 --- a/API/SignalR/Presence/PresenceTracker.cs +++ b/API/SignalR/Presence/PresenceTracker.cs @@ -15,13 +15,20 @@ namespace API.SignalR.Presence } + internal class ConnectionDetail + { + public List ConnectionIds { get; set; } + public bool IsAdmin { get; set; } + } + + // TODO: This can respond to UserRoleUpdate events to handle online users /// /// This is a singleton service for tracking what users have a SignalR connection and their difference connectionIds /// public class PresenceTracker : IPresenceTracker { private readonly IUnitOfWork _unitOfWork; - private static readonly Dictionary> OnlineUsers = new Dictionary>(); + private static readonly Dictionary OnlineUsers = new Dictionary(); public PresenceTracker(IUnitOfWork unitOfWork) { @@ -30,20 +37,25 @@ namespace API.SignalR.Presence public async Task UserConnected(string username, string connectionId) { + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(username); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); lock (OnlineUsers) { if (OnlineUsers.ContainsKey(username)) { - OnlineUsers[username].Add(connectionId); + OnlineUsers[username].ConnectionIds.Add(connectionId); } else { - OnlineUsers.Add(username, new List() { connectionId }); + OnlineUsers.Add(username, new ConnectionDetail() + { + ConnectionIds = new List() {connectionId}, + IsAdmin = isAdmin + }); } } // Update the last active for the user - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(username); user.LastActive = DateTime.Now; await _unitOfWork.CommitAsync(); } @@ -54,9 +66,9 @@ namespace API.SignalR.Presence { if (!OnlineUsers.ContainsKey(username)) return Task.CompletedTask; - OnlineUsers[username].Remove(connectionId); + OnlineUsers[username].ConnectionIds.Remove(connectionId); - if (OnlineUsers[username].Count == 0) + if (OnlineUsers[username].ConnectionIds.Count == 0) { OnlineUsers.Remove(username); } @@ -75,18 +87,16 @@ namespace API.SignalR.Presence return Task.FromResult(onlineUsers); } - public async Task GetOnlineAdmins() + public Task GetOnlineAdmins() { string[] onlineUsers; lock (OnlineUsers) { - onlineUsers = OnlineUsers.OrderBy(k => k.Key).Select(k => k.Key).ToArray(); + onlineUsers = OnlineUsers.Where(pair => pair.Value.IsAdmin).OrderBy(k => k.Key).Select(k => k.Key).ToArray(); } - var admins = await _unitOfWork.UserRepository.GetAdminUsersAsync(); - var result = admins.Select(a => a.UserName).Intersect(onlineUsers).ToArray(); - return result; + return Task.FromResult(onlineUsers); } public Task> GetConnectionsForUser(string username) @@ -94,7 +104,7 @@ namespace API.SignalR.Presence List connectionIds; lock (OnlineUsers) { - connectionIds = OnlineUsers.GetValueOrDefault(username); + connectionIds = OnlineUsers.GetValueOrDefault(username)?.ConnectionIds; } return Task.FromResult(connectionIds); diff --git a/API/SignalR/ProgressEventType.cs b/API/SignalR/ProgressEventType.cs new file mode 100644 index 000000000..89ba758c5 --- /dev/null +++ b/API/SignalR/ProgressEventType.cs @@ -0,0 +1,17 @@ +namespace API.SignalR; + +public static class ProgressEventType +{ + public const string Started = "started"; + + public const string Updated = "updated"; + /// + /// End of the update chain + /// + public const string Ended = "ended"; + /// + /// Represents a single update + /// + public const string Single = "started"; + +} diff --git a/API/SignalR/ProgressType.cs b/API/SignalR/ProgressType.cs new file mode 100644 index 000000000..b0fbe341d --- /dev/null +++ b/API/SignalR/ProgressType.cs @@ -0,0 +1,21 @@ +namespace API.SignalR; + +/// +/// How progress should be represented on the UI +/// +public static class ProgressType +{ + /// + /// Progress scales from 0F -> 1F + /// + public const string Determinate = "determinate"; + /// + /// Progress has no understanding of quantity + /// + public const string Indeterminate = "indeterminate"; + /// + /// No progress component to the event + /// + public const string None = ""; + +} diff --git a/API/SignalR/SignalREvents.cs b/API/SignalR/SignalREvents.cs deleted file mode 100644 index 7f9f44cf9..000000000 --- a/API/SignalR/SignalREvents.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace API.SignalR -{ - public static class SignalREvents - { - /// - /// An update is available for the Kavita instance - /// - public const string UpdateAvailable = "UpdateAvailable"; - /// - /// Used to tell when a scan series completes - /// - public const string ScanSeries = "ScanSeries"; - /// - /// Event sent out during Refresh Metadata for progress tracking - /// - public const string RefreshMetadataProgress = "RefreshMetadataProgress"; - /// - /// Series is added to server - /// - public const string SeriesAdded = "SeriesAdded"; - /// - /// Series is removed from server - /// - public const string SeriesRemoved = "SeriesRemoved"; - /// - /// Progress event for Scan library - /// - public const string ScanLibraryProgress = "ScanLibraryProgress"; - /// - /// When a user is connects/disconnects from server - /// - public const string OnlineUsers = "OnlineUsers"; - /// - /// When a series is added to a collection - /// - public const string SeriesAddedToCollection = "SeriesAddedToCollection"; - /// - /// When an error occurs during a scan library task - /// - public const string ScanLibraryError = "ScanLibraryError"; - /// - /// Event sent out during backing up the database - /// - public const string BackupDatabaseProgress = "BackupDatabaseProgress"; - /// - /// Event sent out during cleaning up temp and cache folders - /// - public const string CleanupProgress = "CleanupProgress"; - /// - /// Event sent out during downloading of files - /// - public const string DownloadProgress = "DownloadProgress"; - /// - /// A cover was updated - /// - public const string CoverUpdate = "CoverUpdate"; - /// - /// A custom site theme was removed or added - /// - public const string SiteThemeProgress = "SiteThemeProgress"; - - } -} diff --git a/API/SignalR/SignalRMessage.cs b/API/SignalR/SignalRMessage.cs index dfb181105..b71d0b813 100644 --- a/API/SignalR/SignalRMessage.cs +++ b/API/SignalR/SignalRMessage.cs @@ -1,14 +1,39 @@ -namespace API.SignalR +using System; + +namespace API.SignalR { /// /// Payload for SignalR messages to Frontend /// public class SignalRMessage { + /// + /// Body of the event type + /// public object Body { get; set; } public string Name { get; set; } - - //[JsonIgnore] - //public ModelAction Action { get; set; } // This will be for when we add new flows + /// + /// User friendly Title of the Event + /// + /// Scanning Manga + public string Title { get; set; } = string.Empty; + /// + /// User friendly subtitle. Should have extra info + /// + /// C:/manga/Accel World V01.cbz + public string SubTitle { get; set; } = string.Empty; + /// + /// Represents what this represents. started | updated | ended | single + /// + /// + public string EventType { get; set; } = ProgressEventType.Updated; + /// + /// How should progress be represented. If Determinate, the Body MUST have a Progress float on it. + /// + public string Progress { get; set; } = ProgressType.None; + /// + /// When event took place + /// + public DateTime EventTime = DateTime.Now; } } diff --git a/API/Startup.cs b/API/Startup.cs index 2f9ad133b..b52640bab 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -18,6 +18,7 @@ using Hangfire; using Hangfire.MemoryStorage; using Kavita.Common; using Kavita.Common.EnvironmentInfo; +using MediatR; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -129,6 +130,8 @@ namespace API // Add IHostedService for startup tasks // Any services that should be bootstrapped go here services.AddHostedService(); + + services.AddMediatR(typeof(Startup)); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. diff --git a/UI/Web/src/app/_models/events/file-scan-progress-event.ts b/UI/Web/src/app/_models/events/file-scan-progress-event.ts new file mode 100644 index 000000000..90025fc9b --- /dev/null +++ b/UI/Web/src/app/_models/events/file-scan-progress-event.ts @@ -0,0 +1,12 @@ +/** + * Represents a file being scanned during a Library Scan + */ +export interface FileScanProgressEvent { + // libraryId: number; + // libraryName: string; + // fileName: string; + + title: string; + subtitle: string; + eventTime: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/notification-container.ts b/UI/Web/src/app/_models/events/notification-container.ts new file mode 100644 index 000000000..03168b2ec --- /dev/null +++ b/UI/Web/src/app/_models/events/notification-container.ts @@ -0,0 +1,40 @@ +export interface NotificationContainer { + /** + * Represents underlying type of event + */ + type: string; + /** + * How many events are in this object + */ + size: number; + + events: Array; +} + +export interface ActivityNotification { + type: string; // library.update.section + /** + * If this notification has some sort of cancellable operation + */ + cancellable: boolean; + + userId: number; + /** + * Main action title ie) Scanning LIBRARY_NAME + */ + title: string; + /** + * Detail information about action. ie) Series Name + */ + subtitle: string; + /** + * Progress of this action [0-100] + */ + progress: number; + /** + * Any additional context backend needs to send to UI + */ + context: { + libraryId: number; + }; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/notification-progress-event.ts b/UI/Web/src/app/_models/events/notification-progress-event.ts new file mode 100644 index 000000000..a647071ba --- /dev/null +++ b/UI/Web/src/app/_models/events/notification-progress-event.ts @@ -0,0 +1,30 @@ +export interface NotificationProgressEvent { + /** + * Payload of the event subtype + */ + body: any; + /** + * Subtype event + */ + name: string; + /** + * Title to display in events widget + */ + title: string; + /** + * Optional subtitle to display. Defaults to empty string + */ + subTitle: string; + /** + * Type of event. Helps events widget to understand how to handle said event + */ + eventType: 'single' | 'started' | 'updated' | 'ended'; + /** + * Type of progress. Helps widget understand how to display spinner + */ + progress: 'none' | 'indeterminate' | 'determinate'; + /** + * When event was sent + */ + eventTime: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/scan-library-progress-event.ts b/UI/Web/src/app/_models/events/progress-event.ts similarity index 58% rename from UI/Web/src/app/_models/events/scan-library-progress-event.ts rename to UI/Web/src/app/_models/events/progress-event.ts index 7b4d9c2d0..2c7d57c50 100644 --- a/UI/Web/src/app/_models/events/scan-library-progress-event.ts +++ b/UI/Web/src/app/_models/events/progress-event.ts @@ -2,4 +2,10 @@ export interface ProgressEvent { libraryId: number; progress: number; eventTime: string; + + // New fields + /** + * Event type + */ + name: string; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/site-theme-progress-event.ts b/UI/Web/src/app/_models/events/site-theme-progress-event.ts index 24228c113..23fab2939 100644 --- a/UI/Web/src/app/_models/events/site-theme-progress-event.ts +++ b/UI/Web/src/app/_models/events/site-theme-progress-event.ts @@ -1,7 +1,3 @@ export interface SiteThemeProgressEvent { - totalUpdates: number; - currentCount: number; themeName: string; - progress: number; - eventTime: string; } \ No newline at end of file diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index b09b17343..141294dad 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -1,19 +1,16 @@ -import { EventEmitter, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; import { ToastrService } from 'ngx-toastr'; import { BehaviorSubject, ReplaySubject } from 'rxjs'; import { environment } from 'src/environments/environment'; -import { ProgressEvent } from '../_models/events/scan-library-progress-event'; -import { ScanSeriesEvent } from '../_models/events/scan-series-event'; -import { SeriesAddedEvent } from '../_models/events/series-added-event'; +import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; import { SiteThemeProgressEvent } from '../_models/events/site-theme-progress-event'; import { User } from '../_models/user'; export enum EVENTS { UpdateAvailable = 'UpdateAvailable', ScanSeries = 'ScanSeries', - RefreshMetadataProgress = 'RefreshMetadataProgress', SeriesAdded = 'SeriesAdded', SeriesRemoved = 'SeriesRemoved', ScanLibraryProgress = 'ScanLibraryProgress', @@ -21,8 +18,22 @@ export enum EVENTS { SeriesAddedToCollection = 'SeriesAddedToCollection', ScanLibraryError = 'ScanLibraryError', BackupDatabaseProgress = 'BackupDatabaseProgress', + /** + * A subtype of NotificationProgress that represents maintenance cleanup on server-owned resources + */ CleanupProgress = 'CleanupProgress', + /** + * A subtype of NotificationProgress that represnts a user downloading a file or group of files + */ DownloadProgress = 'DownloadProgress', + /** + * A generic progress event + */ + NotificationProgress = 'NotificationProgress', + /** + * A subtype of NotificationProgress that represents the underlying file being processed during a scan + */ + FileScanProgress = 'FileScanProgress', /** * A custom user site theme is added or removed during a scan */ @@ -30,7 +41,11 @@ export enum EVENTS { /** * A cover is updated */ - CoverUpdate = 'CoverUpdate' + CoverUpdate = 'CoverUpdate', + /** + * A subtype of NotificationProgress that represents a file being processed for cover image extraction + */ + CoverUpdateProgress = 'CoverUpdateProgress', } export interface Message { @@ -38,6 +53,7 @@ export interface Message { payload: T; } + @Injectable({ providedIn: 'root' }) @@ -46,19 +62,36 @@ export class MessageHubService { private hubConnection!: HubConnection; private messagesSource = new ReplaySubject>(1); - public messages$ = this.messagesSource.asObservable(); - private onlineUsersSource = new BehaviorSubject([]); - onlineUsers$ = this.onlineUsersSource.asObservable(); - public scanSeries: EventEmitter = new EventEmitter(); - public scanLibrary: EventEmitter = new EventEmitter(); // TODO: Refactor this name to be generic - public seriesAdded: EventEmitter = new EventEmitter(); + /** + * Any events that come from the backend + */ + public messages$ = this.messagesSource.asObservable(); + /** + * Users that are online + */ + public onlineUsers$ = this.onlineUsersSource.asObservable(); + isAdmin: boolean = false; constructor(private toastr: ToastrService, private router: Router) { - + + } + + /** + * Tests that an event is of the type passed + * @param event + * @param eventType + * @returns + */ + public isEventType(event: Message, eventType: EVENTS) { + if (event.event == EVENTS.NotificationProgress) { + const notification = event.payload as NotificationProgressEvent; + return notification.eventType.toLowerCase() == eventType.toLowerCase(); + } + return event.event === eventType; } createHubConnection(user: User, isAdmin: boolean) { @@ -85,7 +118,6 @@ export class MessageHubService { event: EVENTS.ScanSeries, payload: resp.body }); - this.scanSeries.emit(resp.body); }); this.hubConnection.on(EVENTS.ScanLibraryProgress, resp => { @@ -93,34 +125,13 @@ export class MessageHubService { event: EVENTS.ScanLibraryProgress, payload: resp.body }); - this.scanLibrary.emit(resp.body); }); - this.hubConnection.on(EVENTS.BackupDatabaseProgress, resp => { - this.messagesSource.next({ - event: EVENTS.BackupDatabaseProgress, - payload: resp.body - }); - }); - this.hubConnection.on(EVENTS.CleanupProgress, resp => { + this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => { this.messagesSource.next({ - event: EVENTS.CleanupProgress, - payload: resp.body - }); - }); - - this.hubConnection.on(EVENTS.DownloadProgress, resp => { - this.messagesSource.next({ - event: EVENTS.DownloadProgress, - payload: resp.body - }); - }); - - this.hubConnection.on(EVENTS.RefreshMetadataProgress, resp => { - this.messagesSource.next({ - event: EVENTS.RefreshMetadataProgress, - payload: resp.body + event: EVENTS.NotificationProgress, + payload: resp }); }); @@ -144,6 +155,7 @@ export class MessageHubService { payload: resp.body }); if (this.isAdmin) { + // TODO: Just show the error, RBS is done in eventhub this.toastr.error('Library Scan had a critical error. Some series were not saved. Check logs'); } }); @@ -153,7 +165,6 @@ export class MessageHubService { event: EVENTS.SeriesAdded, payload: resp.body }); - this.seriesAdded.emit(resp.body); }); this.hubConnection.on(EVENTS.SeriesRemoved, resp => { @@ -163,14 +174,6 @@ export class MessageHubService { }); }); - // this.hubConnection.on(EVENTS.RefreshMetadata, resp => { - // this.messagesSource.next({ - // event: EVENTS.RefreshMetadata, - // payload: resp.body - // }); - // this.refreshMetadata.emit(resp.body); // TODO: Remove this - // }); - this.hubConnection.on(EVENTS.CoverUpdate, resp => { this.messagesSource.next({ event: EVENTS.CoverUpdate, @@ -195,5 +198,5 @@ export class MessageHubService { sendMessage(methodName: string, body?: any) { return this.hubConnection.invoke(methodName, body); } - + } diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.ts b/UI/Web/src/app/admin/manage-library/manage-library.component.ts index d6e5a2364..d8c7c63ba 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.ts +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.ts @@ -2,12 +2,13 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import { Subject } from 'rxjs'; -import { take, takeUntil } from 'rxjs/operators'; +import { take, takeUntil, takeWhile } from 'rxjs/operators'; import { ConfirmService } from 'src/app/shared/confirm.service'; -import { ProgressEvent } from 'src/app/_models/events/scan-library-progress-event'; +import { NotificationProgressEvent } from 'src/app/_models/events/notification-progress-event'; +import { ProgressEvent } from 'src/app/_models/events/progress-event'; import { Library, LibraryType } from 'src/app/_models/library'; import { LibraryService } from 'src/app/_services/library.service'; -import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service'; +import { EVENTS, Message, MessageHubService } from 'src/app/_services/message-hub.service'; import { LibraryEditorModalComponent } from '../_modals/library-editor-modal/library-editor-modal.component'; @Component({ @@ -37,18 +38,20 @@ export class ManageLibraryComponent implements OnInit, OnDestroy { this.getLibraries(); // when a progress event comes in, show it on the UI next to library - this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => { - if (event.event !== EVENTS.ScanLibraryProgress) return; + this.hubService.messages$.pipe(takeUntil(this.onDestroy), takeWhile(event => event.event === EVENTS.NotificationProgress)) + .subscribe((event: Message) => { + if (event.event !== EVENTS.NotificationProgress && (event.payload as NotificationProgressEvent).name === EVENTS.ScanSeries) return; console.log('scan event: ', event.payload); + // TODO: Refactor this to use EventyType on NotificationProgress interface rather than float comparison - const scanEvent = event.payload as ProgressEvent; + const scanEvent = event.payload.body as ProgressEvent; this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 1}; if (scanEvent.progress === 0) { this.scanInProgress[scanEvent.libraryId].timestamp = scanEvent.eventTime; } - if (this.scanInProgress[scanEvent.libraryId].progress === false && scanEvent.progress === 1) { + if (this.scanInProgress[scanEvent.libraryId].progress === false && (scanEvent.progress === 1 || event.payload.eventType === 'ended')) { this.libraryService.getLibraries().pipe(take(1)).subscribe(libraries => { const newLibrary = libraries.find(lib => lib.id === scanEvent.libraryId); const existingLibrary = this.libraries.find(lib => lib.id === scanEvent.libraryId); diff --git a/UI/Web/src/app/all-series/all-series.component.ts b/UI/Web/src/app/all-series/all-series.component.ts index 11fa904f6..8d2f2134d 100644 --- a/UI/Web/src/app/all-series/all-series.component.ts +++ b/UI/Web/src/app/all-series/all-series.component.ts @@ -13,7 +13,7 @@ import { Series } from '../_models/series'; import { FilterEvent, SeriesFilter } from '../_models/series-filter'; import { ActionItem, Action } from '../_services/action-factory.service'; import { ActionService } from '../_services/action.service'; -import { MessageHubService } from '../_services/message-hub.service'; +import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service'; import { SeriesService } from '../_services/series.service'; @Component({ @@ -82,7 +82,8 @@ export class AllSeriesComponent implements OnInit, OnDestroy { } ngOnInit(): void { - this.hubService.seriesAdded.pipe(debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event: SeriesAddedEvent) => { + this.hubService.messages$.pipe(debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event: Message) => { + if (event.event !== EVENTS.SeriesAdded) return; this.loadPage(); }); } diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts index 5eebc5bd3..f3ef43837 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts @@ -108,7 +108,7 @@ export class CollectionDetailComponent implements OnInit, OnDestroy { ngOnInit(): void { this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this)); - this.messageHub.messages$.pipe(takeWhile(event => event.event === EVENTS.SeriesAddedToCollection), takeUntil(this.onDestory), debounceTime(2000)).subscribe(event => { + this.messageHub.messages$.pipe(takeUntil(this.onDestory), debounceTime(2000)).subscribe(event => { if (event.event == EVENTS.SeriesAddedToCollection) { const collectionEvent = event.payload as SeriesAddedToCollectionEvent; if (collectionEvent.tagId === this.collectionTag.id) { diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index ab465bf9e..e1b382d66 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -14,7 +14,7 @@ import { FilterEvent, SeriesFilter } from '../_models/series-filter'; import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service'; import { ActionService } from '../_services/action.service'; import { LibraryService } from '../_services/library.service'; -import { MessageHubService } from '../_services/message-hub.service'; +import { EVENTS, MessageHubService } from '../_services/message-hub.service'; import { SeriesService } from '../_services/series.service'; @Component({ @@ -92,12 +92,13 @@ export class LibraryDetailComponent implements OnInit, OnDestroy { [this.filterSettings.presets, this.filterSettings.openByDefault] = this.utilityService.filterPresetsFromUrl(this.route.snapshot, this.seriesService.createSeriesFilter()); this.filterSettings.presets.libraries = [this.libraryId]; - - //this.loadPage(); } ngOnInit(): void { - this.hubService.seriesAdded.pipe(takeWhile(event => event.libraryId === this.libraryId), debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event: SeriesAddedEvent) => { + this.hubService.messages$.pipe(debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event) => { + if (event.event !== EVENTS.SeriesAdded) return; + const seriesAdded = event.payload as SeriesAddedEvent; + if (seriesAdded.libraryId !== this.libraryId) return; this.loadPage(); }); } diff --git a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html index 8a73923a8..1f23e5c00 100644 --- a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html +++ b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html @@ -1,25 +1,90 @@ -
    -
  • -
    - Scan for {{event.libraryName}} in progress -
    - {{prettyPrintProgress(event.progress)}}% - {{prettyPrintEvent(event.eventType, event)}} {{event.libraryName}} -
  • -
  • Not much going on here
  • -
  • -  Update available -
  • + + +
  • + +
    Title goes here
    +
    Subtitle goes here
    +
    +
    +
    +
    +
    +
  • +
  • +
    Title goes here
    +
    Subtitle goes here
    +
  • +
  • +
    +
    Scanning Books
    +
    E:\\Books\\Demon King Daimaou\\Demon King Daimaou - Volume 11.epub
    +
    +
    {{prettyPrintProgress(0.1)}}%
    +
    +
    +
    +
    + +
    + +
  • +
    + + + +
  • +
    {{message.title}}
    +
    {{message.subTitle}}
    +
    +
    +
    +
    +
    +
  • + +
  • +
    {{message.title}}
    +
    {{message.subTitle}}
    +
    +
    {{prettyPrintProgress(message.body.progress) + '%'}}
    +
    +
    +
    +
    +
  • +
    +
    +
    + + + + +
  • +  Update available +
  • +
  • +
    {{singleUpdate.title}}
    +
    {{singleUpdate.subTitle}}
    +
  • +
    +
    + + + +
  • +
    {{onlineUsers.length}} Users online
    +
  • +
  • Not much going on here
  • +
\ No newline at end of file diff --git a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss index 02e4a2a56..5ed59af74 100644 --- a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss +++ b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss @@ -8,7 +8,7 @@ .dark-menu-item { color: var(--body-text-color); background-color: rgb(1, 4, 9); - border-color: rgba(1, 4, 9, 0.5); + border-color: rgba(53, 53, 53, 0.5); } // Popovers need to be their own component @@ -16,17 +16,37 @@ border-bottom-color: transparent; } -.nav-events { - background-color: var(--navbar-bg-color); +::ng-deep .nav-events { + + .popover-body { + min-width: 250px; + max-width: 250px; + padding: 0px; + box-shadow: 0px 0px 12px rgb(0 0 0 / 75%); + } + + .popover { + min-width: 300px; + } } -// .nav-events { -// background-color: white; -// } +.progress-container { + width: 100%; +} +.progress { + padding: 0; +} + +.accent-text { + width: 100%; + text-overflow: ellipsis; + overflow:hidden; + white-space:nowrap; +} .btn:focus, .btn:hover { - box-shadow: 0 0 0 0.1rem rgba(255, 255, 255, 1); // TODO: Used in nav as well, move to dark for btn-icon focus + box-shadow: 0 0 0 0.1rem var(--navbar-btn-hover-outline-color); } .small-spinner { @@ -36,9 +56,6 @@ -.nav-events .popover-body { - padding: 0px; -} .btn-icon { color: white; diff --git a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.ts b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.ts index ff8f1f300..d41dc5d29 100644 --- a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.ts +++ b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.ts @@ -1,25 +1,16 @@ -import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { BehaviorSubject, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component'; -import { ProgressEvent } from '../_models/events/scan-library-progress-event'; +import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; +import { UpdateVersionEvent } from '../_models/events/update-version-event'; import { User } from '../_models/user'; import { AccountService } from '../_services/account.service'; -import { LibraryService } from '../_services/library.service'; import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service'; -interface ProcessedEvent { - eventType: string; - timestamp?: string; - progress: number; - libraryId: number; - libraryName: string; -} -type ProgressType = EVENTS.ScanLibraryProgress | EVENTS.RefreshMetadataProgress | EVENTS.BackupDatabaseProgress | EVENTS.CleanupProgress; -const acceptedEvents = [EVENTS.ScanLibraryProgress, EVENTS.RefreshMetadataProgress, EVENTS.BackupDatabaseProgress, EVENTS.CleanupProgress, EVENTS.DownloadProgress, EVENTS.SiteThemeProgress]; // TODO: Rename this to events widget @Component({ @@ -28,37 +19,48 @@ const acceptedEvents = [EVENTS.ScanLibraryProgress, EVENTS.RefreshMetadataProgre styleUrls: ['./nav-events-toggle.component.scss'] }) export class NavEventsToggleComponent implements OnInit, OnDestroy { - @Input() user!: User; + isAdmin: boolean = false; private readonly onDestroy = new Subject(); /** - * Events that come through and are merged (ie progress event gets merged into a progress event) + * Progress events (Event Type: 'started', 'ended', 'updated' that have progress property) */ - progressEventsSource = new BehaviorSubject([]); + progressEventsSource = new BehaviorSubject([]); progressEvents$ = this.progressEventsSource.asObservable(); - updateAvailable: boolean = false; - updateBody: any; + singleUpdateSource = new BehaviorSubject([]); + singleUpdates$ = this.singleUpdateSource.asObservable(); + private updateNotificationModalRef: NgbModalRef | null = null; - constructor(private messageHub: MessageHubService, private libraryService: LibraryService, private modalService: NgbModal, private accountService: AccountService) { } - + activeEvents: number = 0; + + debugMode: boolean = false; + + + get EVENTS() { + return EVENTS; + } + + constructor(public messageHub: MessageHubService, private modalService: NgbModal, private accountService: AccountService) { } + ngOnDestroy(): void { this.onDestroy.next(); this.onDestroy.complete(); this.progressEventsSource.complete(); + this.singleUpdateSource.complete(); } ngOnInit(): void { + // Debounce for testing. Kavita's too fast this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => { - if (acceptedEvents.includes(event.event)) { - this.processProgressEvent(event, event.event); - } else if (event.event === EVENTS.UpdateAvailable) { - this.updateAvailable = true; - this.updateBody = event.payload; + if (event.event.endsWith('error')) { + // TODO: Show an error handle + } else if (event.event === EVENTS.NotificationProgress) { + this.processNotificationProgressEvent(event); } }); this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => { @@ -70,32 +72,49 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy { }); } + processNotificationProgressEvent(event: Message) { + const message = event.payload as NotificationProgressEvent; + let data; - processProgressEvent(event: Message, eventType: string) { - const scanEvent = event.payload as ProgressEvent; - - this.libraryService.getLibraryNames().subscribe(names => { - const data = this.progressEventsSource.getValue(); - const index = data.findIndex(item => item.eventType === eventType && item.libraryId === event.payload.libraryId); - if (index >= 0) { - data.splice(index, 1); - } - - if (scanEvent.progress !== 1) { - const libraryName = names[scanEvent.libraryId] || ''; - const newEvent = {eventType: eventType, timestamp: scanEvent.eventTime, progress: scanEvent.progress, libraryId: scanEvent.libraryId, libraryName, rawBody: event.payload}; - data.push(newEvent); - } - - - this.progressEventsSource.next(data); - }); + switch (event.payload.eventType) { + case 'single': + const values = this.singleUpdateSource.getValue(); + values.push(message); + this.singleUpdateSource.next(values); + this.activeEvents += 1; + break; + case 'started': + data = this.progressEventsSource.getValue(); + data.push(message); + this.progressEventsSource.next(data); + this.activeEvents += 1; + break; + case 'updated': + data = this.progressEventsSource.getValue(); + const index = data.findIndex(m => m.name === message.name); + if (index < 0) { + data.push(message); + } else { + data[index] = message; + } + this.progressEventsSource.next(data); + break; + case 'ended': + data = this.progressEventsSource.getValue(); + data = data.filter(m => m.name !== message.name); // This does not work // && m.title !== message.title + this.progressEventsSource.next(data); + this.activeEvents = Math.max(this.activeEvents - 1, 0); + break; + default: + break; + } } - handleUpdateAvailableClick() { + + handleUpdateAvailableClick(message: NotificationProgressEvent) { if (this.updateNotificationModalRef != null) { return; } this.updateNotificationModalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' }); - this.updateNotificationModalRef.componentInstance.updateData = this.updateBody; + this.updateNotificationModalRef.componentInstance.updateData = message.body as UpdateVersionEvent; this.updateNotificationModalRef.closed.subscribe(() => { this.updateNotificationModalRef = null; }); @@ -107,16 +126,4 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy { prettyPrintProgress(progress: number) { return Math.trunc(progress * 100); } - - prettyPrintEvent(eventType: string, event: any) { - switch(eventType) { - case (EVENTS.ScanLibraryProgress): return 'Scanning '; - case (EVENTS.RefreshMetadataProgress): return 'Refreshing Covers for '; - case (EVENTS.CleanupProgress): return 'Clearing Cache'; - case (EVENTS.BackupDatabaseProgress): return 'Backing up Database'; - case (EVENTS.DownloadProgress): return event.rawBody.userName.charAt(0).toUpperCase() + event.rawBody.userName.substr(1) + ' is downloading ' + event.rawBody.downloadName; - default: return eventType; - } - } - } diff --git a/UI/Web/src/app/recently-added/recently-added.component.ts b/UI/Web/src/app/recently-added/recently-added.component.ts index c7ec27d58..46d7a2a0d 100644 --- a/UI/Web/src/app/recently-added/recently-added.component.ts +++ b/UI/Web/src/app/recently-added/recently-added.component.ts @@ -12,7 +12,7 @@ import { Series } from '../_models/series'; import { FilterEvent, SeriesFilter } from '../_models/series-filter'; import { Action } from '../_services/action-factory.service'; import { ActionService } from '../_services/action.service'; -import { MessageHubService } from '../_services/message-hub.service'; +import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service'; import { SeriesService } from '../_services/series.service'; /** @@ -63,7 +63,10 @@ export class RecentlyAddedComponent implements OnInit, OnDestroy { } ngOnInit() { - this.hubService.seriesAdded.pipe(takeWhile(event => event.libraryId === this.libraryId), debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event: SeriesAddedEvent) => { + this.hubService.messages$.pipe(debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event) => { + if (event.event !== EVENTS.SeriesAdded) return; + const seriesAdded = event.payload as SeriesAddedEvent; + if (seriesAdded.libraryId !== this.libraryId) return; this.loadPage(); }); } diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index 281a8efe1..52ae482b9 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -4,7 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { NgbModal, NgbNavChangeEvent, NgbRatingConfig } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import { forkJoin, Subject } from 'rxjs'; -import { finalize, take, takeUntil, takeWhile } from 'rxjs/operators'; +import { finalize, map, take, takeUntil, takeWhile } from 'rxjs/operators'; import { BulkSelectionService } from '../cards/bulk-selection.service'; import { CardDetailsModalComponent } from '../cards/_modals/card-details-modal/card-details-modal.component'; import { EditSeriesModalComponent } from '../cards/_modals/edit-series-modal/edit-series-modal.component'; @@ -185,12 +185,13 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { return; } - this.messageHub.scanSeries.pipe(takeUntil(this.onDestroy)).subscribe((event: ScanSeriesEvent) => { - if (event.seriesId == this.series.id) - this.loadSeries(seriesId); - this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.series.id)); - this.toastr.success('Scan series completed'); - }); + // this.messageHub.messages$.pipe(takeUntil(this.onDestroy), takeWhile(e => this.messageHub.isEventType(e, EVENTS.ScanSeries))).subscribe((e) => { + // const event = e.payload as ScanSeriesEvent; + // if (event.seriesId == this.series.id) + // this.loadSeries(seriesId); + // this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.series.id)); + // this.toastr.success('Scan series completed'); + // }); this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => { if (event.event === EVENTS.SeriesRemoved) { @@ -203,6 +204,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { const seriesCoverUpdatedEvent = event.payload as ScanSeriesEvent; if (seriesCoverUpdatedEvent.seriesId === this.series.id) { this.loadSeries(seriesId); + this.seriesImage = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.series.id)); // NOTE: Is this needed as cover update will update the image for us } } }); diff --git a/UI/Web/src/app/shared/image/image.component.ts b/UI/Web/src/app/shared/image/image.component.ts index 99540b086..4d4e50204 100644 --- a/UI/Web/src/app/shared/image/image.component.ts +++ b/UI/Web/src/app/shared/image/image.component.ts @@ -56,7 +56,6 @@ export class ImageComponent implements OnChanges, OnDestroy { //...seriesId=123&random= const id = tokens[0].replace(enityType + 'Id=', ''); if (id === (updateEvent.id + '')) { - console.log('Image url: ', this.imageUrl, ' matches update event: ', updateEvent); this.imageUrl = this.imageService.randomize(this.imageUrl); } } diff --git a/UI/Web/src/app/theme.service.ts b/UI/Web/src/app/theme.service.ts index 782bd30d3..bf6c33ba0 100644 --- a/UI/Web/src/app/theme.service.ts +++ b/UI/Web/src/app/theme.service.ts @@ -5,6 +5,7 @@ import { DomSanitizer } from '@angular/platform-browser'; import { map, ReplaySubject, Subject, takeUntil } from 'rxjs'; import { environment } from 'src/environments/environment'; import { ConfirmService } from './shared/confirm.service'; +import { NotificationProgressEvent } from './_models/events/notification-progress-event'; import { SiteThemeProgressEvent } from './_models/events/site-theme-progress-event'; import { SiteTheme, ThemeProvider } from './_models/preferences/site-theme'; import { EVENTS, MessageHubService } from './_services/message-hub.service'; @@ -41,10 +42,13 @@ export class ThemeService implements OnDestroy { this.getThemes(); messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(message => { - if (message.event === EVENTS.SiteThemeProgress) { - if ((message.payload as SiteThemeProgressEvent).progress === 1) { - this.getThemes().subscribe(() => {}); - } + + if (message.event !== EVENTS.NotificationProgress) return; + const notificationEvent = (message.payload as NotificationProgressEvent); + if (notificationEvent.name !== EVENTS.SiteThemeProgress) return; + + if (notificationEvent.eventType === 'ended') { + this.getThemes().subscribe(() => {}); } }); } @@ -59,7 +63,6 @@ export class ThemeService implements OnDestroy { } isDarkTheme() { - console.log('color scheme: ', getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim().toLowerCase()); return this.getColorScheme().toLowerCase() === 'dark'; } diff --git a/UI/Web/src/styles.scss b/UI/Web/src/styles.scss index d2fc43d1a..2247605e6 100644 --- a/UI/Web/src/styles.scss +++ b/UI/Web/src/styles.scss @@ -34,6 +34,7 @@ @import './theme/components/slider'; @import './theme/components/radios'; @import './theme/components/selects'; +@import './theme/components/progress'; @import './theme/utilities/utilities'; diff --git a/UI/Web/src/theme/components/_popover.scss b/UI/Web/src/theme/components/_popover.scss index 0eee91ec6..b37111e2b 100644 --- a/UI/Web/src/theme/components/_popover.scss +++ b/UI/Web/src/theme/components/_popover.scss @@ -1,5 +1,22 @@ - +.popover { + background-color: var(--popover-bg-color); + border-color: var(--popover-border-color); +} + +.bs-popover-bottom { + > .popover-arrow { + + &::before { + border-bottom-color: var(--popover-outerarrow-color); + } + + &::after { + border-bottom-color: var(--popover-arrow-color); + } + } +} + .popover-body { background-color: var(--popover-body-bg-color); color: var(--popover-body-text-color) -} \ No newline at end of file +} diff --git a/UI/Web/src/theme/components/progress.scss b/UI/Web/src/theme/components/progress.scss new file mode 100644 index 000000000..33c52d33e --- /dev/null +++ b/UI/Web/src/theme/components/progress.scss @@ -0,0 +1,13 @@ +.progress { + background-color: var(--progress-bg-color); + +} + +.progress-bar { + background-color: var(--progress-bar-color); +} + +.progress-bar-striped { + background-image: var(--progress-striped-animated-color); + background-color: unset; +} diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index 3576dbe8a..edc602ba8 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -96,6 +96,10 @@ /* Popover */ --popover-body-bg-color: var(--navbar-bg-color); --popover-body-text-color: var(--navbar-text-color); + --popover-outerarrow-color: transparent; + --popover-arrow-color: transparent; + --popover-bg-color: black; + --popover-border-color: black; /* Pagination */ --pagination-active-link-border-color: var(--primary-color); @@ -106,9 +110,14 @@ --pagination-link-bg-color: rgba(1, 4, 9, 0.5); --pagination-focus-border-color: var(--primary-color); + /* Progress Bar */ + --progress-striped-animated-color: linear-gradient(45deg, rgba(74,198,148, 0.75) 25%, rgba(51, 138, 103, 0.75) 25%, rgba(51, 138, 103, 0.75) 50%, rgba(74,198,148, 0.75) 50%, rgba(74,198,148, 0.75) 75%, rgba(51, 138, 103, 0.75) 75%, rgba(51, 138, 103, 0.75)); + --progress-bg-color: var(--nav-header-bg-color); + --progress-bar-color: var(--primary-color-dark-shade); + /* Dropdown */ --dropdown-item-hover-text-color: white; - --dropdown-item-hover-bg-color: var(--primary-color); + --dropdown-item-hover-bg-color: var(--primary-color-dark-shade); --dropdown-item-text-color: var(--navbar-text-color); --dropdown-item-bg-color: var(--navbar-bg-color); --dropdown-overlay-color: rgba(0,0,0,0.5);