diff --git a/.gitignore b/.gitignore index e226d00c7..a87e29ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -449,37 +449,4 @@ appsettings.json /API/Hangfire.db /API/Hangfire-log.db cache/ -/API/wwwroot/assets/images/image-placeholder.jpg -/API/wwwroot/assets/images/mock-cover.jpg -/API/wwwroot/assets/images/preset-light.png -/API/wwwroot/assets/themes/plex/_bootswatch.scss -/API/wwwroot/assets/themes/plex/_variables.scss -/API/wwwroot/admin-admin-module.js -/API/wwwroot/admin-admin-module.js.map -/API/wwwroot/fa-brands-400.eot -/API/wwwroot/fa-brands-400.svg -/API/wwwroot/fa-brands-400.ttf -/API/wwwroot/fa-brands-400.woff -/API/wwwroot/fa-brands-400.woff2 -/API/wwwroot/fa-regular-400.eot -/API/wwwroot/fa-regular-400.svg -/API/wwwroot/fa-regular-400.ttf -/API/wwwroot/fa-regular-400.woff -/API/wwwroot/fa-regular-400.woff2 -/API/wwwroot/fa-solid-900.eot -/API/wwwroot/fa-solid-900.svg -/API/wwwroot/fa-solid-900.ttf -/API/wwwroot/fa-solid-900.woff -/API/wwwroot/fa-solid-900.woff2 -/API/wwwroot/favicon.ico -/API/wwwroot/index.html -/API/wwwroot/main.js -/API/wwwroot/main.js.map -/API/wwwroot/polyfills.js -/API/wwwroot/polyfills.js.map -/API/wwwroot/runtime.js -/API/wwwroot/runtime.js.map -/API/wwwroot/styles.css -/API/wwwroot/styles.css.map -/API/wwwroot/vendor.js -/API/wwwroot/vendor.js.map +/API/wwwroot/ diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index ec5ee39dd..b70443941 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -8,6 +8,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API.Tests/ParserTest.cs b/API.Tests/ParserTest.cs index d551c6393..6cf1a64d1 100644 --- a/API.Tests/ParserTest.cs +++ b/API.Tests/ParserTest.cs @@ -1,3 +1,6 @@ +using System.Collections.Generic; +using API.Entities; +using API.Parser; using Xunit; using static API.Parser.Parser; @@ -12,11 +15,24 @@ namespace API.Tests [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", "1")] [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", "1")] [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "1")] - //[InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "16-17")] + [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "16-17")] [InlineData("Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz", "1")] [InlineData("v001", "1")] + [InlineData("No Volume", "0")] [InlineData("U12 (Under 12) Vol. 0001 Ch. 0001 - Reiwa Scans (gb)", "1")] [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip", "1")] + [InlineData("Tonikaku Cawaii [Volume 11].cbz", "11")] + [InlineData("[WS]_Ichiban_Ushiro_no_Daimaou_v02_ch10.zip", "2")] + [InlineData("[xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans]", "1")] + [InlineData("Tower Of God S01 014 (CBT) (digital).cbz", "1")] + [InlineData("Tenjou_Tenge_v17_c100[MT].zip", "17")] + [InlineData("Shimoneta - Manmaru Hen - c001-006 (v01) [Various].zip", "1")] + [InlineData("Future Diary v02 (2009) (Digital) (Viz).cbz", "2")] + [InlineData("Mujaki no Rakuen Vol12 ch76", "12")] + [InlineData("Ichinensei_ni_Nacchattara_v02_ch11_[Taruby]_v1.3.zip", "2")] + [InlineData("Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz", "1")] + [InlineData("Dorohedoro v11 (2013) (Digital) (LostNerevarine-Empire).cbz", "11")] + [InlineData("Dorohedoro v12 (2013) (Digital) (LostNerevarine-Empire).cbz", "12")] public void ParseVolumeTest(string filename, string expected) { Assert.Equal(expected, ParseVolume(filename)); @@ -31,13 +47,39 @@ namespace API.Tests [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "Gokukoku no Brynhildr")] [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "Dance in the Vampire Bund")] [InlineData("v001", "")] - [InlineData("U12 (Under 12) Vol. 0001 Ch. 0001 - Reiwa Scans (gb)", "U12 (Under 12)")] + [InlineData("U12 (Under 12) Vol. 0001 Ch. 0001 - Reiwa Scans (gb)", "U12")] [InlineData("Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ)", "Akame ga KILL! ZERO")] + [InlineData("APOSIMZ 017 (2018) (Digital) (danke-Empire).cbz", "APOSIMZ")] + [InlineData("Akiiro Bousou Biyori - 01.jpg", "Akiiro Bousou Biyori")] + [InlineData("Beelzebub_172_RHS.zip", "Beelzebub")] + [InlineData("Dr. STONE 136 (2020) (Digital) (LuCaZ).cbz", "Dr. STONE")] + [InlineData("Cynthia the Mission 29.rar", "Cynthia the Mission")] + [InlineData("Darling in the FranXX - Volume 01.cbz", "Darling in the FranXX")] + [InlineData("Darwin's Game - Volume 14 (F).cbz", "Darwin's Game")] + [InlineData("[BAA]_Darker_than_Black_c7.zip", "Darker than Black")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 19 [Dametrans].zip", "Kedouin Makoto - Corpse Party Musume")] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 01", "Kedouin Makoto - Corpse Party Musume")] + [InlineData("[WS]_Ichiban_Ushiro_no_Daimaou_v02_ch10.zip", "Ichiban Ushiro no Daimaou")] + [InlineData("[xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans]", "Kyochuu Rettou")] + [InlineData("Loose_Relation_Between_Wizard_and_Apprentice_c07[AN].zip", "Loose Relation Between Wizard and Apprentice")] + [InlineData("Tower Of God S01 014 (CBT) (digital).cbz", "Tower Of God")] + [InlineData("Tenjou_Tenge_c106[MT].zip", "Tenjou Tenge")] + [InlineData("Tenjou_Tenge_v17_c100[MT].zip", "Tenjou Tenge")] + [InlineData("Shimoneta - Manmaru Hen - c001-006 (v01) [Various].zip", "Shimoneta - Manmaru Hen")] + [InlineData("Future Diary v02 (2009) (Digital) (Viz).cbz", "Future Diary")] + [InlineData("Tonikaku Cawaii [Volume 11].cbz", "Tonikaku Cawaii")] + [InlineData("Mujaki no Rakuen Vol12 ch76", "Mujaki no Rakuen")] + [InlineData("Knights of Sidonia c000 (S2 LE BD Omake - BLAME!) [Habanero Scans]", "Knights of Sidonia")] + [InlineData("Vol 1.cbz", "")] + [InlineData("Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip", "Ichinensei ni Nacchattara")] + [InlineData("Chrno_Crusade_Dragon_Age_All_Stars[AS].zip", "")] + [InlineData("Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip", "Ichiban Ushiro no Daimaou")] + //[InlineData("[Tempus Edax Rerum] Epigraph of the Closed Curve - Chapter 6.zip", "Epigraph of the Closed Curve")] public void ParseSeriesTest(string filename, string expected) { Assert.Equal(expected, ParseSeries(filename)); } - + [Theory] [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")] [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "9")] @@ -49,6 +91,18 @@ namespace API.Tests [InlineData("c001", "1")] [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.12.zip", "12")] [InlineData("Adding volume 1 with File: Ana Satsujin Vol. 1 Ch. 5 - Manga Box (gb).cbz", "5")] + [InlineData("Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz", "18")] + [InlineData("Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip", "0-6")] + [InlineData("[WS]_Ichiban_Ushiro_no_Daimaou_v02_ch10.zip", "10")] + [InlineData("Loose_Relation_Between_Wizard_and_Apprentice_c07[AN].zip", "7")] + [InlineData("Tower Of God S01 014 (CBT) (digital).cbz", "14")] + [InlineData("Tenjou_Tenge_c106[MT].zip", "106")] + [InlineData("Tenjou_Tenge_v17_c100[MT].zip", "100")] + [InlineData("Shimoneta - Manmaru Hen - c001-006 (v01) [Various].zip", "1-6")] + [InlineData("Mujaki no Rakuen Vol12 ch76", "76")] + [InlineData("Beelzebub_01_[Noodles].zip", "1")] + [InlineData("Yumekui-Merry_DKThias_Chapter21.zip", "21")] + //[InlineData("[Tempus Edax Rerum] Epigraph of the Closed Curve - Chapter 6.zip", "6")] public void ParseChaptersTest(string filename, string expected) { Assert.Equal(expected, ParseChapter(filename)); @@ -78,6 +132,7 @@ namespace API.Tests [InlineData("Hello_I_am_here ", "Hello I am here")] [InlineData("[ReleaseGroup] The Title", "The Title")] [InlineData("[ReleaseGroup]_The_Title", "The Title")] + [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1", "Kasumi Otoko no Ko v1.1")] public void CleanTitleTest(string input, string expected) { Assert.Equal(expected, CleanTitle(input)); @@ -85,13 +140,108 @@ namespace API.Tests [Theory] [InlineData("test.cbz", true)] - [InlineData("test.cbr", true)] + [InlineData("test.cbr", false)] [InlineData("test.zip", true)] - [InlineData("test.rar", true)] + [InlineData("test.rar", false)] [InlineData("test.rar.!qb", false)] + [InlineData("[shf-ma-khs-aqs]negi_pa_vol15007.jpg", false)] public void IsArchiveTest(string input, bool expected) { Assert.Equal(expected, IsArchive(input)); } + + [Theory] + [InlineData("Tenjou Tenge Omnibus", "Omnibus")] + [InlineData("Tenjou Tenge {Full Contact Edition}", "Full Contact Edition")] + [InlineData("Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", "Full Contact Edition")] + public void ParseEditionTest(string input, string expected) + { + Assert.Equal(expected, ParseEdition(input)); + } + + [Fact] + public void ParseInfoTest() + { + const string rootPath = @"E:/Manga/"; + var expected = new Dictionary(); + var filepath = @"E:/Manga/Mujaki no Rakuen/Mujaki no Rakuen Vol12 ch76.cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Mujaki no Rakuen", Volumes = "12", + Chapters = "76", Filename = "Mujaki no Rakuen Vol12 ch76.cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:/Manga/Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/Vol 1.cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen", Volumes = "1", + Chapters = "0", Filename = "Vol 1.cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Beelzebub\Beelzebub_01_[Noodles].zip"; + expected.Add(filepath, new ParserInfo + { + Series = "Beelzebub", Volumes = "0", + Chapters = "1", Filename = "Beelzebub_01_[Noodles].zip", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Ichinensei ni Nacchattara\Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip"; + expected.Add(filepath, new ParserInfo + { + Series = "Ichinensei ni Nacchattara", Volumes = "1", + Chapters = "1", Filename = "Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Tenjo Tenge (Color)\Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Tenjo Tenge", Volumes = "1", Edition = "Full Contact Edition", + Chapters = "0", Filename = "Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ)\Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Akame ga KILL! ZERO", Volumes = "1", Edition = "", + Chapters = "0", Filename = "Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + filepath = @"E:\Manga\Dorohedoro\Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz"; + expected.Add(filepath, new ParserInfo + { + Series = "Dorohedoro", Volumes = "1", Edition = "", + Chapters = "0", Filename = "Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + + + + + foreach (var file in expected.Keys) + { + var expectedInfo = expected[file]; + var actual = Parse(file, rootPath); + if (expectedInfo == null) + { + Assert.Null(actual); + return; + } + Assert.NotNull(actual); + Assert.Equal(expectedInfo.Format, actual.Format); + Assert.Equal(expectedInfo.Series, actual.Series); + Assert.Equal(expectedInfo.Chapters, actual.Chapters); + Assert.Equal(expectedInfo.Volumes, actual.Volumes); + Assert.Equal(expectedInfo.Edition, actual.Edition); + Assert.Equal(expectedInfo.Filename, actual.Filename); + Assert.Equal(expectedInfo.FullFilePath, actual.FullFilePath); + } + } } } \ No newline at end of file diff --git a/API.Tests/Services/ImageProviderTest.cs b/API.Tests/Services/ImageProviderTest.cs index 915d589c4..5636d39a4 100644 --- a/API.Tests/Services/ImageProviderTest.cs +++ b/API.Tests/Services/ImageProviderTest.cs @@ -1,21 +1,20 @@ using System.IO; -using API.IO; using Xunit; namespace API.Tests.Services { public class ImageProviderTest { - [Theory] - [InlineData("v10.cbz", "v10.expected.jpg")] - [InlineData("v10 - with folder.cbz", "v10 - with folder.expected.jpg")] - [InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.jpg")] - public void GetCoverImageTest(string inputFile, string expectedOutputFile) - { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ImageProvider"); - var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile)); - - Assert.Equal(expectedBytes, ImageProvider.GetCoverImage(Path.Join(testDirectory, inputFile))); - } + // [Theory] + // [InlineData("v10.cbz", "v10.expected.jpg")] + // [InlineData("v10 - with folder.cbz", "v10 - with folder.expected.jpg")] + // [InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.jpg")] + // public void GetCoverImageTest(string inputFile, string expectedOutputFile) + // { + // var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ImageProvider"); + // var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile)); + // // TODO: Implement this with ScannerService + // //Assert.Equal(expectedBytes, ImageProvider.GetCoverImage(Path.Join(testDirectory, inputFile))); + // } } } \ No newline at end of file diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs new file mode 100644 index 000000000..79b487a36 --- /dev/null +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -0,0 +1,20 @@ +using API.Interfaces; +using API.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace API.Tests.Services +{ + public class ScannerServiceTests + { + private readonly ScannerService _scannerService; + private readonly ILogger _logger = Substitute.For>(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + public ScannerServiceTests() + { + _scannerService = new ScannerService(_unitOfWork, _logger); + } + + // TODO: Start adding tests for how scanner works so we can ensure fallbacks, etc work + } +} \ No newline at end of file diff --git a/API/API.csproj b/API/API.csproj index 73575c864..8c3278449 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -22,6 +22,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 78b7f23b7..1406ce1e7 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -1,11 +1,14 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using API.Constants; using API.DTOs; using API.Entities; +using API.Extensions; using API.Interfaces; using AutoMapper; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -36,6 +39,21 @@ namespace API.Controllers _mapper = mapper; } + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("reset-password")] + public async Task UpdatePassword(ResetPasswordDto resetPasswordDto) + { + _logger.LogInformation($"{User.GetUsername()} is changing {resetPasswordDto.UserName}'s password."); + var user = await _userManager.Users.SingleAsync(x => x.UserName == resetPasswordDto.UserName); + var result = await _userManager.RemovePasswordAsync(user); + if (!result.Succeeded) return BadRequest("Unable to update password"); + + result = await _userManager.AddPasswordAsync(user, resetPasswordDto.Password); + if (!result.Succeeded) return BadRequest("Unable to update password"); + + return Ok($"{resetPasswordDto.UserName}'s Password has been reset."); + } + [HttpPost("register")] public async Task> Register(RegisterDto registerDto) { @@ -59,15 +77,14 @@ namespace API.Controllers if (registerDto.IsAdmin) { _logger.LogInformation($"{user.UserName} is being registered as admin. Granting access to all libraries."); - var libraries = await _unitOfWork.LibraryRepository.GetLibrariesAsync(); + var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); foreach (var lib in libraries) { lib.AppUsers ??= new List(); lib.AppUsers.Add(user); } + if (libraries.Any() && !await _unitOfWork.Complete()) _logger.LogInformation("There was an issue granting library access. Please do this manually."); } - - if (!await _unitOfWork.Complete()) _logger.LogInformation("There was an issue granting library access. Please do this manually."); return new UserDto { @@ -80,7 +97,11 @@ namespace API.Controllers public async Task> Login(LoginDto loginDto) { var user = await _userManager.Users - .SingleOrDefaultAsync(x => x.UserName == loginDto.Username.ToLower()); + .SingleOrDefaultAsync(x => x.NormalizedUserName == loginDto.Username.ToUpper()); + + var debugUsers = await _userManager.Users.Select(x => x.NormalizedUserName).ToListAsync(); + + _logger.LogInformation($"All Users: {String.Join(",", debugUsers)}"); if (user == null) return Unauthorized("Invalid username"); diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 67990db2e..ed193a4d0 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -144,6 +144,14 @@ namespace API.Controllers [Authorize(Policy = "RequireAdminRole")] [HttpPost("scan")] public ActionResult Scan(int libraryId) + { + _taskScheduler.ScanLibrary(libraryId, false); + return Ok(); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("refresh-metadata")] + public ActionResult RefreshMetadata(int libraryId) { _taskScheduler.ScanLibrary(libraryId, true); return Ok(); diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 445a28107..fcd945c7c 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -60,6 +60,14 @@ namespace API.Controllers var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); return Ok(await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(volumeId, user.Id)); } + + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("scan")] + public ActionResult Scan(int libraryId, int seriesId) + { + _taskScheduler.ScanSeries(libraryId, seriesId); + return Ok(); + } [HttpPost("update-rating")] public async Task UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto) diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs new file mode 100644 index 000000000..910b10f89 --- /dev/null +++ b/API/Controllers/SettingsController.cs @@ -0,0 +1,66 @@ +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.Entities; +using API.Extensions; +using API.Interfaces; +using API.Services; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Controllers +{ + [Authorize] + public class SettingsController : BaseApiController + { + private readonly DataContext _dataContext; + private readonly ILogger _logger; + private readonly IMapper _mapper; + private readonly ITaskScheduler _taskScheduler; + + public SettingsController(DataContext dataContext, ILogger logger, IMapper mapper, ITaskScheduler taskScheduler) + { + _dataContext = dataContext; + _logger = logger; + _mapper = mapper; + _taskScheduler = taskScheduler; + } + + [HttpGet("")] + public async Task> GetSettings() + { + var settings = await _dataContext.ServerSetting.Select(x => x).ToListAsync(); + return _mapper.Map(settings); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("")] + 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"); + } + + if (!Directory.Exists(updateSettingsDto.CacheDirectory)) + { + 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"); + } + } +} \ No newline at end of file diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs index 61fd26f96..5ff816522 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -7,7 +7,7 @@ namespace API.DTOs [Required] public string Username { get; set; } [Required] - [StringLength(8, MinimumLength = 4)] + [StringLength(16, MinimumLength = 4)] public string Password { get; set; } public bool IsAdmin { get; set; } } diff --git a/API/DTOs/ResetPasswordDto.cs b/API/DTOs/ResetPasswordDto.cs new file mode 100644 index 000000000..535d0df2f --- /dev/null +++ b/API/DTOs/ResetPasswordDto.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs +{ + public class ResetPasswordDto + { + [Required] + public string UserName { get; init; } + [Required] + [StringLength(16, MinimumLength = 4)] + public string Password { get; init; } + } +} \ No newline at end of file diff --git a/API/DTOs/ServerSettingDTO.cs b/API/DTOs/ServerSettingDTO.cs new file mode 100644 index 000000000..455859305 --- /dev/null +++ b/API/DTOs/ServerSettingDTO.cs @@ -0,0 +1,9 @@ +namespace API.DTOs +{ + public class ServerSettingDto + { + public string CacheDirectory { get; set; } + // public string Kind { get; init; } + // public string Value { get; init; } + } +} \ No newline at end of file diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index a8567849a..f89340f82 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -24,11 +24,16 @@ namespace API.Data public DbSet AppUser { get; set; } public DbSet AppUserProgresses { get; set; } public DbSet AppUserRating { get; set; } + public DbSet ServerSetting { get; set; } protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); + // builder.Entity() + // .HasAlternateKey(s => s.Key) + // .HasName("AlternateKey_Key"); + builder.Entity() .HasMany(ur => ur.UserRoles) .WithOne(u => u.User) diff --git a/API/Data/LibraryRepository.cs b/API/Data/LibraryRepository.cs index a85435df2..436736439 100644 --- a/API/Data/LibraryRepository.cs +++ b/API/Data/LibraryRepository.cs @@ -53,15 +53,6 @@ namespace API.Data .ToListAsync(); } - public async Task GetLibraryForNameAsync(string libraryName) - { - return await _context.Library - .Where(x => x.Name == libraryName) - .Include(f => f.Folders) - .Include(s => s.Series) - .SingleAsync(); - } - public async Task DeleteLibrary(int libraryId) { var library = await GetLibraryForIdAsync(libraryId); diff --git a/API/Data/Migrations/20210121180051_AddedServerSettings.Designer.cs b/API/Data/Migrations/20210121180051_AddedServerSettings.Designer.cs new file mode 100644 index 000000000..23894ae47 --- /dev/null +++ b/API/Data/Migrations/20210121180051_AddedServerSettings.Designer.cs @@ -0,0 +1,626 @@ +// +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("20210121180051_AddedServerSettings")] + partial class AddedServerSettings + { + 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("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.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + 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("Chapter") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("NumberOfPages") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + 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("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.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.Volume", "Volume") + .WithMany("Files") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + 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.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("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210121180051_AddedServerSettings.cs b/API/Data/Migrations/20210121180051_AddedServerSettings.cs new file mode 100644 index 000000000..98fb77452 --- /dev/null +++ b/API/Data/Migrations/20210121180051_AddedServerSettings.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class AddedServerSettings : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ServerSetting", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Kind = table.Column(type: "INTEGER", nullable: false), + Value = table.Column(type: "TEXT", maxLength: 65535, nullable: false), + RowVersion = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ServerSetting", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ServerSetting"); + } + } +} diff --git a/API/Data/Migrations/20210121215532_ServerSettingsAdjustment.Designer.cs b/API/Data/Migrations/20210121215532_ServerSettingsAdjustment.Designer.cs new file mode 100644 index 000000000..8072786e9 --- /dev/null +++ b/API/Data/Migrations/20210121215532_ServerSettingsAdjustment.Designer.cs @@ -0,0 +1,621 @@ +// +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("20210121215532_ServerSettingsAdjustment")] + partial class ServerSettingsAdjustment + { + 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("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.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + 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("Chapter") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("NumberOfPages") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CacheDirectory") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + 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("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.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.Volume", "Volume") + .WithMany("Files") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + 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.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("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210121215532_ServerSettingsAdjustment.cs b/API/Data/Migrations/20210121215532_ServerSettingsAdjustment.cs new file mode 100644 index 000000000..6c1f1b268 --- /dev/null +++ b/API/Data/Migrations/20210121215532_ServerSettingsAdjustment.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class ServerSettingsAdjustment : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Kind", + table: "ServerSetting"); + + migrationBuilder.DropColumn( + name: "Value", + table: "ServerSetting"); + + migrationBuilder.AddColumn( + name: "CacheDirectory", + table: "ServerSetting", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CacheDirectory", + table: "ServerSetting"); + + migrationBuilder.AddColumn( + name: "Kind", + table: "ServerSetting", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "Value", + table: "ServerSetting", + type: "TEXT", + maxLength: 65535, + nullable: false, + defaultValue: ""); + } + } +} diff --git a/API/Data/Migrations/20210122165809_ServerSettingsChange.Designer.cs b/API/Data/Migrations/20210122165809_ServerSettingsChange.Designer.cs new file mode 100644 index 000000000..f277eae77 --- /dev/null +++ b/API/Data/Migrations/20210122165809_ServerSettingsChange.Designer.cs @@ -0,0 +1,624 @@ +// +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("20210122165809_ServerSettingsChange")] + partial class ServerSettingsChange + { + 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("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.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + 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("Chapter") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("NumberOfPages") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + 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("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.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.Volume", "Volume") + .WithMany("Files") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + 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.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("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210122165809_ServerSettingsChange.cs b/API/Data/Migrations/20210122165809_ServerSettingsChange.cs new file mode 100644 index 000000000..69df81fa0 --- /dev/null +++ b/API/Data/Migrations/20210122165809_ServerSettingsChange.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class ServerSettingsChange : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "CacheDirectory", + table: "ServerSetting", + newName: "Value"); + + migrationBuilder.AddColumn( + name: "Key", + table: "ServerSetting", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Key", + table: "ServerSetting"); + + migrationBuilder.RenameColumn( + name: "Value", + table: "ServerSetting", + newName: "CacheDirectory"); + } + } +} diff --git a/API/Data/Migrations/20210122172455_ServerSettingsPrimaryKey.Designer.cs b/API/Data/Migrations/20210122172455_ServerSettingsPrimaryKey.Designer.cs new file mode 100644 index 000000000..8bf49d1c5 --- /dev/null +++ b/API/Data/Migrations/20210122172455_ServerSettingsPrimaryKey.Designer.cs @@ -0,0 +1,620 @@ +// +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("20210122172455_ServerSettingsPrimaryKey")] + partial class ServerSettingsPrimaryKey + { + 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("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.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + 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("Chapter") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("NumberOfPages") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + 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("TEXT"); + + 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("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.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.Volume", "Volume") + .WithMany("Files") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + 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.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("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210122172455_ServerSettingsPrimaryKey.cs b/API/Data/Migrations/20210122172455_ServerSettingsPrimaryKey.cs new file mode 100644 index 000000000..795c82683 --- /dev/null +++ b/API/Data/Migrations/20210122172455_ServerSettingsPrimaryKey.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class ServerSettingsPrimaryKey : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_ServerSetting", + table: "ServerSetting"); + + migrationBuilder.DropColumn( + name: "Id", + table: "ServerSetting"); + + migrationBuilder.AlterColumn( + name: "Key", + table: "ServerSetting", + type: "TEXT", + nullable: false, + defaultValue: "", + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AddPrimaryKey( + name: "PK_ServerSetting", + table: "ServerSetting", + column: "Key"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "PK_ServerSetting", + table: "ServerSetting"); + + migrationBuilder.AlterColumn( + name: "Key", + table: "ServerSetting", + type: "TEXT", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT"); + + migrationBuilder.AddColumn( + name: "Id", + table: "ServerSetting", + type: "INTEGER", + nullable: false, + defaultValue: 0) + .Annotation("Sqlite:Autoincrement", true); + + migrationBuilder.AddPrimaryKey( + name: "PK_ServerSetting", + table: "ServerSetting", + column: "Id"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index cbb687e1d..918500434 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -296,6 +296,23 @@ namespace API.Data.Migrations b.ToTable("Series"); }); + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + 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") diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 9729d0a82..9ad42b61c 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -1,7 +1,10 @@ using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Threading.Tasks; using API.Constants; using API.Entities; +using API.Services; using Microsoft.AspNetCore.Identity; namespace API.Data @@ -25,5 +28,23 @@ namespace API.Data } } } + + public static async Task SeedSettings(DataContext context) + { + // NOTE: This needs to check if settings already exists before inserting. + // IList defaultSettings = new List() + // { + // new ServerSetting() {Key = "CacheDirectory", Value = CacheService.CacheDirectory} + // }; + // + // await context.ServerSetting.AddRangeAsync(defaultSettings); + // await context.SaveChangesAsync(); + // await context.ServerSetting.AddAsync(new ServerSetting + // { + // CacheDirectory = CacheService.CacheDirectory + // }); + // + // await context.SaveChangesAsync(); + } } } \ No newline at end of file diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs index a8a5c1f98..77e9b579f 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/SeriesRepository.cs @@ -160,7 +160,15 @@ namespace API.Data { return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId); } - + + public async Task GetSeriesByIdAsync(int seriesId) + { + return await _context.Series + .Include(s => s.Volumes) + .Where(s => s.Id == seriesId) + .SingleOrDefaultAsync(); + } + private async Task AddSeriesModifiers(int userId, List series) { var userProgress = await _context.AppUserProgresses diff --git a/API/Entities/LibraryType.cs b/API/Entities/LibraryType.cs index 6061e3e8f..944279a25 100644 --- a/API/Entities/LibraryType.cs +++ b/API/Entities/LibraryType.cs @@ -9,6 +9,8 @@ namespace API.Entities [Description("Comic")] Comic = 1, [Description("Book")] - Book = 2 + Book = 2, + [Description("Webtoon")] + Webtoon = 3 } } \ No newline at end of file diff --git a/API/Entities/ServerSetting.cs b/API/Entities/ServerSetting.cs new file mode 100644 index 000000000..8bbb3dcf4 --- /dev/null +++ b/API/Entities/ServerSetting.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using API.Entities.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace API.Entities +{ + public class ServerSetting : IHasConcurrencyToken + { + [Key] + public string Key { get; set; } + public string Value { get; set; } + + [ConcurrencyCheck] + public uint RowVersion { get; set; } + public void OnSavingChanges() + { + RowVersion++; + } + } +} \ No newline at end of file diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index fba75e148..318c87340 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -8,6 +8,7 @@ using Hangfire.LiteDB; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace API.Extensions { @@ -21,6 +22,7 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); @@ -29,6 +31,12 @@ namespace API.Extensions options.UseSqlite(config.GetConnectionString("DefaultConnection")); }); + services.AddLogging(loggingBuilder => + { + var loggingSection = config.GetSection("Logging"); + loggingBuilder.AddFile(loggingSection); + }); + services.AddHangfire(configuration => configuration .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() diff --git a/API/Extensions/DirectoryInfoExtensions.cs b/API/Extensions/DirectoryInfoExtensions.cs index 257b7c045..60997b2ca 100644 --- a/API/Extensions/DirectoryInfoExtensions.cs +++ b/API/Extensions/DirectoryInfoExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.IO; namespace API.Extensions @@ -50,7 +51,6 @@ namespace API.Extensions if (file.Directory == null) continue; var newName = $"{file.Directory.Name}_{file.Name}"; var newPath = Path.Join(root.FullName, newName); - Console.WriteLine($"Renaming/Moving file to: {newPath}"); file.MoveTo(newPath); } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 714871813..b874d9949 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -1,6 +1,8 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using API.DTOs; using API.Entities; +using API.Helpers.Converters; using AutoMapper; namespace API.Helpers @@ -24,6 +26,9 @@ namespace API.Helpers .AfterMap((ps, pst, context) => context.Mapper.Map(ps.Libraries, pst.Libraries)); CreateMap(); + + CreateMap, ServerSettingDto>() + .ConvertUsing(); } } } \ No newline at end of file diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs new file mode 100644 index 000000000..78e395ec3 --- /dev/null +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using API.DTOs; +using API.Entities; +using AutoMapper; + +namespace API.Helpers.Converters +{ + public class ServerSettingConverter : ITypeConverter, ServerSettingDto> + { + public ServerSettingDto Convert(IEnumerable source, ServerSettingDto destination, ResolutionContext context) + { + destination = new ServerSettingDto(); + foreach (var row in source) + { + switch (row.Key) + { + case "CacheDirectory": + destination.CacheDirectory = row.Value; + break; + default: + break; + } + } + + return destination; + } + } +} \ No newline at end of file diff --git a/API/IO/ImageProvider.cs b/API/IO/ImageProvider.cs deleted file mode 100644 index ebfe85d76..000000000 --- a/API/IO/ImageProvider.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.IO; -using System.IO.Compression; -using System.Linq; -using API.Extensions; -using NetVips; - -namespace API.IO -{ - public static class ImageProvider - { - /// - /// Generates byte array of cover image. - /// Given a path to a compressed file (zip, rar, cbz, cbr, etc), will ensure the first image is returned unless - /// a folder.extension exists in the root directory of the compressed file. - /// - /// - /// Create a smaller variant of file extracted from archive. Archive images are usually 1MB each. - /// - public static byte[] GetCoverImage(string filepath, bool createThumbnail = false) - { - if (string.IsNullOrEmpty(filepath) || !File.Exists(filepath) || !Parser.Parser.IsArchive(filepath)) return Array.Empty(); - - using ZipArchive archive = ZipFile.OpenRead(filepath); - if (!archive.HasFiles()) return Array.Empty(); - - - - var folder = archive.Entries.SingleOrDefault(x => Path.GetFileNameWithoutExtension(x.Name).ToLower() == "folder"); - var entry = archive.Entries.Where(x => Path.HasExtension(x.FullName)).OrderBy(x => x.FullName).ToList()[0]; - - if (folder != null) - { - entry = folder; - } - - if (createThumbnail) - { - try - { - using var stream = entry.Open(); - var thumbnail = Image.ThumbnailStream(stream, 320); - Console.WriteLine(thumbnail.ToString()); - return thumbnail.WriteToBuffer(".jpg"); - } - catch (Exception ex) - { - Console.WriteLine("There was a critical error and prevented thumbnail generation."); - Console.WriteLine(ex.Message); - } - } - - return ExtractEntryToImage(entry); - } - - private static byte[] ExtractEntryToImage(ZipArchiveEntry entry) - { - using var stream = entry.Open(); - using var ms = new MemoryStream(); - stream.CopyTo(ms); - var data = ms.ToArray(); - - return data; - } - } -} \ No newline at end of file diff --git a/API/Interfaces/IDirectoryService.cs b/API/Interfaces/IDirectoryService.cs index 93e7c1e7b..5f9958f03 100644 --- a/API/Interfaces/IDirectoryService.cs +++ b/API/Interfaces/IDirectoryService.cs @@ -13,35 +13,6 @@ namespace API.Interfaces /// List of folder names IEnumerable ListDirectory(string rootPath); - /// - /// Given a library id, scans folders for said library. Parses files and generates DB updates. Will overwrite - /// cover images if forceUpdate is true. - /// - /// Library to scan against - /// Force overwriting for cover images - void ScanLibrary(int libraryId, bool forceUpdate); - - void ScanLibraries(); - - /// - /// Returns the path a volume would be extracted to. - /// Deprecated. - /// - /// - /// - string GetExtractPath(int volumeId); - Task ReadImageAsync(string imagePath); - - /// - /// Extracts an archive to a temp cache directory. Returns path to new directory. If temp cache directory already exists, - /// will return that without performing an extraction. Returns empty string if there are any invalidations which would - /// prevent operations to perform correctly (missing archivePath file, empty archive, etc). - /// - /// A valid file to an archive file. - /// Path to extract to - /// - string ExtractArchive(string archivePath, string extractPath); - } } \ No newline at end of file diff --git a/API/Interfaces/ILibraryRepository.cs b/API/Interfaces/ILibraryRepository.cs index 1a0d3f778..d1de28288 100644 --- a/API/Interfaces/ILibraryRepository.cs +++ b/API/Interfaces/ILibraryRepository.cs @@ -14,7 +14,6 @@ namespace API.Interfaces Task GetLibraryForIdAsync(int libraryId); Task> GetLibraryDtosForUsernameAsync(string userName); Task> GetLibrariesAsync(); - Task GetLibraryForNameAsync(string libraryName); Task DeleteLibrary(int libraryId); } } \ No newline at end of file diff --git a/API/Interfaces/IScannerService.cs b/API/Interfaces/IScannerService.cs new file mode 100644 index 000000000..8d4399eb7 --- /dev/null +++ b/API/Interfaces/IScannerService.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using API.DTOs; + +namespace API.Interfaces +{ + public interface IScannerService + { + /// + /// Given a library id, scans folders for said library. Parses files and generates DB updates. Will overwrite + /// cover images if forceUpdate is true. + /// + /// Library to scan against + /// Force overwriting for cover images + void ScanLibrary(int libraryId, bool forceUpdate); + + void ScanLibraries(); + + /// + /// Performs a forced scan of just a series folder. + /// + /// + /// + void ScanSeries(int libraryId, int seriesId); + } +} \ No newline at end of file diff --git a/API/Interfaces/ISeriesRepository.cs b/API/Interfaces/ISeriesRepository.cs index d3be26a60..db758b2e5 100644 --- a/API/Interfaces/ISeriesRepository.cs +++ b/API/Interfaces/ISeriesRepository.cs @@ -23,6 +23,7 @@ namespace API.Interfaces Task> GetVolumesForSeriesAsync(int[] seriesIds); Task DeleteSeriesAsync(int seriesId); Task GetVolumeByIdAsync(int volumeId); - + Task GetSeriesByIdAsync(int seriesId); + } } \ No newline at end of file diff --git a/API/Interfaces/ITaskScheduler.cs b/API/Interfaces/ITaskScheduler.cs index 7f0370a9a..b19dc9291 100644 --- a/API/Interfaces/ITaskScheduler.cs +++ b/API/Interfaces/ITaskScheduler.cs @@ -2,8 +2,8 @@ { public interface ITaskScheduler { - public void ScanLibrary(int libraryId, bool forceUpdate = false); - - public void CleanupVolumes(int[] volumeIds); + void ScanLibrary(int libraryId, bool forceUpdate = false); + void CleanupVolumes(int[] volumeIds); + void ScanSeries(int libraryId, int seriesId); } } \ No newline at end of file diff --git a/API/Middleware/ExceptionMiddleware.cs b/API/Middleware/ExceptionMiddleware.cs index 413fbdb16..5c168cf3d 100644 --- a/API/Middleware/ExceptionMiddleware.cs +++ b/API/Middleware/ExceptionMiddleware.cs @@ -25,7 +25,6 @@ namespace API.Middleware public async Task InvokeAsync(HttpContext context) { - _logger.LogError("The middleware called"); try { await _next(context); // downstream middlewares or http call diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 6f2d4eaf2..111ae0fbd 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -7,67 +7,92 @@ namespace API.Parser { public static class Parser { - public static readonly string MangaFileExtensions = @"\.cbz|\.cbr|\.png|\.jpeg|\.jpg|\.zip|\.rar"; + public static readonly string MangaFileExtensions = @"\.cbz|\.zip"; // |\.rar|\.cbr public static readonly string ImageFileExtensions = @"\.png|\.jpeg|\.jpg|\.gif"; //?: is a non-capturing group in C#, else anything in () will be a group private static readonly Regex[] MangaVolumeRegex = new[] { - // Historys Strongest Disciple Kenichi_v11_c90-98.zip + // Dance in the Vampire Bund v16-17 new Regex( - - @"(?.*)(\b|_)v(?\d+)", + @"(?.*)(\b|_)v(?\d+-?\d+)( |_)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Historys Strongest Disciple Kenichi_v11_c90-98.zip or Dance in the Vampire Bund v16-17 + new Regex( + @"(?.*)(\b|_)v(?\d+-?\d*)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) new Regex( - @"(vol. ?)(?0*[1-9]+)", + @"(vol\.? ?)(?0*[1-9]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled), - // Dance in the Vampire Bund v16-17 + // Tonikaku Cawaii [Volume 11].cbz new Regex( - - @"(?.*)(\b|_)v(?\d+-?\d+)", + @"(volume )(?0?[1-9]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled), + + // Tower Of God S01 014 (CBT) (digital).cbz new Regex( - @"(?:v)(?0*[1-9]+)", + @"(?.*)(\b|_|)(S(?\d+))", RegexOptions.IgnoreCase | RegexOptions.Compiled), }; private static readonly Regex[] MangaSeriesRegex = new[] { + // Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip + new Regex( + @"(?.*)(\b|_)v(?\d+-?\d*)( |_)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), // Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA], Black Bullet - v4 c17 [batoto] new Regex( - @"(?.*)( - )(?:v|vo|c)\d", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Historys Strongest Disciple Kenichi_v11_c90-98.zip, Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) new Regex( - - @"(?.*)(\b|_)v", + @"(?.*) (\b|_|-)v", RegexOptions.IgnoreCase | RegexOptions.Compiled), - - // Black Bullet + //Tonikaku Cawaii [Volume 11], Darling in the FranXX - Volume 01.cbz new Regex( - - @"(?.*)(\b|_)(v|vo|c)", + @"(?.*)(?: _|-|\[|\() ?v", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + //Knights of Sidonia c000 (S2 LE BD Omake - BLAME!) [Habanero Scans] + new Regex( + @"(?.*)(\bc\d+\b)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + //Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip must be before [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip + // due to duplicate version identifiers in file. + new Regex( + @"(?.*)(v|s)\d+(-\d+)?(_| )", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + //[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip + new Regex( + @"(?.*)(v|s)\d+(-\d+)?", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz + new Regex( + @"(?.*) (?\d+) (?:\(\d{4}\)) ", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Kedouin Makoto - Corpse Party Musume, Chapter 19 [Dametrans].zip + new Regex( + @"(?.*)(?:, Chapter )(?\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled), - // Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ) new Regex( - @"(?.*)\(\d", RegexOptions.IgnoreCase | RegexOptions.Compiled), - - // [BAA]_Darker_than_Black_c1 (This is very greedy, make sure it's always last) + + // Black Bullet (This is very loose, keep towards bottom) (?.*)(_)(v|vo|c|volume) new Regex( - @"(?.*)(\b|_)(c)", + @"(?.*)(_)(v|vo|c|volume)( |_)\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled), - // Darker Than Black (This takes anything, we have to account for perfectly named folders) + // Akiiro Bousou Biyori - 01.jpg, Beelzebub_172_RHS.zip, Cynthia the Mission 29.rar new Regex( - @"(?.*)", + @"^(?!Vol)(?.*)( |_)(\d+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // [BAA]_Darker_than_Black_c1 (This is very greedy, make sure it's close to last) + new Regex( + @"(?.*)( |_)(c)\d+", RegexOptions.IgnoreCase | RegexOptions.Compiled), - - }; private static readonly Regex[] ReleaseGroupRegex = new[] @@ -83,7 +108,6 @@ namespace API.Parser private static readonly Regex[] MangaChapterRegex = new[] { new Regex( - @"(c|ch)(\.? ?)(?\d+-?\d*)", RegexOptions.IgnoreCase | RegexOptions.Compiled), // [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip @@ -91,28 +115,118 @@ namespace API.Parser @"v\d+\.(?\d+-?\d*)", RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz + new Regex( + @"(?.*) (?\d+) (?:\(\d{4}\))", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Tower Of God S01 014 (CBT) (digital).cbz + new Regex( + @"(?.*) S(?\d+) (?\d+)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Beelzebub_01_[Noodles].zip + new Regex( + @"^((?!v|vo|vol|Volume).)*( |_)(?\.?\d+)( |_)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Yumekui-Merry_DKThias_Chapter21.zip + new Regex( + @"Chapter(?\d+(-\d+)?)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), }; + private static readonly Regex[] MangaEditionRegex = { + //Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz + new Regex( + @"(?({|\(|\[).* Edition(}|\)|\]))", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + //Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz + new Regex( + @"(\b|_)(?Omnibus)(\b|_)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + }; + + private static readonly Regex[] CleanupRegex = + { + // (), {}, [] + new Regex( + @"(?(\{\}|\[\]|\(\)))", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // (Complete) + new Regex( + @"(?(\{Complete\}|\[Complete\]|\(Complete\)))", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + // Anything in parenthesis + new Regex( + @"\(.*\)", + RegexOptions.IgnoreCase | RegexOptions.Compiled), + }; - public static ParserInfo Parse(string filePath) + /// + /// Parses information out of a file path. Will fallback to using directory name if Series couldn't be parsed + /// from filename. + /// + /// + /// Root folder + /// or null if Series was empty + public static ParserInfo? Parse(string filePath, string rootPath) { - return new ParserInfo() + var fileName = Path.GetFileName(filePath); + var directoryName = (new FileInfo(filePath)).Directory?.Name; + var rootName = (new DirectoryInfo(rootPath)).Name; + + var ret = new ParserInfo() { - Chapters = ParseChapter(filePath), - Series = ParseSeries(filePath), - Volumes = ParseVolume(filePath), - Filename = filePath, - Format = ParseFormat(filePath) + Chapters = ParseChapter(fileName), + Series = ParseSeries(fileName), + Volumes = ParseVolume(fileName), + Filename = fileName, + Format = ParseFormat(filePath), + FullFilePath = filePath }; + + if (ret.Series == string.Empty && directoryName != null && directoryName != rootName) + { + ret.Series = ParseSeries(directoryName); + if (ret.Series == string.Empty) ret.Series = CleanTitle(directoryName); + } + + var edition = ParseEdition(fileName); + if (!string.IsNullOrEmpty(edition)) + { + ret.Series = CleanTitle(ret.Series.Replace(edition, "")); + ret.Edition = edition; + } + + + return ret.Series == string.Empty ? null : ret; } - public static MangaFormat ParseFormat(string filePath) + private static MangaFormat ParseFormat(string filePath) { if (IsArchive(filePath)) return MangaFormat.Archive; if (IsImage(filePath)) return MangaFormat.Image; return MangaFormat.Unknown; } + + public static string ParseEdition(string filePath) + { + foreach (var regex in MangaEditionRegex) + { + var matches = regex.Matches(filePath); + foreach (Match match in matches) + { + if (match.Groups["Edition"].Success && match.Groups["Edition"].Value != string.Empty) + { + var edition = match.Groups["Edition"].Value.Replace("{", "").Replace("}", "") + .Replace("[", "").Replace("]", "").Replace("(", "").Replace(")", ""); + + return edition; + } + } + } + + return string.Empty; + } public static string ParseSeries(string filename) { @@ -121,16 +235,14 @@ namespace API.Parser var matches = regex.Matches(filename); foreach (Match match in matches) { - if (match.Groups["Volume"] != Match.Empty) + if (match.Groups["Series"].Success && match.Groups["Series"].Value != string.Empty) { - return CleanTitle(match.Groups["Series"].Value); + return CleanTitle(match.Groups["Series"].Value); } - } } - - Console.WriteLine("Unable to parse {0}", filename); - return ""; + + return string.Empty; } public static string ParseVolume(string filename) @@ -140,16 +252,19 @@ namespace API.Parser var matches = regex.Matches(filename); foreach (Match match in matches) { - if (match.Groups["Volume"] != Match.Empty) - { - return RemoveLeadingZeroes(match.Groups["Volume"].Value); - } + if (match.Groups["Volume"] == Match.Empty) continue; + var value = match.Groups["Volume"].Value; + if (!value.Contains("-")) return RemoveLeadingZeroes(match.Groups["Volume"].Value); + var tokens = value.Split("-"); + var from = RemoveLeadingZeroes(tokens[0]); + var to = RemoveLeadingZeroes(tokens[1]); + return $"{@from}-{to}"; + } } - - Console.WriteLine("Unable to parse {0}", filename); - return ""; + + return "0"; } public static string ParseChapter(string filename) @@ -163,7 +278,6 @@ namespace API.Parser { var value = match.Groups["Chapter"].Value; - if (value.Contains("-")) { var tokens = value.Split("-"); @@ -180,6 +294,23 @@ namespace API.Parser return "0"; } + + private static string RemoveEditionTagHolders(string title) + { + foreach (var regex in CleanupRegex) + { + var matches = regex.Matches(title); + foreach (Match match in matches) + { + if (match.Success) + { + title = title.Replace(match.Value, ""); + } + } + } + + return title; + } /// /// Translates _ -> spaces, trims front and back of string, removes release groups @@ -187,6 +318,21 @@ namespace API.Parser /// /// public static string CleanTitle(string title) + { + title = RemoveReleaseGroup(title); + + title = RemoveEditionTagHolders(title); + + title = title.Replace("_", " ").Trim(); + if (title.EndsWith("-")) + { + title = title.Substring(0, title.Length - 1); + } + + return title.Trim(); + } + + private static string RemoveReleaseGroup(string title) { foreach (var regex in ReleaseGroupRegex) { @@ -200,8 +346,7 @@ namespace API.Parser } } - title = title.Replace("_", " "); - return title.Trim(); + return title; } @@ -235,7 +380,8 @@ namespace API.Parser public static string RemoveLeadingZeroes(string title) { - return title.TrimStart(new[] { '0' }); + var ret = title.TrimStart(new[] { '0' }); + return ret == string.Empty ? "0" : ret; } public static bool IsArchive(string filePath) diff --git a/API/Parser/ParserInfo.cs b/API/Parser/ParserInfo.cs index ab0c06788..3288063e5 100644 --- a/API/Parser/ParserInfo.cs +++ b/API/Parser/ParserInfo.cs @@ -9,15 +9,21 @@ namespace API.Parser public class ParserInfo { // This can be multiple - public string Chapters { get; set; } - public string Series { get; set; } + public string Chapters { get; set; } = ""; + public string Series { get; set; } = ""; // This can be multiple - public string Volumes { get; set; } - public string Filename { get; init; } - public string FullFilePath { get; set; } + public string Volumes { get; set; } = ""; + public string Filename { get; init; } = ""; + public string FullFilePath { get; set; } = ""; + /// - /// Raw (image), Archive + /// that represents the type of the file (so caching service knows how to cache for reading) /// - public MangaFormat Format { get; set; } + public MangaFormat Format { get; set; } = MangaFormat.Unknown; + + /// + /// This can potentially story things like "Omnibus, Color, Full Contact Edition, Extra, Final, etc" + /// + public string Edition { get; set; } = ""; } } \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index bafc68d4c..21d37e064 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -31,6 +31,7 @@ namespace API // Apply all migrations on startup await context.Database.MigrateAsync(); await Seed.SeedRoles(roleManager); + await Seed.SeedSettings(context); } catch (Exception ex) { @@ -43,6 +44,11 @@ namespace API private static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) + // .ConfigureLogging(logging => + // { + // logging.ClearProviders(); + // logging.AddConsole(); + // }) .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index ae92e3261..85774e128 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.IO.Compression; using System.Linq; using System.Threading.Tasks; using API.Comparators; @@ -16,7 +17,7 @@ namespace API.Services private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly NumericComparer _numericComparer; - private readonly string _cacheDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../cache/")); + public static readonly string CacheDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../cache/")); public CacheService(IDirectoryService directoryService, ILogger logger, IUnitOfWork unitOfWork) { @@ -28,7 +29,8 @@ namespace API.Services private bool CacheDirectoryIsAccessible() { - var di = new DirectoryInfo(_cacheDirectory); + _logger.LogDebug($"Checking if valid Cache directory: {CacheDirectory}"); + var di = new DirectoryInfo(CacheDirectory); return di.Exists; } @@ -43,7 +45,7 @@ namespace API.Services { var extractPath = GetVolumeCachePath(volumeId, file); - _directoryService.ExtractArchive(file.FilePath, extractPath); + ExtractArchive(file.FilePath, extractPath); } return volume; @@ -55,11 +57,11 @@ namespace API.Services if (!CacheDirectoryIsAccessible()) { - _logger.LogError($"Cache directory {_cacheDirectory} is not accessible or does not exist."); + _logger.LogError($"Cache directory {CacheDirectory} is not accessible or does not exist."); return; } - DirectoryInfo di = new DirectoryInfo(_cacheDirectory); + DirectoryInfo di = new DirectoryInfo(CacheDirectory); try { @@ -79,7 +81,7 @@ namespace API.Services foreach (var volume in volumeIds) { - var di = new DirectoryInfo(Path.Join(_cacheDirectory, volume + "")); + var di = new DirectoryInfo(Path.Join(CacheDirectory, volume + "")); if (di.Exists) { di.Delete(true); @@ -88,6 +90,45 @@ namespace API.Services } _logger.LogInformation("Cache directory purged"); } + + /// + /// Extracts an archive to a temp cache directory. Returns path to new directory. If temp cache directory already exists, + /// will return that without performing an extraction. Returns empty string if there are any invalidations which would + /// prevent operations to perform correctly (missing archivePath file, empty archive, etc). + /// + /// A valid file to an archive file. + /// Path to extract to + /// + private string ExtractArchive(string archivePath, string extractPath) + { + // NOTE: This is used by Cache Service + if (!File.Exists(archivePath) || !Parser.Parser.IsArchive(archivePath)) + { + _logger.LogError($"Archive {archivePath} could not be found."); + return ""; + } + + if (Directory.Exists(extractPath)) + { + _logger.LogDebug($"Archive {archivePath} has already been extracted. Returning existing folder."); + return extractPath; + } + + using ZipArchive archive = ZipFile.OpenRead(archivePath); + // TODO: Throw error if we couldn't extract + var needsFlattening = archive.Entries.Count > 0 && !Path.HasExtension(archive.Entries.ElementAt(0).FullName); + if (!archive.HasFiles() && !needsFlattening) return ""; + + archive.ExtractToDirectory(extractPath); + _logger.LogDebug($"Extracting archive to {extractPath}"); + + if (!needsFlattening) return extractPath; + + _logger.LogInformation("Extracted archive is nested in root folder, flattening..."); + new DirectoryInfo(extractPath).Flatten(); + + return extractPath; + } private string GetVolumeCachePath(int volumeId, MangaFile file) diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 9c619408a..d910c5c1b 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -1,38 +1,20 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; -using System.Diagnostics; using System.IO; -using System.IO.Compression; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using API.DTOs; -using API.Entities; -using API.Extensions; using API.Interfaces; -using API.IO; -using API.Parser; -using Microsoft.Extensions.Logging; using NetVips; namespace API.Services { public class DirectoryService : IDirectoryService { - private readonly ILogger _logger; - private readonly IUnitOfWork _unitOfWork; - private ConcurrentDictionary> _scannedSeries; - - public DirectoryService(ILogger logger, IUnitOfWork unitOfWork) - { - _logger = logger; - _unitOfWork = unitOfWork; - } - /// /// Given a set of regex search criteria, get files in the given path. /// @@ -69,302 +51,23 @@ namespace API.Services return dirs; } - - /// - /// Processes files found during a library scan. Generates a collection of series->volume->files for DB processing later. - /// - /// Path of a file - private void Process(string path) - { - var fileName = Path.GetFileName(path); - _logger.LogDebug($"Parsing file {fileName}"); - - var info = Parser.Parser.Parse(fileName); - info.FullFilePath = path; - if (info.Volumes == string.Empty) - { - return; - } - - ConcurrentBag newBag = new ConcurrentBag(); - if (_scannedSeries.TryGetValue(info.Series, out var tempBag)) - { - var existingInfos = tempBag.ToArray(); - foreach (var existingInfo in existingInfos) - { - newBag.Add(existingInfo); - } - } - else - { - tempBag = new ConcurrentBag(); - } - - newBag.Add(info); - - if (!_scannedSeries.TryUpdate(info.Series, newBag, tempBag)) - { - _scannedSeries.TryAdd(info.Series, newBag); - } - } - private Series UpdateSeries(Series series, ParserInfo[] infos, bool forceUpdate) + public async Task ReadImageAsync(string imagePath) { - var volumes = UpdateVolumes(series, infos, forceUpdate); - series.Volumes = volumes; - series.Pages = volumes.Sum(v => v.Pages); - if (series.CoverImage == null || forceUpdate) - { - series.CoverImage = volumes.OrderBy(x => x.Number).FirstOrDefault()?.CoverImage; - } - if (string.IsNullOrEmpty(series.Summary) || forceUpdate) - { - series.Summary = ""; // TODO: Check if comicInfo.xml in file and parse metadata out. - } - + using var image = Image.NewFromFile(imagePath); - return series; - } - - private MangaFile CreateMangaFile(ParserInfo info) - { - _logger.LogDebug($"Creating File Entry for {info.FullFilePath}"); - int.TryParse(info.Chapters, out var chapter); - _logger.LogDebug($"Found Chapter: {chapter}"); - return new MangaFile() + return new ImageDto { - FilePath = info.FullFilePath, - Chapter = chapter, - Format = info.Format, - NumberOfPages = GetNumberOfPagesFromArchive(info.FullFilePath) + Content = await File.ReadAllBytesAsync(imagePath), + Filename = Path.GetFileNameWithoutExtension(imagePath), + FullPath = Path.GetFullPath(imagePath), + Width = image.Width, + Height = image.Height, + Format = image.Format }; } - /// - /// 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(); - - 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); - if (existingFile != null) - { - existingFile.Chapter = Int32.Parse(info.Chapters); - existingFile.Format = info.Format; - existingFile.NumberOfPages = GetNumberOfPagesFromArchive(info.FullFilePath); - } - else - { - existingVolume.Files.Add(CreateMangaFile(info)); - } - - volumes.Add(existingVolume); - } - else - { - 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 = Int32.Parse(info.Volumes), - Files = new List() - { - CreateMangaFile(info) - } - }; - volumes.Add(vol); - } - } - - Console.WriteLine($"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()?.FilePath; - volume.CoverImage = ImageProvider.GetCoverImage(firstFile, true); - } - - volume.Pages = volume.Files.Sum(x => x.NumberOfPages); - } - - return volumes; - } - - public void ScanLibraries() - { - var libraries = Task.Run(() => _unitOfWork.LibraryRepository.GetLibrariesAsync()).Result.ToList(); - foreach (var lib in libraries) - { - ScanLibrary(lib.Id, false); - } - } - - public void ScanLibrary(int libraryId, bool forceUpdate) - { - var sw = Stopwatch.StartNew(); - Library library; - try - { - library = Task.Run(() => _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId)).Result; - } - catch (Exception ex) - { - // This usually only fails if user is not authenticated. - _logger.LogError($"There was an issue fetching Library {libraryId}.", ex); - return; - } - - _scannedSeries = new ConcurrentDictionary>(); - _logger.LogInformation($"Beginning scan on {library.Name}"); - - var totalFiles = 0; - foreach (var folderPath in library.Folders) - { - try { - totalFiles = TraverseTreeParallelForEach(folderPath.Path, (f) => - { - try - { - Process(f); - } - catch (FileNotFoundException exception) - { - _logger.LogError(exception, "The file could not be found"); - } - }); - } - catch (ArgumentException ex) { - _logger.LogError(ex, $"The directory '{folderPath}' does not exist"); - } - } - - var filtered = _scannedSeries.Where(kvp => !kvp.Value.IsEmpty); - var series = filtered.ToImmutableDictionary(v => v.Key, v => v.Value); - - // Perform DB activities - var allSeries = Task.Run(() => _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(libraryId)).Result.ToList(); - foreach (var seriesKey in series.Keys) - { - var mangaSeries = allSeries.SingleOrDefault(s => s.Name == seriesKey) ?? new Series - { - Name = seriesKey, - OriginalName = seriesKey, - SortName = seriesKey, - Summary = "" - }; - mangaSeries = UpdateSeries(mangaSeries, series[seriesKey].ToArray(), forceUpdate); - _logger.LogInformation($"Created/Updated series {mangaSeries.Name} for {library.Name} library"); - library.Series ??= new List(); - library.Series.Add(mangaSeries); - } - - // Remove series that are no longer on disk - foreach (var existingSeries in allSeries) - { - if (!series.ContainsKey(existingSeries.Name) || !series.ContainsKey(existingSeries.OriginalName)) - { - // Delete series, there is no file to backup any longer. - library.Series.Remove(existingSeries); - } - } - - _unitOfWork.LibraryRepository.Update(library); - - if (Task.Run(() => _unitOfWork.Complete()).Result) - { - _logger.LogInformation($"Scan completed on {library.Name}. Parsed {series.Keys.Count()} series."); - } - else - { - _logger.LogError("There was a critical error that resulted in a failed scan. Please rescan."); - } - - _scannedSeries = null; - _logger.LogInformation("Processed {0} files in {1} milliseconds for {2}", totalFiles, sw.ElapsedMilliseconds, library.Name); - } - - public string GetExtractPath(int volumeId) - { - return Path.Join(Directory.GetCurrentDirectory(), $"../cache/{volumeId}/"); - } - - public string ExtractArchive(string archivePath, string extractPath) - { - if (!File.Exists(archivePath) || !Parser.Parser.IsArchive(archivePath)) - { - _logger.LogError($"Archive {archivePath} could not be found."); - return ""; - } - - if (Directory.Exists(extractPath)) - { - _logger.LogDebug($"Archive {archivePath} has already been extracted. Returning existing folder."); - return extractPath; - } - - using ZipArchive archive = ZipFile.OpenRead(archivePath); - // TODO: Throw error if we couldn't extract - var needsFlattening = archive.Entries.Count > 0 && !Path.HasExtension(archive.Entries.ElementAt(0).FullName); - if (!archive.HasFiles() && !needsFlattening) return ""; - - archive.ExtractToDirectory(extractPath); - _logger.LogDebug($"Extracting archive to {extractPath}"); - - if (!needsFlattening) return extractPath; - - _logger.LogInformation("Extracted archive is nested in root folder, flattening..."); - new DirectoryInfo(extractPath).Flatten(); - - return extractPath; - } - - private int GetNumberOfPagesFromArchive(string archivePath) - { - if (!File.Exists(archivePath) || !Parser.Parser.IsArchive(archivePath)) - { - _logger.LogError($"Archive {archivePath} could not be found."); - return 0; - } - - using ZipArchive archive = ZipFile.OpenRead(archivePath); - return archive.Entries.Count(e => Parser.Parser.IsImage(e.FullName)); - } - - - public async Task ReadImageAsync(string imagePath) - { - using var image = Image.NewFromFile(imagePath); - - return new ImageDto - { - Content = await File.ReadAllBytesAsync(imagePath), - Filename = Path.GetFileNameWithoutExtension(imagePath), - FullPath = Path.GetFullPath(imagePath), - Width = image.Width, - Height = image.Height, - Format = image.Format - }; - } + /// /// Recursively scans files and applies an action on them. This uses as many cores the underlying PC has to speed @@ -373,16 +76,16 @@ namespace API.Services /// Directory to scan /// Action to apply on file path /// - private static int TraverseTreeParallelForEach(string root, Action action) + public static int TraverseTreeParallelForEach(string root, Action action) { - //Count of files traversed and timer for diagnostic output - int fileCount = 0; + //Count of files traversed and timer for diagnostic output + var fileCount = 0; // Determine whether to parallelize file processing on each folder based on processor count. - int procCount = Environment.ProcessorCount; + var procCount = Environment.ProcessorCount; // Data structure to hold names of subfolders to be examined for files. - Stack dirs = new Stack(); + var dirs = new Stack(); if (!Directory.Exists(root)) { throw new ArgumentException("The directory doesn't exist"); @@ -390,7 +93,7 @@ namespace API.Services dirs.Push(root); while (dirs.Count > 0) { - string currentDir = dirs.Pop(); + var currentDir = dirs.Pop(); string[] subDirs; string[] files; @@ -409,7 +112,9 @@ namespace API.Services } try { - files = DirectoryService.GetFilesWithCertainExtensions(currentDir, Parser.Parser.MangaFileExtensions) + // TODO: In future, we need to take LibraryType into consideration for what extensions to allow (RAW should allow images) + // or we need to move this filtering to another area (Process) + files = GetFilesWithCertainExtensions(currentDir, Parser.Parser.MangaFileExtensions) .ToArray(); } catch (UnauthorizedAccessException e) { diff --git a/API/Services/ScannerService.cs b/API/Services/ScannerService.cs new file mode 100644 index 000000000..5d1366603 --- /dev/null +++ b/API/Services/ScannerService.cs @@ -0,0 +1,402 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +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 +{ + public class ScannerService : IScannerService + { + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private ConcurrentDictionary> _scannedSeries; + + public ScannerService(IUnitOfWork unitOfWork, ILogger logger) + { + _unitOfWork = unitOfWork; + _logger = logger; + } + + public void ScanLibraries() + { + var libraries = Task.Run(() => _unitOfWork.LibraryRepository.GetLibrariesAsync()).Result.ToList(); + foreach (var lib in libraries) + { + ScanLibrary(lib.Id, false); + } + } + + public void ScanLibrary(int libraryId, bool forceUpdate) + { + + var sw = Stopwatch.StartNew(); + Library library; + try + { + library = Task.Run(() => _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId)).Result; + } + catch (Exception ex) + { + // This usually only fails if user is not authenticated. + _logger.LogError($"There was an issue fetching Library {libraryId}.", ex); + return; + } + + _scannedSeries = new ConcurrentDictionary>(); + _logger.LogInformation($"Beginning scan on {library.Name}. Forcing metadata update: {forceUpdate}"); + + var totalFiles = 0; + foreach (var folderPath in library.Folders) + { + try { + totalFiles += DirectoryService.TraverseTreeParallelForEach(folderPath.Path, (f) => + { + try + { + ProcessFile(f, folderPath.Path); + } + catch (FileNotFoundException exception) + { + _logger.LogError(exception, "The file could not be found"); + } + }); + } + catch (ArgumentException ex) { + _logger.LogError(ex, $"The directory '{folderPath}' does not exist"); + } + } + + var filtered = _scannedSeries.Where(kvp => kvp.Value.Count != 0); + var series = filtered.ToImmutableDictionary(v => v.Key, v => v.Value); + + // Perform DB activities + var allSeries = UpsertSeries(libraryId, forceUpdate, series, library); + + // Remove series that are no longer on disk + RemoveSeriesNotOnDisk(allSeries, series, library); + + _unitOfWork.LibraryRepository.Update(library); + + if (Task.Run(() => _unitOfWork.Complete()).Result) + { + _logger.LogInformation($"Scan completed on {library.Name}. Parsed {series.Keys.Count()} series."); + } + else + { + _logger.LogError("There was a critical error that resulted in a failed scan. Please rescan."); + } + + _scannedSeries = null; + _logger.LogInformation("Processed {0} files in {1} milliseconds for {2}", totalFiles, sw.ElapsedMilliseconds, library.Name); + } + + private List UpsertSeries(int libraryId, bool forceUpdate, ImmutableDictionary> series, Library library) + { + var allSeries = Task.Run(() => _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(libraryId)).Result.ToList(); + foreach (var seriesKey in series.Keys) + { + var mangaSeries = allSeries.SingleOrDefault(s => s.Name == seriesKey) ?? new Series + { + Name = seriesKey, + OriginalName = seriesKey, + SortName = seriesKey, + Summary = "" + }; + try + { + mangaSeries = UpdateSeries(mangaSeries, series[seriesKey].ToArray(), forceUpdate); + _logger.LogInformation($"Created/Updated series {mangaSeries.Name} for {library.Name} library"); + library.Series ??= new List(); + library.Series.Add(mangaSeries); + } + catch (Exception ex) + { + _logger.LogError(ex, $"There was an error during scanning of library. {seriesKey} will be skipped."); + } + } + + return allSeries; + } + + private void RemoveSeriesNotOnDisk(List allSeries, ImmutableDictionary> series, Library library) + { + var count = 0; + foreach (var existingSeries in allSeries) + { + if (!series.ContainsKey(existingSeries.Name) || !series.ContainsKey(existingSeries.OriginalName)) + { + // Delete series, there is no file to backup any longer. + library.Series?.Remove(existingSeries); + count++; + } + } + _logger.LogInformation($"Removed {count} series that are no longer on disk"); + } + + + /// + /// Attempts to either add a new instance of a show mapping to the scannedSeries bag or adds to an existing. + /// + /// + private void TrackSeries(ParserInfo info) + { + if (info.Series == string.Empty) return; + + _scannedSeries.AddOrUpdate(info.Series, new List() {info}, (key, oldValue) => + { + oldValue ??= new List(); + if (!oldValue.Contains(info)) + { + oldValue.Add(info); + } + + return oldValue; + }); + } + + /// + /// Processes files found during a library scan. + /// Populates a collection of for DB updates later. + /// + /// Path of a file + /// + private void ProcessFile(string path, string rootPath) + { + var info = Parser.Parser.Parse(path, rootPath); + + if (info == null) + { + _logger.LogInformation($"Could not parse series from {path}"); + return; + } + + TrackSeries(info); + } + + private Series UpdateSeries(Series series, ParserInfo[] infos, bool forceUpdate) + { + var volumes = UpdateVolumes(series, infos, forceUpdate); + series.Volumes = volumes; + series.Pages = volumes.Sum(v => v.Pages); + if (series.CoverImage == null || forceUpdate) + { + var firstCover = volumes.OrderBy(x => x.Number).FirstOrDefault(x => x.Number != 0); + if (firstCover == null && volumes.Any()) + { + firstCover = volumes.FirstOrDefault(x => x.Number == 0); + } + series.CoverImage = firstCover?.CoverImage; + } + if (string.IsNullOrEmpty(series.Summary) || forceUpdate) + { + series.Summary = ""; + } + + + return series; + } + + private MangaFile CreateMangaFile(ParserInfo info) + { + _logger.LogDebug($"Creating File Entry for {info.FullFilePath}"); + + int.TryParse(info.Chapters, out var chapter); + _logger.LogDebug($"Found Chapter: {chapter}"); + return new MangaFile() + { + FilePath = info.FullFilePath, + Chapter = chapter, + Format = info.Format, + NumberOfPages = info.Format == MangaFormat.Archive ? GetNumberOfPagesFromArchive(info.FullFilePath): 1 + }; + } + + private int MinimumNumberFromRange(string range) + { + var tokens = range.Split("-"); + return Int32.Parse(tokens.Length >= 1 ? tokens[0] : range); + } + + /// + /// 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(); + + 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); + if (existingFile != null) + { + existingFile.Chapter = MinimumNumberFromRange(info.Chapters); + existingFile.Format = info.Format; + existingFile.NumberOfPages = 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 = 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 = GetCoverImage(firstFile.FilePath, true); // ZIPFILE + } + + volume.Pages = volume.Files.Sum(x => x.NumberOfPages); + } + + return volumes; + } + + + + + public void ScanSeries(int libraryId, int seriesId) + { + throw new NotImplementedException(); + } + + private int GetNumberOfPagesFromArchive(string archivePath) + { + if (!File.Exists(archivePath) || !Parser.Parser.IsArchive(archivePath)) + { + _logger.LogError($"Archive {archivePath} could not be found."); + return 0; + } + + _logger.LogDebug($"Getting Page numbers from {archivePath}"); + + using ZipArchive archive = ZipFile.OpenRead(archivePath); // ZIPFILE + return archive.Entries.Count(e => Parser.Parser.IsImage(e.FullName)); + } + + /// + /// Generates byte array of cover image. + /// Given a path to a compressed file (zip, rar, cbz, cbr, etc), will ensure the first image is returned unless + /// a folder.extension exists in the root directory of the compressed file. + /// + /// + /// Create a smaller variant of file extracted from archive. Archive images are usually 1MB each. + /// + public byte[] GetCoverImage(string filepath, bool createThumbnail = false) + { + try + { + if (string.IsNullOrEmpty(filepath) || !File.Exists(filepath) || !Parser.Parser.IsArchive(filepath)) return Array.Empty(); + + _logger.LogDebug($"Extracting Cover image from {filepath}"); + using ZipArchive archive = ZipFile.OpenRead(filepath); + if (!archive.HasFiles()) return Array.Empty(); + + var folder = archive.Entries.SingleOrDefault(x => Path.GetFileNameWithoutExtension(x.Name).ToLower() == "folder"); + var entries = archive.Entries.Where(x => Path.HasExtension(x.FullName) && Parser.Parser.IsImage(x.FullName)).OrderBy(x => x.FullName).ToList(); + ZipArchiveEntry entry; + + if (folder != null) + { + entry = folder; + } else if (!entries.Any()) + { + return Array.Empty(); + } + else + { + entry = entries[0]; + } + + + if (createThumbnail) + { + try + { + using var stream = entry.Open(); + var thumbnail = Image.ThumbnailStream(stream, 320); + return thumbnail.WriteToBuffer(".jpg"); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was a critical error and prevented thumbnail generation."); + } + } + + return ExtractEntryToImage(entry); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when reading archive stream."); + return Array.Empty(); + } + } + + private static byte[] ExtractEntryToImage(ZipArchiveEntry entry) + { + using var stream = entry.Open(); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + var data = ms.ToArray(); + + return data; + } + + } +} \ No newline at end of file diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 490f8b2b3..2ed039b8d 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -8,25 +8,30 @@ namespace API.Services { private readonly ICacheService _cacheService; private readonly ILogger _logger; - private readonly IDirectoryService _directoryService; + private readonly IScannerService _scannerService; public BackgroundJobServer Client => new BackgroundJobServer(); - public TaskScheduler(ICacheService cacheService, ILogger logger, - IDirectoryService directoryService) + public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService) { _cacheService = cacheService; _logger = logger; - _directoryService = directoryService; + _scannerService = scannerService; _logger.LogInformation("Scheduling/Updating cache cleanup on a daily basis."); RecurringJob.AddOrUpdate(() => _cacheService.Cleanup(), Cron.Daily); - RecurringJob.AddOrUpdate(() => directoryService.ScanLibraries(), Cron.Daily); + RecurringJob.AddOrUpdate(() => _scannerService.ScanLibraries(), Cron.Daily); + } + + public void ScanSeries(int libraryId, int seriesId) + { + _logger.LogInformation($"Enqueuing series scan for series: {seriesId}"); + BackgroundJob.Enqueue(() => _scannerService.ScanSeries(libraryId, seriesId)); } public void ScanLibrary(int libraryId, bool forceUpdate = false) { _logger.LogInformation($"Enqueuing library scan for: {libraryId}"); - BackgroundJob.Enqueue(() => _directoryService.ScanLibrary(libraryId, forceUpdate)); + BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, forceUpdate)); } public void CleanupVolumes(int[] volumeIds) @@ -34,5 +39,6 @@ namespace API.Services BackgroundJob.Enqueue(() => _cacheService.CleanupVolumes(volumeIds)); } + } } \ No newline at end of file diff --git a/API/Startup.cs b/API/Startup.cs index aaa2dae49..766b1d01d 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -42,18 +42,16 @@ namespace API { app.UseSwagger(); app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1")); + app.UseHangfireDashboard(); } - - - app.UseHangfireDashboard(); - app.UseHttpsRedirection(); + //app.UseHttpsRedirection(); app.UseRouting(); // Ordering is important. Cors, authentication, authorization - app.UseCors(policy => policy.AllowAnyHeader().AllowAnyMethod().WithOrigins("https://localhost:4200")); - + app.UseCors(policy => policy.AllowAnyHeader().AllowAnyMethod().WithOrigins("http://localhost:4200")); + app.UseAuthentication(); app.UseAuthorization(); diff --git a/API/appsettings.Development.json b/API/appsettings.Development.json index 740eb4c1e..5c67bda6f 100644 --- a/API/appsettings.Development.json +++ b/API/appsettings.Development.json @@ -9,6 +9,12 @@ "Microsoft": "Information", "Microsoft.Hosting.Lifetime": "Information", "Hangfire": "Information" + }, + "File": { + "Path": "kavita.log", + "Append": "True", + "FileSizeLimitBytes": 0, + "MaxRollingFiles": 0 } } }