diff --git a/.gitignore b/.gitignore index a87e29ab0..9999bab3e 100644 --- a/.gitignore +++ b/.gitignore @@ -450,3 +450,4 @@ appsettings.json /API/Hangfire-log.db cache/ /API/wwwroot/ +/API/cache/ \ No newline at end of file diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs index 345ff93fb..5459133af 100644 --- a/API.Tests/Services/CacheServiceTests.cs +++ b/API.Tests/Services/CacheServiceTests.cs @@ -62,37 +62,37 @@ namespace API.Tests.Services [Fact] public void GetOrderedChaptersTest() { - var files = new List() - { - new() - { - Chapter = 1 - }, - new() - { - Chapter = 2 - }, - new() - { - Chapter = 0 - }, - }; - var expected = new List() - { - new() - { - Chapter = 1 - }, - new() - { - Chapter = 2 - }, - new() - { - Chapter = 0 - }, - }; - Assert.NotStrictEqual(expected, _cacheService.GetOrderedChapters(files)); + // var files = new List() + // { + // new() + // { + // Number = "1" + // }, + // new() + // { + // Chapter = 2 + // }, + // new() + // { + // Chapter = 0 + // }, + // }; + // var expected = new List() + // { + // new() + // { + // Chapter = 1 + // }, + // new() + // { + // Chapter = 2 + // }, + // new() + // { + // Chapter = 0 + // }, + // }; + // Assert.NotStrictEqual(expected, _cacheService.GetOrderedChapters(files)); } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 7aaba8c94..cb10f46e5 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -31,27 +31,29 @@ namespace API.Controllers } [HttpGet("image")] - public async Task> GetImage(int volumeId, int page) + public async Task> GetImage(int chapterId, int page) { // Temp let's iterate the directory each call to get next image - var volume = await _cacheService.Ensure(volumeId); + var chapter = await _cacheService.Ensure(chapterId); - var (path, mangaFile) = _cacheService.GetCachedPagePath(volume, page); + if (chapter == null) return BadRequest("There was an issue finding image file for reading."); + + var (path, mangaFile) = await _cacheService.GetCachedPagePath(chapter, page); if (string.IsNullOrEmpty(path)) return BadRequest($"No such image for page {page}"); var file = await _directoryService.ReadImageAsync(path); file.Page = page; - file.Chapter = mangaFile.Chapter; + //file.Chapter = chapter.Number; file.MangaFileName = mangaFile.FilePath; return Ok(file); } [HttpGet("get-bookmark")] - public async Task> GetBookmark(int volumeId) + public async Task> GetBookmark(int chapterId) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user.Progresses == null) return Ok(0); - var progress = user.Progresses.SingleOrDefault(x => x.AppUserId == user.Id && x.VolumeId == volumeId); + var progress = user.Progresses.SingleOrDefault(x => x.AppUserId == user.Id && x.ChapterId == chapterId); if (progress != null) return Ok(progress.PagesRead); @@ -62,12 +64,12 @@ namespace API.Controllers public async Task Bookmark(BookmarkDto bookmarkDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - _logger.LogInformation($"Saving {user.UserName} progress for {bookmarkDto.VolumeId} to page {bookmarkDto.PageNum}"); + _logger.LogInformation($"Saving {user.UserName} progress for Chapter {bookmarkDto.ChapterId} to page {bookmarkDto.PageNum}"); // TODO: Don't let user bookmark past total pages. user.Progresses ??= new List(); - var userProgress = user.Progresses.SingleOrDefault(x => x.VolumeId == bookmarkDto.VolumeId && x.AppUserId == user.Id); + var userProgress = user.Progresses.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id); if (userProgress == null) { @@ -77,13 +79,14 @@ namespace API.Controllers PagesRead = bookmarkDto.PageNum, VolumeId = bookmarkDto.VolumeId, SeriesId = bookmarkDto.SeriesId, + ChapterId = bookmarkDto.ChapterId }); } else { userProgress.PagesRead = bookmarkDto.PageNum; userProgress.SeriesId = bookmarkDto.SeriesId; - + userProgress.VolumeId = bookmarkDto.VolumeId; } _unitOfWork.UserRepository.Update(user); @@ -92,8 +95,7 @@ namespace API.Controllers { return Ok(); } - - + return BadRequest("Could not save progress"); } } diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index 4649a2ece..561b4ec20 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -47,6 +47,11 @@ namespace API.Controllers return Ok(result); } + /// + /// Returns All volumes for a series with progress information and Chapters + /// + /// + /// [HttpGet("volumes")] public async Task>> GetVolumes(int seriesId) { @@ -61,13 +66,12 @@ namespace API.Controllers return Ok(await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(volumeId, user.Id)); } - // [HttpGet("volume-files")] - // public async Task>> GetMangaFiles(int volumeId) - // { - // var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - // return Ok(await _unitOfWork.SeriesRepository.GetVolumeMangaFileDtos(volumeId)); - // } - + [HttpGet("chapter")] + public async Task> GetChapter(int chapterId) + { + return Ok(await _unitOfWork.VolumeRepository.GetChapterDtoAsync(chapterId)); + } + [Authorize(Policy = "RequireAdminRole")] [HttpPost("scan")] public ActionResult Scan(int libraryId, int seriesId) diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs new file mode 100644 index 000000000..ee58e6c18 --- /dev/null +++ b/API/DTOs/ChapterDto.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace API.DTOs +{ + public class ChapterDto + { + public int Id { get; set; } + /// + /// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2". + /// + public string Range { get; set; } + /// + /// Smallest number of the Range. + /// + public string Number { get; set; } + public byte[] CoverImage { get; set; } + /// + /// Total number of pages in all MangaFiles + /// + public int Pages { get; set; } + /// + /// The files that represent this Chapter + /// + public ICollection Files { get; set; } + /// + /// Calculated at API time. Number of pages read for this Chapter for logged in user. + /// + public int PagesRead { get; set; } + public int VolumeId { get; set; } + } +} \ No newline at end of file diff --git a/API/DTOs/MangaFileDto.cs b/API/DTOs/MangaFileDto.cs index 8d6535aa6..8cf706ea8 100644 --- a/API/DTOs/MangaFileDto.cs +++ b/API/DTOs/MangaFileDto.cs @@ -5,7 +5,6 @@ namespace API.DTOs public class MangaFileDto { public string FilePath { get; set; } - public int Chapter { get; set; } public int NumberOfPages { get; set; } public MangaFormat Format { get; set; } diff --git a/API/DTOs/VolumeDto.cs b/API/DTOs/VolumeDto.cs index 22266e710..39872c05a 100644 --- a/API/DTOs/VolumeDto.cs +++ b/API/DTOs/VolumeDto.cs @@ -11,6 +11,6 @@ namespace API.DTOs public byte[] CoverImage { get; set; } public int Pages { get; set; } public int PagesRead { get; set; } - public ICollection Files { get; set; } + public ICollection Chapters { get; set; } } } \ No newline at end of file diff --git a/API/Data/BookmarkDto.cs b/API/Data/BookmarkDto.cs index ea6654165..de7f1b6a7 100644 --- a/API/Data/BookmarkDto.cs +++ b/API/Data/BookmarkDto.cs @@ -3,6 +3,7 @@ public class BookmarkDto { public int VolumeId { get; init; } + public int ChapterId { get; init; } public int PageNum { get; init; } public int SeriesId { get; init; } } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index f89340f82..b5dd4710d 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -20,8 +20,11 @@ namespace API.Data } public DbSet Library { get; set; } public DbSet Series { get; set; } + + public DbSet Chapter { get; set; } public DbSet Volume { get; set; } public DbSet AppUser { get; set; } + public DbSet MangaFile { get; set; } public DbSet AppUserProgresses { get; set; } public DbSet AppUserRating { get; set; } public DbSet ServerSetting { get; set; } diff --git a/API/Data/Migrations/20210128143348_SeriesVolumeChapterChange.Designer.cs b/API/Data/Migrations/20210128143348_SeriesVolumeChapterChange.Designer.cs new file mode 100644 index 000000000..17cb4b81d --- /dev/null +++ b/API/Data/Migrations/20210128143348_SeriesVolumeChapterChange.Designer.cs @@ -0,0 +1,688 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210128143348_SeriesVolumeChapterChange")] + partial class SeriesVolumeChapterChange + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Chapter") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("NumberOfPages") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("VolumeId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("Files") + .HasForeignKey("ChapterId"); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210128143348_SeriesVolumeChapterChange.cs b/API/Data/Migrations/20210128143348_SeriesVolumeChapterChange.cs new file mode 100644 index 000000000..ae6e6b6d1 --- /dev/null +++ b/API/Data/Migrations/20210128143348_SeriesVolumeChapterChange.cs @@ -0,0 +1,111 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class SeriesVolumeChapterChange : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsSpecial", + table: "Volume", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "ChapterId", + table: "MangaFile", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastScanned", + table: "FolderPath", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + + migrationBuilder.AddColumn( + name: "ChapterId", + table: "AppUserProgresses", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "Chapter", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Range = table.Column(type: "TEXT", nullable: true), + Number = table.Column(type: "TEXT", nullable: true), + Created = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false), + CoverImage = table.Column(type: "BLOB", nullable: true), + Pages = table.Column(type: "INTEGER", nullable: false), + VolumeId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Chapter", x => x.Id); + table.ForeignKey( + name: "FK_Chapter_Volume_VolumeId", + column: x => x.VolumeId, + principalTable: "Volume", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_MangaFile_ChapterId", + table: "MangaFile", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_Chapter_VolumeId", + table: "Chapter", + column: "VolumeId"); + + migrationBuilder.AddForeignKey( + name: "FK_MangaFile_Chapter_ChapterId", + table: "MangaFile", + column: "ChapterId", + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_MangaFile_Chapter_ChapterId", + table: "MangaFile"); + + migrationBuilder.DropTable( + name: "Chapter"); + + migrationBuilder.DropIndex( + name: "IX_MangaFile_ChapterId", + table: "MangaFile"); + + migrationBuilder.DropColumn( + name: "IsSpecial", + table: "Volume"); + + migrationBuilder.DropColumn( + name: "ChapterId", + table: "MangaFile"); + + migrationBuilder.DropColumn( + name: "LastScanned", + table: "FolderPath"); + + migrationBuilder.DropColumn( + name: "ChapterId", + table: "AppUserProgresses"); + } + } +} diff --git a/API/Data/Migrations/20210128201832_MangaFileChapterRelationship.Designer.cs b/API/Data/Migrations/20210128201832_MangaFileChapterRelationship.Designer.cs new file mode 100644 index 000000000..5d0cfa7b5 --- /dev/null +++ b/API/Data/Migrations/20210128201832_MangaFileChapterRelationship.Designer.cs @@ -0,0 +1,676 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20210128201832_MangaFileChapterRelationship")] + partial class MangaFileChapterRelationship + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.1"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("NumberOfPages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20210128201832_MangaFileChapterRelationship.cs b/API/Data/Migrations/20210128201832_MangaFileChapterRelationship.cs new file mode 100644 index 000000000..a04e77dd2 --- /dev/null +++ b/API/Data/Migrations/20210128201832_MangaFileChapterRelationship.cs @@ -0,0 +1,98 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class MangaFileChapterRelationship : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_MangaFile_Chapter_ChapterId", + table: "MangaFile"); + + migrationBuilder.DropForeignKey( + name: "FK_MangaFile_Volume_VolumeId", + table: "MangaFile"); + + migrationBuilder.DropIndex( + name: "IX_MangaFile_VolumeId", + table: "MangaFile"); + + migrationBuilder.DropColumn( + name: "Chapter", + table: "MangaFile"); + + migrationBuilder.DropColumn( + name: "VolumeId", + table: "MangaFile"); + + migrationBuilder.AlterColumn( + name: "ChapterId", + table: "MangaFile", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_MangaFile_Chapter_ChapterId", + table: "MangaFile", + column: "ChapterId", + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_MangaFile_Chapter_ChapterId", + table: "MangaFile"); + + migrationBuilder.AlterColumn( + name: "ChapterId", + table: "MangaFile", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AddColumn( + name: "Chapter", + table: "MangaFile", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "VolumeId", + table: "MangaFile", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_MangaFile_VolumeId", + table: "MangaFile", + column: "VolumeId"); + + migrationBuilder.AddForeignKey( + name: "FK_MangaFile_Chapter_ChapterId", + table: "MangaFile", + column: "ChapterId", + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_MangaFile_Volume_VolumeId", + table: "MangaFile", + column: "VolumeId", + principalTable: "Volume", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 918500434..2ba32129a 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -127,6 +127,9 @@ namespace API.Data.Migrations b.Property("AppUserId") .HasColumnType("INTEGER"); + b.Property("ChapterId") + .HasColumnType("INTEGER"); + b.Property("PagesRead") .HasColumnType("INTEGER"); @@ -183,12 +186,49 @@ namespace API.Data.Migrations b.ToTable("AspNetUserRoles"); }); + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + modelBuilder.Entity("API.Entities.FolderPath", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("LastScanned") + .HasColumnType("TEXT"); + b.Property("LibraryId") .HasColumnType("INTEGER"); @@ -234,7 +274,7 @@ namespace API.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("Chapter") + b.Property("ChapterId") .HasColumnType("INTEGER"); b.Property("FilePath") @@ -246,12 +286,9 @@ namespace API.Data.Migrations b.Property("NumberOfPages") .HasColumnType("INTEGER"); - b.Property("VolumeId") - .HasColumnType("INTEGER"); - b.HasKey("Id"); - b.HasIndex("VolumeId"); + b.HasIndex("ChapterId"); b.ToTable("MangaFile"); }); @@ -325,6 +362,9 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + b.Property("LastModified") .HasColumnType("TEXT"); @@ -487,6 +527,17 @@ namespace API.Data.Migrations b.Navigation("User"); }); + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + modelBuilder.Entity("API.Entities.FolderPath", b => { b.HasOne("API.Entities.Library", "Library") @@ -500,13 +551,13 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.MangaFile", b => { - b.HasOne("API.Entities.Volume", "Volume") + b.HasOne("API.Entities.Chapter", "Chapter") .WithMany("Files") - .HasForeignKey("VolumeId") + .HasForeignKey("ChapterId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.Navigation("Volume"); + b.Navigation("Chapter"); }); modelBuilder.Entity("API.Entities.Series", b => @@ -596,6 +647,11 @@ namespace API.Data.Migrations b.Navigation("UserRoles"); }); + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + modelBuilder.Entity("API.Entities.Library", b => { b.Navigation("Folders"); @@ -610,7 +666,7 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.Volume", b => { - b.Navigation("Files"); + b.Navigation("Chapters"); }); #pragma warning restore 612, 618 } diff --git a/API/Data/SeriesRepository.cs b/API/Data/SeriesRepository.cs index 27dc38e43..14d2c0349 100644 --- a/API/Data/SeriesRepository.cs +++ b/API/Data/SeriesRepository.cs @@ -82,6 +82,7 @@ namespace API.Data { var volumes = await _context.Volume .Where(vol => vol.SeriesId == seriesId) + .Include(vol => vol.Chapters) .OrderBy(volume => volume.Number) .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking() @@ -98,7 +99,8 @@ namespace API.Data { return _context.Volume .Where(vol => vol.SeriesId == seriesId) - .Include(vol => vol.Files) + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Files) .OrderBy(vol => vol.Number) .ToList(); } @@ -118,7 +120,8 @@ namespace API.Data public async Task GetVolumeAsync(int volumeId) { return await _context.Volume - .Include(vol => vol.Files) + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Files) .SingleOrDefaultAsync(vol => vol.Id == volumeId); } @@ -126,14 +129,15 @@ namespace API.Data { var volume = await _context.Volume .Where(vol => vol.Id == volumeId) - .Include(vol => vol.Files) + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Files) .ProjectTo(_mapper.ConfigurationProvider) .SingleAsync(vol => vol.Id == volumeId); var volumeList = new List() {volume}; await AddVolumeModifiers(userId, volumeList); - volumeList[0].Files = volumeList[0].Files.OrderBy(f => f.Chapter).ToList(); + //TODO: volumeList[0].Files = volumeList[0].Files.OrderBy(f => f.Chapter).ToList(); return volumeList[0]; } @@ -199,6 +203,11 @@ namespace API.Data foreach (var v in volumes) { + foreach (var c in v.Chapters) + { + c.PagesRead = userProgress.Where(p => p.ChapterId == c.Id).Sum(p => p.PagesRead); + } + v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead); } } diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 25d0002c7..f183e247a 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -22,6 +22,8 @@ namespace API.Data public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper); public IUserRepository UserRepository => new UserRepository(_context, _userManager); public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper); + + public IVolumeRepository VolumeRepository => new VolumeRepository(_context, _mapper); public async Task Complete() { diff --git a/API/Data/VolumeRepository.cs b/API/Data/VolumeRepository.cs new file mode 100644 index 000000000..ce8dd0eea --- /dev/null +++ b/API/Data/VolumeRepository.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; +using API.Interfaces; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data +{ + public class VolumeRepository : IVolumeRepository + { + private readonly DataContext _context; + private readonly IMapper _mapper; + + public VolumeRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Update(Volume volume) + { + _context.Entry(volume).State = EntityState.Modified; + } + + /// + /// Returns a Chapter for an Id. Includes linked s. + /// + /// + /// + public async Task GetChapterAsync(int chapterId) + { + return await _context.Chapter + .Include(c => c.Files) + .AsNoTracking() + .SingleOrDefaultAsync(c => c.Id == chapterId); + } + + public async Task GetChapterDtoAsync(int chapterId) + { + var chapter = await _context.Chapter + .Include(c => c.Files) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking() + .SingleOrDefaultAsync(c => c.Id == chapterId); + + return chapter; + } + + public async Task> GetFilesForChapter(int chapterId) + { + return await _context.MangaFile + .Where(c => chapterId == c.Id) + .AsNoTracking() + .ToListAsync(); + } + } +} \ No newline at end of file diff --git a/API/Entities/AppUserProgress.cs b/API/Entities/AppUserProgress.cs index 0f05f4dee..be3953246 100644 --- a/API/Entities/AppUserProgress.cs +++ b/API/Entities/AppUserProgress.cs @@ -2,7 +2,7 @@ namespace API.Entities { /// - /// Represents the progress a single user has on a given Volume. + /// Represents the progress a single user has on a given Volume. Progress is realistically tracked against the Volume's chapters. /// public class AppUserProgress { @@ -11,6 +11,8 @@ namespace API.Entities public int VolumeId { get; set; } public int SeriesId { get; set; } + public int ChapterId { get; set; } + // Relationships public AppUser AppUser { get; set; } public int AppUserId { get; set; } diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs new file mode 100644 index 000000000..b4d957fe8 --- /dev/null +++ b/API/Entities/Chapter.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using API.Entities.Interfaces; + +namespace API.Entities +{ + public class Chapter : IEntityDate + { + public int Id { get; set; } + /// + /// Range of numbers. Chapter 2-4 -> "2-4". Chapter 2 -> "2". + /// + public string Range { get; set; } + /// + /// Smallest number of the Range. Can be a partial like Chapter 4.5 + /// + public string Number { get; set; } + /// + /// The files that represent this Chapter + /// + public ICollection Files { get; set; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } + public byte[] CoverImage { get; set; } + /// + /// Total number of pages in all MangaFiles + /// + public int Pages { get; set; } + + // Relationships + public Volume Volume { get; set; } + public int VolumeId { get; set; } + + } +} \ No newline at end of file diff --git a/API/Entities/FolderPath.cs b/API/Entities/FolderPath.cs index 84d3ea798..dab3d86cd 100644 --- a/API/Entities/FolderPath.cs +++ b/API/Entities/FolderPath.cs @@ -1,10 +1,18 @@  +using System; + namespace API.Entities { public class FolderPath { public int Id { get; set; } public string Path { get; set; } + /// + /// Used when scanning to see if we can skip if nothing has changed. + /// + public DateTime LastScanned { get; set; } + + // Relationship public Library Library { get; set; } public int LibraryId { get; set; } } diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index 4c0c675de..a1e4ff81d 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -9,18 +9,14 @@ namespace API.Entities /// public string FilePath { get; set; } /// - /// Used to track if multiple MangaFiles (archives) represent a single Volume. If only one volume file, this will be 0. - /// - public int Chapter { get; set; } - /// /// Number of pages for the given file /// public int NumberOfPages { get; set; } public MangaFormat Format { get; set; } // Relationship Mapping - public Volume Volume { get; set; } - public int VolumeId { get; set; } + public Chapter Chapter { get; set; } + public int ChapterId { get; set; } } } \ No newline at end of file diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index a4b225736..f7e5f366e 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -12,14 +12,14 @@ namespace API.Entities /// public string Name { get; set; } /// - /// Original Japanese Name - /// - public string OriginalName { get; set; } - /// /// The name used to sort the Series. By default, will be the same as Name. /// public string SortName { get; set; } /// + /// Original Name on disk. Not exposed to UI. + /// + public string OriginalName { get; set; } + /// /// Summary information related to the Series /// public string Summary { get; set; } @@ -30,7 +30,7 @@ namespace API.Entities /// Sum of all Volume page counts /// public int Pages { get; set; } - + // Relationships public ICollection Volumes { get; set; } public Library Library { get; set; } diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index 304c2bfae..0b8077aae 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -9,11 +9,16 @@ namespace API.Entities public int Id { get; set; } public string Name { get; set; } public int Number { get; set; } - public ICollection Files { get; set; } + public ICollection Chapters { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } public byte[] CoverImage { get; set; } public int Pages { get; set; } + + /// + /// Represents a Side story that is linked to the original Series. Omake, One Shot, etc. + /// + public bool IsSpecial { get; set; } = false; diff --git a/API/Extensions/DirectoryInfoExtensions.cs b/API/Extensions/DirectoryInfoExtensions.cs index 98480d6bc..2e37057da 100644 --- a/API/Extensions/DirectoryInfoExtensions.cs +++ b/API/Extensions/DirectoryInfoExtensions.cs @@ -51,7 +51,8 @@ namespace API.Extensions if (file.Directory == null) continue; var newName = $"{file.Directory.Name}_{file.Name}"; var newPath = Path.Join(root.FullName, newName); - file.MoveTo(newPath); + if (!File.Exists(newPath)) file.MoveTo(newPath); + } } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 8db743695..744fc08bb 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -16,6 +16,8 @@ namespace API.Helpers CreateMap(); CreateMap(); + + CreateMap(); CreateMap(); diff --git a/API/Interfaces/ICacheService.cs b/API/Interfaces/ICacheService.cs index 9bf9eaa71..9d9721b4a 100644 --- a/API/Interfaces/ICacheService.cs +++ b/API/Interfaces/ICacheService.cs @@ -6,12 +6,12 @@ namespace API.Interfaces public interface ICacheService { /// - /// Ensures the cache is created for the given volume and if not, will create it. Should be called before any other + /// Ensures the cache is created for the given chapter and if not, will create it. Should be called before any other /// cache operations (except cleanup). /// - /// - /// Volume for the passed volumeId. Side-effect from ensuring cache. - Task Ensure(int volumeId); + /// + /// Chapter for the passed chapterId. Side-effect from ensuring cache. + Task Ensure(int chapterId); /// /// Clears cache directory of all folders and files. @@ -28,11 +28,11 @@ namespace API.Interfaces /// /// Returns the absolute path of a cached page. /// - /// + /// Chapter entity with Files populated. /// Page number to look for /// - (string path, MangaFile file) GetCachedPagePath(Volume volume, int page); + Task<(string path, MangaFile file)> GetCachedPagePath(Chapter chapter, int page); - bool CacheDirectoryIsAccessible(); + void EnsureCacheDirectory(); } } \ No newline at end of file diff --git a/API/Interfaces/IUnitOfWork.cs b/API/Interfaces/IUnitOfWork.cs index 3b1cf4347..d4268bf64 100644 --- a/API/Interfaces/IUnitOfWork.cs +++ b/API/Interfaces/IUnitOfWork.cs @@ -7,6 +7,7 @@ namespace API.Interfaces ISeriesRepository SeriesRepository { get; } IUserRepository UserRepository { get; } ILibraryRepository LibraryRepository { get; } + IVolumeRepository VolumeRepository { get; } Task Complete(); bool HasChanges(); } diff --git a/API/Interfaces/IVolumeRepository.cs b/API/Interfaces/IVolumeRepository.cs new file mode 100644 index 000000000..0bc28253b --- /dev/null +++ b/API/Interfaces/IVolumeRepository.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.DTOs; +using API.Entities; + +namespace API.Interfaces +{ + public interface IVolumeRepository + { + void Update(Volume volume); + Task GetChapterAsync(int chapterId); + Task GetChapterDtoAsync(int chapterId); + Task> GetFilesForChapter(int chapterId); + } +} \ No newline at end of file diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 41d20a8e7..4113c56f6 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -161,7 +161,7 @@ namespace API.Services var needsFlattening = ArchiveNeedsFlattening(archive); if (!archive.HasFiles() && !needsFlattening) return; - archive.ExtractToDirectory(extractPath); + archive.ExtractToDirectory(extractPath, true); _logger.LogDebug($"Extracted archive to {extractPath} in {sw.ElapsedMilliseconds} milliseconds."); if (needsFlattening) diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index f9d9242f1..d59a7f2db 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -18,7 +18,7 @@ namespace API.Services private readonly IArchiveService _archiveService; private readonly IDirectoryService _directoryService; private readonly NumericComparer _numericComparer; - public static readonly string CacheDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../cache/")); + public static readonly string CacheDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "cache/")); public CacheService(ILogger logger, IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService) { @@ -29,40 +29,36 @@ namespace API.Services _numericComparer = new NumericComparer(); } - public bool CacheDirectoryIsAccessible() + public void EnsureCacheDirectory() { _logger.LogDebug($"Checking if valid Cache directory: {CacheDirectory}"); var di = new DirectoryInfo(CacheDirectory); - return di.Exists; + if (!di.Exists) + { + _logger.LogError($"Cache directory {CacheDirectory} is not accessible or does not exist. Creating..."); + Directory.CreateDirectory(CacheDirectory); + } } - public async Task Ensure(int volumeId) + public async Task Ensure(int chapterId) { - if (!CacheDirectoryIsAccessible()) - { - return null; - } - Volume volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId); + EnsureCacheDirectory(); + Chapter chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId); - foreach (var file in volume.Files) + foreach (var file in chapter.Files) { - var extractPath = GetVolumeCachePath(volumeId, file); + var extractPath = GetCachePath(chapterId, file); _archiveService.ExtractArchive(file.FilePath, extractPath); } - return volume; + return chapter; } public void Cleanup() { _logger.LogInformation("Performing cleanup of Cache directory"); - - if (!CacheDirectoryIsAccessible()) - { - _logger.LogError($"Cache directory {CacheDirectory} is not accessible or does not exist."); - return; - } - + EnsureCacheDirectory(); + DirectoryInfo di = new DirectoryInfo(CacheDirectory); try @@ -79,6 +75,7 @@ namespace API.Services public void CleanupVolumes(int[] volumeIds) { + // TODO: Fix this code to work with chapters _logger.LogInformation($"Running Cache cleanup on Volumes"); foreach (var volume in volumeIds) @@ -96,13 +93,19 @@ namespace API.Services - public string GetVolumeCachePath(int volumeId, MangaFile file) + /// + /// Returns the cache path for a given Chapter. Should be cacheDirectory/{chapterId}/ + /// + /// + /// + /// + public string GetCachePath(int chapterId, MangaFile file) { - var extractPath = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), $"../cache/{volumeId}/")); - if (file.Chapter > 0) - { - extractPath = Path.Join(extractPath, file.Chapter + ""); - } + var extractPath = Path.GetFullPath(Path.Join(CacheDirectory, $"{chapterId}/")); + // if (file.Chapter != null) + // { + // extractPath = Path.Join(extractPath, chapterId + ""); + // } return extractPath; } @@ -110,28 +113,29 @@ namespace API.Services { // BUG: This causes a problem because total pages on a volume assumes "specials" to be there //return files.OrderBy(f => f.Chapter).Where(f => f.Chapter > 0 || f.Volume.Number != 0); - return files.OrderBy(f => f.Chapter, new ChapterSortComparer()); + return files; + //return files.OrderBy(f => f.Chapter, new ChapterSortComparer()); } - public (string path, MangaFile file) GetCachedPagePath(Volume volume, int page) + public async Task<(string path, MangaFile file)> GetCachedPagePath(Chapter chapter, int page) { // Calculate what chapter the page belongs to var pagesSoFar = 0; - // Do not allow chapters with 0, as those are specials and break ordering for reading. - var orderedChapters = GetOrderedChapters(volume.Files); - foreach (var mangaFile in orderedChapters) + var chapterFiles = chapter.Files ?? await _unitOfWork.VolumeRepository.GetFilesForChapter(chapter.Id); + foreach (var mangaFile in chapterFiles) { if (page + 1 < (mangaFile.NumberOfPages + pagesSoFar)) { - var path = GetVolumeCachePath(volume.Id, mangaFile); + var path = GetCachePath(chapter.Id, mangaFile); var files = _directoryService.GetFiles(path); Array.Sort(files, _numericComparer); return (files.ElementAt(page - pagesSoFar), mangaFile); } - + pagesSoFar += mangaFile.NumberOfPages; } + return ("", null); } } diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index a4e7c5ce8..c93485821 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -8,12 +8,19 @@ using System.Threading; using System.Threading.Tasks; using API.DTOs; using API.Interfaces; +using Microsoft.Extensions.Logging; using NetVips; namespace API.Services { public class DirectoryService : IDirectoryService { + private readonly ILogger _logger; + + public DirectoryService(ILogger logger) + { + _logger = logger; + } /// /// Given a set of regex search criteria, get files in the given path. @@ -52,6 +59,11 @@ namespace API.Services public async Task ReadImageAsync(string imagePath) { + if (!File.Exists(imagePath)) + { + _logger.LogError("Image does not exist on disk."); + return null; + } using var image = Image.NewFromFile(imagePath); return new ImageDto diff --git a/API/Services/ScannerService.cs b/API/Services/ScannerService.cs index eaf2fff5c..b3cd09e9b 100644 --- a/API/Services/ScannerService.cs +++ b/API/Services/ScannerService.cs @@ -61,6 +61,12 @@ namespace API.Services var totalFiles = 0; foreach (var folderPath in library.Folders) { + // if (!forceUpdate && Directory.GetLastWriteTime(folderPath.Path) <= folderPath.LastScanned) + // { + // _logger.LogDebug($"{folderPath.Path} hasn't been updated since last scan. Skipping."); + // continue; + // } + try { totalFiles += DirectoryService.TraverseTreeParallelForEach(folderPath.Path, (f) => { @@ -88,10 +94,12 @@ namespace API.Services // Remove series that are no longer on disk RemoveSeriesNotOnDisk(allSeries, series, library); + foreach (var folder in library.Folders) folder.LastScanned = DateTime.Now; _unitOfWork.LibraryRepository.Update(library); if (Task.Run(() => _unitOfWork.Complete()).Result) { + _logger.LogInformation($"Scan completed on {library.Name}. Parsed {series.Keys.Count()} series."); } else @@ -188,10 +196,10 @@ namespace API.Services private Series UpdateSeries(Series series, ParserInfo[] infos, bool forceUpdate) { - var volumes = UpdateVolumes(series, infos, forceUpdate); + var volumes = UpdateVolumesWithChapters(series, infos, forceUpdate); series.Volumes = volumes; series.Pages = volumes.Sum(v => v.Pages); - if (series.CoverImage == null || forceUpdate) + if (ShouldFindCoverImage(forceUpdate, series.CoverImage)) { var firstCover = volumes.OrderBy(x => x.Number).FirstOrDefault(x => x.Number != 0); if (firstCover == null && volumes.Any()) @@ -213,16 +221,20 @@ namespace API.Services { _logger.LogDebug($"Creating File Entry for {info.FullFilePath}"); - int.TryParse(info.Chapters, out var chapter); - _logger.LogDebug($"Found Chapter: {chapter}"); return new MangaFile() { FilePath = info.FullFilePath, - Chapter = chapter, Format = info.Format, NumberOfPages = info.Format == MangaFormat.Archive ? _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath): 1 }; } + + private bool ShouldFindCoverImage(bool forceUpdate, byte[] coverImage) + { + return forceUpdate || coverImage == null || !coverImage.Any(); + } + + @@ -237,16 +249,20 @@ namespace API.Services { ICollection volumes = new List(); IList existingVolumes = _unitOfWork.SeriesRepository.GetVolumes(series.Id).ToList(); - + + //var justVolumes = infos.Select(pi => pi.Chapters == "0"); + + foreach (var info in infos) { var existingVolume = existingVolumes.SingleOrDefault(v => v.Name == info.Volumes); if (existingVolume != null) { - var existingFile = existingVolume.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); + //var existingFile = existingVolume.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); + var existingFile = new MangaFile(); if (existingFile != null) { - existingFile.Chapter = Parser.Parser.MinimumNumberFromRange(info.Chapters); + //existingFile.Chapter = Parser.Parser.MinimumNumberFromRange(info.Chapters); existingFile.Format = info.Format; existingFile.NumberOfPages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath); } @@ -254,7 +270,7 @@ namespace API.Services { if (info.Format == MangaFormat.Archive) { - existingVolume.Files.Add(CreateMangaFile(info)); + // existingVolume.Files.Add(CreateMangaFile(info)); } else { @@ -271,7 +287,7 @@ namespace API.Services existingVolume = volumes.SingleOrDefault(v => v.Name == info.Volumes); if (existingVolume != null) { - existingVolume.Files.Add(CreateMangaFile(info)); + //existingVolume.Files.Add(CreateMangaFile(info)); } else { @@ -279,10 +295,10 @@ namespace API.Services { Name = info.Volumes, Number = Parser.Parser.MinimumNumberFromRange(info.Volumes), - Files = new List() - { - CreateMangaFile(info) - } + // Files = new List() + // { + // CreateMangaFile(info) + // } }; volumes.Add(vol); } @@ -290,23 +306,121 @@ namespace API.Services _logger.LogInformation($"Adding volume {volumes.Last().Number} with File: {info.Filename}"); } - + foreach (var volume in volumes) { - if (forceUpdate || volume.CoverImage == null || !volume.Files.Any()) - { - var firstFile = volume.Files.OrderBy(x => x.Chapter).FirstOrDefault(); - if (firstFile != null) volume.CoverImage = _archiveService.GetCoverImage(firstFile.FilePath, true); - } - - volume.Pages = volume.Files.Sum(x => x.NumberOfPages); + // if (forceUpdate || volume.CoverImage == null || !volume.Files.Any()) + // { + // var firstFile = volume.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + // if (firstFile != null) volume.CoverImage = _archiveService.GetCoverImage(firstFile.FilePath, true); + // } + + //volume.Pages = volume.Files.Sum(x => x.NumberOfPages); } - + return volumes; } + /// + /// + /// + /// + /// + /// + /// + private ICollection UpdateChapters(Volume volume, IEnumerable infos, bool forceUpdate) + { + var chapters = new List(); + foreach (var info in infos) + { + volume.Chapters ??= new List(); + var chapter = volume.Chapters.SingleOrDefault(c => c.Range == info.Chapters) ?? + chapters.SingleOrDefault(v => v.Range == info.Chapters) ?? + new Chapter() + { + Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + "", + Range = info.Chapters, + }; + + chapter.Files ??= new List(); + var existingFile = chapter?.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); + if (existingFile != null) + { + existingFile.Format = info.Format; + existingFile.NumberOfPages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath); + } + else + { + if (info.Format == MangaFormat.Archive) + { + chapter.Files.Add(CreateMangaFile(info)); + } + else + { + _logger.LogDebug($"Ignoring {info.Filename} as it is not an archive."); + } + + } + + chapter.Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + ""; + chapter.Range = info.Chapters; + + chapters.Add(chapter); + } + + foreach (var chapter in chapters) + { + chapter.Pages = chapter.Files.Sum(f => f.NumberOfPages); + + if (ShouldFindCoverImage(forceUpdate, chapter.CoverImage)) + { + var firstFile = chapter?.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + if (firstFile != null) chapter.CoverImage = _archiveService.GetCoverImage(firstFile.FilePath, true); + } + } + + return chapters; + } + + + private ICollection UpdateVolumesWithChapters(Series series, ParserInfo[] infos, bool forceUpdate) + { + ICollection volumes = new List(); + IList existingVolumes = _unitOfWork.SeriesRepository.GetVolumes(series.Id).ToList(); + + foreach (var info in infos) + { + var volume = (existingVolumes.SingleOrDefault(v => v.Name == info.Volumes) ?? + volumes.SingleOrDefault(v => v.Name == info.Volumes)) ?? new Volume + { + Name = info.Volumes, + Number = Parser.Parser.MinimumNumberFromRange(info.Volumes), + }; + + + var chapters = UpdateChapters(volume, infos.Where(pi => pi.Volumes == volume.Name).ToArray(), forceUpdate); + volume.Chapters = chapters; + volume.Pages = chapters.Sum(c => c.Pages); + volumes.Add(volume); + } + + foreach (var volume in volumes) + { + if (ShouldFindCoverImage(forceUpdate, volume.CoverImage)) + { + // TODO: Create a custom sorter for Chapters so it's consistent across the application + var firstChapter = volume.Chapters.OrderBy(x => Double.Parse(x.Number)).FirstOrDefault(); + var firstFile = firstChapter?.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + if (firstFile != null) volume.CoverImage = _archiveService.GetCoverImage(firstFile.FilePath, true); + } + } + + return volumes; + } + + public void ScanSeries(int libraryId, int seriesId) { diff --git a/API/Startup.cs b/API/Startup.cs index 766b1d01d..7e2864c0e 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -3,6 +3,7 @@ using API.Middleware; using Hangfire; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -57,7 +58,11 @@ namespace API app.UseAuthorization(); app.UseDefaultFiles(); - app.UseStaticFiles(); + + app.UseStaticFiles(new StaticFileOptions + { + ContentTypeProvider = new FileExtensionContentTypeProvider() // this is not set by default + }); app.UseEndpoints(endpoints => {