diff --git a/API.Tests/Converters/CronConverterTests.cs b/API.Tests/Converters/CronConverterTests.cs new file mode 100644 index 000000000..6f5cd9419 --- /dev/null +++ b/API.Tests/Converters/CronConverterTests.cs @@ -0,0 +1,28 @@ +using System; +using API.Helpers.Converters; +using AutoMapper; +using Hangfire; +using Xunit; +using Xunit.Abstractions; + +namespace API.Tests.Converters +{ + public class CronConverterTests + { + private readonly ITestOutputHelper _testOutputHelper; + + public CronConverterTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Theory] + [InlineData("daily", "0 0 * * *")] + [InlineData("disabled", "0 0 31 2 *")] + [InlineData("weekly", "0 0 * * 1")] + public void ConvertTest(string input, string expected) + { + Assert.Equal(expected, CronConverter.ConvertToCronNotation(input)); + } + } +} \ No newline at end of file diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs index 4aba6b7bd..3002947a2 100644 --- a/API/Controllers/AdminController.cs +++ b/API/Controllers/AdminController.cs @@ -20,9 +20,5 @@ namespace API.Controllers var users = await _userManager.GetUsersInRoleAsync("Admin"); return users.Count > 0; } - - - - } } \ No newline at end of file diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 1b4cdd3af..2e0f8f57f 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -1,14 +1,14 @@ -using System.IO; -using System.Linq; +using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; using API.Data; using API.DTOs; +using API.Entities; using API.Extensions; +using API.Helpers.Converters; using API.Interfaces; -using AutoMapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Controllers @@ -18,30 +18,30 @@ namespace API.Controllers { private readonly DataContext _dataContext; private readonly ILogger _logger; - private readonly IMapper _mapper; + private readonly IUnitOfWork _unitOfWork; private readonly ITaskScheduler _taskScheduler; - public SettingsController(DataContext dataContext, ILogger logger, IMapper mapper, ITaskScheduler taskScheduler) + public SettingsController(DataContext dataContext, ILogger logger, IUnitOfWork unitOfWork, + ITaskScheduler taskScheduler) { _dataContext = dataContext; _logger = logger; - _mapper = mapper; + _unitOfWork = unitOfWork; _taskScheduler = taskScheduler; } [HttpGet("")] public async Task> GetSettings() { - var settings = await _dataContext.ServerSetting.Select(x => x).ToListAsync(); - return _mapper.Map(settings); + return Ok(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()); } - + [Authorize(Policy = "RequireAdminRole")] [HttpPost("")] - public async Task UpdateSettings(ServerSettingDto updateSettingsDto) + public async Task> UpdateSettings(ServerSettingDto updateSettingsDto) { _logger.LogInformation($"{User.GetUsername()} is updating Server Settings"); - + if (updateSettingsDto.CacheDirectory.Equals(string.Empty)) { return BadRequest("Cache Directory cannot be empty"); @@ -51,13 +51,39 @@ namespace API.Controllers { return BadRequest("Directory does not exist or is not accessible."); } - // TODO: Figure out how to handle a change. This means that on clean, we need to clean up old cache - // directory and new one, but what if someone is reading? - // I can just clean both always, /cache/ is an owned folder, so users shouldn't use it. - - - //_dataContext.ServerSetting.Update - return BadRequest("Not Implemented"); + + // We do not allow CacheDirectory changes, so we will ignore. + var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); + + foreach (var setting in currentSettings) + { + if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value) + { + setting.Value = updateSettingsDto.TaskBackup; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) + { + setting.Value = updateSettingsDto.TaskScan; + _unitOfWork.SettingsRepository.Update(setting); + } + } + + if (_unitOfWork.HasChanges() && await _unitOfWork.Complete()) + { + _logger.LogInformation("Server Settings updated."); + return Ok(updateSettingsDto); + } + + return BadRequest("There was a critical issue. Please try again."); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("task-frequencies")] + public ActionResult> GetTaskFrequencies() + { + return Ok(CronConverter.Options); } } } \ No newline at end of file diff --git a/API/DTOs/ServerSettingDTO.cs b/API/DTOs/ServerSettingDTO.cs index 455859305..e16d16506 100644 --- a/API/DTOs/ServerSettingDTO.cs +++ b/API/DTOs/ServerSettingDTO.cs @@ -3,7 +3,8 @@ public class ServerSettingDto { public string CacheDirectory { get; set; } - // public string Kind { get; init; } - // public string Value { get; init; } + public string TaskScan { get; set; } + public string LoggingLevel { get; set; } + public string TaskBackup { get; set; } } } \ No newline at end of file diff --git a/API/Data/Migrations/20210203164258_ServerSettingsKey.Designer.cs b/API/Data/Migrations/20210203164258_ServerSettingsKey.Designer.cs new file mode 100644 index 000000000..75d0a2244 --- /dev/null +++ b/API/Data/Migrations/20210203164258_ServerSettingsKey.Designer.cs @@ -0,0 +1,676 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210203164258_ServerSettingsKey")] + partial class ServerSettingsKey + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("NumberOfPages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210203164258_ServerSettingsKey.cs b/API/Data/Migrations/20210203164258_ServerSettingsKey.cs new file mode 100644 index 000000000..0a2a64920 --- /dev/null +++ b/API/Data/Migrations/20210203164258_ServerSettingsKey.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class ServerSettingsKey : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Key", + table: "ServerSetting", + type: "INTEGER", + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Key", + table: "ServerSetting", + type: "TEXT", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 2ba32129a..fcbfaa084 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -335,8 +335,8 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.ServerSetting", b => { - b.Property("Key") - .HasColumnType("TEXT"); + b.Property("Key") + .HasColumnType("INTEGER"); b.Property("RowVersion") .IsConcurrencyToken() diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 5071b9fa5..9b4a49e6b 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -31,20 +31,23 @@ namespace API.Data public static async Task SeedSettings(DataContext context) { + context.Database.EnsureCreated(); + IList defaultSettings = new List() { - new() {Key = "CacheDirectory", Value = CacheService.CacheDirectory} + new() {Key = ServerSettingKey.CacheDirectory, Value = CacheService.CacheDirectory}, + new () {Key = ServerSettingKey.TaskScan, Value = "daily"} }; - var settings = await context.ServerSetting.Select(s => s).ToListAsync(); + foreach (var defaultSetting in defaultSettings) { - var existing = settings.SingleOrDefault(s => s.Key == defaultSetting.Key); + var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key); if (existing == null) { - settings.Add(defaultSetting); + context.ServerSetting.Add(defaultSetting); } } - + await context.SaveChangesAsync(); } } diff --git a/API/Data/SettingsRepository.cs b/API/Data/SettingsRepository.cs new file mode 100644 index 000000000..21e33deb2 --- /dev/null +++ b/API/Data/SettingsRepository.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; +using API.Interfaces; +using AutoMapper; +using Microsoft.EntityFrameworkCore; + +namespace API.Data +{ + public class SettingsRepository : ISettingsRepository + { + private readonly DataContext _context; + private readonly IMapper _mapper; + + public SettingsRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Update(ServerSetting settings) + { + _context.Entry(settings).State = EntityState.Modified; + } + + public async Task GetSettingsDtoAsync() + { + var settings = await _context.ServerSetting + .Select(x => x) + .AsNoTracking() + .ToListAsync(); + return _mapper.Map(settings); + } + + public Task GetSettingAsync(ServerSettingKey key) + { + return _context.ServerSetting.SingleOrDefaultAsync(x => x.Key == key); + } + + public async Task> GetSettingsAsync() + { + return await _context.ServerSetting.ToListAsync(); + } + } +} \ No newline at end of file diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index f183e247a..6cffc1392 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -24,6 +24,8 @@ namespace API.Data public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper); public IVolumeRepository VolumeRepository => new VolumeRepository(_context, _mapper); + + public ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper); public async Task Complete() { diff --git a/API/Entities/ServerSetting.cs b/API/Entities/ServerSetting.cs index 8bbb3dcf4..a851f834b 100644 --- a/API/Entities/ServerSetting.cs +++ b/API/Entities/ServerSetting.cs @@ -7,7 +7,7 @@ namespace API.Entities public class ServerSetting : IHasConcurrencyToken { [Key] - public string Key { get; set; } + public ServerSettingKey Key { get; set; } public string Value { get; set; } [ConcurrencyCheck] diff --git a/API/Entities/ServerSettingKey.cs b/API/Entities/ServerSettingKey.cs new file mode 100644 index 000000000..19b2f9f9c --- /dev/null +++ b/API/Entities/ServerSettingKey.cs @@ -0,0 +1,10 @@ +namespace API.Entities +{ + public enum ServerSettingKey + { + TaskScan = 0, + CacheDirectory = 1, + TaskBackup = 2, + LoggingLevel = 3 + } +} \ No newline at end of file diff --git a/API/Helpers/Converters/CronConverter.cs b/API/Helpers/Converters/CronConverter.cs new file mode 100644 index 000000000..6fece1bdb --- /dev/null +++ b/API/Helpers/Converters/CronConverter.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using Hangfire; + +namespace API.Helpers.Converters +{ + public static class CronConverter + { + public static readonly IEnumerable Options = new [] + { + "disabled", + "daily", + "weekly", + }; + public static string ConvertToCronNotation(string source) + { + string destination = ""; + destination = source.ToLower() switch + { + "daily" => Cron.Daily(), + "weekly" => Cron.Weekly(), + "disabled" => Cron.Never(), + "" => Cron.Never(), + _ => destination + }; + + return destination; + } + + public static string ConvertFromCronNotation(string cronNotation) + { + string destination = ""; + destination = cronNotation.ToLower() switch + { + "0 0 31 2 *" => "disabled", + _ => destination + }; + + return destination; + } + } +} \ No newline at end of file diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 78e395ec3..1795a9ba0 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -9,16 +9,24 @@ namespace API.Helpers.Converters { public ServerSettingDto Convert(IEnumerable source, ServerSettingDto destination, ResolutionContext context) { - destination = new ServerSettingDto(); + destination ??= new ServerSettingDto(); foreach (var row in source) { switch (row.Key) { - case "CacheDirectory": + case ServerSettingKey.CacheDirectory: destination.CacheDirectory = row.Value; break; - default: + case ServerSettingKey.TaskScan: + destination.TaskScan = row.Value; break; + case ServerSettingKey.LoggingLevel: + destination.LoggingLevel = row.Value; + break; + case ServerSettingKey.TaskBackup: + destination.TaskBackup = row.Value; + break; + } } diff --git a/API/Interfaces/ISettingsRepository.cs b/API/Interfaces/ISettingsRepository.cs new file mode 100644 index 000000000..fd9657678 --- /dev/null +++ b/API/Interfaces/ISettingsRepository.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; + +namespace API.Interfaces +{ + public interface ISettingsRepository + { + void Update(ServerSetting settings); + Task GetSettingsDtoAsync(); + Task GetSettingAsync(ServerSettingKey key); + Task> GetSettingsAsync(); + + } +} \ No newline at end of file diff --git a/API/Interfaces/IUnitOfWork.cs b/API/Interfaces/IUnitOfWork.cs index d4268bf64..24a074e29 100644 --- a/API/Interfaces/IUnitOfWork.cs +++ b/API/Interfaces/IUnitOfWork.cs @@ -8,6 +8,7 @@ namespace API.Interfaces IUserRepository UserRepository { get; } ILibraryRepository LibraryRepository { get; } IVolumeRepository VolumeRepository { get; } + ISettingsRepository SettingsRepository { get; } Task Complete(); bool HasChanges(); } diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 4113c56f6..500605883 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -74,19 +74,18 @@ namespace API.Services private byte[] CreateThumbnail(ZipArchiveEntry entry) { - var coverImage = Array.Empty(); try { using var stream = entry.Open(); using var thumbnail = Image.ThumbnailStream(stream, ThumbnailWidth); - coverImage = thumbnail.WriteToBuffer(".jpg"); + return thumbnail.WriteToBuffer(".jpg"); } catch (Exception ex) { _logger.LogError(ex, "There was a critical error and prevented thumbnail generation. Defaulting to no cover image."); } - return coverImage; + return Array.Empty(); } private static byte[] ConvertEntryToByteArray(ZipArchiveEntry entry) diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index b1e04a83d..692be4266 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -75,7 +75,6 @@ namespace API.Services public void CleanupChapters(int[] chapterIds) { - // TODO: Fix this code to work with chapters _logger.LogInformation($"Running Cache cleanup on Volumes"); foreach (var chapter in chapterIds) @@ -112,7 +111,7 @@ namespace API.Services { var path = GetCachePath(chapter.Id); // TODO: GetFiles should only get image files. - var files = _directoryService.GetFiles(path); + var files = _directoryService.GetFiles(path); Array.Sort(files, _numericComparer); return (files.ElementAt(page - pagesSoFar), mangaFile); diff --git a/API/Services/ScannerService.cs b/API/Services/ScannerService.cs index b3cd09e9b..5f84b6e3f 100644 --- a/API/Services/ScannerService.cs +++ b/API/Services/ScannerService.cs @@ -4,15 +4,12 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.IO; -using System.IO.Compression; using System.Linq; using System.Threading.Tasks; using API.Entities; -using API.Extensions; using API.Interfaces; using API.Parser; using Microsoft.Extensions.Logging; -using NetVips; namespace API.Services { @@ -61,11 +58,11 @@ namespace API.Services var totalFiles = 0; foreach (var folderPath in library.Folders) { - // if (!forceUpdate && Directory.GetLastWriteTime(folderPath.Path) <= folderPath.LastScanned) - // { - // _logger.LogDebug($"{folderPath.Path} hasn't been updated since last scan. Skipping."); - // continue; - // } + if (!forceUpdate && Directory.GetLastWriteTime(folderPath.Path) <= folderPath.LastScanned) + { + _logger.LogDebug($"{folderPath.Path} hasn't been updated since last scan. Skipping."); + continue; + } try { totalFiles += DirectoryService.TraverseTreeParallelForEach(folderPath.Path, (f) => @@ -163,7 +160,7 @@ namespace API.Services { if (info.Series == string.Empty) return; - _scannedSeries.AddOrUpdate(info.Series, new List() {info}, (key, oldValue) => + _scannedSeries.AddOrUpdate(info.Series, new List() {info}, (_, oldValue) => { oldValue ??= new List(); if (!oldValue.Contains(info)) @@ -234,94 +231,6 @@ namespace API.Services return forceUpdate || coverImage == null || !coverImage.Any(); } - - - - - /// - /// Creates or Updates volumes for a given series - /// - /// Series wanting to be updated - /// Parser info - /// Forces metadata update (cover image) even if it's already been set. - /// Updated Volumes for given series - private ICollection UpdateVolumes(Series series, ParserInfo[] infos, bool forceUpdate) - { - ICollection volumes = new List(); - IList existingVolumes = _unitOfWork.SeriesRepository.GetVolumes(series.Id).ToList(); - - //var justVolumes = infos.Select(pi => pi.Chapters == "0"); - - - foreach (var info in infos) - { - var existingVolume = existingVolumes.SingleOrDefault(v => v.Name == info.Volumes); - if (existingVolume != null) - { - //var existingFile = existingVolume.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); - var existingFile = new MangaFile(); - if (existingFile != null) - { - //existingFile.Chapter = Parser.Parser.MinimumNumberFromRange(info.Chapters); - existingFile.Format = info.Format; - existingFile.NumberOfPages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath); - } - else - { - if (info.Format == MangaFormat.Archive) - { - // existingVolume.Files.Add(CreateMangaFile(info)); - } - else - { - _logger.LogDebug($"Ignoring {info.Filename} as it is not an archive."); - } - - } - - volumes.Add(existingVolume); - } - else - { - // Create New Volume - existingVolume = volumes.SingleOrDefault(v => v.Name == info.Volumes); - if (existingVolume != null) - { - //existingVolume.Files.Add(CreateMangaFile(info)); - } - else - { - var vol = new Volume() - { - Name = info.Volumes, - Number = Parser.Parser.MinimumNumberFromRange(info.Volumes), - // Files = new List() - // { - // CreateMangaFile(info) - // } - }; - volumes.Add(vol); - } - } - - _logger.LogInformation($"Adding volume {volumes.Last().Number} with File: {info.Filename}"); - } - - foreach (var volume in volumes) - { - // if (forceUpdate || volume.CoverImage == null || !volume.Files.Any()) - // { - // var firstFile = volume.Files.OrderBy(x => x.Chapter).FirstOrDefault(); - // if (firstFile != null) volume.CoverImage = _archiveService.GetCoverImage(firstFile.FilePath, true); - // } - - //volume.Pages = volume.Files.Sum(x => x.NumberOfPages); - } - - return volumes; - } - - /// /// /// @@ -345,7 +254,7 @@ namespace API.Services }; chapter.Files ??= new List(); - var existingFile = chapter?.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); + var existingFile = chapter.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); if (existingFile != null) { existingFile.Format = info.Format; @@ -376,7 +285,8 @@ namespace API.Services if (ShouldFindCoverImage(forceUpdate, chapter.CoverImage)) { - var firstFile = chapter?.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + chapter.Files ??= new List(); + var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); if (firstFile != null) chapter.CoverImage = _archiveService.GetCoverImage(firstFile.FilePath, true); } } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index f888087ea..12b1f2f60 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -1,4 +1,8 @@ -using API.Interfaces; +using System; +using System.Threading.Tasks; +using API.Entities; +using API.Helpers.Converters; +using API.Interfaces; using Hangfire; using Microsoft.Extensions.Logging; @@ -9,17 +13,30 @@ namespace API.Services private readonly ICacheService _cacheService; private readonly ILogger _logger; private readonly IScannerService _scannerService; + private readonly IUnitOfWork _unitOfWork; public BackgroundJobServer Client => new BackgroundJobServer(); - public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService) + public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, IUnitOfWork unitOfWork) { _cacheService = cacheService; _logger = logger; _scannerService = scannerService; + _unitOfWork = unitOfWork; _logger.LogInformation("Scheduling/Updating cache cleanup on a daily basis."); - RecurringJob.AddOrUpdate(() => _cacheService.Cleanup(), Cron.Daily); - RecurringJob.AddOrUpdate(() => _scannerService.ScanLibraries(), Cron.Daily); + var setting = Task.Run(() => _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskScan)).Result; + if (setting != null) + { + RecurringJob.AddOrUpdate(() => _scannerService.ScanLibraries(), () => CronConverter.ConvertToCronNotation(setting.Value)); + } + else + { + RecurringJob.AddOrUpdate(() => _cacheService.Cleanup(), Cron.Daily); + RecurringJob.AddOrUpdate(() => _scannerService.ScanLibraries(), Cron.Daily); + } + + //JobStorage.Current.GetMonitoringApi(). + } public void ScanSeries(int libraryId, int seriesId) diff --git a/API/Startup.cs b/API/Startup.cs index 7e2864c0e..426616e51 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -46,8 +46,6 @@ namespace API app.UseHangfireDashboard(); } - //app.UseHttpsRedirection(); - app.UseRouting(); // Ordering is important. Cors, authentication, authorization