diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index e19d7abc9..ec5ee39dd 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -23,4 +23,8 @@ + + + + diff --git a/API.Tests/ParserTest.cs b/API.Tests/ParserTest.cs index 82c4a4892..320856c32 100644 --- a/API.Tests/ParserTest.cs +++ b/API.Tests/ParserTest.cs @@ -82,5 +82,15 @@ namespace API.Tests { Assert.Equal(expected, CleanTitle(input)); } + + [Theory] + [InlineData("test.cbz", true)] + [InlineData("test.cbr", true)] + [InlineData("test.zip", true)] + [InlineData("test.rar", true)] + public void IsArchiveTest(string input, bool expected) + { + Assert.Equal(expected, IsArchive(input)); + } } } \ No newline at end of file diff --git a/API.Tests/Services/ImageProviderTest.cs b/API.Tests/Services/ImageProviderTest.cs new file mode 100644 index 000000000..e7e393bfc --- /dev/null +++ b/API.Tests/Services/ImageProviderTest.cs @@ -0,0 +1,24 @@ +using System; +using System.IO; +using API.IO; +using NetVips; +using Xunit; +using Xunit.Abstractions; + +namespace API.Tests.Services +{ + public class ImageProviderTest + { + [Theory] + [InlineData("v10.cbz", "v10.expected.jpg")] + [InlineData("v10 - with folder.cbz", "v10 - with folder.expected.jpg")] + //[InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.jpg")] + public void GetCoverImageTest(string inputFile, string expectedOutputFile) + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ImageProvider"); + var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile)); + + Assert.Equal(expectedBytes, ImageProvider.GetCoverImage(Path.Join(testDirectory, inputFile))); + } + } +} \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ImageProvider/thumbnail.expected.jpg b/API.Tests/Services/Test Data/ImageProvider/thumbnail.expected.jpg new file mode 100644 index 000000000..7cbc36328 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageProvider/thumbnail.expected.jpg differ diff --git a/API.Tests/Services/Test Data/ImageProvider/thumbnail.jpg b/API.Tests/Services/Test Data/ImageProvider/thumbnail.jpg new file mode 100644 index 000000000..b192a9c62 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageProvider/thumbnail.jpg differ diff --git a/API.Tests/Services/Test Data/ImageProvider/v10 - nested folder.cbz b/API.Tests/Services/Test Data/ImageProvider/v10 - nested folder.cbz new file mode 100644 index 000000000..0d2886130 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageProvider/v10 - nested folder.cbz differ diff --git a/API.Tests/Services/Test Data/ImageProvider/v10 - nested folder.expected.jpg b/API.Tests/Services/Test Data/ImageProvider/v10 - nested folder.expected.jpg new file mode 100644 index 000000000..51fd89ca0 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageProvider/v10 - nested folder.expected.jpg differ diff --git a/API.Tests/Services/Test Data/ImageProvider/v10 - with folder.cbz b/API.Tests/Services/Test Data/ImageProvider/v10 - with folder.cbz new file mode 100644 index 000000000..013fddebc Binary files /dev/null and b/API.Tests/Services/Test Data/ImageProvider/v10 - with folder.cbz differ diff --git a/API.Tests/Services/Test Data/ImageProvider/v10 - with folder.expected.jpg b/API.Tests/Services/Test Data/ImageProvider/v10 - with folder.expected.jpg new file mode 100644 index 000000000..888eeb5ec Binary files /dev/null and b/API.Tests/Services/Test Data/ImageProvider/v10 - with folder.expected.jpg differ diff --git a/API.Tests/Services/Test Data/ImageProvider/v10.cbz b/API.Tests/Services/Test Data/ImageProvider/v10.cbz new file mode 100644 index 000000000..99fbfa400 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageProvider/v10.cbz differ diff --git a/API.Tests/Services/Test Data/ImageProvider/v10.expected.jpg b/API.Tests/Services/Test Data/ImageProvider/v10.expected.jpg new file mode 100644 index 000000000..51fd89ca0 Binary files /dev/null and b/API.Tests/Services/Test Data/ImageProvider/v10.expected.jpg differ diff --git a/API/API.csproj b/API/API.csproj index 1b99ef72e..73575c864 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -20,6 +20,8 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -30,6 +32,20 @@ + + + + + + + + + + + + + + diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 3b0481866..6de66f861 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -72,7 +72,7 @@ namespace API.Controllers if (await _userRepository.SaveAllAsync()) { var createdLibrary = await _libraryRepository.GetLibraryForNameAsync(library.Name); - BackgroundJob.Enqueue(() => _directoryService.ScanLibrary(createdLibrary.Id)); + BackgroundJob.Enqueue(() => _directoryService.ScanLibrary(createdLibrary.Id, false)); return Ok(); } @@ -129,9 +129,9 @@ namespace API.Controllers [Authorize(Policy = "RequireAdminRole")] [HttpPost("scan")] - public ActionResult ScanLibrary(int libraryId) + public ActionResult Scan(int libraryId) { - BackgroundJob.Enqueue(() => _directoryService.ScanLibrary(libraryId)); + BackgroundJob.Enqueue(() => _directoryService.ScanLibrary(libraryId, true)); return Ok(); } diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs index 9e9491d7e..eb10f3d0d 100644 --- a/API/DTOs/SeriesDto.cs +++ b/API/DTOs/SeriesDto.cs @@ -7,5 +7,6 @@ public string OriginalName { get; set; } public string SortName { get; set; } public string Summary { get; set; } + public byte[] CoverImage { get; set; } } } \ No newline at end of file diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index b3dab2834..e7c7927e1 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -7,7 +7,6 @@ namespace API.DTOs public int Id { get; set; } public int Number { get; set; } public string Name { get; set; } - public string CoverImage { get; set; } - public ICollection Files { get; set; } + public byte[] CoverImage { get; set; } } } \ No newline at end of file diff --git a/API/Data/Migrations/20210103230812_SeriesCoverImage.Designer.cs b/API/Data/Migrations/20210103230812_SeriesCoverImage.Designer.cs new file mode 100644 index 000000000..03f94a6a2 --- /dev/null +++ b/API/Data/Migrations/20210103230812_SeriesCoverImage.Designer.cs @@ -0,0 +1,509 @@ +// +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("20210103230812_SeriesCoverImage")] + partial class SeriesCoverImage + { + 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("FilePath") + .HasColumnType("TEXT"); + + 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("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/20210103230812_SeriesCoverImage.cs b/API/Data/Migrations/20210103230812_SeriesCoverImage.cs new file mode 100644 index 000000000..9436cbdcf --- /dev/null +++ b/API/Data/Migrations/20210103230812_SeriesCoverImage.cs @@ -0,0 +1,32 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class SeriesCoverImage : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "CoverImage", + table: "Series", + type: "BLOB", + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT", + oldNullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "CoverImage", + table: "Series", + type: "TEXT", + nullable: true, + oldClrType: typeof(byte[]), + oldType: "BLOB", + oldNullable: true); + } + } +} diff --git a/API/Data/Migrations/20210104011624_VolumeCoverImage.Designer.cs b/API/Data/Migrations/20210104011624_VolumeCoverImage.Designer.cs new file mode 100644 index 000000000..437daca24 --- /dev/null +++ b/API/Data/Migrations/20210104011624_VolumeCoverImage.Designer.cs @@ -0,0 +1,512 @@ +// +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("20210104011624_VolumeCoverImage")] + partial class VolumeCoverImage + { + 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("FilePath") + .HasColumnType("TEXT"); + + 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/20210104011624_VolumeCoverImage.cs b/API/Data/Migrations/20210104011624_VolumeCoverImage.cs new file mode 100644 index 000000000..49bc17fea --- /dev/null +++ b/API/Data/Migrations/20210104011624_VolumeCoverImage.cs @@ -0,0 +1,24 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class VolumeCoverImage : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CoverImage", + table: "Volume", + type: "BLOB", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CoverImage", + table: "Volume"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 6e0a0fae0..bb0146e52 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -203,8 +203,8 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("CoverImage") - .HasColumnType("TEXT"); + b.Property("CoverImage") + .HasColumnType("BLOB"); b.Property("Created") .HasColumnType("TEXT"); @@ -240,6 +240,9 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("CoverImage") + .HasColumnType("BLOB"); + b.Property("Created") .HasColumnType("TEXT"); diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs index 011550348..bed3bb093 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/SeriesRepository.cs @@ -66,6 +66,7 @@ namespace API.Data { return _context.Volume .Where(vol => vol.SeriesId == seriesId) + .Include(vol => vol.Files) .OrderBy(vol => vol.Number) .ToList(); } diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index f20fc8658..368a04e50 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -23,14 +23,11 @@ namespace API.Entities /// Summary information related to the Series /// public string Summary { get; set; } - public string CoverImage { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } + public byte[] CoverImage { get; set; } public ICollection Volumes { get; set; } - public Library Library { get; set; } public int LibraryId { get; set; } - - } } \ No newline at end of file diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index 18cadee8d..9bbd1f6ae 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -12,8 +12,9 @@ namespace API.Entities public ICollection Files { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } + public byte[] CoverImage { get; set; } - // Many-to-Many relationships + // Many-to-One relationships public Series Series { get; set; } public int SeriesId { get; set; } } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 35d7bb951..714871813 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -11,9 +11,7 @@ namespace API.Helpers { CreateMap(); - CreateMap() - .ForMember(dest => dest.Files, - opt => opt.MapFrom(src => src.Files.Select(x => x.FilePath).ToList())); + CreateMap(); CreateMap(); diff --git a/API/IO/ImageProvider.cs b/API/IO/ImageProvider.cs new file mode 100644 index 000000000..9374ce9bf --- /dev/null +++ b/API/IO/ImageProvider.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using NetVips; + +namespace API.IO +{ + public static class ImageProvider + { + /// + /// Generates byte array of cover image. + /// Given a path to a compressed file (zip, rar, cbz, cbr, etc), will ensure the first image is returned unless + /// a folder.extension exists in the root directory of the compressed file. + /// + /// + /// Create a smaller variant of file extracted from archive. Archive images are usually 1MB each. + /// + public static byte[] GetCoverImage(string filepath, bool createThumbnail = false) + { + if (!File.Exists(filepath) || !Parser.Parser.IsArchive(filepath)) return Array.Empty(); + + using ZipArchive archive = ZipFile.OpenRead(filepath); + if (archive.Entries.Count <= 0) 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]; + + if (folder != null) + { + entry = folder; + } + + if (entry.FullName.EndsWith(Path.PathSeparator)) + { + // TODO: Implement nested directory support + } + + if (createThumbnail) + { + try + { + using var stream = entry.Open(); + var thumbnail = Image.ThumbnailStream(stream, 320); + Console.WriteLine(thumbnail.ToString()); + return thumbnail.WriteToBuffer(".jpg"); + } + catch (Exception ex) + { + Console.WriteLine("There was a critical error and prevented thumbnail generation."); + } + } + + return ExtractEntryToImage(entry); + } + + private static byte[] ExtractEntryToImage(ZipArchiveEntry entry) + { + using var stream = entry.Open(); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + var data = ms.ToArray(); + + return data; + } + } +} \ No newline at end of file diff --git a/API/Interfaces/IDirectoryService.cs b/API/Interfaces/IDirectoryService.cs index 351e67a68..f8e0f7100 100644 --- a/API/Interfaces/IDirectoryService.cs +++ b/API/Interfaces/IDirectoryService.cs @@ -6,6 +6,6 @@ namespace API.Interfaces { IEnumerable ListDirectory(string rootPath); - void ScanLibrary(int libraryId); + void ScanLibrary(int libraryId, bool forceUpdate); } } \ No newline at end of file diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index f0633c36e..b94f6f719 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Text.RegularExpressions; namespace API.Parser @@ -226,5 +227,12 @@ namespace API.Parser { return title.TrimStart(new[] { '0' }); } + + public static bool IsArchive(string filePath) + { + var fileInfo = new FileInfo(filePath); + + return MangaFileExtensions.Contains(fileInfo.Extension); + } } } \ No newline at end of file diff --git a/API/Parser/ParserInfo.cs b/API/Parser/ParserInfo.cs index e1f35b597..68e5a4eb7 100644 --- a/API/Parser/ParserInfo.cs +++ b/API/Parser/ParserInfo.cs @@ -12,5 +12,6 @@ namespace API.Parser // This can be multiple public string Volumes { get; set; } public string File { get; init; } + public string FullFilePath { get; set; } } } \ No newline at end of file diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 706967bc1..3adfae100 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -10,7 +10,9 @@ using System.Threading; using System.Threading.Tasks; using API.Entities; using API.Interfaces; +using API.IO; using API.Parser; +using Hangfire; using Microsoft.Extensions.Logging; namespace API.Services @@ -20,9 +22,12 @@ namespace API.Services private readonly ILogger _logger; private readonly ISeriesRepository _seriesRepository; private readonly ILibraryRepository _libraryRepository; + private ConcurrentDictionary> _scannedSeries; - public DirectoryService(ILogger logger, ISeriesRepository seriesRepository, ILibraryRepository libraryRepository) + public DirectoryService(ILogger logger, + ISeriesRepository seriesRepository, + ILibraryRepository libraryRepository) { _logger = logger; _seriesRepository = seriesRepository; @@ -45,10 +50,7 @@ namespace API.Services .Where(file => reSearchPattern.IsMatch(Path.GetExtension(file))); } - - - /// /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. /// @@ -69,69 +71,47 @@ namespace API.Services /// - /// Processes files found during a library scan. + /// Processes files found during a library scan. Generates a collection of series->volume->files for DB processing later. /// - /// + /// Path of a file private void Process(string path) { - // NOTE: In current implementation, this never runs. We can probably remove. - if (Directory.Exists(path)) - { - DirectoryInfo di = new DirectoryInfo(path); - _logger.LogDebug($"Parsing directory {di.Name}"); + var fileName = Path.GetFileName(path); + _logger.LogDebug($"Parsing file {fileName}"); - var seriesName = Parser.Parser.ParseSeries(di.Name); - if (string.IsNullOrEmpty(seriesName)) + var info = Parser.Parser.Parse(fileName); + info.FullFilePath = path; + if (info.Volumes == string.Empty) + { + return; + } + + ConcurrentBag tempBag; + ConcurrentBag newBag = new ConcurrentBag(); + if (_scannedSeries.TryGetValue(info.Series, out tempBag)) + { + var existingInfos = tempBag.ToArray(); + foreach (var existingInfo in existingInfos) { - return; - } - - // We don't need ContainsKey, this is a race condition. We can replace with TryAdd instead - if (!_scannedSeries.ContainsKey(seriesName)) - { - _scannedSeries.TryAdd(seriesName, new ConcurrentBag()); + newBag.Add(existingInfo); } } else { - var fileName = Path.GetFileName(path); - _logger.LogDebug($"Parsing file {fileName}"); - - var info = Parser.Parser.Parse(fileName); - if (info.Volumes != string.Empty) - { - ConcurrentBag tempBag; - ConcurrentBag newBag = new ConcurrentBag(); - if (_scannedSeries.TryGetValue(info.Series, out tempBag)) - { - var existingInfos = tempBag.ToArray(); - foreach (var existingInfo in existingInfos) - { - newBag.Add(existingInfo); - } - } - else - { - tempBag = new ConcurrentBag(); - } - - newBag.Add(info); + tempBag = new ConcurrentBag(); + } - if (!_scannedSeries.TryUpdate(info.Series, newBag, tempBag)) - { - _scannedSeries.TryAdd(info.Series, newBag); - } - - } - - + newBag.Add(info); + + if (!_scannedSeries.TryUpdate(info.Series, newBag, tempBag)) + { + _scannedSeries.TryAdd(info.Series, newBag); } } - - private Series UpdateSeries(string seriesName, ParserInfo[] infos) + + private Series UpdateSeries(string seriesName, ParserInfo[] infos, bool forceUpdate) { var series = _seriesRepository.GetSeriesByName(seriesName); - ICollection volumes = new List(); if (series == null) { @@ -140,14 +120,29 @@ namespace API.Services Name = seriesName, OriginalName = seriesName, SortName = seriesName, - Summary = "", + Summary = "" }; } - - // BUG: This is creating new volume entries and not resetting each run. + 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; + } + + /// + /// Creates or Updates volumes for a given series + /// + /// Series wanting to be updated + /// Parser info + /// Forces metadata update (cover image) even if it's already been set. + /// Updated Volumes for given series + private ICollection UpdateVolumes(Series series, ParserInfo[] infos, bool forceUpdate) + { + ICollection volumes = new List(); IList existingVolumes = _seriesRepository.GetVolumes(series.Id).ToList(); - //IList existingVolumes = Task.Run(() => _seriesRepository.GetVolumesAsync(series.Id)).Result.ToList(); + foreach (var info in infos) { var existingVolume = existingVolumes.SingleOrDefault(v => v.Name == info.Volumes); @@ -161,6 +156,11 @@ namespace API.Services FilePath = info.File } }; + + if (forceUpdate || existingVolume.CoverImage == null || existingVolumes.Count == 0) + { + existingVolume.CoverImage = ImageProvider.GetCoverImage(info.FullFilePath, true); + } volumes.Add(existingVolume); } else @@ -169,6 +169,7 @@ namespace API.Services { Name = info.Volumes, Number = Int32.Parse(info.Volumes), + CoverImage = ImageProvider.GetCoverImage(info.FullFilePath, true), Files = new List() { new MangaFile() @@ -183,12 +184,10 @@ namespace API.Services Console.WriteLine($"Adding volume {volumes.Last().Number} with File: {info.File}"); } - series.Volumes = volumes; - - return series; + return volumes; } - public void ScanLibrary(int libraryId) + public void ScanLibrary(int libraryId, bool forceUpdate) { var library = Task.Run(() => _libraryRepository.GetLibraryForIdAsync(libraryId)).Result; _scannedSeries = new ConcurrentDictionary>(); @@ -221,11 +220,13 @@ 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()); + var s = UpdateSeries(seriesKey, series[seriesKey].ToArray(), forceUpdate); _logger.LogInformation($"Created/Updated series {s.Name}"); library.Series.Add(s); } + + _libraryRepository.Update(library); if (_libraryRepository.SaveAll()) @@ -236,13 +237,12 @@ namespace API.Services { _logger.LogError("There was a critical error that resulted in a failed scan. Please rescan."); } - _scannedSeries = null; } private static void TraverseTreeParallelForEach(string root, Action action) - { + { //Count of files traversed and timer for diagnostic output int fileCount = 0; var sw = Stopwatch.StartNew(); @@ -335,6 +335,7 @@ namespace API.Services // For diagnostic purposes. Console.WriteLine("Processed {0} files in {1} milliseconds", fileCount, sw.ElapsedMilliseconds); - } + } + } } \ No newline at end of file diff --git a/API/Startup.cs b/API/Startup.cs index 28ed23b46..4116a1aa7 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -47,7 +47,7 @@ namespace API app.UseHangfireDashboard(); - backgroundJobs.Enqueue(() => Console.WriteLine("Hello world from Hangfire!")); + //backgroundJobs.Enqueue(() => Console.WriteLine("Hello world from Hangfire!")); app.UseHttpsRedirection();