diff --git a/.gitignore b/.gitignore index a87e29ab0..9999bab3e 100644 --- a/.gitignore +++ b/.gitignore @@ -450,3 +450,4 @@ appsettings.json /API/Hangfire-log.db cache/ /API/wwwroot/ +/API/cache/ \ No newline at end of file diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index b70443941..95af52570 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -25,7 +25,7 @@ - + diff --git a/API.Tests/ChapterSortComparerTest.cs b/API.Tests/ChapterSortComparerTest.cs new file mode 100644 index 000000000..7ab909ec5 --- /dev/null +++ b/API.Tests/ChapterSortComparerTest.cs @@ -0,0 +1,19 @@ +using System.Linq; +using API.Comparators; +using Xunit; + +namespace API.Tests +{ + public class ChapterSortComparerTest + { + [Theory] + [InlineData(new[] {1, 2, 0}, new[] {1, 2, 0})] + [InlineData(new[] {3, 1, 2}, new[] {1, 2, 3})] + [InlineData(new[] {1, 0, 0}, new[] {1, 0, 0})] + public void ChapterSortTest(int[] input, int[] expected) + { + Assert.Equal(expected, input.OrderBy(f => f, new ChapterSortComparer()).ToArray()); + } + + } +} \ No newline at end of file diff --git a/API.Tests/Converters/CronConverterTests.cs b/API.Tests/Converters/CronConverterTests.cs new file mode 100644 index 000000000..df1ca6294 --- /dev/null +++ b/API.Tests/Converters/CronConverterTests.cs @@ -0,0 +1,25 @@ +using API.Helpers.Converters; +using Xunit; +using Xunit.Abstractions; + +namespace API.Tests.Converters +{ + public class CronConverterTests + { + private readonly ITestOutputHelper _testOutputHelper; + + public CronConverterTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Theory] + [InlineData("daily", "0 0 * * *")] + [InlineData("disabled", "0 0 31 2 *")] + [InlineData("weekly", "0 0 * * 1")] + public void ConvertTest(string input, string expected) + { + Assert.Equal(expected, CronConverter.ConvertToCronNotation(input)); + } + } +} \ No newline at end of file diff --git a/API.Tests/ParserTest.cs b/API.Tests/ParserTest.cs index 6cf1a64d1..34b7798a3 100644 --- a/API.Tests/ParserTest.cs +++ b/API.Tests/ParserTest.cs @@ -33,6 +33,9 @@ namespace API.Tests [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")] + [InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "1")] + [InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", "0")] + public void ParseVolumeTest(string filename, string expected) { Assert.Equal(expected, ParseVolume(filename)); @@ -74,6 +77,8 @@ namespace API.Tests [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("Rent a Girlfriend v01.cbr", "Rent a Girlfriend")] + [InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "Yumekui Merry")] //[InlineData("[Tempus Edax Rerum] Epigraph of the Closed Curve - Chapter 6.zip", "Epigraph of the Closed Curve")] public void ParseSeriesTest(string filename, string expected) { @@ -102,6 +107,9 @@ namespace API.Tests [InlineData("Mujaki no Rakuen Vol12 ch76", "76")] [InlineData("Beelzebub_01_[Noodles].zip", "1")] [InlineData("Yumekui-Merry_DKThias_Chapter21.zip", "21")] + [InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "1")] + [InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", "11")] + [InlineData("Beelzebub_53[KSH].zip", "53")] //[InlineData("[Tempus Edax Rerum] Epigraph of the Closed Curve - Chapter 6.zip", "6")] public void ParseChaptersTest(string filename, string expected) { @@ -158,6 +166,16 @@ namespace API.Tests { Assert.Equal(expected, ParseEdition(input)); } + + [Theory] + [InlineData("12-14", 12)] + [InlineData("24", 24)] + [InlineData("18-04", 4)] + public void MinimumNumberFromRangeTest(string input, int expected) + { + Assert.Equal(expected, MinimumNumberFromRange(input)); + } + [Fact] public void ParseInfoTest() diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs new file mode 100644 index 000000000..86f186b95 --- /dev/null +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -0,0 +1,73 @@ +using System.IO; +using System.IO.Compression; +using API.Interfaces; +using API.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services +{ + public class ArchiveServiceTests + { + private readonly IArchiveService _archiveService; + private readonly ILogger _logger = Substitute.For>(); + + public ArchiveServiceTests() + { + _archiveService = new ArchiveService(_logger); + } + + [Theory] + [InlineData("flat file.zip", false)] + [InlineData("file in folder in folder.zip", true)] + [InlineData("file in folder.zip", true)] + [InlineData("file in folder_alt.zip", true)] + public void ArchiveNeedsFlatteningTest(string archivePath, bool expected) + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); + var file = Path.Join(testDirectory, archivePath); + using ZipArchive archive = ZipFile.OpenRead(file); + Assert.Equal(expected, _archiveService.ArchiveNeedsFlattening(archive)); + } + + [Theory] + [InlineData("non existent file.zip", false)] + [InlineData("wrong extension.rar", false)] + [InlineData("empty.zip", false)] + [InlineData("flat file.zip", true)] + [InlineData("file in folder in folder.zip", true)] + [InlineData("file in folder.zip", true)] + [InlineData("file in folder_alt.zip", true)] + public void IsValidArchiveTest(string archivePath, bool expected) + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); + Assert.Equal(expected, _archiveService.IsValidArchive(Path.Join(testDirectory, archivePath))); + } + + [Theory] + [InlineData("non existent file.zip", 0)] + [InlineData("wrong extension.rar", 0)] + [InlineData("empty.zip", 0)] + [InlineData("flat file.zip", 1)] + [InlineData("file in folder in folder.zip", 1)] + [InlineData("file in folder.zip", 1)] + [InlineData("file in folder_alt.zip", 1)] + public void GetNumberOfPagesFromArchiveTest(string archivePath, int expected) + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); + Assert.Equal(expected, _archiveService.GetNumberOfPagesFromArchive(Path.Join(testDirectory, archivePath))); + } + + [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/ArchiveService/CoverImages"); + var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile)); + Assert.Equal(expectedBytes, _archiveService.GetCoverImage(Path.Join(testDirectory, inputFile))); + } + } +} \ No newline at end of file diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs new file mode 100644 index 000000000..80ee0438e --- /dev/null +++ b/API.Tests/Services/CacheServiceTests.cs @@ -0,0 +1,98 @@ +using API.Interfaces; +using API.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace API.Tests.Services +{ + public class CacheServiceTests + { + // private readonly CacheService _cacheService; + // private readonly ILogger _logger = Substitute.For>(); + // private readonly IUnitOfWork _unitOfWork = Substitute.For(); + // private readonly IArchiveService _archiveService = Substitute.For(); + // private readonly IDirectoryService _directoryService = Substitute.For(); + + public CacheServiceTests() + { + //_cacheService = new CacheService(_logger, _unitOfWork, _archiveService, _directoryService); + } + + //string GetCachedPagePath(Volume volume, int page) + [Fact] + //[InlineData("", 0, "")] + public void GetCachedPagePathTest_Should() + { + // TODO: Figure out how to test this + // string archivePath = "flat file.zip"; + // int pageNum = 0; + // string expected = "cache/1/pexels-photo-6551949.jpg"; + // + // var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); + // var file = Path.Join(testDirectory, archivePath); + // var volume = new Volume + // { + // Id = 1, + // Files = new List() + // { + // new() + // { + // Id = 1, + // Chapter = 0, + // FilePath = archivePath, + // Format = MangaFormat.Archive, + // NumberOfPages = 1, + // } + // }, + // Name = "1", + // Number = 1 + // }; + // + // var cacheService = Substitute.ForPartsOf(); + // cacheService.Configure().CacheDirectoryIsAccessible().Returns(true); + // cacheService.Configure().GetVolumeCachePath(1, volume.Files.ElementAt(0)).Returns("cache/1/"); + // _directoryService.Configure().GetFiles("cache/1/").Returns(new string[] {"pexels-photo-6551949.jpg"}); + // Assert.Equal(expected, _cacheService.GetCachedPagePath(volume, pageNum)); + Assert.True(true); + } + + [Fact] + public void GetOrderedChaptersTest() + { + // var files = new List() + // { + // new() + // { + // Number = "1" + // }, + // new() + // { + // Chapter = 2 + // }, + // new() + // { + // Chapter = 0 + // }, + // }; + // var expected = new List() + // { + // new() + // { + // Chapter = 1 + // }, + // new() + // { + // Chapter = 2 + // }, + // new() + // { + // Chapter = 0 + // }, + // }; + // Assert.NotStrictEqual(expected, _cacheService.GetOrderedChapters(files)); + } + + + } +} \ No newline at end of file diff --git a/API.Tests/Services/ImageProviderTest.cs b/API.Tests/Services/ImageProviderTest.cs deleted file mode 100644 index 5636d39a4..000000000 --- a/API.Tests/Services/ImageProviderTest.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.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)); - // // 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 deleted file mode 100644 index 79b487a36..000000000 --- a/API.Tests/Services/ScannerServiceTests.cs +++ /dev/null @@ -1,20 +0,0 @@ -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.Tests/Services/StringLogicalComparerTest.cs b/API.Tests/Services/StringLogicalComparerTest.cs index 25c5d3b2f..3ffa0f8a6 100644 --- a/API.Tests/Services/StringLogicalComparerTest.cs +++ b/API.Tests/Services/StringLogicalComparerTest.cs @@ -11,7 +11,6 @@ namespace API.Tests.Services new[] {"x1.jpg", "x10.jpg", "x3.jpg", "x4.jpg", "x11.jpg"}, new[] {"x1.jpg", "x3.jpg", "x4.jpg", "x10.jpg", "x11.jpg"} )] - public void TestLogicalComparer(string[] input, string[] expected) { NumericComparer nc = new NumericComparer(); diff --git a/API.Tests/Services/Test Data/ArchiveService/Archives/LICENSE.md b/API.Tests/Services/Test Data/ArchiveService/Archives/LICENSE.md new file mode 100644 index 000000000..580f5f351 --- /dev/null +++ b/API.Tests/Services/Test Data/ArchiveService/Archives/LICENSE.md @@ -0,0 +1,2 @@ +Files in this test are all royalty free and can be found here: +https://www.pexels.com/photo/snow-wood-light-art-6551949/ \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ArchiveService/Archives/empty.zip b/API.Tests/Services/Test Data/ArchiveService/Archives/empty.zip new file mode 100644 index 000000000..15cb0ecb3 Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/Archives/empty.zip differ diff --git a/API.Tests/Services/Test Data/ArchiveService/Archives/file in folder in folder.zip b/API.Tests/Services/Test Data/ArchiveService/Archives/file in folder in folder.zip new file mode 100644 index 000000000..7598e0fa3 Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/Archives/file in folder in folder.zip differ diff --git a/API.Tests/Services/Test Data/ArchiveService/Archives/file in folder.zip b/API.Tests/Services/Test Data/ArchiveService/Archives/file in folder.zip new file mode 100644 index 000000000..b13a312b8 Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/Archives/file in folder.zip differ diff --git a/API.Tests/Services/Test Data/ArchiveService/Archives/file in folder_alt.zip b/API.Tests/Services/Test Data/ArchiveService/Archives/file in folder_alt.zip new file mode 100644 index 000000000..6607659b8 Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/Archives/file in folder_alt.zip differ diff --git a/API.Tests/Services/Test Data/ArchiveService/Archives/flat file.zip b/API.Tests/Services/Test Data/ArchiveService/Archives/flat file.zip new file mode 100644 index 000000000..344d49d64 Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/Archives/flat file.zip differ diff --git a/API.Tests/Services/Test Data/ImageProvider/thumbnail.expected.jpg b/API.Tests/Services/Test Data/ArchiveService/CoverImages/thumbnail.expected.jpg similarity index 100% rename from API.Tests/Services/Test Data/ImageProvider/thumbnail.expected.jpg rename to API.Tests/Services/Test Data/ArchiveService/CoverImages/thumbnail.expected.jpg diff --git a/API.Tests/Services/Test Data/ImageProvider/thumbnail.jpg b/API.Tests/Services/Test Data/ArchiveService/CoverImages/thumbnail.jpg similarity index 100% rename from API.Tests/Services/Test Data/ImageProvider/thumbnail.jpg rename to API.Tests/Services/Test Data/ArchiveService/CoverImages/thumbnail.jpg diff --git a/API.Tests/Services/Test Data/ImageProvider/v10 - nested folder.cbz b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - nested folder.cbz similarity index 100% rename from API.Tests/Services/Test Data/ImageProvider/v10 - nested folder.cbz rename to API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - nested folder.cbz diff --git a/API.Tests/Services/Test Data/ImageProvider/v10 - nested folder.expected.jpg b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - nested folder.expected.jpg similarity index 100% rename from API.Tests/Services/Test Data/ImageProvider/v10 - nested folder.expected.jpg rename to API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - nested folder.expected.jpg diff --git a/API.Tests/Services/Test Data/ImageProvider/v10 - with folder.cbz b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - with folder.cbz similarity index 100% rename from API.Tests/Services/Test Data/ImageProvider/v10 - with folder.cbz rename to API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - with folder.cbz diff --git a/API.Tests/Services/Test Data/ImageProvider/v10 - with folder.expected.jpg b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - with folder.expected.jpg similarity index 100% rename from API.Tests/Services/Test Data/ImageProvider/v10 - with folder.expected.jpg rename to API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - with folder.expected.jpg diff --git a/API.Tests/Services/Test Data/ImageProvider/v10.cbz b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10.cbz similarity index 100% rename from API.Tests/Services/Test Data/ImageProvider/v10.cbz rename to API.Tests/Services/Test Data/ArchiveService/CoverImages/v10.cbz diff --git a/API.Tests/Services/Test Data/ImageProvider/v10.expected.jpg b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10.expected.jpg similarity index 100% rename from API.Tests/Services/Test Data/ImageProvider/v10.expected.jpg rename to API.Tests/Services/Test Data/ArchiveService/CoverImages/v10.expected.jpg diff --git a/API/Comparators/ChapterSortComparer.cs b/API/Comparators/ChapterSortComparer.cs new file mode 100644 index 000000000..725622bec --- /dev/null +++ b/API/Comparators/ChapterSortComparer.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace API.Comparators +{ + public class ChapterSortComparer : IComparer + { + public int Compare(int x, int y) + { + if (x == 0 && y == 0) return 0; + // if x is 0, it comes second + if (x == 0) return 1; + // if y is 0, it comes second + if (y == 0) return -1; + + return x.CompareTo(y); + } + } +} \ No newline at end of file diff --git a/API/Comparators/StringLogicalComparer.cs b/API/Comparators/StringLogicalComparer.cs index f6a8c1249..fe930c45c 100644 --- a/API/Comparators/StringLogicalComparer.cs +++ b/API/Comparators/StringLogicalComparer.cs @@ -20,16 +20,16 @@ namespace API.Comparators if (string.IsNullOrEmpty(s2)) return -1; //WE style, special case - bool sp1 = Char.IsLetterOrDigit(s1, 0); - bool sp2 = Char.IsLetterOrDigit(s2, 0); + var sp1 = Char.IsLetterOrDigit(s1, 0); + var sp2 = Char.IsLetterOrDigit(s2, 0); if(sp1 && !sp2) return 1; if(!sp1 && sp2) return -1; int i1 = 0, i2 = 0; //current index while(true) { - bool c1 = Char.IsDigit(s1, i1); - bool c2 = Char.IsDigit(s2, i2); + var c1 = Char.IsDigit(s1, i1); + var c2 = Char.IsDigit(s2, i2); int r; // temp result if(!c1 && !c2) { diff --git a/API/Controllers/AdminController.cs b/API/Controllers/AdminController.cs index 4aba6b7bd..3002947a2 100644 --- a/API/Controllers/AdminController.cs +++ b/API/Controllers/AdminController.cs @@ -20,9 +20,5 @@ namespace API.Controllers var users = await _userManager.GetUsersInRoleAsync("Admin"); return users.Count > 0; } - - - - } } \ No newline at end of file diff --git a/API/Controllers/FallbackController.cs b/API/Controllers/FallbackController.cs index 74ff82999..36b173745 100644 --- a/API/Controllers/FallbackController.cs +++ b/API/Controllers/FallbackController.cs @@ -1,5 +1,4 @@ using System.IO; -using API.Services; using Microsoft.AspNetCore.Mvc; namespace API.Controllers diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index ed193a4d0..3ecd6bf8a 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -145,7 +145,7 @@ namespace API.Controllers [HttpPost("scan")] public ActionResult Scan(int libraryId) { - _taskScheduler.ScanLibrary(libraryId, false); + _taskScheduler.ScanLibrary(libraryId); return Ok(); } @@ -177,13 +177,13 @@ namespace API.Controllers var username = User.GetUsername(); _logger.LogInformation($"Library {libraryId} is being deleted by {username}."); var series = await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(libraryId); - var volumes = (await _unitOfWork.SeriesRepository.GetVolumesForSeriesAsync(series.Select(x => x.Id).ToArray())) - .Select(x => x.Id).ToArray(); + var chapterIds = + await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(series.Select(x => x.Id).ToArray()); var result = await _unitOfWork.LibraryRepository.DeleteLibrary(libraryId); - if (result && volumes.Any()) + if (result && chapterIds.Any()) { - _taskScheduler.CleanupVolumes(volumes); + _taskScheduler.CleanupChapters(chapterIds); } return Ok(result); diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index d2fed05f7..7c535aa57 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -28,24 +28,28 @@ namespace API.Controllers } [HttpGet("image")] - public async Task> GetImage(int volumeId, int page) + public async Task> GetImage(int chapterId, int page) { // Temp let's iterate the directory each call to get next image - var volume = await _cacheService.Ensure(volumeId); + var chapter = await _cacheService.Ensure(chapterId); - var path = _cacheService.GetCachedPagePath(volume, page); + if (chapter == null) return BadRequest("There was an issue finding image file for reading."); + + var (path, mangaFile) = await _cacheService.GetCachedPagePath(chapter, page); + if (string.IsNullOrEmpty(path)) return BadRequest($"No such image for page {page}"); var file = await _directoryService.ReadImageAsync(path); file.Page = page; + file.MangaFileName = mangaFile.FilePath; return Ok(file); } [HttpGet("get-bookmark")] - public async Task> GetBookmark(int volumeId) + public async Task> GetBookmark(int chapterId) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user.Progresses == null) return Ok(0); - var progress = user.Progresses.SingleOrDefault(x => x.AppUserId == user.Id && x.VolumeId == volumeId); + var progress = user.Progresses.SingleOrDefault(x => x.AppUserId == user.Id && x.ChapterId == chapterId); if (progress != null) return Ok(progress.PagesRead); @@ -56,10 +60,12 @@ namespace API.Controllers public async Task Bookmark(BookmarkDto bookmarkDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - _logger.LogInformation($"Saving {user.UserName} progress for {bookmarkDto.VolumeId} to page {bookmarkDto.PageNum}"); + _logger.LogInformation($"Saving {user.UserName} progress for Chapter {bookmarkDto.ChapterId} to page {bookmarkDto.PageNum}"); + + // TODO: Don't let user bookmark past total pages. user.Progresses ??= new List(); - var userProgress = user.Progresses.SingleOrDefault(x => x.VolumeId == bookmarkDto.VolumeId && x.AppUserId == user.Id); + var userProgress = user.Progresses.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id); if (userProgress == null) { @@ -69,13 +75,14 @@ namespace API.Controllers PagesRead = bookmarkDto.PageNum, VolumeId = bookmarkDto.VolumeId, SeriesId = bookmarkDto.SeriesId, + ChapterId = bookmarkDto.ChapterId }); } else { userProgress.PagesRead = bookmarkDto.PageNum; userProgress.SeriesId = bookmarkDto.SeriesId; - + userProgress.VolumeId = bookmarkDto.VolumeId; } _unitOfWork.UserRepository.Update(user); @@ -84,8 +91,7 @@ namespace API.Controllers { return Ok(); } - - + return BadRequest("Could not save progress"); } } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index fcd945c7c..78a16f015 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using API.DTOs; using API.Entities; @@ -36,17 +35,22 @@ namespace API.Controllers public async Task> DeleteSeries(int seriesId) { var username = User.GetUsername(); - var volumes = (await _unitOfWork.SeriesRepository.GetVolumesForSeriesAsync(new []{seriesId})).Select(x => x.Id).ToArray(); + var chapterIds = (await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new []{seriesId})); _logger.LogInformation($"Series {seriesId} is being deleted by {username}."); var result = await _unitOfWork.SeriesRepository.DeleteSeriesAsync(seriesId); if (result) { - _taskScheduler.CleanupVolumes(volumes); + _taskScheduler.CleanupChapters(chapterIds); } return Ok(result); } + /// + /// Returns All volumes for a series with progress information and Chapters + /// + /// + /// [HttpGet("volumes")] public async Task>> GetVolumes(int seriesId) { @@ -61,6 +65,12 @@ namespace API.Controllers return Ok(await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(volumeId, user.Id)); } + [HttpGet("chapter")] + public async Task> GetChapter(int chapterId) + { + return Ok(await _unitOfWork.VolumeRepository.GetChapterDtoAsync(chapterId)); + } + [Authorize(Policy = "RequireAdminRole")] [HttpPost("scan")] public ActionResult Scan(int libraryId, int seriesId) diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index 910b10f89..eecece06c 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -1,17 +1,13 @@ -using System.IO; -using System.Linq; +using System.Collections.Generic; +using System.IO; using System.Threading.Tasks; -using API.Data; using API.DTOs; using API.Entities; using API.Extensions; +using API.Helpers.Converters; using API.Interfaces; -using 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 @@ -19,32 +15,27 @@ namespace API.Controllers [Authorize] public class SettingsController : BaseApiController { - private readonly DataContext _dataContext; private readonly ILogger _logger; - private readonly IMapper _mapper; - private readonly ITaskScheduler _taskScheduler; + private readonly IUnitOfWork _unitOfWork; - public SettingsController(DataContext dataContext, ILogger logger, IMapper mapper, ITaskScheduler taskScheduler) + public SettingsController(ILogger logger, IUnitOfWork unitOfWork) { - _dataContext = dataContext; _logger = logger; - _mapper = mapper; - _taskScheduler = taskScheduler; + _unitOfWork = unitOfWork; } [HttpGet("")] public async Task> GetSettings() { - var settings = await _dataContext.ServerSetting.Select(x => x).ToListAsync(); - return _mapper.Map(settings); + return Ok(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()); } - + [Authorize(Policy = "RequireAdminRole")] [HttpPost("")] - public async Task UpdateSettings(ServerSettingDto updateSettingsDto) + public async Task> UpdateSettings(ServerSettingDto updateSettingsDto) { _logger.LogInformation($"{User.GetUsername()} is updating Server Settings"); - + if (updateSettingsDto.CacheDirectory.Equals(string.Empty)) { return BadRequest("Cache Directory cannot be empty"); @@ -54,13 +45,39 @@ namespace API.Controllers { return BadRequest("Directory does not exist or is not accessible."); } - // TODO: Figure out how to handle a change. This means that on clean, we need to clean up old cache - // directory and new one, but what if someone is reading? - // I can just clean both always, /cache/ is an owned folder, so users shouldn't use it. - - - //_dataContext.ServerSetting.Update - return BadRequest("Not Implemented"); + + // We do not allow CacheDirectory changes, so we will ignore. + var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); + + foreach (var setting in currentSettings) + { + if (setting.Key == ServerSettingKey.TaskBackup && updateSettingsDto.TaskBackup != setting.Value) + { + setting.Value = updateSettingsDto.TaskBackup; + _unitOfWork.SettingsRepository.Update(setting); + } + + if (setting.Key == ServerSettingKey.TaskScan && updateSettingsDto.TaskScan != setting.Value) + { + setting.Value = updateSettingsDto.TaskScan; + _unitOfWork.SettingsRepository.Update(setting); + } + } + + if (_unitOfWork.HasChanges() && await _unitOfWork.Complete()) + { + _logger.LogInformation("Server Settings updated."); + return Ok(updateSettingsDto); + } + + return BadRequest("There was a critical issue. Please try again."); + } + + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("task-frequencies")] + public ActionResult> GetTaskFrequencies() + { + return Ok(CronConverter.Options); } } } \ No newline at end of file diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs new file mode 100644 index 000000000..ee58e6c18 --- /dev/null +++ b/API/DTOs/ChapterDto.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace API.DTOs +{ + public class ChapterDto + { + public int Id { get; set; } + /// + /// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2". + /// + public string Range { get; set; } + /// + /// Smallest number of the Range. + /// + public string Number { get; set; } + public byte[] CoverImage { get; set; } + /// + /// Total number of pages in all MangaFiles + /// + public int Pages { get; set; } + /// + /// The files that represent this Chapter + /// + public ICollection Files { get; set; } + /// + /// Calculated at API time. Number of pages read for this Chapter for logged in user. + /// + public int PagesRead { get; set; } + public int VolumeId { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/ImageDto.cs b/API/DTOs/ImageDto.cs index 473f2c110..18ffe7178 100644 --- a/API/DTOs/ImageDto.cs +++ b/API/DTOs/ImageDto.cs @@ -9,5 +9,7 @@ public int Height { get; init; } public string Format { get; init; } public byte[] Content { get; init; } + public int Chapter { get; set; } + public string MangaFileName { get; set; } } } \ No newline at end of file diff --git a/API/DTOs/MangaFileDto.cs b/API/DTOs/MangaFileDto.cs new file mode 100644 index 000000000..8cf706ea8 --- /dev/null +++ b/API/DTOs/MangaFileDto.cs @@ -0,0 +1,12 @@ +using API.Entities; + +namespace API.DTOs +{ + public class MangaFileDto + { + public string FilePath { get; set; } + public int NumberOfPages { get; set; } + public MangaFormat Format { get; set; } + + } +} \ No newline at end of file diff --git a/API/DTOs/ServerSettingDTO.cs b/API/DTOs/ServerSettingDTO.cs index 455859305..e16d16506 100644 --- a/API/DTOs/ServerSettingDTO.cs +++ b/API/DTOs/ServerSettingDTO.cs @@ -3,7 +3,8 @@ public class ServerSettingDto { public string CacheDirectory { get; set; } - // public string Kind { get; init; } - // public string Value { get; init; } + public string TaskScan { get; set; } + public string LoggingLevel { get; set; } + public string TaskBackup { get; set; } } } \ No newline at end of file diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index a57465857..39872c05a 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -1,4 +1,6 @@  +using System.Collections.Generic; + namespace API.DTOs { public class VolumeDto @@ -9,5 +11,6 @@ namespace API.DTOs public byte[] CoverImage { get; set; } public int Pages { get; set; } public int PagesRead { get; set; } + public ICollection Chapters { get; set; } } } \ No newline at end of file diff --git a/API/Data/BookmarkDto.cs b/API/Data/BookmarkDto.cs index ea6654165..de7f1b6a7 100644 --- a/API/Data/BookmarkDto.cs +++ b/API/Data/BookmarkDto.cs @@ -3,6 +3,7 @@ public class BookmarkDto { public int VolumeId { get; init; } + public int ChapterId { get; init; } public int PageNum { get; init; } public int SeriesId { get; init; } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index f89340f82..6aea9a959 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -20,8 +20,11 @@ namespace API.Data } public DbSet Library { get; set; } public DbSet Series { get; set; } + + public DbSet Chapter { get; set; } public DbSet Volume { get; set; } public DbSet AppUser { get; set; } + public DbSet MangaFile { get; set; } public DbSet AppUserProgresses { get; set; } public DbSet AppUserRating { get; set; } public DbSet ServerSetting { get; set; } @@ -30,10 +33,6 @@ namespace API.Data { 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/Migrations/20210128143348_SeriesVolumeChapterChange.Designer.cs b/API/Data/Migrations/20210128143348_SeriesVolumeChapterChange.Designer.cs new file mode 100644 index 000000000..17cb4b81d --- /dev/null +++ b/API/Data/Migrations/20210128143348_SeriesVolumeChapterChange.Designer.cs @@ -0,0 +1,688 @@ +// +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("20210128143348_SeriesVolumeChapterChange")] + partial class SeriesVolumeChapterChange + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Chapter") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .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("ChapterId"); + + 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("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("Files") + .HasForeignKey("ChapterId"); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .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.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210128143348_SeriesVolumeChapterChange.cs b/API/Data/Migrations/20210128143348_SeriesVolumeChapterChange.cs new file mode 100644 index 000000000..ae6e6b6d1 --- /dev/null +++ b/API/Data/Migrations/20210128143348_SeriesVolumeChapterChange.cs @@ -0,0 +1,111 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class SeriesVolumeChapterChange : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsSpecial", + table: "Volume", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ChapterId", + table: "MangaFile", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastScanned", + table: "FolderPath", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "ChapterId", + table: "AppUserProgresses", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "Chapter", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Range = table.Column(type: "TEXT", nullable: true), + Number = table.Column(type: "TEXT", nullable: true), + Created = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false), + CoverImage = table.Column(type: "BLOB", nullable: true), + Pages = table.Column(type: "INTEGER", nullable: false), + VolumeId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Chapter", x => x.Id); + table.ForeignKey( + name: "FK_Chapter_Volume_VolumeId", + column: x => x.VolumeId, + principalTable: "Volume", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_MangaFile_ChapterId", + table: "MangaFile", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_Chapter_VolumeId", + table: "Chapter", + column: "VolumeId"); + + migrationBuilder.AddForeignKey( + name: "FK_MangaFile_Chapter_ChapterId", + table: "MangaFile", + column: "ChapterId", + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_MangaFile_Chapter_ChapterId", + table: "MangaFile"); + + migrationBuilder.DropTable( + name: "Chapter"); + + migrationBuilder.DropIndex( + name: "IX_MangaFile_ChapterId", + table: "MangaFile"); + + migrationBuilder.DropColumn( + name: "IsSpecial", + table: "Volume"); + + migrationBuilder.DropColumn( + name: "ChapterId", + table: "MangaFile"); + + migrationBuilder.DropColumn( + name: "LastScanned", + table: "FolderPath"); + + migrationBuilder.DropColumn( + name: "ChapterId", + table: "AppUserProgresses"); + } + } +} diff --git a/API/Data/Migrations/20210128201832_MangaFileChapterRelationship.Designer.cs b/API/Data/Migrations/20210128201832_MangaFileChapterRelationship.Designer.cs new file mode 100644 index 000000000..5d0cfa7b5 --- /dev/null +++ b/API/Data/Migrations/20210128201832_MangaFileChapterRelationship.Designer.cs @@ -0,0 +1,676 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210128201832_MangaFileChapterRelationship")] + partial class MangaFileChapterRelationship + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("NumberOfPages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("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("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210128201832_MangaFileChapterRelationship.cs b/API/Data/Migrations/20210128201832_MangaFileChapterRelationship.cs new file mode 100644 index 000000000..a04e77dd2 --- /dev/null +++ b/API/Data/Migrations/20210128201832_MangaFileChapterRelationship.cs @@ -0,0 +1,98 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class MangaFileChapterRelationship : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_MangaFile_Chapter_ChapterId", + table: "MangaFile"); + + migrationBuilder.DropForeignKey( + name: "FK_MangaFile_Volume_VolumeId", + table: "MangaFile"); + + migrationBuilder.DropIndex( + name: "IX_MangaFile_VolumeId", + table: "MangaFile"); + + migrationBuilder.DropColumn( + name: "Chapter", + table: "MangaFile"); + + migrationBuilder.DropColumn( + name: "VolumeId", + table: "MangaFile"); + + migrationBuilder.AlterColumn( + name: "ChapterId", + table: "MangaFile", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_MangaFile_Chapter_ChapterId", + table: "MangaFile", + column: "ChapterId", + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_MangaFile_Chapter_ChapterId", + table: "MangaFile"); + + migrationBuilder.AlterColumn( + name: "ChapterId", + table: "MangaFile", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AddColumn( + name: "Chapter", + table: "MangaFile", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "VolumeId", + table: "MangaFile", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_MangaFile_VolumeId", + table: "MangaFile", + column: "VolumeId"); + + migrationBuilder.AddForeignKey( + name: "FK_MangaFile_Chapter_ChapterId", + table: "MangaFile", + column: "ChapterId", + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_MangaFile_Volume_VolumeId", + table: "MangaFile", + column: "VolumeId", + principalTable: "Volume", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/API/Data/Migrations/20210203164258_ServerSettingsKey.Designer.cs b/API/Data/Migrations/20210203164258_ServerSettingsKey.Designer.cs new file mode 100644 index 000000000..75d0a2244 --- /dev/null +++ b/API/Data/Migrations/20210203164258_ServerSettingsKey.Designer.cs @@ -0,0 +1,676 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210203164258_ServerSettingsKey")] + partial class ServerSettingsKey + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("NumberOfPages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210203164258_ServerSettingsKey.cs b/API/Data/Migrations/20210203164258_ServerSettingsKey.cs new file mode 100644 index 000000000..0a2a64920 --- /dev/null +++ b/API/Data/Migrations/20210203164258_ServerSettingsKey.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class ServerSettingsKey : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Key", + table: "ServerSetting", + type: "INTEGER", + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Key", + table: "ServerSetting", + type: "TEXT", + nullable: false, + oldClrType: typeof(int), + oldType: "INTEGER"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 918500434..fcbfaa084 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -127,6 +127,9 @@ namespace API.Data.Migrations b.Property("AppUserId") .HasColumnType("INTEGER"); + b.Property("ChapterId") + .HasColumnType("INTEGER"); + b.Property("PagesRead") .HasColumnType("INTEGER"); @@ -183,12 +186,49 @@ namespace API.Data.Migrations b.ToTable("AspNetUserRoles"); }); + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + modelBuilder.Entity("API.Entities.FolderPath", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("LastScanned") + .HasColumnType("TEXT"); + b.Property("LibraryId") .HasColumnType("INTEGER"); @@ -234,7 +274,7 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("Chapter") + b.Property("ChapterId") .HasColumnType("INTEGER"); b.Property("FilePath") @@ -246,12 +286,9 @@ namespace API.Data.Migrations b.Property("NumberOfPages") .HasColumnType("INTEGER"); - b.Property("VolumeId") - .HasColumnType("INTEGER"); - b.HasKey("Id"); - b.HasIndex("VolumeId"); + b.HasIndex("ChapterId"); b.ToTable("MangaFile"); }); @@ -298,8 +335,8 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.ServerSetting", b => { - b.Property("Key") - .HasColumnType("TEXT"); + b.Property("Key") + .HasColumnType("INTEGER"); b.Property("RowVersion") .IsConcurrencyToken() @@ -325,6 +362,9 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + b.Property("LastModified") .HasColumnType("TEXT"); @@ -487,6 +527,17 @@ namespace API.Data.Migrations b.Navigation("User"); }); + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + modelBuilder.Entity("API.Entities.FolderPath", b => { b.HasOne("API.Entities.Library", "Library") @@ -500,13 +551,13 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.MangaFile", b => { - b.HasOne("API.Entities.Volume", "Volume") + b.HasOne("API.Entities.Chapter", "Chapter") .WithMany("Files") - .HasForeignKey("VolumeId") + .HasForeignKey("ChapterId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("Volume"); + b.Navigation("Chapter"); }); modelBuilder.Entity("API.Entities.Series", b => @@ -596,6 +647,11 @@ namespace API.Data.Migrations b.Navigation("UserRoles"); }); + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + modelBuilder.Entity("API.Entities.Library", b => { b.Navigation("Folders"); @@ -610,7 +666,7 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.Volume", b => { - b.Navigation("Files"); + b.Navigation("Chapters"); }); #pragma warning restore 612, 618 } diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 9ad42b61c..c7a216215 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading.Tasks; using API.Constants; @@ -31,20 +30,24 @@ 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(); + context.Database.EnsureCreated(); + + IList defaultSettings = new List() + { + new() {Key = ServerSettingKey.CacheDirectory, Value = CacheService.CacheDirectory}, + new () {Key = ServerSettingKey.TaskScan, Value = "daily"} + }; + + foreach (var defaultSetting in defaultSettings) + { + var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key); + if (existing == null) + { + context.ServerSetting.Add(defaultSetting); + } + } + + await context.SaveChangesAsync(); } } } \ No newline at end of file diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs index 77e9b579f..e682648a6 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/SeriesRepository.cs @@ -82,6 +82,7 @@ namespace API.Data { var volumes = await _context.Volume .Where(vol => vol.SeriesId == seriesId) + .Include(vol => vol.Chapters) .OrderBy(volume => volume.Number) .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking() @@ -98,7 +99,8 @@ namespace API.Data { return _context.Volume .Where(vol => vol.SeriesId == seriesId) - .Include(vol => vol.Files) + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Files) .OrderBy(vol => vol.Number) .ToList(); } @@ -118,7 +120,8 @@ namespace API.Data public async Task GetVolumeAsync(int volumeId) { return await _context.Volume - .Include(vol => vol.Files) + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Files) .SingleOrDefaultAsync(vol => vol.Id == volumeId); } @@ -126,10 +129,11 @@ namespace API.Data { var volume = await _context.Volume .Where(vol => vol.Id == volumeId) - .Include(vol => vol.Files) + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Files) .ProjectTo(_mapper.ConfigurationProvider) .SingleAsync(vol => vol.Id == volumeId); - + var volumeList = new List() {volume}; await AddVolumeModifiers(userId, volumeList); @@ -169,6 +173,30 @@ namespace API.Data .SingleOrDefaultAsync(); } + public async Task GetChapterIdsForSeriesAsync(int[] seriesIds) + { + var series = await _context.Series + .Where(s => seriesIds.Contains(s.Id)) + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ToListAsync(); + + // TODO: refactor this + IList chapterIds = new List(); + foreach (var s in series) + { + foreach (var v in s.Volumes) + { + foreach (var c in v.Chapters) + { + chapterIds.Add(c.Id); + } + } + } + + return chapterIds.ToArray(); + } + private async Task AddSeriesModifiers(int userId, List series) { var userProgress = await _context.AppUserProgresses @@ -197,6 +225,11 @@ namespace API.Data foreach (var v in volumes) { + foreach (var c in v.Chapters) + { + c.PagesRead = userProgress.Where(p => p.ChapterId == c.Id).Sum(p => p.PagesRead); + } + v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead); } } diff --git a/API/Data/SettingsRepository.cs b/API/Data/SettingsRepository.cs new file mode 100644 index 000000000..21e33deb2 --- /dev/null +++ b/API/Data/SettingsRepository.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; +using API.Interfaces; +using AutoMapper; +using Microsoft.EntityFrameworkCore; + +namespace API.Data +{ + public class SettingsRepository : ISettingsRepository + { + private readonly DataContext _context; + private readonly IMapper _mapper; + + public SettingsRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Update(ServerSetting settings) + { + _context.Entry(settings).State = EntityState.Modified; + } + + public async Task GetSettingsDtoAsync() + { + var settings = await _context.ServerSetting + .Select(x => x) + .AsNoTracking() + .ToListAsync(); + return _mapper.Map(settings); + } + + public Task GetSettingAsync(ServerSettingKey key) + { + return _context.ServerSetting.SingleOrDefaultAsync(x => x.Key == key); + } + + public async Task> GetSettingsAsync() + { + return await _context.ServerSetting.ToListAsync(); + } + } +} \ No newline at end of file diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 25d0002c7..6cffc1392 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -22,6 +22,10 @@ namespace API.Data public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper); public IUserRepository UserRepository => new UserRepository(_context, _userManager); public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper); + + public IVolumeRepository VolumeRepository => new VolumeRepository(_context, _mapper); + + public ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper); public async Task Complete() { diff --git a/API/Data/VolumeRepository.cs b/API/Data/VolumeRepository.cs new file mode 100644 index 000000000..ce8dd0eea --- /dev/null +++ b/API/Data/VolumeRepository.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; +using API.Interfaces; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data +{ + public class VolumeRepository : IVolumeRepository + { + private readonly DataContext _context; + private readonly IMapper _mapper; + + public VolumeRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Update(Volume volume) + { + _context.Entry(volume).State = EntityState.Modified; + } + + /// + /// Returns a Chapter for an Id. Includes linked s. + /// + /// + /// + public async Task GetChapterAsync(int chapterId) + { + return await _context.Chapter + .Include(c => c.Files) + .AsNoTracking() + .SingleOrDefaultAsync(c => c.Id == chapterId); + } + + public async Task GetChapterDtoAsync(int chapterId) + { + var chapter = await _context.Chapter + .Include(c => c.Files) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking() + .SingleOrDefaultAsync(c => c.Id == chapterId); + + return chapter; + } + + public async Task> GetFilesForChapter(int chapterId) + { + return await _context.MangaFile + .Where(c => chapterId == c.Id) + .AsNoTracking() + .ToListAsync(); + } + } +} \ No newline at end of file diff --git a/API/Entities/AppUserProgress.cs b/API/Entities/AppUserProgress.cs index 0f05f4dee..be3953246 100644 --- a/API/Entities/AppUserProgress.cs +++ b/API/Entities/AppUserProgress.cs @@ -2,7 +2,7 @@ namespace API.Entities { /// - /// Represents the progress a single user has on a given Volume. + /// Represents the progress a single user has on a given Volume. Progress is realistically tracked against the Volume's chapters. /// public class AppUserProgress { @@ -11,6 +11,8 @@ namespace API.Entities public int VolumeId { get; set; } public int SeriesId { get; set; } + public int ChapterId { get; set; } + // Relationships public AppUser AppUser { get; set; } public int AppUserId { get; set; } diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs new file mode 100644 index 000000000..b4d957fe8 --- /dev/null +++ b/API/Entities/Chapter.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using API.Entities.Interfaces; + +namespace API.Entities +{ + public class Chapter : IEntityDate + { + public int Id { get; set; } + /// + /// Range of numbers. Chapter 2-4 -> "2-4". Chapter 2 -> "2". + /// + public string Range { get; set; } + /// + /// Smallest number of the Range. Can be a partial like Chapter 4.5 + /// + public string Number { get; set; } + /// + /// The files that represent this Chapter + /// + public ICollection Files { get; set; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } + public byte[] CoverImage { get; set; } + /// + /// Total number of pages in all MangaFiles + /// + public int Pages { get; set; } + + // Relationships + public Volume Volume { get; set; } + public int VolumeId { get; set; } + + } +} \ No newline at end of file diff --git a/API/Entities/FolderPath.cs b/API/Entities/FolderPath.cs index 84d3ea798..dab3d86cd 100644 --- a/API/Entities/FolderPath.cs +++ b/API/Entities/FolderPath.cs @@ -1,10 +1,18 @@  +using System; + namespace API.Entities { public class FolderPath { public int Id { get; set; } public string Path { get; set; } + /// + /// Used when scanning to see if we can skip if nothing has changed. + /// + public DateTime LastScanned { get; set; } + + // Relationship public Library Library { get; set; } public int LibraryId { get; set; } } diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index 4c0c675de..a1e4ff81d 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -9,18 +9,14 @@ namespace API.Entities /// public string FilePath { get; set; } /// - /// Used to track if multiple MangaFiles (archives) represent a single Volume. If only one volume file, this will be 0. - /// - public int Chapter { get; set; } - /// /// Number of pages for the given file /// public int NumberOfPages { get; set; } public MangaFormat Format { get; set; } // Relationship Mapping - public Volume Volume { get; set; } - public int VolumeId { get; set; } + public Chapter Chapter { get; set; } + public int ChapterId { get; set; } } } \ No newline at end of file diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index 8fe8e6628..f7e5f366e 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -12,14 +12,14 @@ namespace API.Entities /// public string Name { get; set; } /// - /// Original Japanese Name - /// - public string OriginalName { get; set; } - /// /// The name used to sort the Series. By default, will be the same as Name. /// public string SortName { get; set; } /// + /// Original Name on disk. Not exposed to UI. + /// + public string OriginalName { get; set; } + /// /// Summary information related to the Series /// public string Summary { get; set; } @@ -27,10 +27,10 @@ namespace API.Entities public DateTime LastModified { get; set; } public byte[] CoverImage { get; set; } /// - /// Sum of all Volume pages + /// Sum of all Volume page counts /// public int Pages { get; set; } - + // Relationships public ICollection Volumes { get; set; } public Library Library { get; set; } diff --git a/API/Entities/ServerSetting.cs b/API/Entities/ServerSetting.cs index 8bbb3dcf4..148286cdf 100644 --- a/API/Entities/ServerSetting.cs +++ b/API/Entities/ServerSetting.cs @@ -1,13 +1,12 @@ 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 ServerSettingKey Key { get; set; } public string Value { get; set; } [ConcurrencyCheck] diff --git a/API/Entities/ServerSettingKey.cs b/API/Entities/ServerSettingKey.cs new file mode 100644 index 000000000..19b2f9f9c --- /dev/null +++ b/API/Entities/ServerSettingKey.cs @@ -0,0 +1,10 @@ +namespace API.Entities +{ + public enum ServerSettingKey + { + TaskScan = 0, + CacheDirectory = 1, + TaskBackup = 2, + LoggingLevel = 3 + } +} \ No newline at end of file diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index 304c2bfae..0b8077aae 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -9,11 +9,16 @@ namespace API.Entities public int Id { get; set; } public string Name { get; set; } public int Number { get; set; } - public ICollection Files { get; set; } + public ICollection Chapters { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } public byte[] CoverImage { get; set; } public int Pages { get; set; } + + /// + /// Represents a Side story that is linked to the original Series. Omake, One Shot, etc. + /// + public bool IsSpecial { get; set; } = false; diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 318c87340..8cda03754 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -23,6 +23,7 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); diff --git a/API/Extensions/DirectoryInfoExtensions.cs b/API/Extensions/DirectoryInfoExtensions.cs index 60997b2ca..e7f65dc51 100644 --- a/API/Extensions/DirectoryInfoExtensions.cs +++ b/API/Extensions/DirectoryInfoExtensions.cs @@ -1,6 +1,4 @@ -using System; -using System.Diagnostics; -using System.IO; +using System.IO; namespace API.Extensions { @@ -51,8 +49,8 @@ namespace API.Extensions if (file.Directory == null) continue; var newName = $"{file.Directory.Name}_{file.Name}"; var newPath = Path.Join(root.FullName, newName); - file.MoveTo(newPath); - + if (!File.Exists(newPath)) file.MoveTo(newPath); + } } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index b874d9949..744fc08bb 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -15,6 +15,10 @@ namespace API.Helpers CreateMap(); + CreateMap(); + + CreateMap(); + CreateMap(); CreateMap() diff --git a/API/Helpers/Converters/CronConverter.cs b/API/Helpers/Converters/CronConverter.cs new file mode 100644 index 000000000..6fece1bdb --- /dev/null +++ b/API/Helpers/Converters/CronConverter.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using Hangfire; + +namespace API.Helpers.Converters +{ + public static class CronConverter + { + public static readonly IEnumerable Options = new [] + { + "disabled", + "daily", + "weekly", + }; + public static string ConvertToCronNotation(string source) + { + string destination = ""; + destination = source.ToLower() switch + { + "daily" => Cron.Daily(), + "weekly" => Cron.Weekly(), + "disabled" => Cron.Never(), + "" => Cron.Never(), + _ => destination + }; + + return destination; + } + + public static string ConvertFromCronNotation(string cronNotation) + { + string destination = ""; + destination = cronNotation.ToLower() switch + { + "0 0 31 2 *" => "disabled", + _ => destination + }; + + return destination; + } + } +} \ No newline at end of file diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 78e395ec3..1795a9ba0 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -9,16 +9,24 @@ namespace API.Helpers.Converters { public ServerSettingDto Convert(IEnumerable source, ServerSettingDto destination, ResolutionContext context) { - destination = new ServerSettingDto(); + destination ??= new ServerSettingDto(); foreach (var row in source) { switch (row.Key) { - case "CacheDirectory": + case ServerSettingKey.CacheDirectory: destination.CacheDirectory = row.Value; break; - default: + case ServerSettingKey.TaskScan: + destination.TaskScan = row.Value; break; + case ServerSettingKey.LoggingLevel: + destination.LoggingLevel = row.Value; + break; + case ServerSettingKey.TaskBackup: + destination.TaskBackup = row.Value; + break; + } } diff --git a/API/Interfaces/IArchiveService.cs b/API/Interfaces/IArchiveService.cs new file mode 100644 index 000000000..3b3ab14cd --- /dev/null +++ b/API/Interfaces/IArchiveService.cs @@ -0,0 +1,13 @@ +using System.IO.Compression; + +namespace API.Interfaces +{ + public interface IArchiveService + { + bool ArchiveNeedsFlattening(ZipArchive archive); + void ExtractArchive(string archivePath, string extractPath); + int GetNumberOfPagesFromArchive(string archivePath); + byte[] GetCoverImage(string filepath, bool createThumbnail = false); + bool IsValidArchive(string archivePath); + } +} \ No newline at end of file diff --git a/API/Interfaces/ICacheService.cs b/API/Interfaces/ICacheService.cs index 81a4ef5fa..470099859 100644 --- a/API/Interfaces/ICacheService.cs +++ b/API/Interfaces/ICacheService.cs @@ -6,12 +6,12 @@ namespace API.Interfaces public interface ICacheService { /// - /// Ensures the cache is created for the given volume and if not, will create it. Should be called before any other + /// Ensures the cache is created for the given chapter and if not, will create it. Should be called before any other /// cache operations (except cleanup). /// - /// - /// Volume for the passed volumeId. Side-effect from ensuring cache. - Task Ensure(int volumeId); + /// + /// Chapter for the passed chapterId. Side-effect from ensuring cache. + Task Ensure(int chapterId); /// /// Clears cache directory of all folders and files. @@ -21,16 +21,18 @@ namespace API.Interfaces /// /// Clears cache directory of all volumes. This can be invoked from deleting a library or a series. /// - /// Volumes that belong to that library. Assume the library might have been deleted before this invocation. - void CleanupVolumes(int[] volumeIds); + /// Volumes that belong to that library. Assume the library might have been deleted before this invocation. + void CleanupChapters(int[] chapterIds); /// /// Returns the absolute path of a cached page. /// - /// + /// Chapter entity with Files populated. /// Page number to look for /// - string GetCachedPagePath(Volume volume, int page); + Task<(string path, MangaFile file)> GetCachedPagePath(Chapter chapter, int page); + + void EnsureCacheDirectory(); } } \ No newline at end of file diff --git a/API/Interfaces/IDirectoryService.cs b/API/Interfaces/IDirectoryService.cs index 5f9958f03..539a4c0f6 100644 --- a/API/Interfaces/IDirectoryService.cs +++ b/API/Interfaces/IDirectoryService.cs @@ -14,5 +14,12 @@ namespace API.Interfaces IEnumerable ListDirectory(string rootPath); Task ReadImageAsync(string imagePath); + /// + /// Gets files in a directory. If searchPatternExpression is passed, will match the regex against for filtering. + /// + /// + /// + /// + string[] GetFiles(string path, string searchPatternExpression = ""); } } \ No newline at end of file diff --git a/API/Interfaces/IScannerService.cs b/API/Interfaces/IScannerService.cs index 8d4399eb7..87274e88b 100644 --- a/API/Interfaces/IScannerService.cs +++ b/API/Interfaces/IScannerService.cs @@ -1,7 +1,4 @@ -using System.Threading.Tasks; -using API.DTOs; - -namespace API.Interfaces +namespace API.Interfaces { public interface IScannerService { diff --git a/API/Interfaces/ISeriesRepository.cs b/API/Interfaces/ISeriesRepository.cs index db758b2e5..6b11ecb8f 100644 --- a/API/Interfaces/ISeriesRepository.cs +++ b/API/Interfaces/ISeriesRepository.cs @@ -16,14 +16,12 @@ namespace API.Interfaces Task> GetVolumesDtoAsync(int seriesId, int userId); IEnumerable GetVolumes(int seriesId); Task GetSeriesDtoByIdAsync(int seriesId, int userId); - Task GetVolumeAsync(int volumeId); Task GetVolumeDtoAsync(int volumeId, int userId); - Task> GetVolumesForSeriesAsync(int[] seriesIds); Task DeleteSeriesAsync(int seriesId); Task GetVolumeByIdAsync(int volumeId); Task GetSeriesByIdAsync(int seriesId); - + Task GetChapterIdsForSeriesAsync(int[] seriesIds); } } \ No newline at end of file diff --git a/API/Interfaces/ISettingsRepository.cs b/API/Interfaces/ISettingsRepository.cs new file mode 100644 index 000000000..fd9657678 --- /dev/null +++ b/API/Interfaces/ISettingsRepository.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; + +namespace API.Interfaces +{ + public interface ISettingsRepository + { + void Update(ServerSetting settings); + Task GetSettingsDtoAsync(); + Task GetSettingAsync(ServerSettingKey key); + Task> GetSettingsAsync(); + + } +} \ No newline at end of file diff --git a/API/Interfaces/ITaskScheduler.cs b/API/Interfaces/ITaskScheduler.cs index b19dc9291..f7e13d7a6 100644 --- a/API/Interfaces/ITaskScheduler.cs +++ b/API/Interfaces/ITaskScheduler.cs @@ -3,7 +3,7 @@ public interface ITaskScheduler { void ScanLibrary(int libraryId, bool forceUpdate = false); - void CleanupVolumes(int[] volumeIds); + void CleanupChapters(int[] chapterIds); void ScanSeries(int libraryId, int seriesId); } } \ No newline at end of file diff --git a/API/Interfaces/IUnitOfWork.cs b/API/Interfaces/IUnitOfWork.cs index 3b1cf4347..24a074e29 100644 --- a/API/Interfaces/IUnitOfWork.cs +++ b/API/Interfaces/IUnitOfWork.cs @@ -7,6 +7,8 @@ namespace API.Interfaces ISeriesRepository SeriesRepository { get; } IUserRepository UserRepository { get; } ILibraryRepository LibraryRepository { get; } + IVolumeRepository VolumeRepository { get; } + ISettingsRepository SettingsRepository { get; } Task Complete(); bool HasChanges(); } diff --git a/API/Interfaces/IVolumeRepository.cs b/API/Interfaces/IVolumeRepository.cs new file mode 100644 index 000000000..0bc28253b --- /dev/null +++ b/API/Interfaces/IVolumeRepository.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; + +namespace API.Interfaces +{ + public interface IVolumeRepository + { + void Update(Volume volume); + Task GetChapterAsync(int chapterId); + Task GetChapterDtoAsync(int chapterId); + Task> GetFilesForChapter(int chapterId); + } +} \ No newline at end of file diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 111ae0fbd..dc59d3b5c 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -1,5 +1,7 @@ using System; using System.IO; +using System.Linq; +using System.Reflection; using System.Text.RegularExpressions; using API.Entities; @@ -9,6 +11,8 @@ namespace API.Parser { public static readonly string MangaFileExtensions = @"\.cbz|\.zip"; // |\.rar|\.cbr public static readonly string ImageFileExtensions = @"\.png|\.jpeg|\.jpg|\.gif"; + private static readonly Regex ImageRegex = new Regex(ImageFileExtensions, RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex MangaFileRegex = new Regex(MangaFileExtensions, RegexOptions.IgnoreCase | RegexOptions.Compiled); //?: is a non-capturing group in C#, else anything in () will be a group private static readonly Regex[] MangaVolumeRegex = new[] @@ -125,7 +129,7 @@ namespace API.Parser RegexOptions.IgnoreCase | RegexOptions.Compiled), // Beelzebub_01_[Noodles].zip new Regex( - @"^((?!v|vo|vol|Volume).)*( |_)(?\.?\d+)( |_)", + @"^((?!v|vo|vol|Volume).)*( |_)(?\.?\d+)( |_|\[|\()", RegexOptions.IgnoreCase | RegexOptions.Compiled), // Yumekui-Merry_DKThias_Chapter21.zip new Regex( @@ -168,7 +172,7 @@ namespace API.Parser /// /// Root folder /// or null if Series was empty - public static ParserInfo? Parse(string filePath, string rootPath) + public static ParserInfo Parse(string filePath, string rootPath) { var fileName = Path.GetFileName(filePath); var directoryName = (new FileInfo(filePath)).Directory?.Name; @@ -387,13 +391,19 @@ namespace API.Parser public static bool IsArchive(string filePath) { var fileInfo = new FileInfo(filePath); - return MangaFileExtensions.Contains(fileInfo.Extension); + return MangaFileRegex.IsMatch(fileInfo.Extension); } public static bool IsImage(string filePath) { var fileInfo = new FileInfo(filePath); - return ImageFileExtensions.Contains(fileInfo.Extension); + return ImageRegex.IsMatch(fileInfo.Extension); + } + + public static int MinimumNumberFromRange(string range) + { + var tokens = range.Split("-"); + return tokens.Min(Int32.Parse); } } } \ No newline at end of file diff --git a/API/Program.cs b/API/Program.cs index 21d37e064..129fbcdc2 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -44,11 +44,6 @@ 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/ArchiveService.cs b/API/Services/ArchiveService.cs new file mode 100644 index 000000000..500605883 --- /dev/null +++ b/API/Services/ArchiveService.cs @@ -0,0 +1,175 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using API.Extensions; +using API.Interfaces; +using Microsoft.Extensions.Logging; +using NetVips; + +namespace API.Services +{ + /// + /// Responsible for manipulating Archive files. Used by and + /// + public class ArchiveService : IArchiveService + { + private readonly ILogger _logger; + private const int ThumbnailWidth = 320; + + public ArchiveService(ILogger logger) + { + _logger = logger; + } + + public int GetNumberOfPagesFromArchive(string archivePath) + { + if (!IsValidArchive(archivePath)) return 0; + _logger.LogDebug($"Getting Page numbers from {archivePath}"); + + try + { + using ZipArchive archive = ZipFile.OpenRead(archivePath); + return archive.Entries.Count(e => Parser.Parser.IsImage(e.FullName)); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when reading archive stream."); + return 0; + } + } + + /// + /// 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 (!IsValidArchive(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(); + var entry = folder ?? entries[0]; + + return createThumbnail ? CreateThumbnail(entry) : ConvertEntryToByteArray(entry); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when reading archive stream."); + } + + return Array.Empty(); + } + + private byte[] CreateThumbnail(ZipArchiveEntry entry) + { + try + { + using var stream = entry.Open(); + using var thumbnail = Image.ThumbnailStream(stream, ThumbnailWidth); + return thumbnail.WriteToBuffer(".jpg"); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was a critical error and prevented thumbnail generation. Defaulting to no cover image."); + } + + return Array.Empty(); + } + + private static byte[] ConvertEntryToByteArray(ZipArchiveEntry entry) + { + using var stream = entry.Open(); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + var data = ms.ToArray(); + + return data; + } + + /// + /// Given an archive stream, will assess whether directory needs to be flattened so that the extracted archive files are directly + /// under extract path and not nested in subfolders. See Flatten method. + /// + /// An opened archive stream + /// + public bool ArchiveNeedsFlattening(ZipArchive archive) + { + // Sometimes ZipArchive will list the directory and others it will just keep it in the FullName + return archive.Entries.Count > 0 && + !Path.HasExtension(archive.Entries.ElementAt(0).FullName) || + archive.Entries.Any(e => e.FullName.Contains(Path.AltDirectorySeparatorChar)); + } + + /// + /// Test if the archive path exists and there are images inside it. This will log as an error. + /// + /// + /// + public bool IsValidArchive(string archivePath) + { + if (!File.Exists(archivePath)) + { + _logger.LogError($"Archive {archivePath} could not be found."); + return false; + } + if (!Parser.Parser.IsArchive(archivePath)) + { + _logger.LogError($"Archive {archivePath} is not a valid archive."); + return false; + } + + using var archive = ZipFile.OpenRead(archivePath); + if (archive.Entries.Any(e => Parser.Parser.IsImage(e.FullName))) return true; + _logger.LogError($"Archive {archivePath} contains no images."); + return false; + + } + + /// + /// 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 + /// + public void ExtractArchive(string archivePath, string extractPath) + { + if (!IsValidArchive(archivePath)) return; + + if (Directory.Exists(extractPath)) + { + _logger.LogDebug($"Archive {archivePath} has already been extracted. Returning existing folder."); + return; + } + + Stopwatch sw = Stopwatch.StartNew(); + using ZipArchive archive = ZipFile.OpenRead(archivePath); + var needsFlattening = ArchiveNeedsFlattening(archive); + if (!archive.HasFiles() && !needsFlattening) return; + + archive.ExtractToDirectory(extractPath, true); + _logger.LogDebug($"Extracted archive to {extractPath} in {sw.ElapsedMilliseconds} milliseconds."); + + if (needsFlattening) + { + sw = Stopwatch.StartNew(); + _logger.LogInformation("Extracted archive is nested in root folder, flattening..."); + new DirectoryInfo(extractPath).Flatten(); + _logger.LogInformation($"Flattened in {sw.ElapsedMilliseconds} milliseconds"); + } + } + } +} \ No newline at end of file diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index 85774e128..1092c57c1 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.IO.Compression; using System.Linq; using System.Threading.Tasks; using API.Comparators; @@ -13,54 +12,52 @@ namespace API.Services { public class CacheService : ICacheService { - private readonly IDirectoryService _directoryService; private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; + private readonly IArchiveService _archiveService; + private readonly IDirectoryService _directoryService; private readonly NumericComparer _numericComparer; - public static 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) + public CacheService(ILogger logger, IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService) { - _directoryService = directoryService; _logger = logger; _unitOfWork = unitOfWork; + _archiveService = archiveService; + _directoryService = directoryService; _numericComparer = new NumericComparer(); } - private bool CacheDirectoryIsAccessible() + public void EnsureCacheDirectory() { _logger.LogDebug($"Checking if valid Cache directory: {CacheDirectory}"); var di = new DirectoryInfo(CacheDirectory); - return di.Exists; + if (!di.Exists) + { + _logger.LogError($"Cache directory {CacheDirectory} is not accessible or does not exist. Creating..."); + Directory.CreateDirectory(CacheDirectory); + } } - public async Task Ensure(int volumeId) + public async Task Ensure(int chapterId) { - if (!CacheDirectoryIsAccessible()) + EnsureCacheDirectory(); + Chapter chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId); + + foreach (var file in chapter.Files) { - return null; - } - Volume volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId); - foreach (var file in volume.Files) - { - var extractPath = GetVolumeCachePath(volumeId, file); - - ExtractArchive(file.FilePath, extractPath); + var extractPath = GetCachePath(chapterId); + _archiveService.ExtractArchive(file.FilePath, extractPath); } - return volume; + return chapter; } public void Cleanup() { _logger.LogInformation("Performing cleanup of Cache directory"); - - if (!CacheDirectoryIsAccessible()) - { - _logger.LogError($"Cache directory {CacheDirectory} is not accessible or does not exist."); - return; - } - + EnsureCacheDirectory(); + DirectoryInfo di = new DirectoryInfo(CacheDirectory); try @@ -75,13 +72,13 @@ namespace API.Services _logger.LogInformation("Cache directory purged."); } - public void CleanupVolumes(int[] volumeIds) + public void CleanupChapters(int[] chapterIds) { _logger.LogInformation($"Running Cache cleanup on Volumes"); - foreach (var volume in volumeIds) + foreach (var chapter in chapterIds) { - var di = new DirectoryInfo(Path.Join(CacheDirectory, volume + "")); + var di = new DirectoryInfo(GetCachePath(chapter)); if (di.Exists) { di.Delete(true); @@ -90,75 +87,38 @@ 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). + /// Returns the cache path for a given Chapter. Should be cacheDirectory/{chapterId}/ /// - /// A valid file to an archive file. - /// Path to extract to + /// /// - private string ExtractArchive(string archivePath, string extractPath) + private string GetCachePath(int chapterId) { - // 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; + return Path.GetFullPath(Path.Join(CacheDirectory, $"{chapterId}/")); } - - private string GetVolumeCachePath(int volumeId, MangaFile file) - { - var extractPath = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), $"../cache/{volumeId}/")); - if (file.Chapter > 0) - { - extractPath = Path.Join(extractPath, file.Chapter + ""); - } - return extractPath; - } - - public string GetCachedPagePath(Volume volume, int page) + public async Task<(string path, MangaFile file)> GetCachedPagePath(Chapter chapter, int page) { // Calculate what chapter the page belongs to var pagesSoFar = 0; - foreach (var mangaFile in volume.Files.OrderBy(f => f.Chapter)) + var chapterFiles = chapter.Files ?? await _unitOfWork.VolumeRepository.GetFilesForChapter(chapter.Id); + foreach (var mangaFile in chapterFiles) { - if (page + 1 < (mangaFile.NumberOfPages + pagesSoFar)) + if (page < (mangaFile.NumberOfPages + pagesSoFar)) { - var path = GetVolumeCachePath(volume.Id, mangaFile); - var files = DirectoryService.GetFiles(path); + var path = GetCachePath(chapter.Id); + var files = _directoryService.GetFiles(path, Parser.Parser.ImageFileExtensions); Array.Sort(files, _numericComparer); - return files.ElementAt(page - pagesSoFar); + return (files.ElementAt(page - pagesSoFar), mangaFile); } - + pagesSoFar += mangaFile.NumberOfPages; } - return ""; + + return ("", null); } } } \ No newline at end of file diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index d910c5c1b..96853914f 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -8,12 +8,19 @@ using System.Threading; using System.Threading.Tasks; using API.DTOs; using API.Interfaces; +using Microsoft.Extensions.Logging; using NetVips; namespace API.Services { public class DirectoryService : IDirectoryService { + private readonly ILogger _logger; + + public DirectoryService(ILogger logger) + { + _logger = logger; + } /// /// Given a set of regex search criteria, get files in the given path. @@ -33,10 +40,14 @@ namespace API.Services reSearchPattern.IsMatch(Path.GetExtension(file))); } - public static string[] GetFiles(string path) + public string[] GetFiles(string path, string searchPatternExpression = "") { - if (!Directory.Exists(path)) return Array.Empty(); - return Directory.GetFiles(path); + if (searchPatternExpression != string.Empty) + { + return GetFilesWithCertainExtensions(path, searchPatternExpression).ToArray(); + } + + return !Directory.Exists(path) ? Array.Empty() : Directory.GetFiles(path); } public IEnumerable ListDirectory(string rootPath) @@ -48,12 +59,16 @@ namespace API.Services .Where(dir => !(dir.Attributes.HasFlag(FileAttributes.Hidden) || dir.Attributes.HasFlag(FileAttributes.System))) .Select(d => d.Name).ToImmutableList(); - return dirs; } public async Task ReadImageAsync(string imagePath) { + if (!File.Exists(imagePath)) + { + _logger.LogError("Image does not exist on disk."); + return null; + } using var image = Image.NewFromFile(imagePath); return new ImageDto @@ -63,7 +78,7 @@ namespace API.Services FullPath = Path.GetFullPath(imagePath), Width = image.Width, Height = image.Height, - Format = image.Format + Format = image.Format, }; } diff --git a/API/Services/ScannerService.cs b/API/Services/ScannerService.cs index 5d1366603..5f84b6e3f 100644 --- a/API/Services/ScannerService.cs +++ b/API/Services/ScannerService.cs @@ -4,16 +4,12 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.IO; -using System.IO.Compression; using System.Linq; -using System.Threading; 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 { @@ -21,12 +17,14 @@ namespace API.Services { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; + private readonly IArchiveService _archiveService; private ConcurrentDictionary> _scannedSeries; - public ScannerService(IUnitOfWork unitOfWork, ILogger logger) + public ScannerService(IUnitOfWork unitOfWork, ILogger logger, IArchiveService archiveService) { _unitOfWork = unitOfWork; _logger = logger; + _archiveService = archiveService; } public void ScanLibraries() @@ -60,6 +58,12 @@ namespace API.Services var totalFiles = 0; foreach (var folderPath in library.Folders) { + if (!forceUpdate && Directory.GetLastWriteTime(folderPath.Path) <= folderPath.LastScanned) + { + _logger.LogDebug($"{folderPath.Path} hasn't been updated since last scan. Skipping."); + continue; + } + try { totalFiles += DirectoryService.TraverseTreeParallelForEach(folderPath.Path, (f) => { @@ -87,10 +91,12 @@ namespace API.Services // Remove series that are no longer on disk RemoveSeriesNotOnDisk(allSeries, series, library); + foreach (var folder in library.Folders) folder.LastScanned = DateTime.Now; _unitOfWork.LibraryRepository.Update(library); if (Task.Run(() => _unitOfWork.Complete()).Result) { + _logger.LogInformation($"Scan completed on {library.Name}. Parsed {series.Keys.Count()} series."); } else @@ -154,7 +160,7 @@ namespace API.Services { if (info.Series == string.Empty) return; - _scannedSeries.AddOrUpdate(info.Series, new List() {info}, (key, oldValue) => + _scannedSeries.AddOrUpdate(info.Series, new List() {info}, (_, oldValue) => { oldValue ??= new List(); if (!oldValue.Contains(info)) @@ -187,10 +193,10 @@ namespace API.Services private Series UpdateSeries(Series series, ParserInfo[] infos, bool forceUpdate) { - var volumes = UpdateVolumes(series, infos, forceUpdate); + var volumes = UpdateVolumesWithChapters(series, infos, forceUpdate); series.Volumes = volumes; series.Pages = volumes.Sum(v => v.Pages); - if (series.CoverImage == null || forceUpdate) + if (ShouldFindCoverImage(forceUpdate, series.CoverImage)) { var firstCover = volumes.OrderBy(x => x.Number).FirstOrDefault(x => x.Number != 0); if (firstCover == null && volumes.Any()) @@ -212,191 +218,125 @@ namespace API.Services { _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 + NumberOfPages = info.Format == MangaFormat.Archive ? _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath): 1 }; } - - private int MinimumNumberFromRange(string range) + + private bool ShouldFindCoverImage(bool forceUpdate, byte[] coverImage) { - var tokens = range.Split("-"); - return Int32.Parse(tokens.Length >= 1 ? tokens[0] : range); + return forceUpdate || coverImage == null || !coverImage.Any(); } /// - /// Creates or Updates volumes for a given series + /// /// - /// Series wanting to be updated - /// Parser info - /// Forces metadata update (cover image) even if it's already been set. - /// Updated Volumes for given series - private ICollection UpdateVolumes(Series series, ParserInfo[] infos, bool forceUpdate) + /// + /// + /// + /// + private ICollection UpdateChapters(Volume volume, IEnumerable infos, bool forceUpdate) + { + var chapters = new List(); + + foreach (var info in infos) + { + volume.Chapters ??= new List(); + var chapter = volume.Chapters.SingleOrDefault(c => c.Range == info.Chapters) ?? + chapters.SingleOrDefault(v => v.Range == info.Chapters) ?? + new Chapter() + { + Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + "", + Range = info.Chapters, + }; + + chapter.Files ??= new List(); + var existingFile = chapter.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); + if (existingFile != null) + { + existingFile.Format = info.Format; + existingFile.NumberOfPages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath); + } + else + { + if (info.Format == MangaFormat.Archive) + { + chapter.Files.Add(CreateMangaFile(info)); + } + else + { + _logger.LogDebug($"Ignoring {info.Filename} as it is not an archive."); + } + + } + + chapter.Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + ""; + chapter.Range = info.Chapters; + + chapters.Add(chapter); + } + + foreach (var chapter in chapters) + { + chapter.Pages = chapter.Files.Sum(f => f.NumberOfPages); + + if (ShouldFindCoverImage(forceUpdate, chapter.CoverImage)) + { + chapter.Files ??= new List(); + var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + if (firstFile != null) chapter.CoverImage = _archiveService.GetCoverImage(firstFile.FilePath, true); + } + } + + return chapters; + } + + + private ICollection UpdateVolumesWithChapters(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 volume = (existingVolumes.SingleOrDefault(v => v.Name == info.Volumes) ?? + volumes.SingleOrDefault(v => v.Name == info.Volumes)) ?? new Volume { - 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}"); + Name = info.Volumes, + Number = Parser.Parser.MinimumNumberFromRange(info.Volumes), + }; + + + var chapters = UpdateChapters(volume, infos.Where(pi => pi.Volumes == volume.Name).ToArray(), forceUpdate); + volume.Chapters = chapters; + volume.Pages = chapters.Sum(c => c.Pages); + volumes.Add(volume); } foreach (var volume in volumes) { - if (forceUpdate || volume.CoverImage == null || !volume.Files.Any()) + if (ShouldFindCoverImage(forceUpdate, volume.CoverImage)) { - var firstFile = volume.Files.OrderBy(x => x.Chapter).FirstOrDefault(); - if (firstFile != null) volume.CoverImage = GetCoverImage(firstFile.FilePath, true); // ZIPFILE + // TODO: Create a custom sorter for Chapters so it's consistent across the application + var firstChapter = volume.Chapters.OrderBy(x => Double.Parse(x.Number)).FirstOrDefault(); + var firstFile = firstChapter?.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + if (firstFile != null) volume.CoverImage = _archiveService.GetCoverImage(firstFile.FilePath, true); } - - volume.Pages = volume.Files.Sum(x => x.NumberOfPages); } - + return volumes; } - - + 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; - } + { + throw new NotImplementedException(); + } + } } \ No newline at end of file diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 2ed039b8d..0c2a143df 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -1,4 +1,7 @@ -using API.Interfaces; +using System.Threading.Tasks; +using API.Entities; +using API.Helpers.Converters; +using API.Interfaces; using Hangfire; using Microsoft.Extensions.Logging; @@ -11,15 +14,26 @@ namespace API.Services private readonly IScannerService _scannerService; public BackgroundJobServer Client => new BackgroundJobServer(); - public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService) + public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, IUnitOfWork unitOfWork) { _cacheService = cacheService; _logger = logger; _scannerService = scannerService; _logger.LogInformation("Scheduling/Updating cache cleanup on a daily basis."); - RecurringJob.AddOrUpdate(() => _cacheService.Cleanup(), Cron.Daily); - RecurringJob.AddOrUpdate(() => _scannerService.ScanLibraries(), Cron.Daily); + var setting = Task.Run(() => unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskScan)).Result; + if (setting != null) + { + RecurringJob.AddOrUpdate(() => _scannerService.ScanLibraries(), () => CronConverter.ConvertToCronNotation(setting.Value)); + } + else + { + RecurringJob.AddOrUpdate(() => _cacheService.Cleanup(), Cron.Daily); + RecurringJob.AddOrUpdate(() => _scannerService.ScanLibraries(), Cron.Daily); + } + + //JobStorage.Current.GetMonitoringApi(). + } public void ScanSeries(int libraryId, int seriesId) @@ -34,9 +48,9 @@ namespace API.Services BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, forceUpdate)); } - public void CleanupVolumes(int[] volumeIds) + public void CleanupChapters(int[] chapterIds) { - BackgroundJob.Enqueue(() => _cacheService.CleanupVolumes(volumeIds)); + BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); } diff --git a/API/Startup.cs b/API/Startup.cs index 766b1d01d..426616e51 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -3,6 +3,7 @@ using API.Middleware; using Hangfire; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -45,8 +46,6 @@ namespace API app.UseHangfireDashboard(); } - //app.UseHttpsRedirection(); - app.UseRouting(); // Ordering is important. Cors, authentication, authorization @@ -57,7 +56,11 @@ namespace API app.UseAuthorization(); app.UseDefaultFiles(); - app.UseStaticFiles(); + + app.UseStaticFiles(new StaticFileOptions + { + ContentTypeProvider = new FileExtensionContentTypeProvider() // this is not set by default + }); app.UseEndpoints(endpoints => { diff --git a/API/appsettings.Development.json b/API/appsettings.Development.json index 5c67bda6f..d2bf464c4 100644 --- a/API/appsettings.Development.json +++ b/API/appsettings.Development.json @@ -1,13 +1,13 @@ { "ConnectionStrings": { - "DefaultConnection": "Data source=kavita.db", + "DefaultConnection": "Data source=kavita.db" }, "TokenKey": "super secret unguessable key", "Logging": { "LogLevel": { "Default": "Debug", - "Microsoft": "Information", - "Microsoft.Hosting.Lifetime": "Information", + "Microsoft": "Error", + "Microsoft.Hosting.Lifetime": "Error", "Hangfire": "Information" }, "File": {