diff --git a/.gitignore b/.gitignore index 343c37b63..ef856c786 100644 --- a/.gitignore +++ b/.gitignore @@ -447,4 +447,5 @@ appsettings.json /API/kavita.db-shm /API/kavita.db-wal /API/Hangfire.db -/API/Hangfire-log.db \ No newline at end of file +/API/Hangfire-log.db +cache/ \ No newline at end of file diff --git a/API.Tests/ParserTest.cs b/API.Tests/ParserTest.cs index 320856c32..d551c6393 100644 --- a/API.Tests/ParserTest.cs +++ b/API.Tests/ParserTest.cs @@ -15,11 +15,11 @@ namespace API.Tests //[InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "16-17")] [InlineData("Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz", "1")] [InlineData("v001", "1")] + [InlineData("U12 (Under 12) Vol. 0001 Ch. 0001 - Reiwa Scans (gb)", "1")] [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip", "1")] public void ParseVolumeTest(string filename, string expected) { - var result = ParseVolume(filename); - Assert.Equal(expected, result); + Assert.Equal(expected, ParseVolume(filename)); } [Theory] @@ -31,27 +31,27 @@ namespace API.Tests [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "Gokukoku no Brynhildr")] [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "Dance in the Vampire Bund")] [InlineData("v001", "")] + [InlineData("U12 (Under 12) Vol. 0001 Ch. 0001 - Reiwa Scans (gb)", "U12 (Under 12)")] [InlineData("Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ)", "Akame ga KILL! ZERO")] public void ParseSeriesTest(string filename, string expected) { - var result = ParseSeries(filename); - Assert.Equal(expected, result); + Assert.Equal(expected, ParseSeries(filename)); } [Theory] [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")] [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "9")] [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98.zip", "90-98")] - [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", "")] - [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", "")] + [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", "0")] + [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", "0")] [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "1-8")] - [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "")] + [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", "0")] [InlineData("c001", "1")] [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.12.zip", "12")] + [InlineData("Adding volume 1 with File: Ana Satsujin Vol. 1 Ch. 5 - Manga Box (gb).cbz", "5")] public void ParseChaptersTest(string filename, string expected) { - var result = ParseChapter(filename); - Assert.Equal(expected, result); + Assert.Equal(expected, ParseChapter(filename)); } @@ -88,6 +88,7 @@ namespace API.Tests [InlineData("test.cbr", true)] [InlineData("test.zip", true)] [InlineData("test.rar", true)] + [InlineData("test.rar.!qb", false)] public void IsArchiveTest(string input, bool expected) { Assert.Equal(expected, IsArchive(input)); diff --git a/API.Tests/Services/ImageProviderTest.cs b/API.Tests/Services/ImageProviderTest.cs index e7e393bfc..915d589c4 100644 --- a/API.Tests/Services/ImageProviderTest.cs +++ b/API.Tests/Services/ImageProviderTest.cs @@ -1,9 +1,6 @@ -using System; -using System.IO; +using System.IO; using API.IO; -using NetVips; using Xunit; -using Xunit.Abstractions; namespace API.Tests.Services { @@ -12,7 +9,7 @@ namespace API.Tests.Services [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")] + [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"); diff --git a/API.Tests/Services/StringLogicalComparerTest.cs b/API.Tests/Services/StringLogicalComparerTest.cs new file mode 100644 index 000000000..25c5d3b2f --- /dev/null +++ b/API.Tests/Services/StringLogicalComparerTest.cs @@ -0,0 +1,28 @@ +using System; +using API.Comparators; +using Xunit; + +namespace API.Tests.Services +{ + public class StringLogicalComparerTest + { + [Theory] + [InlineData( + 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(); + Array.Sort(input, nc); + + var i = 0; + foreach (var s in input) + { + Assert.Equal(s, expected[i]); + i++; + } + } + } +} \ No newline at end of file diff --git a/API/Comparators/NumericComparer.cs b/API/Comparators/NumericComparer.cs new file mode 100644 index 000000000..b40e33e0a --- /dev/null +++ b/API/Comparators/NumericComparer.cs @@ -0,0 +1,17 @@ +using System.Collections; + +namespace API.Comparators +{ + public class NumericComparer : IComparer + { + + public int Compare(object x, object y) + { + if((x is string xs) && (y is string ys)) + { + return StringLogicalComparer.Compare(xs, ys); + } + return -1; + } + } +} \ No newline at end of file diff --git a/API/Comparators/StringLogicalComparer.cs b/API/Comparators/StringLogicalComparer.cs new file mode 100644 index 000000000..f6a8c1249 --- /dev/null +++ b/API/Comparators/StringLogicalComparer.cs @@ -0,0 +1,130 @@ +//(c) Vasian Cepa 2005 +// Version 2 +// Taken from: https://www.codeproject.com/Articles/11016/Numeric-String-Sort-in-C + +using System; + +namespace API.Comparators +{ + public static class StringLogicalComparer + { + public static int Compare(string s1, string s2) + { + //get rid of special cases + if((s1 == null) && (s2 == null)) return 0; + if(s1 == null) return -1; + if(s2 == null) return 1; + + if (string.IsNullOrEmpty(s1) && string.IsNullOrEmpty(s2)) return 0; + if (string.IsNullOrEmpty(s1)) return -1; + if (string.IsNullOrEmpty(s2)) return -1; + + //WE style, special case + bool sp1 = Char.IsLetterOrDigit(s1, 0); + bool 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); + int r; // temp result + if(!c1 && !c2) + { + bool letter1 = Char.IsLetter(s1, i1); + bool letter2 = Char.IsLetter(s2, i2); + if((letter1 && letter2) || (!letter1 && !letter2)) + { + if(letter1 && letter2) + { + r = Char.ToLower(s1[i1]).CompareTo(Char.ToLower(s2[i2])); + } + else + { + r = s1[i1].CompareTo(s2[i2]); + } + if(r != 0) return r; + } + else if(!letter1 && letter2) return -1; + else if(letter1 && !letter2) return 1; + } + else if(c1 && c2) + { + r = CompareNum(s1, ref i1, s2, ref i2); + if(r != 0) return r; + } + else if(c1) + { + return -1; + } + else if(c2) + { + return 1; + } + i1++; + i2++; + if((i1 >= s1.Length) && (i2 >= s2.Length)) + { + return 0; + } + if(i1 >= s1.Length) + { + return -1; + } + if(i2 >= s2.Length) + { + return -1; + } + } + } + + private static int CompareNum(string s1, ref int i1, string s2, ref int i2) + { + int nzStart1 = i1, nzStart2 = i2; // nz = non zero + int end1 = i1, end2 = i2; + + ScanNumEnd(s1, i1, ref end1, ref nzStart1); + ScanNumEnd(s2, i2, ref end2, ref nzStart2); + var start1 = i1; i1 = end1 - 1; + var start2 = i2; i2 = end2 - 1; + + var nzLength1 = end1 - nzStart1; + var nzLength2 = end2 - nzStart2; + + if(nzLength1 < nzLength2) return -1; + if(nzLength1 > nzLength2) return 1; + + for(int j1 = nzStart1,j2 = nzStart2; j1 <= i1; j1++,j2++) + { + var r = s1[j1].CompareTo(s2[j2]); + if(r != 0) return r; + } + // the nz parts are equal + var length1 = end1 - start1; + var length2 = end2 - start2; + if(length1 == length2) return 0; + if(length1 > length2) return -1; + return 1; + } + + //lookahead + private static void ScanNumEnd(string s, int start, ref int end, ref int nzStart) + { + nzStart = start; + end = start; + bool countZeros = true; + while(Char.IsDigit(s, end)) + { + if(countZeros && s[end].Equals('0')) + { + nzStart++; + } + else countZeros = false; + end++; + if(end >= s.Length) break; + } + } + } +} \ No newline at end of file diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 6de66f861..9cc9202e8 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using API.DTOs; using API.Entities; +using API.Extensions; using API.Interfaces; using AutoMapper; using Hangfire; @@ -23,10 +24,11 @@ namespace API.Controllers private readonly IMapper _mapper; private readonly ITaskScheduler _taskScheduler; private readonly ISeriesRepository _seriesRepository; + private readonly ICacheService _cacheService; public LibraryController(IDirectoryService directoryService, ILibraryRepository libraryRepository, ILogger logger, IUserRepository userRepository, - IMapper mapper, ITaskScheduler taskScheduler, ISeriesRepository seriesRepository) + IMapper mapper, ITaskScheduler taskScheduler, ISeriesRepository seriesRepository, ICacheService cacheService) { _directoryService = directoryService; _libraryRepository = libraryRepository; @@ -35,6 +37,7 @@ namespace API.Controllers _mapper = mapper; _taskScheduler = taskScheduler; _seriesRepository = seriesRepository; + _cacheService = cacheService; } /// @@ -71,6 +74,7 @@ namespace API.Controllers if (await _userRepository.SaveAllAsync()) { + _logger.LogInformation($"Created a new library: {library.Name}"); var createdLibrary = await _libraryRepository.GetLibraryForNameAsync(library.Name); BackgroundJob.Enqueue(() => _directoryService.ScanLibrary(createdLibrary.Id, false)); return Ok(); @@ -121,6 +125,7 @@ namespace API.Controllers if (await _userRepository.SaveAllAsync()) { + _logger.LogInformation($"Added: {updateLibraryForUserDto.SelectedLibraries} to {updateLibraryForUserDto.Username}"); return Ok(user); } @@ -151,7 +156,19 @@ namespace API.Controllers [HttpDelete("delete")] public async Task> DeleteLibrary(int libraryId) { - return Ok(await _libraryRepository.DeleteLibrary(libraryId)); + var username = User.GetUsername(); + _logger.LogInformation($"Library {libraryId} is being deleted by {username}."); + var series = await _seriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId); + var volumes = (await _seriesRepository.GetVolumesForSeriesAsync(series.Select(x => x.Id).ToArray())) + .Select(x => x.Id).ToArray(); + var result = await _libraryRepository.DeleteLibrary(libraryId); + + if (result && volumes.Any()) + { + BackgroundJob.Enqueue(() => _cacheService.CleanupVolumes(volumes)); + } + + return Ok(result); } [Authorize(Policy = "RequireAdminRole")] @@ -174,7 +191,7 @@ namespace API.Controllers { if (differenceBetweenFolders.Any()) { - BackgroundJob.Enqueue(() => _directoryService.ScanLibrary(library.Id)); + BackgroundJob.Enqueue(() => _directoryService.ScanLibrary(library.Id, true)); } return Ok(); diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs new file mode 100644 index 000000000..17a5d753d --- /dev/null +++ b/API/Controllers/ReaderController.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; +using API.DTOs; +using API.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers +{ + public class ReaderController : BaseApiController + { + private readonly IDirectoryService _directoryService; + private readonly ICacheService _cacheService; + + public ReaderController(IDirectoryService directoryService, ICacheService cacheService) + { + _directoryService = directoryService; + _cacheService = cacheService; + } + + [HttpGet("image")] + public async Task> GetImage(int volumeId, int page) + { + // Temp let's iterate the directory each call to get next image + var volume = await _cacheService.Ensure(volumeId); + + var path = _cacheService.GetCachedPagePath(volume, page); + var file = await _directoryService.ReadImageAsync(path); + file.Page = page; + + return Ok(file); + } + } +} \ No newline at end of file diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 4c5e66a41..199c5971c 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -1,8 +1,12 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using API.DTOs; +using API.Extensions; using API.Interfaces; using AutoMapper; +using Hangfire; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -14,14 +18,17 @@ namespace API.Controllers private readonly IMapper _mapper; private readonly ITaskScheduler _taskScheduler; private readonly ISeriesRepository _seriesRepository; + private readonly ICacheService _cacheService; public SeriesController(ILogger logger, IMapper mapper, - ITaskScheduler taskScheduler, ISeriesRepository seriesRepository) + ITaskScheduler taskScheduler, ISeriesRepository seriesRepository, + ICacheService cacheService) { _logger = logger; _mapper = mapper; _taskScheduler = taskScheduler; _seriesRepository = seriesRepository; + _cacheService = cacheService; } [HttpGet("{seriesId}")] @@ -30,10 +37,32 @@ namespace API.Controllers return Ok(await _seriesRepository.GetSeriesDtoByIdAsync(seriesId)); } + [Authorize(Policy = "RequireAdminRole")] + [HttpDelete("{seriesId}")] + public async Task> DeleteSeries(int seriesId) + { + var username = User.GetUsername(); + var volumes = (await _seriesRepository.GetVolumesForSeriesAsync(new []{seriesId})).Select(x => x.Id).ToArray(); + _logger.LogInformation($"Series {seriesId} is being deleted by {username}."); + var result = await _seriesRepository.DeleteSeriesAsync(seriesId); + + if (result) + { + BackgroundJob.Enqueue(() => _cacheService.CleanupVolumes(volumes)); + } + return Ok(result); + } + [HttpGet("volumes")] public async Task>> GetVolumes(int seriesId) { return Ok(await _seriesRepository.GetVolumesDtoAsync(seriesId)); } + + [HttpGet("volume")] + public async Task> GetVolume(int volumeId) + { + return Ok(await _seriesRepository.GetVolumeDtoAsync(volumeId)); + } } } \ No newline at end of file diff --git a/API/DTOs/ImageDto.cs b/API/DTOs/ImageDto.cs new file mode 100644 index 000000000..473f2c110 --- /dev/null +++ b/API/DTOs/ImageDto.cs @@ -0,0 +1,13 @@ +namespace API.DTOs +{ + public class ImageDto + { + public int Page { get; set; } + public string Filename { get; init; } + public string FullPath { get; init; } + public int Width { get; init; } + public int Height { get; init; } + public string Format { get; init; } + public byte[] Content { get; init; } + } +} \ No newline at end of file diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index e7c7927e1..33ed702c8 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; - + namespace API.DTOs { public class VolumeDto @@ -8,5 +7,6 @@ namespace API.DTOs public int Number { get; set; } public string Name { get; set; } public byte[] CoverImage { get; set; } + public int Pages { get; set; } } } \ No newline at end of file diff --git a/API/Data/LibraryRepository.cs b/API/Data/LibraryRepository.cs index 9c2c74b29..15cd38d8b 100644 --- a/API/Data/LibraryRepository.cs +++ b/API/Data/LibraryRepository.cs @@ -38,10 +38,12 @@ namespace API.Data public async Task> GetLibrariesDtoForUsernameAsync(string userName) { + // TODO: Speed this query up return await _context.Library .Include(l => l.AppUsers) .Where(library => library.AppUsers.Any(x => x.UserName == userName)) - .ProjectTo(_mapper.ConfigurationProvider).ToListAsync(); + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); } public async Task GetLibraryForNameAsync(string libraryName) diff --git a/API/Data/Migrations/20210109205034_CacheMetadata.Designer.cs b/API/Data/Migrations/20210109205034_CacheMetadata.Designer.cs new file mode 100644 index 000000000..66b17bf30 --- /dev/null +++ b/API/Data/Migrations/20210109205034_CacheMetadata.Designer.cs @@ -0,0 +1,521 @@ +// +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("20210109205034_CacheMetadata")] + partial class CacheMetadata + { + 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.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Chapter") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("NumberOfPages") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("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.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Files") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210109205034_CacheMetadata.cs b/API/Data/Migrations/20210109205034_CacheMetadata.cs new file mode 100644 index 000000000..476591e15 --- /dev/null +++ b/API/Data/Migrations/20210109205034_CacheMetadata.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class CacheMetadata : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Chapter", + table: "MangaFile", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "Format", + table: "MangaFile", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "NumberOfPages", + table: "MangaFile", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Chapter", + table: "MangaFile"); + + migrationBuilder.DropColumn( + name: "Format", + table: "MangaFile"); + + migrationBuilder.DropColumn( + name: "NumberOfPages", + table: "MangaFile"); + } + } +} diff --git a/API/Data/Migrations/20210111231840_VolumePages.Designer.cs b/API/Data/Migrations/20210111231840_VolumePages.Designer.cs new file mode 100644 index 000000000..f351a04e1 --- /dev/null +++ b/API/Data/Migrations/20210111231840_VolumePages.Designer.cs @@ -0,0 +1,524 @@ +// +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("20210111231840_VolumePages")] + partial class VolumePages + { + 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.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Chapter") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("NumberOfPages") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Files") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Files"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210111231840_VolumePages.cs b/API/Data/Migrations/20210111231840_VolumePages.cs new file mode 100644 index 000000000..c9b36b03a --- /dev/null +++ b/API/Data/Migrations/20210111231840_VolumePages.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class VolumePages : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Pages", + table: "Volume", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Pages", + table: "Volume"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index bb0146e52..aa9889bcc 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -184,9 +184,18 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("Chapter") + .HasColumnType("INTEGER"); + b.Property("FilePath") .HasColumnType("TEXT"); + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("NumberOfPages") + .HasColumnType("INTEGER"); + b.Property("VolumeId") .HasColumnType("INTEGER"); @@ -255,6 +264,9 @@ namespace API.Data.Migrations b.Property("Number") .HasColumnType("INTEGER"); + b.Property("Pages") + .HasColumnType("INTEGER"); + b.Property("SeriesId") .HasColumnType("INTEGER"); diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index 8d7f619c3..9729d0a82 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -18,7 +18,11 @@ namespace API.Data foreach (var role in roles) { - await roleManager.CreateAsync(role); + var exists = await roleManager.RoleExistsAsync(role.Name); + if (!exists) + { + await roleManager.CreateAsync(role); + } } } } diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs index bed3bb093..932b5ca37 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/SeriesRepository.cs @@ -76,5 +76,41 @@ namespace API.Data return await _context.Series.Where(x => x.Id == seriesId) .ProjectTo(_mapper.ConfigurationProvider).SingleAsync(); } + + public async Task GetVolumeAsync(int volumeId) + { + return await _context.Volume + .Include(vol => vol.Files) + .SingleOrDefaultAsync(vol => vol.Id == volumeId); + } + + public async Task GetVolumeDtoAsync(int volumeId) + { + return await _context.Volume + .Where(vol => vol.Id == volumeId) + .Include(vol => vol.Files) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleAsync(vol => vol.Id == volumeId); + } + + /// + /// Returns all volumes that contain a seriesId in passed array. + /// + /// + /// + public async Task> GetVolumesForSeriesAsync(int[] seriesIds) + { + return await _context.Volume + .Where(v => seriesIds.Contains(v.SeriesId)) + .ToListAsync(); + } + + public async Task DeleteSeriesAsync(int seriesId) + { + var series = await _context.Series.Where(s => s.Id == seriesId).SingleOrDefaultAsync(); + _context.Series.Remove(series); + + return await _context.SaveChangesAsync() > 0; + } } } \ No newline at end of file diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index 7b7dfe8f4..9e66a7d00 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -16,6 +16,8 @@ namespace API.Entities public uint RowVersion { get; set; } public ICollection UserRoles { get; set; } + + //public ICollection SeriesProgresses { get; set; } public void OnSavingChanges() { diff --git a/API/Entities/AppUserProgress.cs b/API/Entities/AppUserProgress.cs new file mode 100644 index 000000000..cfff3d9d4 --- /dev/null +++ b/API/Entities/AppUserProgress.cs @@ -0,0 +1,10 @@ +namespace API.Entities +{ + /// + /// Represents the progress a single user has on a given Volume. + /// + public class AppUserProgress + { + + } +} \ No newline at end of file diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index 06e132193..b93c128a3 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -5,6 +5,15 @@ namespace API.Entities { public int Id { get; set; } public string FilePath { get; set; } + /// + /// Do not expect this to be set. If this MangaFile represents a volume file, this will be null. + /// + 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; } diff --git a/API/Entities/MangaFormat.cs b/API/Entities/MangaFormat.cs new file mode 100644 index 000000000..960f72145 --- /dev/null +++ b/API/Entities/MangaFormat.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; + +namespace API.Entities +{ + public enum MangaFormat + { + [Description("Image")] + Image = 0, + [Description("Archive")] + Archive = 1, + [Description("Unknown")] + Unknown = 2 + } +} \ No newline at end of file diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index 9bbd1f6ae..1550ab335 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -13,6 +13,10 @@ namespace API.Entities public DateTime Created { get; set; } public DateTime LastModified { get; set; } public byte[] CoverImage { get; set; } + public int Pages { get; set; } + + // public string CachePath {get; set;} // Path where cache is located. Default null, resets to null on deletion. + //public ICollection AppUserProgress { get; set; } // Many-to-One relationships public Series Series { get; set; } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 2e0bb342c..63d6a04a7 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -19,9 +19,12 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + + services.AddDbContext(options => { options.UseSqlite(config.GetConnectionString("DefaultConnection")); diff --git a/API/Extensions/DirectoryInfoExtensions.cs b/API/Extensions/DirectoryInfoExtensions.cs new file mode 100644 index 000000000..257b7c045 --- /dev/null +++ b/API/Extensions/DirectoryInfoExtensions.cs @@ -0,0 +1,65 @@ +using System; +using System.IO; + +namespace API.Extensions +{ + public static class DirectoryInfoExtensions + { + public static void Empty(this DirectoryInfo directory) + { + foreach(FileInfo file in directory.EnumerateFiles()) file.Delete(); + foreach(DirectoryInfo subDirectory in directory.EnumerateDirectories()) subDirectory.Delete(true); + } + + /// + /// Flattens all files in subfolders to the passed directory recursively. + /// + /// + /// foo + /// ├── 1.txt + /// ├── 2.txt + /// ├── 3.txt + /// ├── 4.txt + /// └── bar + /// ├── 1.txt + /// ├── 2.txt + /// └── 5.txt + /// + /// becomes: + /// foo + /// ├── 1.txt + /// ├── 2.txt + /// ├── 3.txt + /// ├── 4.txt + /// ├── bar_1.txt + /// ├── bar_2.txt + /// └── bar_5.txt + /// + /// + public static void Flatten(this DirectoryInfo directory) + { + FlattenDirectory(directory, directory); + } + + private static void FlattenDirectory(DirectoryInfo root, DirectoryInfo directory) + { + if (!root.FullName.Equals(directory.FullName)) // I might be able to replace this with root === directory + { + foreach (var file in directory.EnumerateFiles()) + { + if (file.Directory == null) continue; + var newName = $"{file.Directory.Name}_{file.Name}"; + var newPath = Path.Join(root.FullName, newName); + Console.WriteLine($"Renaming/Moving file to: {newPath}"); + file.MoveTo(newPath); + + } + } + + foreach (var subDirectory in directory.EnumerateDirectories()) + { + FlattenDirectory(root, subDirectory); + } + } + } +} \ No newline at end of file diff --git a/API/Extensions/ZipArchiveExtensions.cs b/API/Extensions/ZipArchiveExtensions.cs new file mode 100644 index 000000000..a871162e8 --- /dev/null +++ b/API/Extensions/ZipArchiveExtensions.cs @@ -0,0 +1,19 @@ +using System.IO; +using System.IO.Compression; +using System.Linq; + +namespace API.Extensions +{ + public static class ZipArchiveExtensions + { + /// + /// Checks if archive has one or more files. Excludes directory entries. + /// + /// + /// + public static bool HasFiles(this ZipArchive archive) + { + return archive.Entries.Any(x => Path.HasExtension(x.FullName)); + } + } +} \ No newline at end of file diff --git a/API/IO/ImageProvider.cs b/API/IO/ImageProvider.cs index 9374ce9bf..ebfe85d76 100644 --- a/API/IO/ImageProvider.cs +++ b/API/IO/ImageProvider.cs @@ -2,6 +2,7 @@ using System.IO; using System.IO.Compression; using System.Linq; +using API.Extensions; using NetVips; namespace API.IO @@ -18,26 +19,21 @@ namespace API.IO /// public static byte[] GetCoverImage(string filepath, bool createThumbnail = false) { - if (!File.Exists(filepath) || !Parser.Parser.IsArchive(filepath)) return Array.Empty(); + if (string.IsNullOrEmpty(filepath) || !File.Exists(filepath) || !Parser.Parser.IsArchive(filepath)) return Array.Empty(); using ZipArchive archive = ZipFile.OpenRead(filepath); - if (archive.Entries.Count <= 0) return Array.Empty(); + if (!archive.HasFiles()) return Array.Empty(); var folder = archive.Entries.SingleOrDefault(x => Path.GetFileNameWithoutExtension(x.Name).ToLower() == "folder"); - var entry = archive.Entries.OrderBy(x => x.FullName).ToList()[0]; + var entry = archive.Entries.Where(x => Path.HasExtension(x.FullName)).OrderBy(x => x.FullName).ToList()[0]; if (folder != null) { entry = folder; } - if (entry.FullName.EndsWith(Path.PathSeparator)) - { - // TODO: Implement nested directory support - } - if (createThumbnail) { try @@ -50,10 +46,11 @@ namespace API.IO catch (Exception ex) { Console.WriteLine("There was a critical error and prevented thumbnail generation."); + Console.WriteLine(ex.Message); } } - return ExtractEntryToImage(entry); + return ExtractEntryToImage(entry); } private static byte[] ExtractEntryToImage(ZipArchiveEntry entry) diff --git a/API/Interfaces/ICacheService.cs b/API/Interfaces/ICacheService.cs new file mode 100644 index 000000000..81a4ef5fa --- /dev/null +++ b/API/Interfaces/ICacheService.cs @@ -0,0 +1,36 @@ +using System.Threading.Tasks; +using API.Entities; + +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 + /// cache operations (except cleanup). + /// + /// + /// Volume for the passed volumeId. Side-effect from ensuring cache. + Task Ensure(int volumeId); + + /// + /// Clears cache directory of all folders and files. + /// + void Cleanup(); + + /// + /// 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); + + + /// + /// Returns the absolute path of a cached page. + /// + /// + /// Page number to look for + /// + string GetCachedPagePath(Volume volume, int page); + } +} \ No newline at end of file diff --git a/API/Interfaces/IDirectoryService.cs b/API/Interfaces/IDirectoryService.cs index f8e0f7100..9592292c4 100644 --- a/API/Interfaces/IDirectoryService.cs +++ b/API/Interfaces/IDirectoryService.cs @@ -1,11 +1,53 @@ using System.Collections.Generic; +using System.Threading.Tasks; +using API.DTOs; namespace API.Interfaces { public interface IDirectoryService { + /// + /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. + /// + /// Absolute path of directory to scan. + /// List of folder names IEnumerable ListDirectory(string rootPath); + /// + /// Lists out top-level files for a given directory. + /// TODO: Implement ability to provide a filter for file types (done in another implementation on DirectoryService) + /// + /// Absolute path + /// List of folder names + IList ListFiles(string rootPath); + + /// + /// Given a library id, scans folders for said library. Parses files and generates DB updates. Will overwrite + /// cover images if forceUpdate is true. + /// + /// Library to scan against + /// Force overwriting for cover images void ScanLibrary(int libraryId, bool forceUpdate); + + /// + /// Returns the path a volume would be extracted to. + /// Deprecated. + /// + /// + /// + string GetExtractPath(int volumeId); + + Task ReadImageAsync(string imagePath); + + /// + /// Extracts an archive to a temp cache directory. Returns path to new directory. If temp cache directory already exists, + /// will return that without performing an extraction. Returns empty string if there are any invalidations which would + /// prevent operations to perform correctly (missing archivePath file, empty archive, etc). + /// + /// A valid file to an archive file. + /// Path to extract to + /// + string ExtractArchive(string archivePath, string extractPath); + } } \ No newline at end of file diff --git a/API/Interfaces/ISeriesRepository.cs b/API/Interfaces/ISeriesRepository.cs index fe2a6b6b3..490628163 100644 --- a/API/Interfaces/ISeriesRepository.cs +++ b/API/Interfaces/ISeriesRepository.cs @@ -16,6 +16,11 @@ namespace API.Interfaces Task> GetVolumesDtoAsync(int seriesId); IEnumerable GetVolumes(int seriesId); Task GetSeriesDtoByIdAsync(int seriesId); - + + Task GetVolumeAsync(int volumeId); + Task GetVolumeDtoAsync(int volumeId); + + Task> GetVolumesForSeriesAsync(int[] seriesIds); + Task DeleteSeriesAsync(int seriesId); } } \ No newline at end of file diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index b94f6f719..6f2d4eaf2 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -1,12 +1,14 @@ using System; using System.IO; using System.Text.RegularExpressions; +using API.Entities; namespace API.Parser { public static class Parser { public static readonly string MangaFileExtensions = @"\.cbz|\.cbr|\.png|\.jpeg|\.jpg|\.zip|\.rar"; + public static readonly string ImageFileExtensions = @"\.png|\.jpeg|\.jpg|\.gif"; //?: is a non-capturing group in C#, else anything in () will be a group private static readonly Regex[] MangaVolumeRegex = new[] @@ -100,9 +102,17 @@ namespace API.Parser Chapters = ParseChapter(filePath), Series = ParseSeries(filePath), Volumes = ParseVolume(filePath), - File = filePath + Filename = filePath, + Format = ParseFormat(filePath) }; } + + public static MangaFormat ParseFormat(string filePath) + { + if (IsArchive(filePath)) return MangaFormat.Archive; + if (IsImage(filePath)) return MangaFormat.Image; + return MangaFormat.Unknown; + } public static string ParseSeries(string filename) { @@ -168,7 +178,7 @@ namespace API.Parser } } - return ""; + return "0"; } /// @@ -231,8 +241,13 @@ namespace API.Parser public static bool IsArchive(string filePath) { var fileInfo = new FileInfo(filePath); - return MangaFileExtensions.Contains(fileInfo.Extension); } + + public static bool IsImage(string filePath) + { + var fileInfo = new FileInfo(filePath); + return ImageFileExtensions.Contains(fileInfo.Extension); + } } } \ No newline at end of file diff --git a/API/Parser/ParserInfo.cs b/API/Parser/ParserInfo.cs index 68e5a4eb7..ab0c06788 100644 --- a/API/Parser/ParserInfo.cs +++ b/API/Parser/ParserInfo.cs @@ -1,4 +1,6 @@  +using API.Entities; + namespace API.Parser { /// @@ -11,7 +13,11 @@ namespace API.Parser public string Series { get; set; } // This can be multiple public string Volumes { get; set; } - public string File { get; init; } + public string Filename { get; init; } public string FullFilePath { get; set; } + /// + /// Raw (image), Archive + /// + public MangaFormat Format { get; set; } } } \ No newline at end of file diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs new file mode 100644 index 000000000..a6372bb1f --- /dev/null +++ b/API/Services/CacheService.cs @@ -0,0 +1,125 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using API.Comparators; +using API.Entities; +using API.Extensions; +using API.Interfaces; +using Microsoft.Extensions.Logging; + +namespace API.Services +{ + public class CacheService : ICacheService + { + private readonly IDirectoryService _directoryService; + private readonly ISeriesRepository _seriesRepository; + private readonly ILogger _logger; + private readonly NumericComparer _numericComparer; + private readonly string _cacheDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../cache/")); + + public CacheService(IDirectoryService directoryService, ISeriesRepository seriesRepository, ILogger logger) + { + _directoryService = directoryService; + _seriesRepository = seriesRepository; + _logger = logger; + _numericComparer = new NumericComparer(); + } + + private bool CacheDirectoryIsAccessible() + { + var di = new DirectoryInfo(_cacheDirectory); + return di.Exists; + } + + public async Task Ensure(int volumeId) + { + if (!CacheDirectoryIsAccessible()) + { + return null; + } + Volume volume = await _seriesRepository.GetVolumeAsync(volumeId); + foreach (var file in volume.Files) + { + var extractPath = GetVolumeCachePath(volumeId, file); + + _directoryService.ExtractArchive(file.FilePath, extractPath); + } + + return volume; + } + + 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; + } + + DirectoryInfo di = new DirectoryInfo(_cacheDirectory); + + try + { + di.Empty(); + } + catch (Exception ex) + { + _logger.LogError("There was an issue deleting one or more folders/files during cleanup.", ex); + } + + _logger.LogInformation("Cache directory purged."); + } + + public void CleanupVolumes(int[] volumeIds) + { + _logger.LogInformation($"Running Cache cleanup on Volumes"); + + foreach (var volume in volumeIds) + { + var di = new DirectoryInfo(Path.Join(_cacheDirectory, volume + "")); + if (di.Exists) + { + di.Delete(true); + } + + } + _logger.LogInformation("Cache directory purged"); + } + + + 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) + { + // Calculate what chapter the page belongs to + var pagesSoFar = 0; + foreach (var mangaFile in volume.Files.OrderBy(f => f.Chapter)) + { + if (page + 1 < (mangaFile.NumberOfPages + pagesSoFar)) + { + var path = GetVolumeCachePath(volume.Id, mangaFile); + + var files = _directoryService.ListFiles(path); + var array = files.ToArray(); + Array.Sort(array, _numericComparer); // TODO: Find a way to apply numericComparer to IList. + + return array.ElementAt((page + 1) - pagesSoFar); + } + + pagesSoFar += mangaFile.NumberOfPages; + } + return ""; + } + } +} \ No newline at end of file diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 3adfae100..02358862c 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -4,16 +4,19 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.IO; +using System.IO.Compression; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using API.DTOs; using API.Entities; +using API.Extensions; using API.Interfaces; using API.IO; using API.Parser; -using Hangfire; using Microsoft.Extensions.Logging; +using NetVips; namespace API.Services { @@ -41,21 +44,16 @@ namespace API.Services /// Regex version of search pattern (ie \.mp3|\.mp4) /// SearchOption to use, defaults to TopDirectoryOnly /// List of file paths - public static IEnumerable GetFiles(string path, + private static IEnumerable GetFiles(string path, string searchPatternExpression = "", SearchOption searchOption = SearchOption.TopDirectoryOnly) { - Regex reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase); + var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase); return Directory.EnumerateFiles(path, "*", searchOption) .Where(file => reSearchPattern.IsMatch(Path.GetExtension(file))); } - /// - /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. - /// - /// Absolute path - /// List of folder names public IEnumerable ListDirectory(string rootPath) { if (!Directory.Exists(rootPath)) return ImmutableList.Empty; @@ -69,6 +67,12 @@ namespace API.Services return dirs; } + public IList ListFiles(string rootPath) + { + if (!Directory.Exists(rootPath)) return ImmutableList.Empty; + return Directory.GetFiles(rootPath); + } + /// /// Processes files found during a library scan. Generates a collection of series->volume->files for DB processing later. @@ -86,9 +90,8 @@ namespace API.Services return; } - ConcurrentBag tempBag; ConcurrentBag newBag = new ConcurrentBag(); - if (_scannedSeries.TryGetValue(info.Series, out tempBag)) + if (_scannedSeries.TryGetValue(info.Series, out var tempBag)) { var existingInfos = tempBag.ToArray(); foreach (var existingInfo in existingInfos) @@ -111,26 +114,34 @@ namespace API.Services private Series UpdateSeries(string seriesName, ParserInfo[] infos, bool forceUpdate) { - var series = _seriesRepository.GetSeriesByName(seriesName); - - if (series == null) + var series = _seriesRepository.GetSeriesByName(seriesName) ?? new Series { - series = new Series() - { - Name = seriesName, - OriginalName = seriesName, - SortName = seriesName, - Summary = "" - }; - } - + Name = seriesName, + OriginalName = seriesName, + SortName = seriesName, + Summary = "" // TODO: Check if comicInfo.xml in file and parse metadata out. + }; + var volumes = UpdateVolumes(series, infos, forceUpdate); series.Volumes = volumes; - // TODO: Instead of taking first entry, re-calculate without compression series.CoverImage = volumes.OrderBy(x => x.Number).FirstOrDefault()?.CoverImage; return series; } + private MangaFile CreateMangaFile(ParserInfo info) + { + _logger.LogDebug($"Creating File Entry for {info.FullFilePath}"); + int.TryParse(info.Chapters, out var chapter); + _logger.LogDebug($"Found Chapter: {chapter}"); + return new MangaFile() + { + FilePath = info.FullFilePath, + Chapter = chapter, + Format = info.Format, + NumberOfPages = GetNumberOfPagesFromArchive(info.FullFilePath) + }; + } + /// /// Creates or Updates volumes for a given series /// @@ -142,46 +153,60 @@ namespace API.Services { ICollection volumes = new List(); IList existingVolumes = _seriesRepository.GetVolumes(series.Id).ToList(); - + foreach (var info in infos) { var existingVolume = existingVolumes.SingleOrDefault(v => v.Name == info.Volumes); if (existingVolume != null) { - // Temp let's overwrite all files (we need to enhance to update files) - existingVolume.Files = new List() + var existingFile = existingVolume.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); + if (existingFile != null) { - new MangaFile() - { - FilePath = info.File - } - }; - - if (forceUpdate || existingVolume.CoverImage == null || existingVolumes.Count == 0) - { - existingVolume.CoverImage = ImageProvider.GetCoverImage(info.FullFilePath, true); + existingFile.Chapter = Int32.Parse(info.Chapters); + existingFile.Format = info.Format; + existingFile.NumberOfPages = GetNumberOfPagesFromArchive(info.FullFilePath); } + else + { + existingVolume.Files.Add(CreateMangaFile(info)); + } + volumes.Add(existingVolume); } else { - var vol = new Volume() + existingVolume = volumes.SingleOrDefault(v => v.Name == info.Volumes); + if (existingVolume != null) { - Name = info.Volumes, - Number = Int32.Parse(info.Volumes), - CoverImage = ImageProvider.GetCoverImage(info.FullFilePath, true), - Files = new List() + existingVolume.Files.Add(CreateMangaFile(info)); + } + else + { + var vol = new Volume() { - new MangaFile() + Name = info.Volumes, + Number = Int32.Parse(info.Volumes), + Files = new List() { - FilePath = info.File + CreateMangaFile(info) } - } - }; - volumes.Add(vol); + }; + volumes.Add(vol); + } } - Console.WriteLine($"Adding volume {volumes.Last().Number} with File: {info.File}"); + Console.WriteLine($"Adding volume {volumes.Last().Number} with File: {info.Filename}"); + } + + foreach (var volume in volumes) + { + if (forceUpdate || volume.CoverImage == null || !volume.Files.Any()) + { + var firstFile = volume.Files.OrderBy(x => x.Chapter).FirstOrDefault()?.FilePath; + volume.CoverImage = ImageProvider.GetCoverImage(firstFile, true); + } + + volume.Pages = volume.Files.Sum(x => x.NumberOfPages); } return volumes; @@ -189,14 +214,27 @@ namespace API.Services public void ScanLibrary(int libraryId, bool forceUpdate) { - var library = Task.Run(() => _libraryRepository.GetLibraryForIdAsync(libraryId)).Result; + var sw = Stopwatch.StartNew(); + Library library; + try + { + library = Task.Run(() => _libraryRepository.GetLibraryForIdAsync(libraryId)).Result; + } + catch (Exception ex) + { + // This usually only fails if user is not authenticated. + _logger.LogError($"There was an issue fetching Library {libraryId}.", ex); + return; + } + _scannedSeries = new ConcurrentDictionary>(); _logger.LogInformation($"Beginning scan on {library.Name}"); - + + var totalFiles = 0; foreach (var folderPath in library.Folders) { try { - TraverseTreeParallelForEach(folderPath.Path, (f) => + totalFiles = TraverseTreeParallelForEach(folderPath.Path, (f) => { try { @@ -220,9 +258,9 @@ namespace API.Services library.Series = new List(); // Temp delete everything until we can mark items Unavailable foreach (var seriesKey in series.Keys) { - var s = UpdateSeries(seriesKey, series[seriesKey].ToArray(), forceUpdate); - _logger.LogInformation($"Created/Updated series {s.Name}"); - library.Series.Add(s); + var mangaSeries = UpdateSeries(seriesKey, series[seriesKey].ToArray(), forceUpdate); + _logger.LogInformation($"Created/Updated series {mangaSeries.Name}"); + library.Series.Add(mangaSeries); } @@ -239,13 +277,123 @@ namespace API.Services } _scannedSeries = null; + _logger.LogInformation("Processed {0} files in {1} milliseconds for {2}", totalFiles, sw.ElapsedMilliseconds, library.Name); } - private static void TraverseTreeParallelForEach(string root, Action action) + public string GetExtractPath(int volumeId) + { + return Path.Join(Directory.GetCurrentDirectory(), $"../cache/{volumeId}/"); + } + + /// + /// TODO: Delete this method + /// + /// + /// + /// + private string ExtractArchive(string archivePath, int volumeId) + { + if (!File.Exists(archivePath) || !Parser.Parser.IsArchive(archivePath)) + { + _logger.LogError($"Archive {archivePath} could not be found."); + return ""; + } + + var extractPath = GetExtractPath(volumeId); + + if (Directory.Exists(extractPath)) + { + _logger.LogInformation($"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.LogInformation($"Extracting archive to {extractPath}"); + + if (needsFlattening) + { + _logger.LogInformation("Extracted archive is nested in root folder, flattening..."); + new DirectoryInfo(extractPath).Flatten(); + } + + return extractPath; + } + + public string ExtractArchive(string archivePath, string extractPath) + { + if (!File.Exists(archivePath) || !Parser.Parser.IsArchive(archivePath)) + { + _logger.LogError($"Archive {archivePath} could not be found."); + return ""; + } + + if (Directory.Exists(extractPath)) + { + _logger.LogDebug($"Archive {archivePath} has already been extracted. Returning existing folder."); + return extractPath; + } + + using ZipArchive archive = ZipFile.OpenRead(archivePath); + // TODO: Throw error if we couldn't extract + var needsFlattening = archive.Entries.Count > 0 && !Path.HasExtension(archive.Entries.ElementAt(0).FullName); + if (!archive.HasFiles() && !needsFlattening) return ""; + + archive.ExtractToDirectory(extractPath); + _logger.LogDebug($"Extracting archive to {extractPath}"); + + if (!needsFlattening) return extractPath; + + _logger.LogInformation("Extracted archive is nested in root folder, flattening..."); + new DirectoryInfo(extractPath).Flatten(); + + return extractPath; + } + + private int GetNumberOfPagesFromArchive(string archivePath) + { + if (!File.Exists(archivePath) || !Parser.Parser.IsArchive(archivePath)) + { + _logger.LogError($"Archive {archivePath} could not be found."); + return 0; + } + + using ZipArchive archive = ZipFile.OpenRead(archivePath); + return archive.Entries.Count(e => Parser.Parser.IsImage(e.FullName)); + } + + + public async Task ReadImageAsync(string imagePath) + { + using var image = Image.NewFromFile(imagePath); + + return new ImageDto + { + Content = await File.ReadAllBytesAsync(imagePath), + Filename = Path.GetFileNameWithoutExtension(imagePath), + FullPath = Path.GetFullPath(imagePath), + Width = image.Width, + Height = image.Height, + Format = image.Format + }; + } + + /// + /// Recursively scans files and applies an action on them. This uses as many cores the underlying PC has to speed + /// up processing. + /// + /// Directory to scan + /// Action to apply on file path + /// + private static int TraverseTreeParallelForEach(string root, Action action) { //Count of files traversed and timer for diagnostic output int fileCount = 0; - var sw = Stopwatch.StartNew(); // Determine whether to parallelize file processing on each folder based on processor count. int procCount = Environment.ProcessorCount; @@ -333,8 +481,7 @@ namespace API.Services dirs.Push(str); } - // For diagnostic purposes. - Console.WriteLine("Processed {0} files in {1} milliseconds", fileCount, sw.ElapsedMilliseconds); + return fileCount; } } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index d5dcdcb5c..00fd5597e 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -1,15 +1,21 @@ using API.Interfaces; using Hangfire; +using Microsoft.Extensions.Logging; namespace API.Services { public class TaskScheduler : ITaskScheduler { + private readonly ILogger _logger; private readonly BackgroundJobServer _client; - public TaskScheduler() + public TaskScheduler(ICacheService cacheService, ILogger logger) { + _logger = logger; _client = new BackgroundJobServer(); + + _logger.LogInformation("Scheduling/Updating cache cleanup on a daily basis."); + RecurringJob.AddOrUpdate(() => cacheService.Cleanup(), Cron.Daily); } diff --git a/API/Startup.cs b/API/Startup.cs index 4116a1aa7..90a4aff92 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -1,4 +1,3 @@ -using System; using API.Extensions; using API.Middleware; using Hangfire; @@ -47,7 +46,6 @@ namespace API app.UseHangfireDashboard(); - //backgroundJobs.Enqueue(() => Console.WriteLine("Hello world from Hangfire!")); app.UseHttpsRedirection(); diff --git a/Kavita.sln.DotSettings b/Kavita.sln.DotSettings new file mode 100644 index 000000000..471c3f677 --- /dev/null +++ b/Kavita.sln.DotSettings @@ -0,0 +1,4 @@ + + ExplicitlyExcluded + True + True \ No newline at end of file diff --git a/images/Image-1.jpg b/images/Image-1.jpg deleted file mode 100644 index b4dfd57c6..000000000 Binary files a/images/Image-1.jpg and /dev/null differ