diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 08d5f29a7..a7575577c 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -6,8 +6,11 @@ using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; +using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; +using API.Helpers; +using API.Helpers.Converters; using API.Services; using API.Services.Tasks; using API.SignalR; @@ -48,7 +51,10 @@ public class CleanupServiceTests _context = new DataContext(contextOptions); Task.Run(SeedDb).GetAwaiter().GetResult(); - _unitOfWork = new UnitOfWork(_context, Substitute.For(), null); + var config = new MapperConfiguration(cfg => cfg.AddProfile()); + var mapper = config.CreateMapper(); + + _unitOfWork = new UnitOfWork(_context, mapper, null); } #region Setup diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 1b72b20d2..a4285431e 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -72,8 +72,9 @@ namespace API.Controllers { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId); + return Ok(items); - return Ok(await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(userId, items.ToList())); + //return Ok(await _unitOfWork.ReadingListRepository.AddReadingProgressModifiers(userId, items.ToList())); } /// @@ -463,7 +464,7 @@ namespace API.Controllers var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet(); var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds)) - .OrderBy(c => float.Parse(c.Volume.Name)) + .OrderBy(c => Parser.Parser.MinNumberFromRange(c.Volume.Name)) .ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting); var index = lastOrder + 1; diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 7fabb2a82..f49a7e092 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -118,7 +118,7 @@ namespace API.Controllers try { var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files, "logs"); - return File(fileBytes, "application/zip", Path.GetFileName(zipPath)); + return File(fileBytes, "application/zip", Path.GetFileName(zipPath), true); } catch (KavitaException ex) { diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index c6937c3c4..a712a39cc 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -54,9 +54,6 @@ namespace API.Controllers public async Task> GetSettings() { var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - // TODO: Is this needed as it gets updated in the DB on startup - settingsDto.Port = Configuration.Port; - settingsDto.LoggingLevel = Configuration.LogLevel; return Ok(settingsDto); } @@ -212,6 +209,16 @@ namespace API.Controllers _unitOfWork.SettingsRepository.Update(setting); } + if (setting.Key == ServerSettingKey.TotalBackups && updateSettingsDto.TotalBackups + string.Empty != setting.Value) + { + if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1) + { + return BadRequest("Total Backups must be between 1 and 30"); + } + setting.Value = updateSettingsDto.TotalBackups + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + } + if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value) { setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl; diff --git a/API/DTOs/Filtering/SortField.cs b/API/DTOs/Filtering/SortField.cs index 3d78494bd..fbb1d511a 100644 --- a/API/DTOs/Filtering/SortField.cs +++ b/API/DTOs/Filtering/SortField.cs @@ -2,8 +2,24 @@ public enum SortField { + /// + /// Sort Name of Series + /// SortName = 1, + /// + /// Date entity was created/imported into Kavita + /// CreatedDate = 2, + /// + /// Date entity was last modified (tag update, etc) + /// LastModifiedDate = 3, - LastChapterAdded = 4 + /// + /// Date series had a chapter added to it + /// + LastChapterAdded = 4, + /// + /// Time it takes to read. Uses Average. + /// + TimeToRead = 5 } diff --git a/API/DTOs/ReadingLists/ReadingListDto.cs b/API/DTOs/ReadingLists/ReadingListDto.cs index 3eb5ded79..ba446d17a 100644 --- a/API/DTOs/ReadingLists/ReadingListDto.cs +++ b/API/DTOs/ReadingLists/ReadingListDto.cs @@ -10,5 +10,9 @@ /// public bool Promoted { get; set; } public bool CoverImageLocked { get; set; } + /// + /// This is used to tell the UI if it should request a Cover Image or not. If null or empty, it has not been set. + /// + public string CoverImage { get; set; } = string.Empty; } } diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index 153d52a69..8de3a692f 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -1,4 +1,5 @@ -using API.Services; +using System.Collections.Generic; +using API.Services; namespace API.DTOs.Settings { @@ -44,5 +45,11 @@ namespace API.DTOs.Settings /// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT. /// public bool EnableSwaggerUi { get; set; } + + /// + /// The amount of Backups before cleanup + /// + /// Value should be between 1 and 30 + public int TotalBackups { get; set; } = 30; } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index f90b0f301..b42bce75c 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -753,6 +753,7 @@ public class SeriesRepository : ISeriesRepository SortField.CreatedDate => query.OrderBy(s => s.Created), SortField.LastModifiedDate => query.OrderBy(s => s.LastModified), SortField.LastChapterAdded => query.OrderBy(s => s.LastChapterAdded), + SortField.TimeToRead => query.OrderBy(s => s.AvgHoursToRead), _ => query }; } @@ -764,6 +765,7 @@ public class SeriesRepository : ISeriesRepository SortField.CreatedDate => query.OrderByDescending(s => s.Created), SortField.LastModifiedDate => query.OrderByDescending(s => s.LastModified), SortField.LastChapterAdded => query.OrderByDescending(s => s.LastChapterAdded), + SortField.TimeToRead => query.OrderByDescending(s => s.AvgHoursToRead), _ => query }; } diff --git a/API/Data/Repositories/SettingsRepository.cs b/API/Data/Repositories/SettingsRepository.cs index be66cbe62..b94204d56 100644 --- a/API/Data/Repositories/SettingsRepository.cs +++ b/API/Data/Repositories/SettingsRepository.cs @@ -5,6 +5,7 @@ using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; using AutoMapper; +using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 1ae69895e..893256357 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -102,6 +102,7 @@ namespace API.Data new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl}, new() {Key = ServerSettingKey.ConvertBookmarkToWebP, Value = "false"}, new() {Key = ServerSettingKey.EnableSwaggerUi, Value = "false"}, + new() {Key = ServerSettingKey.TotalBackups, Value = "30"}, }.ToArray()); foreach (var defaultSetting in DefaultSettings) diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 55c13b629..b387f1d85 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -86,5 +86,10 @@ namespace API.Entities.Enums /// [Description("EnableSwaggerUi")] EnableSwaggerUi = 15, + /// + /// Total Number of Backups to maintain before cleaning. Default 30, min 1. + /// + [Description("TotalBackups")] + TotalBackups = 16, } } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 2ebaec592..1b637b25f 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -62,7 +62,7 @@ namespace API.Extensions } private static void AddSqLite(this IServiceCollection services, IConfiguration config, - IWebHostEnvironment env) + IHostEnvironment env) { services.AddDbContext(options => { diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index c6682fa85..a15913374 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -138,7 +138,8 @@ namespace API.Helpers CreateMap(); - + CreateMap, ServerSettingDto>() + .ConvertUsing(); CreateMap, ServerSettingDto>() .ConvertUsing(); diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 12759c739..862b9a10c 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -54,6 +54,9 @@ namespace API.Helpers.Converters case ServerSettingKey.EnableSwaggerUi: destination.EnableSwaggerUi = bool.Parse(row.Value); break; + case ServerSettingKey.TotalBackups: + destination.TotalBackups = int.Parse(row.Value); + break; } } diff --git a/API/Helpers/UserParams.cs b/API/Helpers/UserParams.cs index 4dc4c5aa3..87cc28471 100644 --- a/API/Helpers/UserParams.cs +++ b/API/Helpers/UserParams.cs @@ -4,7 +4,7 @@ { private const int MaxPageSize = int.MaxValue; public int PageNumber { get; init; } = 1; - private readonly int _pageSize = 30; + private readonly int _pageSize = MaxPageSize; /// /// If set to 0, will set as MaxInt diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index b5a19d18a..8aa57d45b 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -412,7 +412,6 @@ namespace API.Services private void ExtractArchiveEntries(ZipArchive archive, string extractPath) { - // TODO: In cases where we try to extract, but there are InvalidPathChars, we need to inform the user (throw exception, let middleware inform user) var needsFlattening = ArchiveNeedsFlattening(archive); if (!archive.HasFiles() && !needsFlattening) return; @@ -476,7 +475,8 @@ namespace API.Services catch (Exception e) { _logger.LogWarning(e, "[ExtractArchive] There was a problem extracting {ArchivePath} to {ExtractPath}",archivePath, extractPath); - return; + throw new KavitaException( + $"There was an error when extracting {archivePath}. Check the file exists, has read permissions or the server OS can support all path characters."); } _logger.LogDebug("Extracted archive to {ExtractPath} in {ElapsedMilliseconds} milliseconds", extractPath, sw.ElapsedMilliseconds); } diff --git a/API/Services/BookmarkService.cs b/API/Services/BookmarkService.cs index b1c0e02cc..3dad57ada 100644 --- a/API/Services/BookmarkService.cs +++ b/API/Services/BookmarkService.cs @@ -174,6 +174,7 @@ public class BookmarkService : IBookmarkService /// /// This is a long-running job that will convert all bookmarks into WebP. Do not invoke anyway except via Hangfire. /// + [DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)] public async Task ConvertAllBookmarkToWebP() { var bookmarkDirectory = diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index e73055350..7bb34e159 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -198,6 +198,8 @@ public class MetadataService : IMetadataService /// This can be heavy on memory first run /// /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image + [DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task RefreshMetadata(int libraryId, bool forceUpdate = false) { var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 8b015d568..4420adedb 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -147,11 +147,11 @@ namespace API.Services.Tasks } /// - /// Removes Database backups older than 30 days. If all backups are older than 30 days, the latest is kept. + /// Removes Database backups older than configured total backups. If all backups are older than total backups days, only the latest is kept. /// public async Task CleanupBackups() { - const int dayThreshold = 30; // TODO: We can make this a config option + var dayThreshold = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).TotalBackups; _logger.LogInformation("Beginning cleanup of Database backups at {Time}", DateTime.Now); var backupDirectory = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BackupDirectory)).Value; diff --git a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs index f4675051b..2b5327244 100644 --- a/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs +++ b/API/Services/Tasks/Metadata/WordCountAnalyzerService.cs @@ -45,6 +45,8 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService } + [DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ScanLibrary(int libraryId, bool forceUpdate = false) { var sw = Stopwatch.StartNew(); diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index f06850ae7..00caa39f2 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -69,6 +69,8 @@ public class ScannerService : IScannerService _wordCountAnalyzerService = wordCountAnalyzerService; } + [DisableConcurrentExecution(60 * 60 * 60)] + [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ScanSeries(int libraryId, int seriesId, CancellationToken token) { var sw = new Stopwatch(); @@ -250,7 +252,8 @@ public class ScannerService : IScannerService return true; } - + [DisableConcurrentExecution(60 * 60 * 60)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ScanLibraries() { _logger.LogInformation("Starting Scan of All Libraries"); @@ -269,7 +272,8 @@ public class ScannerService : IScannerService /// ie) all entities will be rechecked for new cover images and comicInfo.xml changes /// /// - + [DisableConcurrentExecution(60 * 60 * 60)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ScanLibrary(int libraryId) { Library library; diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index da7932acc..3a1dd7297 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -21,4 +21,8 @@ export interface ReadingList { promoted: boolean; coverImageLocked: boolean; items: Array; + /** + * If this is empty or null, the cover image isn't set. Do not use this externally. + */ + coverImage: string; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/series-filter.ts b/UI/Web/src/app/_models/series-filter.ts index c2b823ce3..e346ccd4f 100644 --- a/UI/Web/src/app/_models/series-filter.ts +++ b/UI/Web/src/app/_models/series-filter.ts @@ -41,7 +41,8 @@ export enum SortField { SortName = 1, Created = 2, LastModified = 3, - LastChapterAdded = 4 + LastChapterAdded = 4, + TimeToRead = 5 } export interface ReadStatus { diff --git a/UI/Web/src/app/_models/system/directory-dto.ts b/UI/Web/src/app/_models/system/directory-dto.ts index 346993f80..d666e59b8 100644 --- a/UI/Web/src/app/_models/system/directory-dto.ts +++ b/UI/Web/src/app/_models/system/directory-dto.ts @@ -1,4 +1,8 @@ export interface DirectoryDto { name: string; fullPath: string; + /** + * This is only on the UI to disable paths + */ + disabled: boolean; } \ No newline at end of file diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index e15249e5b..5a1bf5283 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -35,7 +35,8 @@ export class AccountService implements OnDestroy { constructor(private httpClient: HttpClient, private router: Router, private messageHub: MessageHubService, private themeService: ThemeService) { messageHub.messages$.pipe(filter(evt => evt.event === EVENTS.UserUpdate), - map(evt => evt.payload as UserUpdateEvent), + map(evt => evt.payload as UserUpdateEvent), + filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username), switchMap(() => this.refreshToken())) .subscribe(() => {}); } diff --git a/UI/Web/src/app/_services/scroll.service.ts b/UI/Web/src/app/_services/scroll.service.ts index 7c4b07ea2..7137c5aeb 100644 --- a/UI/Web/src/app/_services/scroll.service.ts +++ b/UI/Web/src/app/_services/scroll.service.ts @@ -1,4 +1,4 @@ -import { ElementRef, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html index 4618a7c2d..d7c9b08f0 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html @@ -3,14 +3,6 @@