diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index eb467ab9f..43058d90f 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -21,7 +21,6 @@ using AutoMapper; using EasyCaching.Core; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration.UserSecrets; using Microsoft.Extensions.Logging; using TaskScheduler = API.Services.TaskScheduler; @@ -134,7 +133,7 @@ public class LibraryController : BaseApiController if (!await _unitOfWork.CommitAsync()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-library")); await _libraryWatcher.RestartWatching(); - _taskScheduler.ScanLibrary(library.Id); + await _taskScheduler.ScanLibrary(library.Id); await _eventHub.SendMessageAsync(MessageFactory.LibraryModified, MessageFactory.LibraryModifiedEvent(library.Id, "create"), false); await _eventHub.SendMessageAsync(MessageFactory.SideNavUpdate, @@ -292,7 +291,7 @@ public class LibraryController : BaseApiController public async Task Scan(int libraryId, bool force = false) { if (libraryId <= 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "greater-0", "libraryId")); - _taskScheduler.ScanLibrary(libraryId, force); + await _taskScheduler.ScanLibrary(libraryId, force); return Ok(); } @@ -500,7 +499,7 @@ public class LibraryController : BaseApiController if (originalFoldersCount != dto.Folders.Count() || typeUpdate) { await _libraryWatcher.RestartWatching(); - _taskScheduler.ScanLibrary(library.Id); + await _taskScheduler.ScanLibrary(library.Id); } if (folderWatchingUpdate) diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 3d29af5f6..509f8fda3 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -868,6 +868,7 @@ public class OpdsController : BaseApiController SetFeedId(feed, $"series-{series.Id}"); feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}")); + var chapterDict = new Dictionary(); var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId); foreach (var volume in seriesDetail.Volumes) { @@ -879,6 +880,7 @@ public class OpdsController : BaseApiController var chapterDto = _mapper.Map(chapter); foreach (var mangaFile in chapter.Files) { + chapterDict.Add(chapterId, 0); feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map(mangaFile), series, chapterDto, apiKey, prefix, baseUrl)); } @@ -892,7 +894,7 @@ public class OpdsController : BaseApiController chapters = seriesDetail.Chapters; } - foreach (var chapter in chapters.Where(c => !c.IsSpecial)) + foreach (var chapter in chapters.Where(c => !c.IsSpecial && !chapterDict.ContainsKey(c.Id))) { var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id); var chapterDto = _mapper.Map(chapter); diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index a003551a1..87080312a 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.RegularExpressions; using System.Threading.Tasks; using API.Constants; using API.Data; @@ -7,11 +10,15 @@ using API.DTOs.Statistics; using API.Entities; using API.Entities.Enums; using API.Extensions; +using API.Helpers; using API.Services; using API.Services.Plus; +using API.Services.Tasks.Scanner.Parser; +using CsvHelper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using MimeTypes; namespace API.Controllers; @@ -24,15 +31,18 @@ public class StatsController : BaseApiController private readonly UserManager _userManager; private readonly ILocalizationService _localizationService; private readonly ILicenseService _licenseService; + private readonly IDirectoryService _directoryService; public StatsController(IStatisticService statService, IUnitOfWork unitOfWork, - UserManager userManager, ILocalizationService localizationService, ILicenseService licenseService) + UserManager userManager, ILocalizationService localizationService, + ILicenseService licenseService, IDirectoryService directoryService) { _statService = statService; _unitOfWork = unitOfWork; _userManager = userManager; _localizationService = localizationService; _licenseService = licenseService; + _directoryService = directoryService; } [HttpGet("user/{userId}/read")] @@ -111,6 +121,34 @@ public class StatsController : BaseApiController return Ok(await _statService.GetFileBreakdown()); } + /// + /// Generates a csv of all file paths for a given extension + /// + /// + [Authorize("RequireAdminRole")] + [HttpGet("server/file-extension")] + [ResponseCache(CacheProfileName = "Statistics")] + public async Task DownloadFilesByExtension(string fileExtension) + { + if (!Regex.IsMatch(fileExtension, Parser.SupportedExtensions)) + { + return BadRequest("Invalid file format"); + } + var tempFile = Path.Join(_directoryService.TempDirectory, + $"file_breakdown_{fileExtension.Replace(".", string.Empty)}.csv"); + + if (!_directoryService.FileSystem.File.Exists(tempFile)) + { + var results = await _statService.GetFilesByExtension(fileExtension); + await using var writer = new StreamWriter(tempFile); + await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); + await csv.WriteRecordsAsync(results); + } + + return PhysicalFile(tempFile, MimeTypeMap.GetMimeType(Path.GetExtension(tempFile)), + System.Web.HttpUtility.UrlEncode(Path.GetFileName(tempFile)), true); + } + /// /// Returns reading history events for a give or all users, broken up by day, and format diff --git a/API/DTOs/Stats/FileExtensionExportDto.cs b/API/DTOs/Stats/FileExtensionExportDto.cs new file mode 100644 index 000000000..6ed554d75 --- /dev/null +++ b/API/DTOs/Stats/FileExtensionExportDto.cs @@ -0,0 +1,15 @@ +using CsvHelper.Configuration.Attributes; + +namespace API.DTOs.Stats; + +/// +/// Excel export for File Extension Report +/// +public class FileExtensionExportDto +{ + [Name("Path")] + public string FilePath { get; set; } + + [Name("Extension")] + public string Extension { get; set; } +} diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 874fabd9a..189ced83e 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -21,6 +21,7 @@ using API.DTOs.Search; using API.DTOs.SeriesDetail; using API.DTOs.Settings; using API.DTOs.SideNav; +using API.DTOs.Stats; using API.DTOs.Theme; using API.Entities; using API.Entities.Enums; @@ -326,5 +327,8 @@ public class AutoMapperProfiles : Profile opt.MapFrom(src => ReviewService.GetCharacters(src.Body))); CreateMap(); + + + CreateMap(); } } diff --git a/API/Program.cs b/API/Program.cs index 9668a06da..925214920 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -97,7 +97,7 @@ public class Program Task.Run(async () => { // Apply all migrations on startup - logger.LogInformation("Running Migrations"); + logger.LogInformation("Running Manual Migrations"); try { @@ -113,7 +113,7 @@ public class Program } await unitOfWork.CommitAsync(); - logger.LogInformation("Running Migrations - complete"); + logger.LogInformation("Running Manual Migrations - complete"); }).GetAwaiter() .GetResult(); } diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 98f03263c..a2fcec81a 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -134,9 +134,16 @@ public class ImageService : IImageService /// public static Enums.Size GetSizeForDimensions(Image image, int targetWidth, int targetHeight) { - if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height)) + try { - return Enums.Size.Force; + if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height)) + { + return Enums.Size.Force; + } + } + catch (Exception) + { + /* Swallow */ } return Enums.Size.Both; @@ -144,9 +151,15 @@ public class ImageService : IImageService public static Enums.Interesting? GetCropForDimensions(Image image, int targetWidth, int targetHeight) { - - if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height)) + try { + if (WillScaleWell(image, targetWidth, targetHeight) || IsLikelyWideImage(image.Width, image.Height)) + { + return null; + } + } catch (Exception) + { + /* Swallow */ return null; } @@ -166,8 +179,8 @@ public class ImageService : IImageService } // Calculate scaling factors - var widthScaleFactor = (double)targetWidth / sourceImage.Width; - var heightScaleFactor = (double)targetHeight / sourceImage.Height; + var widthScaleFactor = (double) targetWidth / sourceImage.Width; + var heightScaleFactor = (double) targetHeight / sourceImage.Height; // Check resolution quality (example thresholds) if (widthScaleFactor > 2.0 || heightScaleFactor > 2.0) @@ -219,14 +232,15 @@ public class ImageService : IImageService /// File name with extension of the file. This will always write to public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default) { - var (width, height) = size.GetDimensions(); - stream.Position = 0; + var (targetWidth, targetHeight) = size.GetDimensions(); + if (stream.CanSeek) stream.Position = 0; using var sourceImage = Image.NewFromStream(stream); - stream.Position = 0; + if (stream.CanSeek) stream.Position = 0; + + using var thumbnail = sourceImage.ThumbnailImage(targetWidth, targetHeight, + size: GetSizeForDimensions(sourceImage, targetWidth, targetHeight), + crop: GetCropForDimensions(sourceImage, targetWidth, targetHeight)); - using var thumbnail = Image.ThumbnailStream(stream, width, height: height, - size: GetSizeForDimensions(sourceImage, width, height), - crop: GetCropForDimensions(sourceImage, width, height)); var filename = fileName + encodeFormat.GetExtension(); _directoryService.ExistOrCreate(outputDirectory); try diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 5294ebafb..a6b3cb347 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -501,6 +501,7 @@ public class SeriesService : ISeriesService StorylineChapters = storylineChapters, TotalCount = chapters.Count, UnreadCount = chapters.Count(c => c.Pages > 0 && c.PagesRead < c.Pages), + // TODO: See if we can get the ContinueFrom here }; } diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index cf73e0211..b2c5cbaeb 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -5,10 +5,12 @@ using System.Threading.Tasks; using API.Data; using API.DTOs; using API.DTOs.Statistics; +using API.DTOs.Stats; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Helpers; using API.Services.Plus; using API.Services.Tasks.Scanner.Parser; using AutoMapper; @@ -35,6 +37,7 @@ public interface IStatisticService Task UpdateServerStatistics(); Task TimeSpentReadingForUsersAsync(IList userIds, IList libraryIds); Task GetKavitaPlusMetadataBreakdown(); + Task> GetFilesByExtension(string fileExtension); } /// @@ -559,6 +562,16 @@ public class StatisticService : IStatisticService } + public async Task> GetFilesByExtension(string fileExtension) + { + var query = _context.MangaFile + .Where(f => f.Extension == fileExtension) + .ProjectTo(_mapper.ConfigurationProvider) + .OrderBy(f => f.FilePath); + + return await query.ToListAsync(); + } + public async Task> GetTopUsers(int days) { var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index f05ebd191..ebfc2f145 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -4,11 +4,13 @@ using System.Collections.Immutable; using System.Linq; using System.Threading.Tasks; using API.Data; +using API.Data.Repositories; using API.Entities.Enums; using API.Helpers.Converters; using API.Services.Plus; using API.Services.Tasks; using API.Services.Tasks.Metadata; +using API.SignalR; using Hangfire; using Microsoft.Extensions.Logging; @@ -22,12 +24,12 @@ public interface ITaskScheduler Task ScheduleKavitaPlusTasks(); void ScanFolder(string folderPath, string originalPath, TimeSpan delay); void ScanFolder(string folderPath); - void ScanLibrary(int libraryId, bool force = false); - void ScanLibraries(bool force = false); + Task ScanLibrary(int libraryId, bool force = false); + Task ScanLibraries(bool force = false); void CleanupChapters(int[] chapterIds); void RefreshMetadata(int libraryId, bool forceUpdate = true); void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false); - void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); + Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false); void AnalyzeFilesForSeries(int libraryId, int seriesId, bool forceUpdate = false); void AnalyzeFilesForLibrary(int libraryId, bool forceUpdate = false); void CancelStatsTasks(); @@ -57,6 +59,7 @@ public class TaskScheduler : ITaskScheduler private readonly ILicenseService _licenseService; private readonly IExternalMetadataService _externalMetadataService; private readonly ISmartCollectionSyncService _smartCollectionSyncService; + private readonly IEventHub _eventHub; public static BackgroundJobServer Client => new (); public const string ScanQueue = "scan"; @@ -93,7 +96,7 @@ public class TaskScheduler : ITaskScheduler ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService, IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService, - IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService) + IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, IEventHub eventHub) { _cacheService = cacheService; _logger = logger; @@ -112,6 +115,7 @@ public class TaskScheduler : ITaskScheduler _licenseService = licenseService; _externalMetadataService = externalMetadataService; _smartCollectionSyncService = smartCollectionSyncService; + _eventHub = eventHub; } public async Task ScheduleTasks() @@ -320,18 +324,21 @@ public class TaskScheduler : ITaskScheduler /// Attempts to call ScanLibraries on ScannerService, but if another scan task is in progress, will reschedule the invocation for 3 hours in future. /// /// - public void ScanLibraries(bool force = false) + public async Task ScanLibraries(bool force = false) { if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) { _logger.LogInformation("A Scan is already running, rescheduling ScanLibraries in 3 hours"); + // Send InfoEvent to UI as this is invoked my API BackgroundJob.Schedule(() => ScanLibraries(force), TimeSpan.FromHours(3)); + await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan libraries task delayed", + $"A scan was ongoing during processing of the scan libraries task. Task has been rescheduled for 3 hours: {DateTime.Now.AddHours(3)}")); return; } BackgroundJob.Enqueue(() => _scannerService.ScanLibraries(force)); } - public void ScanLibrary(int libraryId, bool force = false) + public async Task ScanLibrary(int libraryId, bool force = false) { if (HasScanTaskRunningForLibrary(libraryId)) { @@ -340,18 +347,18 @@ public class TaskScheduler : ITaskScheduler } if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) { + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); _logger.LogInformation("A Scan is already running, rescheduling ScanLibrary in 3 hours"); + await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan library task delayed", + $"A scan was ongoing during processing of the {library!.Name} scan task. Task has been rescheduled for 3 hours: {DateTime.Now.AddHours(3)}")); BackgroundJob.Schedule(() => ScanLibrary(libraryId, force), TimeSpan.FromHours(3)); return; } - // await _eventHub.SendMessageAsync(MessageFactory.Info, - // MessageFactory.InfoEvent($"Scan library invoked but a task is already running for {library.Name}. Rescheduling request for 10 mins", string.Empty)); - _logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId); - BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, force, true)); + var jobId = BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, force, true)); // When we do a scan, force cache to re-unpack in case page numbers change - BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory()); + BackgroundJob.ContinueJobWith(jobId, () => _cleanupService.CleanupCacheDirectory()); } public void TurnOnScrobbling(int userId = 0) @@ -392,7 +399,7 @@ public class TaskScheduler : ITaskScheduler BackgroundJob.Enqueue(() => _metadataService.GenerateCoversForSeries(libraryId, seriesId, forceUpdate)); } - public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) + public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) { if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanSeries", [seriesId, forceUpdate], ScanQueue)) { @@ -402,7 +409,10 @@ public class TaskScheduler : ITaskScheduler if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) { // BUG: This can end up triggering a ton of scan series calls (but i haven't seen in practice) + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.None); _logger.LogInformation("A Scan is already running, rescheduling ScanSeries in 10 minutes"); + await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan series task delayed: {series!.Name}", + $"A scan was ongoing during processing of the scan series task. Task has been rescheduled for 10 minutes: {DateTime.Now.AddMinutes(10)}")); BackgroundJob.Schedule(() => ScanSeries(libraryId, seriesId, forceUpdate), TimeSpan.FromMinutes(10)); return; } diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index 60262136a..e429a5aed 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -150,13 +150,28 @@ public class LibraryWatcher : ILibraryWatcher { _logger.LogTrace("[LibraryWatcher] Changed: {FullPath}, {Name}, {ChangeType}", e.FullPath, e.Name, e.ChangeType); if (e.ChangeType != WatcherChangeTypes.Changed) return; - BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)))); + + var isDirectoryChange = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)); + + if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, isDirectoryChange], + checkRunningJobs: true)) + { + return; + } + + BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, isDirectoryChange)); } private void OnCreated(object sender, FileSystemEventArgs e) { _logger.LogTrace("[LibraryWatcher] Created: {FullPath}, {Name}", e.FullPath, e.Name); - BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, !_directoryService.FileSystem.File.Exists(e.Name))); + var isDirectoryChange = !_directoryService.FileSystem.File.Exists(e.Name); + if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, isDirectoryChange], + checkRunningJobs: true)) + { + return; + } + BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, isDirectoryChange)); } /// @@ -168,6 +183,11 @@ public class LibraryWatcher : ILibraryWatcher var isDirectory = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)); if (!isDirectory) return; _logger.LogTrace("[LibraryWatcher] Deleted: {FullPath}, {Name}", e.FullPath, e.Name); + if (TaskScheduler.HasAlreadyEnqueuedTask("LibraryWatcher", "ProcessChange", [e.FullPath, true], + checkRunningJobs: true)) + { + return; + } BackgroundJob.Enqueue(() => ProcessChange(e.FullPath, true)); } @@ -298,7 +318,7 @@ public class LibraryWatcher : ILibraryWatcher /// This is called via Hangfire to decrement the counter. Must work around a lock /// // ReSharper disable once MemberCanBePrivate.Global - public void UpdateLastBufferOverflow() + public static void UpdateLastBufferOverflow() { lock (Lock) { diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 6f6ba9336..3c347e39d 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -351,14 +351,17 @@ public class ParseScannedFiles { await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Starting", library.Name, ProgressEventType.Started)); + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 1.A: Process {FolderCount} folders", library.Name, folders.Count()); var processedScannedSeries = new List(); //var processedScannedSeries = new ConcurrentBag(); foreach (var folderPath in folders) { try { + _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.B: Scan files in {Folder}", library.Name, folderPath); var scanResults = await ProcessFiles(folderPath, isLibraryScan, seriesPaths, library, forceCheck); + _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.C: Process files in {Folder}", library.Name, folderPath); foreach (var scanResult in scanResults) { await ParseAndTrackSeries(library, seriesPaths, scanResult, processedScannedSeries); diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 513164298..5f7c1695a 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -76,7 +76,7 @@ public enum ScanCancelReason public class ScannerService : IScannerService { public const string Name = "ScannerService"; - public const int Timeout = 60 * 60 * 60; + private const int Timeout = 60 * 60 * 60; // 2.5 days private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IMetadataService _metadataService; @@ -157,11 +157,11 @@ public class ScannerService : IScannerService } // TODO: Figure out why we have the library type restriction here - if (series != null && series.Library.Type is not (LibraryType.Book or LibraryType.LightNovel)) + if (series != null)// && series.Library.Type is not (LibraryType.Book or LibraryType.LightNovel) { if (TaskScheduler.HasScanTaskRunningForSeries(series.Id)) { - _logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder); + _logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder); return; } _logger.LogInformation("[ScannerService] Scan folder invoked for {Folder}, Series matched to folder and ScanSeries enqueued for 1 minute", folder); @@ -185,7 +185,7 @@ public class ScannerService : IScannerService { if (TaskScheduler.HasScanTaskRunningForLibrary(library.Id)) { - _logger.LogInformation("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder); + _logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this library. Dropping request", folder); return; } BackgroundJob.Schedule(() => ScanLibrary(library.Id, false, true), TimeSpan.FromMinutes(1)); @@ -198,20 +198,21 @@ public class ScannerService : IScannerService /// /// Not Used. Scan series will always force [Queue(TaskScheduler.ScanQueue)] + [DisableConcurrentExecution(Timeout)] + [AutomaticRetry(Attempts = 200, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = true) { + if (TaskScheduler.HasAlreadyEnqueuedTask(Name, "ScanSeries", [seriesId, bypassFolderOptimizationChecks], TaskScheduler.ScanQueue)) + { + _logger.LogInformation("[ScannerService] Scan series invoked but a task is already running/enqueued. Dropping request"); + return; + } + var sw = Stopwatch.StartNew(); var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); if (series == null) return; // This can occur when UI deletes a series but doesn't update and user re-requests update - // if (TaskScheduler.HasScanTaskRunningForSeries(seriesId)) - // { - // _logger.LogInformation("[ScannerService] Scan series invoked but a task is already running/enqueued. Rescheduling request for 1 mins"); - // BackgroundJob.Schedule(() => ScanSeries(seriesId, bypassFolderOptimizationChecks), TimeSpan.FromMinutes(1)); - // return; - // } - var existingChapterIdsToClean = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId}); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(series.LibraryId, LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns); @@ -444,7 +445,7 @@ public class ScannerService : IScannerService // 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.LogCritical("Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName); + _logger.LogCritical("[ScannerService] Some of the root folders for library ({LibraryName} are not accessible. Please check that drives are connected and rescan. Scan will be aborted", libraryName); await _eventHub.SendMessageAsync(MessageFactory.Error, MessageFactory.ErrorEvent("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted", @@ -458,7 +459,7 @@ public class ScannerService : IScannerService if (folders.Any(f => _directoryService.IsDirectoryEmpty(f))) { // 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. " + + _logger.LogError("[ScannerService] 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 has be aborted. " + "Check that your mount is connected or change the library's root folder and rescan"); @@ -479,22 +480,23 @@ public class ScannerService : IScannerService [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ScanLibraries(bool forceUpdate = false) { - _logger.LogInformation("Starting Scan of All Libraries, Forced: {Forced}", forceUpdate); + _logger.LogInformation("[ScannerService] Starting Scan of All Libraries, Forced: {Forced}", forceUpdate); foreach (var lib in await _unitOfWork.LibraryRepository.GetLibrariesAsync()) { - if (TaskScheduler.RunningAnyTasksByMethod(TaskScheduler.ScanTasks, TaskScheduler.ScanQueue)) + // BUG: This will trigger the first N libraries to scan over and over if there is always an interruption later in the chain + if (TaskScheduler.HasScanTaskRunningForLibrary(lib.Id)) { - _logger.LogInformation("[ScannerService] Scan library invoked via nightly scan job but a task is already running. Rescheduling for 4 hours"); - await _eventHub.SendMessageAsync(MessageFactory.Info, MessageFactory.InfoEvent($"Scan libraries task delayed", - $"A scan was ongoing during processing of the scan libraries task. Task has been rescheduled for {DateTime.UtcNow.AddHours(4)} UTC")); - BackgroundJob.Schedule(() => ScanLibraries(forceUpdate), TimeSpan.FromHours(4)); - return; + // We don't need to send SignalR event as this is a background job that user doesn't need insight into + _logger.LogInformation("[ScannerService] Scan library invoked via nightly scan job but a task is already running for {LibraryName}. Rescheduling for 4 hours", lib.Name); + await Task.Delay(TimeSpan.FromHours(4)); + //BackgroundJob.Schedule(() => ScanLibraries(forceUpdate), TimeSpan.FromHours(4)); + //return; } await ScanLibrary(lib.Id, forceUpdate, true); } _processSeries.Reset(); - _logger.LogInformation("Scan of All Libraries Finished"); + _logger.LogInformation("[ScannerService] Scan of All Libraries Finished"); } @@ -526,23 +528,27 @@ public class ScannerService : IScannerService var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths)); if (!shouldUseLibraryScan) { - _logger.LogError("Library {LibraryName} consists of one or more Series folders, using series scan", library.Name); + _logger.LogError("[ScannerService] Library {LibraryName} consists of one or more Series folders, using series scan", library.Name); } + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 1: Scan Files", library.Name); var (scanElapsedTime, processedSeries) = await ScanFiles(library, libraryFolderPaths, shouldUseLibraryScan, forceUpdate); + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 2: Track Found Series", library.Name); var parsedSeries = new Dictionary>(); TrackFoundSeriesAndFiles(parsedSeries, processedSeries); // We need to remove any keys where there is no actual parser info + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 3: Process Parsed Series", library.Name); var totalFiles = await ProcessParsedSeries(forceUpdate, parsedSeries, library, scanElapsedTime); UpdateLastScanned(library); _unitOfWork.LibraryRepository.Update(library); + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 4: Save Library", library.Name); if (await _unitOfWork.CommitAsync()) { if (isSingleScan) @@ -563,6 +569,7 @@ public class ScannerService : IScannerService totalFiles, parsedSeries.Count, sw.ElapsedMilliseconds, library.Name); } + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 5: Remove Deleted Series", library.Name); await RemoveSeriesNotFound(parsedSeries, library); } else diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index ff04e3201..27bca2a80 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -345,6 +345,7 @@ public static class MessageFactory EventType = ProgressEventType.Single, Body = new { + Name = Error, Title = title, SubTitle = subtitle, } @@ -362,6 +363,7 @@ public static class MessageFactory EventType = ProgressEventType.Single, Body = new { + Name = Info, Title = title, SubTitle = subtitle, } diff --git a/API/Startup.cs b/API/Startup.cs index 30c663a4b..1acc5638a 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -427,8 +427,8 @@ public class Startup catch (Exception) { /* Swallow Exception */ + Console.WriteLine($"Kavita - v{BuildInfo.Version}"); } - Console.WriteLine($"Kavita - v{BuildInfo.Version}"); }); logger.LogInformation("Starting with base url as {BaseUrl}", basePath); diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index 887a70093..0c6352d06 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -2,7 +2,7 @@ "TokenKey": "super secret unguessable key that is longer because we require it", "Port": 5000, "IpAddresses": "0.0.0.0,::", - "BaseUrl": "/tes/", + "BaseUrl": "/", "Cache": 75, "AllowIFraming": false } \ No newline at end of file diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index 2e2173e6a..f729084c5 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -1,9 +1,9 @@ import { HttpClient } from '@angular/common/http'; -import {inject, Injectable} from '@angular/core'; +import {Inject, inject, Injectable} from '@angular/core'; import { environment } from 'src/environments/environment'; import { UserReadStatistics } from '../statistics/_models/user-read-statistics'; import { PublicationStatusPipe } from '../_pipes/publication-status.pipe'; -import { map } from 'rxjs'; +import {asyncScheduler, finalize, map, tap} from 'rxjs'; import { MangaFormatPipe } from '../_pipes/manga-format.pipe'; import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown'; import { TopUserRead } from '../statistics/_models/top-reads'; @@ -15,6 +15,10 @@ import { MangaFormat } from '../_models/manga-format'; import { TextResonse } from '../_types/text-response'; import {TranslocoService} from "@ngneat/transloco"; import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown"; +import {throttleTime} from "rxjs/operators"; +import {DEBOUNCE_TIME} from "../shared/_services/download.service"; +import {download} from "../shared/_models/download"; +import {Saver, SAVER} from "../_providers/saver.provider"; export enum DayOfWeek { @@ -37,7 +41,7 @@ export class StatisticsService { publicationStatusPipe = new PublicationStatusPipe(this.translocoService); mangaFormatPipe = new MangaFormatPipe(this.translocoService); - constructor(private httpClient: HttpClient) { } + constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { } getUserStatistics(userId: number, libraryIds: Array = []) { // TODO: Convert to httpParams object @@ -109,6 +113,20 @@ export class StatisticsService { return this.httpClient.get(this.baseUrl + 'stats/server/file-breakdown'); } + downloadFileBreakdown(extension: string) { + return this.httpClient.get(this.baseUrl + 'stats/server/file-extension?fileExtension=' + encodeURIComponent(extension), + {observe: 'events', responseType: 'blob', reportProgress: true} + ).pipe( + throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }), + download((blob, filename) => { + this.save(blob, decodeURIComponent(filename)); + }), + // tap((d) => this.updateDownloadState(d, downloadType, subtitle, 0)), + // finalize(() => this.finalizeDownloadState(downloadType, subtitle)) + ); + + } + getReadCountByDay(userId: number = 0, days: number = 0) { return this.httpClient.get>(this.baseUrl + 'stats/reading-count-by-day?userId=' + userId + '&days=' + days); } diff --git a/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts b/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts index 20df49758..3f5d880d6 100644 --- a/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts +++ b/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts @@ -1,4 +1,4 @@ -import { Directive, EventEmitter, Input, Output } from "@angular/core"; +import {ChangeDetectorRef, Directive, EventEmitter, inject, Input, OnInit, Output} from "@angular/core"; export const compare = (v1: string | number, v2: string | number) => (v1 < v2 ? -1 : v1 > v2 ? 1 : 0); export type SortColumn = keyof T | ''; @@ -11,6 +11,7 @@ export interface SortEvent { } @Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector selector: 'th[sortable]', host: { '[class.asc]': 'direction === "asc"', @@ -29,4 +30,4 @@ export class SortableHeader { this.direction = rotate[this.direction]; this.sort.emit({ column: this.sortable, direction: this.direction }); } -} \ No newline at end of file +} diff --git a/UI/Web/src/app/library-detail/library-detail.component.html b/UI/Web/src/app/library-detail/library-detail.component.html index aa765b59a..07db7aef6 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.html +++ b/UI/Web/src/app/library-detail/library-detail.component.html @@ -4,27 +4,33 @@ {{libraryName}} -
{{t('common.series-count', {num: pagination.totalItems | number})}}
+ @if (active.fragment === '') { +
{{t('common.series-count', {num: pagination.totalItems | number})}}
+ } + - - - - - + @if (filter) { + + + + + + } + 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 d672e83c7..589eabf4d 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -33,18 +33,18 @@ import {SentenceCasePipe} from '../_pipes/sentence-case.pipe'; import {BulkOperationsComponent} from '../cards/bulk-operations/bulk-operations.component'; import {SeriesCardComponent} from '../cards/series-card/series-card.component'; import {CardDetailLayoutComponent} from '../cards/card-detail-layout/card-detail-layout.component'; -import {DecimalPipe, NgFor, NgIf} from '@angular/common'; +import {DecimalPipe} from '@angular/common'; import {NgbNav, NgbNavContent, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavOutlet} from '@ng-bootstrap/ng-bootstrap'; import { SideNavCompanionBarComponent } from '../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component'; import {TranslocoDirective} from "@ngneat/transloco"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; -import {MetadataService} from "../_services/metadata.service"; import {FilterComparison} from "../_models/metadata/v2/filter-comparison"; import {FilterField} from "../_models/metadata/v2/filter-field"; import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component"; import {LoadingComponent} from "../shared/loading/loading.component"; +import {debounceTime, ReplaySubject, tap} from "rxjs"; @Component({ selector: 'app-library-detail', @@ -52,14 +52,25 @@ import {LoadingComponent} from "../shared/loading/loading.component"; styleUrls: ['./library-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgFor, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, NgIf - , CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent] + imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, + CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent] }) export class LibraryDetailComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); - private readonly metadataService = inject(MetadataService); private readonly cdRef = inject(ChangeDetectorRef); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly seriesService = inject(SeriesService); + private readonly libraryService = inject(LibraryService); + private readonly titleService = inject(Title); + private readonly actionFactoryService = inject(ActionFactoryService); + private readonly actionService = inject(ActionService); + private readonly hubService = inject(MessageHubService); + private readonly utilityService = inject(UtilityService); + private readonly filterUtilityService = inject(FilterUtilitiesService); + public readonly navService = inject(NavService); + public readonly bulkSelectionService = inject(BulkSelectionService); libraryId!: number; libraryName = ''; @@ -82,6 +93,8 @@ export class LibraryDetailComponent implements OnInit { ]; active = this.tabs[0]; + loadPageSource = new ReplaySubject(1); + loadPage$ = this.loadPageSource.asObservable(); bulkActionCallback = async (action: ActionItem, data: any) => { const selectedSeriesIndices = this.bulkSelectionService.getSelectedCardsForSource('series'); @@ -142,10 +155,8 @@ export class LibraryDetailComponent implements OnInit { } } - constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService, - private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService, - private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService, - private utilityService: UtilityService, public navService: NavService, private filterUtilityService: FilterUtilitiesService) { + + constructor() { const routeId = this.route.snapshot.paramMap.get('libraryId'); if (routeId === null) { this.router.navigateByUrl('/home'); @@ -180,6 +191,8 @@ export class LibraryDetailComponent implements OnInit { this.filterSettings.presetsV2 = this.filter; + this.loadPage$.pipe(takeUntilDestroyed(this.destroyRef), debounceTime(100), tap(_ => this.loadPage())).subscribe(); + this.cdRef.markForCheck(); }); } @@ -191,7 +204,7 @@ export class LibraryDetailComponent implements OnInit { const seriesAdded = event.payload as SeriesAddedEvent; if (seriesAdded.libraryId !== this.libraryId) return; if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) { - this.loadPage(); + this.loadPageSource.next(true); return; } this.seriesService.getSeries(seriesAdded.seriesId).subscribe(s => { @@ -211,7 +224,7 @@ export class LibraryDetailComponent implements OnInit { const seriesRemoved = event.payload as SeriesRemovedEvent; if (seriesRemoved.libraryId !== this.libraryId) return; if (!this.utilityService.deepEqual(this.filter, this.filterActiveCheck)) { - this.loadPage(); // TODO: This can be quite expensive when bulk deleting. We can refactor this to an ReplaySubject to debounce + this.loadPageSource.next(true); return; } @@ -286,12 +299,12 @@ export class LibraryDetailComponent implements OnInit { this.filter = data.filterV2; if (data.isFirst) { - this.loadPage(); + this.loadPageSource.next(true); return; } this.filterUtilityService.updateUrlFromFilter(this.filter).subscribe((encodedFilter) => { - this.loadPage(); + this.loadPageSource.next(true); }); } diff --git a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.html b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.html index 2525e5edd..91a38a4a4 100644 --- a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.html +++ b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.html @@ -1,139 +1,118 @@ - - - - - - - - + @if (isAdmin$ | async) { + @if (downloadService.activeDownloads$ | async; as activeDownloads) { + @if (errors$ | async; as errors) { + @if (infos$ | async; as infos) { + @if (messageHub.onlineUsers$ | async; as onlineUsers) { + + } + } + } + } -
    - - -
  • - {{t('dismiss-all')}} -
  • -
    -
    - @if (debugMode) { - -
  • -
    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)}}%
    -
    -
    -
    -
    - -
    -
  • -
  • -
    -
    There was some library scan error
    -
    Click for more information
    -
    - -
  • -
  • -
    -
    Scan didn't run becasuse nothing to do
    -
    Click for more information
    -
    - -
  • -
  • -
    - - - - 10% downloaded - - - Downloading {{'series' | sentenceCase}} -
    -
    PDFs
    -
  • -
    + @if(errors$ | async; as errors) { + @if(infos$ | async; as infos) { + @if (errors.length > 0 || infos.length > 0) { +
  • + {{t('dismiss-all')}} +
  • + } + } } - - - -
  • -
    {{message.title}}
    - @if (message.subTitle !== '') { -
    {{message.subTitle}}
    - } - @if (message.name === EVENTS.ScanProgress && message.body.leftToProcess > 0) { -
    {{t('left-to-process', {leftToProcess: message.body.leftToProcess})}}
    - } -
    - @if(message.progress === 'indeterminate') { -
    -
    -
    - } -
    -
  • - + @if (progressEvents$ | async; as progressUpdates) { + @for (message of progressUpdates; track message) { + @if (message.progress === 'indeterminate' || message.progress === 'none') {
  • {{message.title}}
    -
    {{message.subTitle}}
    + @if (message.subTitle !== '') { +
    {{message.subTitle}}
    + } + @if (message.name === EVENTS.ScanProgress && message.body.leftToProcess > 0) { +
    + {{t('left-to-process', {leftToProcess: message.body.leftToProcess})}} +
    + } +
    + @if(message.progress === 'indeterminate') { +
    +
    +
    + } +
    +
  • + } @else { +
  • +
    {{message.title}}
    + @if (message.subTitle !== '') { +
    {{message.subTitle}}
    + }
    {{prettyPrintProgress(message.body.progress) + '%'}}
    -
    +
  • -
    -
    -
    + } + } + } - - -
  • - {{t('update-available')}} -
  • -
  • -
    {{singleUpdate.title}}
    -
    {{singleUpdate.subTitle}}
    -
  • -
    -
    + @if (singleUpdates$ | async; as singleUpdates) { + @for(singleUpdate of singleUpdates; track singleUpdate) { + @if (singleUpdate.name === EVENTS.UpdateAvailable) { +
  • + {{t('update-available')}} +
  • + } @else { +
  • +
    {{singleUpdate.title}}
    + @if (singleUpdate.subTitle !== '') { +
    {{singleUpdate.subTitle}}
    + } +
  • + } + } + } - - + @if (downloadService.activeDownloads$ | async; as activeDownloads) { + @for(download of activeDownloads; track download) {
  • {{t('downloading-item', {item: download.entityType | sentenceCase})}}
    -
    {{download.subTitle}}
    + + @if (download.subTitle !== '') { +
    {{download.subTitle}}
    + } +
    {{download.progress}}%
    @@ -141,57 +120,49 @@
  • -
    - @if(activeDownloads.length > 1) { -
  • {{activeDownloads.length}} downloads in Queue
  • } -
    - + @if(activeDownloads.length > 1) { +
  • {{t('download-in-queue', {num: activeDownloads.length})}}
  • + } + } - - + @if (errors$ | async; as errors) { + @for (error of errors; track error) { - - + } + } - - + @if (infos$ | async; as infos) { + @for (info of infos; track info) { - - - - - @if (messageHub.onlineUsers$ | async; as onlineUsers) { - @if (onlineUsers.length > 1) { -
  • -
    {{t('users-online-count', {num: onlineUsers.length})}}
    -
  • - } - - @if (debugMode) { -
  • {{t('active-events-title')}} {{activeEvents}}
  • } } - -
  • {{t('no-data')}}
  • -
    - + @if (downloadService.activeDownloads$ | async; as activeDownloads) { + @if (errors$ | async; as errors) { + @if (infos$ | async; as infos) { + @if (infos.length === 0 && errors.length === 0 && activeDownloads.length === 0 && activeEvents === 0) { +
  • {{t('no-data')}}
  • + } + } + } + }
-
+ } +
diff --git a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.scss b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.scss index cd8143905..59594c8a3 100644 --- a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.scss +++ b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.scss @@ -14,6 +14,26 @@ border-bottom-color: transparent; } +.colored { + color: var(--event-widget-activity-bg-color) !important; +} + +.widget-button--indicator { + position: absolute; + top: 30px; + color: var(--event-widget-activity-bg-color); + + &.error { + color: var(--event-widget-error-bg-color) !important; + } + &.info { + color: var(--event-widget-info-bg-color) !important; + } + &.update { + color: var(--event-widget-update-bg-color) !important; + } +} + ::ng-deep .nav-events { .popover-body { @@ -56,67 +76,56 @@ .btn-icon { - color: white; + color: var(--event-widget-text-color); } -.colored { - background-color: var(--primary-color); - border-radius: 60px; -} -.colored-error { - background-color: var(--error-color) !important; - border-radius: 60px; -} - -.colored-info { - background-color: var(--event-widget-info-bg-color) !important; - border-radius: 60px; -} - -.update-available { +.dark-menu-item { + &.update-available { cursor: pointer; i.fa { - color: var(--primary-color) !important; + color: var(--primary-color) !important; } color: var(--primary-color); -} + } -.error { + &.error { cursor: pointer; position: relative; .h6 { - color: var(--error-color); + color: var(--event-widget-error-bg-color); } i.fa { - color: var(--primary-color) !important; + color: var(--primary-color) !important; } .btn-close { - top: 5px; - right: 10px; - font-size: 11px; - position: absolute; + top: 5px; + right: 10px; + font-size: 11px; + position: absolute; } -} + } -.info { + &.info { cursor: pointer; position: relative; .h6 { - color: var(--event-widget-info-bg-color); + color: var(--event-widget-info-bg-color); } i.fa { - color: var(--primary-color) !important; + color: var(--primary-color) !important; } .btn-close { - top: 10px; - right: 10px; - font-size: 11px; - position: absolute; + top: 10px; + right: 10px; + font-size: 11px; + position: absolute; } + } + } diff --git a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts index 7f073519a..1dab6dc3e 100644 --- a/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts +++ b/UI/Web/src/app/nav/_components/events-widget/events-widget.component.ts @@ -25,7 +25,7 @@ import { EVENTS, Message, MessageHubService } from 'src/app/_services/message-hu import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import { SentenceCasePipe } from '../../../_pipes/sentence-case.pipe'; import { CircularLoaderComponent } from '../../../shared/circular-loader/circular-loader.component'; -import { NgIf, NgClass, NgStyle, NgFor, AsyncPipe } from '@angular/common'; +import { NgClass, NgStyle, AsyncPipe } from '@angular/common'; import {TranslocoDirective} from "@ngneat/transloco"; @Component({ @@ -34,12 +34,20 @@ import {TranslocoDirective} from "@ngneat/transloco"; styleUrls: ['./events-widget.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [NgIf, NgClass, NgbPopover, NgStyle, CircularLoaderComponent, NgFor, AsyncPipe, SentenceCasePipe, TranslocoDirective] + imports: [NgClass, NgbPopover, NgStyle, CircularLoaderComponent, AsyncPipe, SentenceCasePipe, TranslocoDirective] }) export class EventsWidgetComponent implements OnInit, OnDestroy { - @Input({required: true}) user!: User; + public readonly downloadService = inject(DownloadService); + public readonly messageHub = inject(MessageHubService); + private readonly modalService = inject(NgbModal); + private readonly accountService = inject(AccountService); + private readonly confirmService = inject(ConfirmService); + private readonly cdRef = inject(ChangeDetectorRef); private readonly destroyRef = inject(DestroyRef); + @Input({required: true}) user!: User; + + isAdmin$: Observable = of(false); /** @@ -60,17 +68,15 @@ export class EventsWidgetComponent implements OnInit, OnDestroy { private updateNotificationModalRef: NgbModalRef | null = null; activeEvents: number = 0; + /** + * Intercepts from Single Updates to show an extra indicator to the user + */ + updateAvailable: boolean = false; debugMode: boolean = false; protected readonly EVENTS = EVENTS; - public readonly downloadService = inject(DownloadService); - - constructor(public messageHub: MessageHubService, private modalService: NgbModal, - private accountService: AccountService, private confirmService: ConfirmService, - private readonly cdRef: ChangeDetectorRef) { - } ngOnDestroy(): void { this.progressEventsSource.complete(); @@ -115,6 +121,9 @@ export class EventsWidgetComponent implements OnInit, OnDestroy { values.push(message); this.singleUpdateSource.next(values); this.activeEvents += 1; + if (event.payload.name === EVENTS.UpdateAvailable) { + this.updateAvailable = true; + } this.cdRef.markForCheck(); break; case 'started': diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index 399c641a5..003d45841 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -1,346 +1,393 @@
- - -

- - {{series.name}} - @if(isLoadingExtra || isLoading) { -
+ @if (series) { + + +

+ + {{series.name}} + @if(isLoadingExtra || isLoading) { +
loading...
- } + }
-

-
- -
{{series.localizedName}}
-
+

+
+ @if (series.localizedName !== series.name) { + +
{{series.localizedName}}
+
+ } + +
+
+

{{t('page-settings-title')}}

+ +
+
+
+
+
+ +
+
+ + - -
-
-

{{t('page-settings-title')}}

- -
-
- -
-
- -
-
- - - - - + + +
-
- + +
-
- + - + + } +
+ @if (series) { +
+
+
+ @if (unreadCount > 0 && unreadCount !== totalCount) { +
+ {{unreadCount}} +
+ } -
-
-
-
- {{unreadCount}} + + @if (series.pagesRead < series.pages && hasReadingProgress && currentlyReadingChapter && !currentlyReadingChapter.isSpecial) { +
+ +
+
+ {{t('continue-from', {title: ContinuePointTitle})}} +
+ }
- - -
- -
-
- {{t('continue-from', {title: ContinuePointTitle})}} -
-
-
-
-
-
-
- -
- -
-
-
- -
-
- -
-
-
- +
+ @if (isAdmin) { +
+ +
+ } + +
+
+ +
+
+ + @if (isAdmin || hasDownloadingRole) { +
+ @if (download$ | async; as download) { + + } @else { + + } +
+ } +
-
- @if (download$ | async; as download) { - - } @else { - - } -
+ @if (seriesMetadata) { +
+ +
+ } +
- @if (seriesMetadata) { -
- -
- } - +
+ + + + + +
-
- - - - - -
-
+ @if (series) { +
- - -
- -
+
- - - + + } -
  • - {{t('specials-tab')}} - - - -
    - - - -
    -
    - - - - + @if (hasRecommendations) { +
  • + {{t('recommendations-tab')}} + + + @switch (renderMode) { + @case (PageLayoutMode.Cards) { +
    + @for(item of scroll.viewPortItems; let idx = $index; track idx) { + @if (!item.hasOwnProperty('coverUrl')) { + + } @else { + + } + } +
    + } + @case (PageLayoutMode.List) { + @for(item of scroll.viewPortItems; let idx = $index; track idx) { + @if (!item.hasOwnProperty('coverUrl')) { + + + + {{item.name}} + + + + } @else { + + + + {{item.name}} + + + + } + } + } + } +
    - - -
  • + + } -
  • - {{t('related-tab')}} - - -
    - - - -
    -
    -
    -
  • -
  • - {{t('recommendations-tab')}} - - - -
    - - - - - - - - -
    -
    - - - - - - - {{item.name}} - - - - - - - - - {{item.name}} - - - - - - + +
    + } -
    -
    -
  • - -
    - + +
    + + @if (nextExpectedChapter) { + @switch (tabId) { + @case (TabID.Volumes) { + @if (nextExpectedChapter.volumeNumber !== SpecialVolumeNumber && nextExpectedChapter.chapterNumber === LooseLeafOrSpecialNumber) { + + } + } + @case (TabID.Chapters) { + + } + @case (TabID.Storyline) { + + } + } + } + + } - -
    - - - - - - - - - - - - - - - - - + @if (!item.isSpecial) { + + + } - - + @if (item.number !== LooseLeafOrSpecialNumber) { + + + } @@ -354,27 +401,32 @@ - - - - - + @if (!item.isSpecial) { + + + + + + } - - - - - + @if (item.number !== LooseLeafOrSpecialNumber) { + + + + + + } + 0)) return true; - - return (this.libraryType !== LibraryType.Book && this.libraryType !== LibraryType.LightNovel && this.libraryType !== LibraryType.Comic) - && (this.volumes.length > 0 || this.chapters.length > 0); - } - - get ShowVolumeTab() { - if (this.libraryType === LibraryType.ComicVine) { - if (this.volumes.length > 1) return true; - if (this.specials.length === 0 && this.chapters.length === 0) return true; - return false; - } - return this.volumes.length > 0; - } - - get ShowChaptersTab() { - return this.chapters.length > 0; - } - get UseBookLogic() { return this.libraryType === LibraryType.Book || this.libraryType === LibraryType.LightNovel; } @@ -380,26 +361,43 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { if (!this.currentlyReadingChapter.isSpecial) { const vol = this.volumes.filter(v => v.id === this.currentlyReadingChapter?.volumeId); + let chapterLocaleKey = 'common.chapter-num-shorthand'; + let volumeLocaleKey = 'common.volume-num-shorthand'; + switch (this.libraryType) { + case LibraryType.ComicVine: + case LibraryType.Comic: + chapterLocaleKey = 'common.issue-num-shorthand'; + break; + case LibraryType.Book: + case LibraryType.Manga: + case LibraryType.LightNovel: + case LibraryType.Images: + chapterLocaleKey = 'common.chapter-num-shorthand'; + break; + } + // This is a lone chapter if (vol.length === 0) { if (this.currentlyReadingChapter.minNumber === LooseLeafOrDefaultNumber) { return this.currentlyReadingChapter.titleName; } - return 'Ch ' + this.currentlyReadingChapter.minNumber; // TODO: Refactor this to use DisplayTitle (or Range) and Localize it + return translate(chapterLocaleKey, {num: this.currentlyReadingChapter.minNumber}); } if (this.currentlyReadingChapter.minNumber === LooseLeafOrDefaultNumber) { - return 'Vol ' + vol[0].minNumber; + return translate(chapterLocaleKey, {num: vol[0].minNumber}); } - return 'Vol ' + vol[0].minNumber + ' Ch ' + this.currentlyReadingChapter.minNumber; + return translate(volumeLocaleKey, {num: vol[0].minNumber}) + + ' ' + translate(chapterLocaleKey, {num: this.currentlyReadingChapter.minNumber}); } return this.currentlyReadingChapter.title; } + constructor(@Inject(DOCUMENT) private document: Document) { this.router.routeReuseStrategy.shouldReuseRoute = () => false; - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { + this.accountService.currentUser$.subscribe(user => { if (user) { this.user = user; this.isAdmin = this.accountService.hasAdminRole(user); @@ -415,6 +413,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.scrollService.setScrollContainer(this.scrollingBlock); } + debugLog(message: string) { + console.log(message); + } + ngOnInit(): void { const routeId = this.route.snapshot.paramMap.get('seriesId'); @@ -424,7 +426,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { return; } - // Setup the download in progress + // Set up the download in progress this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => { return this.downloadService.mapToEntityType(events, this.series); })); @@ -652,12 +654,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.titleService.setTitle('Kavita - ' + this.series.name + ' Details'); - this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this)) - .filter(action => action.action !== Action.Edit); - this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this)); this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)); - + this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this)) + .filter(action => action.action !== Action.Edit); this.seriesService.getRelatedForSeries(this.seriesId).subscribe((relations: RelatedSeries) => { @@ -677,6 +677,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { ...relations.editions.map(item => this.createRelatedSeries(item, RelationKind.Edition)), ...relations.annuals.map(item => this.createRelatedSeries(item, RelationKind.Annual)), ]; + if (this.relations.length > 0) { this.hasRelations = true; this.cdRef.markForCheck(); @@ -690,7 +691,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.router.navigateByUrl('/home'); return of(null); })).subscribe(detail => { - if (detail == null) return; + if (detail == null) { + this.router.navigateByUrl('/home'); + return; + } + this.unreadCount = detail.unreadCount; this.totalCount = detail.totalCount; @@ -700,6 +705,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.chapters = detail.chapters; this.volumes = detail.volumes; this.storyChapters = detail.storylineChapters; + this.storylineItems = []; const v = this.volumes.map(v => { return {volume: v, chapter: undefined, isChapter: false} as StoryLineItem; @@ -710,10 +716,13 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { }); this.storylineItems.push(...c); - + this.updateWhichTabsToShow(); this.updateSelectedTab(); + + this.isLoading = false; this.cdRef.markForCheck(); + console.log('isLoading is now false') }); }, err => { this.router.navigateByUrl('/home'); @@ -724,6 +733,35 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { return {series, relation} as RelatedSeriesPair; } + shouldShowStorylineTab() { + if (this.libraryType === LibraryType.ComicVine) return false; + // Edge case for bad pdf parse + if (this.libraryType === LibraryType.Book && (this.volumes.length === 0 && this.chapters.length === 0 && this.storyChapters.length > 0)) return true; + + return (this.libraryType !== LibraryType.Book && this.libraryType !== LibraryType.LightNovel && this.libraryType !== LibraryType.Comic) + && (this.volumes.length > 0 || this.chapters.length > 0); + } + + shouldShowVolumeTab() { + if (this.libraryType === LibraryType.ComicVine) { + if (this.volumes.length > 1) return true; + if (this.specials.length === 0 && this.chapters.length === 0) return true; + return false; + } + return this.volumes.length > 0; + } + + shouldShowChaptersTab() { + return this.chapters.length > 0; + } + + updateWhichTabsToShow() { + this.showVolumeTab = this.shouldShowVolumeTab(); + this.showStorylineTab = this.shouldShowStorylineTab(); + this.showChapterTab = this.shouldShowChaptersTab(); + this.cdRef.markForCheck(); + } + /** * This will update the selected tab * @@ -771,10 +809,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { loadPlusMetadata(seriesId: number, libraryType: LibraryType) { this.isLoadingExtra = true; this.cdRef.markForCheck(); + this.metadataService.getSeriesMetadataFromPlus(seriesId, libraryType).subscribe(data => { - this.isLoadingExtra = false; - this.cdRef.markForCheck(); - if (data === null) return; + if (data === null) { + this.isLoadingExtra = false; + this.cdRef.markForCheck(); + console.log('isLoadingExtra is false') + return; + } // Reviews this.reviews = [...data.reviews]; @@ -790,7 +832,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.hasRecommendations = this.combinedRecs.length > 0; + this.isLoadingExtra = false; this.cdRef.markForCheck(); + console.log('isLoadingExtra is false') }); } @@ -970,11 +1014,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { downloadSeries() { this.downloadService.download('series', this.series, (d) => { - if (d) { - this.downloadInProgress = true; - } else { - this.downloadInProgress = false; - } + this.downloadInProgress = !!d; this.cdRef.markForCheck(); }); } diff --git a/UI/Web/src/app/shared/_services/download.service.ts b/UI/Web/src/app/shared/_services/download.service.ts index 7703d9f0b..352f3c431 100644 --- a/UI/Web/src/app/shared/_services/download.service.ts +++ b/UI/Web/src/app/shared/_services/download.service.ts @@ -220,6 +220,7 @@ export class DownloadService { ); } + private getIdKey(entity: Chapter | Volume) { if (this.utilityService.isVolume(entity)) return 'volumeId'; if (this.utilityService.isChapter(entity)) return 'chapterId'; diff --git a/UI/Web/src/app/shared/circular-loader/circular-loader.component.html b/UI/Web/src/app/shared/circular-loader/circular-loader.component.html index b703d6a86..8bc22ebf1 100644 --- a/UI/Web/src/app/shared/circular-loader/circular-loader.component.html +++ b/UI/Web/src/app/shared/circular-loader/circular-loader.component.html @@ -1,27 +1,30 @@ - -
    +@if (currentValue > 0) { + @if (showIcon) { +
    -
    -
    - -
    - +
    + } + +
    + +
    +} diff --git a/UI/Web/src/app/shared/circular-loader/circular-loader.component.ts b/UI/Web/src/app/shared/circular-loader/circular-loader.component.ts index e55b2ad12..f03ae2e04 100644 --- a/UI/Web/src/app/shared/circular-loader/circular-loader.component.ts +++ b/UI/Web/src/app/shared/circular-loader/circular-loader.component.ts @@ -1,14 +1,11 @@ import {ChangeDetectionStrategy, Component, Input} from '@angular/core'; -import {CommonModule} from "@angular/common"; +import {CommonModule, NgClass, NgStyle} from "@angular/common"; import {NgCircleProgressModule } from "ng-circle-progress"; @Component({ selector: 'app-circular-loader', standalone: true, - imports: [CommonModule, NgCircleProgressModule], - // providers: [ - // importProvidersFrom(NgCircleProgressModule), - // ], + imports: [NgCircleProgressModule, NgStyle, NgClass], templateUrl: './circular-loader.component.html', styleUrls: ['./circular-loader.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html b/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html index 39591cca6..bfbd2c311 100644 --- a/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html +++ b/UI/Web/src/app/statistics/_components/_modals/generic-list-modal/generic-list-modal.component.html @@ -5,21 +5,28 @@