diff --git a/API.Benchmark/ArchiveServiceBenchmark.cs b/API.Benchmark/ArchiveServiceBenchmark.cs index e856aa7c8..0d13623c2 100644 --- a/API.Benchmark/ArchiveServiceBenchmark.cs +++ b/API.Benchmark/ArchiveServiceBenchmark.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using API.Services; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Order; +using NSubstitute; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.Formats.Webp; @@ -31,7 +32,7 @@ public class ArchiveServiceBenchmark { _directoryService = new DirectoryService(null, new FileSystem()); _imageService = new ImageService(null, _directoryService); - _archiveService = new ArchiveService(new NullLogger(), _directoryService, _imageService); + _archiveService = new ArchiveService(new NullLogger(), _directoryService, _imageService, Substitute.For()); } [Benchmark(Baseline = true)] diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs index 139dd0df9..7961bc5dc 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -26,7 +26,9 @@ public class ArchiveServiceTests public ArchiveServiceTests(ITestOutputHelper testOutputHelper) { _testOutputHelper = testOutputHelper; - _archiveService = new ArchiveService(_logger, _directoryService, new ImageService(Substitute.For>(), _directoryService)); + _archiveService = new ArchiveService(_logger, _directoryService, + new ImageService(Substitute.For>(), _directoryService), + Substitute.For()); } [Theory] @@ -164,7 +166,7 @@ public class ArchiveServiceTests { var ds = Substitute.For(_directoryServiceLogger, new FileSystem()); var imageService = new ImageService(Substitute.For>(), ds); - var archiveService = Substitute.For(_logger, ds, imageService); + var archiveService = Substitute.For(_logger, ds, imageService, Substitute.For()); var testDirectory = Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages")); var expectedBytes = Image.Thumbnail(Path.Join(testDirectory, expectedOutputFile), 320).WriteToBuffer(".png"); @@ -196,7 +198,8 @@ public class ArchiveServiceTests { var imageService = new ImageService(Substitute.For>(), _directoryService); var archiveService = Substitute.For(_logger, - new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService); + new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService, + Substitute.For()); var testDirectory = API.Services.Tasks.Scanner.Parser.Parser.NormalizePath(Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"))); var outputDir = Path.Join(testDirectory, "output"); @@ -220,7 +223,7 @@ public class ArchiveServiceTests { var imageService = Substitute.For(); imageService.WriteCoverThumbnail(Arg.Any(), Arg.Any(), Arg.Any()).Returns(x => "cover.jpg"); - var archiveService = new ArchiveService(_logger, _directoryService, imageService); + var archiveService = new ArchiveService(_logger, _directoryService, imageService, Substitute.For()); var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/"); var inputPath = Path.GetFullPath(Path.Join(testDirectory, inputFile)); var outputPath = Path.Join(testDirectory, Path.GetFileNameWithoutExtension(inputFile) + "_output"); diff --git a/API.Tests/Services/BookServiceTests.cs b/API.Tests/Services/BookServiceTests.cs index 4665ab691..f810b9e22 100644 --- a/API.Tests/Services/BookServiceTests.cs +++ b/API.Tests/Services/BookServiceTests.cs @@ -15,7 +15,9 @@ public class BookServiceTests public BookServiceTests() { var directoryService = new DirectoryService(Substitute.For>(), new FileSystem()); - _bookService = new BookService(_logger, directoryService, new ImageService(Substitute.For>(), directoryService)); + _bookService = new BookService(_logger, directoryService, + new ImageService(Substitute.For>(), directoryService) + , Substitute.For()); } [Theory] diff --git a/API/Constants/ControllerConstants.cs b/API/Constants/ControllerConstants.cs new file mode 100644 index 000000000..34a2482ee --- /dev/null +++ b/API/Constants/ControllerConstants.cs @@ -0,0 +1,6 @@ +namespace API.Constants; + +public abstract class ControllerConstants +{ + public const int MaxUploadSizeBytes = 8_000_000; +} diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index e8221e3fb..82ac5d507 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -3,10 +3,13 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; +using API.Data; using API.DTOs.Jobs; +using API.DTOs.MediaErrors; using API.DTOs.Stats; using API.DTOs.Update; using API.Extensions; +using API.Helpers; using API.Services; using API.Services.Tasks; using Hangfire; @@ -14,7 +17,6 @@ using Hangfire.Storage; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using TaskScheduler = API.Services.TaskScheduler; @@ -23,7 +25,6 @@ namespace API.Controllers; [Authorize(Policy = "RequireAdminRole")] public class ServerController : BaseApiController { - private readonly IHostApplicationLifetime _applicationLifetime; private readonly ILogger _logger; private readonly IBackupService _backupService; private readonly IArchiveService _archiveService; @@ -34,13 +35,13 @@ public class ServerController : BaseApiController private readonly IScannerService _scannerService; private readonly IAccountService _accountService; private readonly ITaskScheduler _taskScheduler; + private readonly IUnitOfWork _unitOfWork; - public ServerController(IHostApplicationLifetime applicationLifetime, ILogger logger, + public ServerController(ILogger logger, IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService, ICleanupService cleanupService, IBookmarkService bookmarkService, IScannerService scannerService, IAccountService accountService, - ITaskScheduler taskScheduler) + ITaskScheduler taskScheduler, IUnitOfWork unitOfWork) { - _applicationLifetime = applicationLifetime; _logger = logger; _backupService = backupService; _archiveService = archiveService; @@ -51,6 +52,7 @@ public class ServerController : BaseApiController _scannerService = scannerService; _accountService = accountService; _taskScheduler = taskScheduler; + _unitOfWork = unitOfWork; } /// @@ -213,5 +215,28 @@ public class ServerController : BaseApiController return Ok(recurringJobs); } + /// + /// Returns a list of issues found during scanning or reading in which files may have corruption or bad metadata (structural metadata) + /// + /// + [Authorize("RequireAdminRole")] + [HttpGet("media-errors")] + public ActionResult> GetMediaErrors() + { + return Ok(_unitOfWork.MediaErrorRepository.GetAllErrorDtosAsync()); + } + + /// + /// Deletes all media errors + /// + /// + [Authorize("RequireAdminRole")] + [HttpPost("clear-media-alerts")] + public async Task ClearMediaErrors() + { + await _unitOfWork.MediaErrorRepository.DeleteAll(); + return Ok(); + } + } diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 946b61933..82ff9237b 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.DTOs.Uploads; using API.Extensions; @@ -78,7 +79,7 @@ public class UploadController : BaseApiController /// /// [Authorize(Policy = "RequireAdminRole")] - [RequestSizeLimit(8_000_000)] + [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("series")] public async Task UploadSeriesCoverImageFromUrl(UploadFileDto uploadFileDto) { @@ -126,7 +127,7 @@ public class UploadController : BaseApiController /// /// [Authorize(Policy = "RequireAdminRole")] - [RequestSizeLimit(8_000_000)] + [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("collection")] public async Task UploadCollectionCoverImageFromUrl(UploadFileDto uploadFileDto) { @@ -174,7 +175,7 @@ public class UploadController : BaseApiController /// This is the only API that can be called by non-admins, but the authenticated user must have a readinglist permission /// /// - [RequestSizeLimit(8_000_000)] + [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("reading-list")] public async Task UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto) { @@ -238,7 +239,7 @@ public class UploadController : BaseApiController /// /// [Authorize(Policy = "RequireAdminRole")] - [RequestSizeLimit(8_000_000)] + [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("chapter")] public async Task UploadChapterCoverImageFromUrl(UploadFileDto uploadFileDto) { @@ -294,7 +295,7 @@ public class UploadController : BaseApiController /// /// [Authorize(Policy = "RequireAdminRole")] - [RequestSizeLimit(8_000_000)] + [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("library")] public async Task UploadLibraryCoverImageFromUrl(UploadFileDto uploadFileDto) { diff --git a/API/DTOs/MediaErrors/MediaErrorDto.cs b/API/DTOs/MediaErrors/MediaErrorDto.cs new file mode 100644 index 000000000..6a6111e4e --- /dev/null +++ b/API/DTOs/MediaErrors/MediaErrorDto.cs @@ -0,0 +1,25 @@ +using System; + +namespace API.DTOs.MediaErrors; + +public class MediaErrorDto +{ + /// + /// Format Type (RAR, ZIP, 7Zip, Epub, PDF) + /// + public required string Extension { get; set; } + /// + /// Full Filepath to the file that has some issue + /// + public required string FilePath { get; set; } + /// + /// Developer defined string + /// + public string Comment { get; set; } + /// + /// Exception message + /// + public string Details { get; set; } + public DateTime Created { get; set; } + public DateTime CreatedUtc { get; set; } +} diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index c1824d2b6..14bb4be9a 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -47,6 +47,7 @@ public sealed class DataContext : IdentityDbContext FolderPath { get; set; } = null!; public DbSet Device { get; set; } = null!; public DbSet ServerStatistics { get; set; } = null!; + public DbSet MediaError { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) diff --git a/API/Data/Migrations/20230505124430_MediaError.Designer.cs b/API/Data/Migrations/20230505124430_MediaError.Designer.cs new file mode 100644 index 000000000..f3e770fa1 --- /dev/null +++ b/API/Data/Migrations/20230505124430_MediaError.Designer.cs @@ -0,0 +1,1912 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20230505124430_MediaError")] + partial class MediaError + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.5"); + + 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", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .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", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + 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.HasIndex("SeriesId"); + + 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", (string)null); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + 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.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInDashboard") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInRecommended") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IncludeInSearch") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("ManageReadingLists") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + 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("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NameLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .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("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + 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", (string)null); + }); + + modelBuilder.Entity("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .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.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .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.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + 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.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany("WantToRead") + .HasForeignKey("AppUserId"); + + 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("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .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("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Devices"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20230505124430_MediaError.cs b/API/Data/Migrations/20230505124430_MediaError.cs new file mode 100644 index 000000000..9bf69d3a2 --- /dev/null +++ b/API/Data/Migrations/20230505124430_MediaError.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class MediaError : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MediaError", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Extension = table.Column(type: "TEXT", nullable: true), + FilePath = table.Column(type: "TEXT", nullable: true), + Comment = table.Column(type: "TEXT", nullable: true), + Details = table.Column(type: "TEXT", nullable: true), + Created = table.Column(type: "TEXT", nullable: false), + LastModified = table.Column(type: "TEXT", nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false), + LastModifiedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MediaError", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MediaError"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index a097d63dc..9da08edda 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -714,6 +714,41 @@ namespace API.Data.Migrations b.ToTable("MangaFile"); }); + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => { b.Property("Id") diff --git a/API/Data/Repositories/MediaErrorRepository.cs b/API/Data/Repositories/MediaErrorRepository.cs new file mode 100644 index 000000000..e9062d285 --- /dev/null +++ b/API/Data/Repositories/MediaErrorRepository.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.MediaErrors; +using API.Entities; +using API.Helpers; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public interface IMediaErrorRepository +{ + void Attach(MediaError error); + void Remove(MediaError error); + Task Find(string filename); + Task> GetAllErrorDtosAsync(UserParams userParams); + IEnumerable GetAllErrorDtosAsync(); + Task ExistsAsync(MediaError error); + Task DeleteAll(); +} + +public class MediaErrorRepository : IMediaErrorRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public MediaErrorRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Attach(MediaError? error) + { + if (error == null) return; + _context.MediaError.Attach(error); + } + + public void Remove(MediaError? error) + { + if (error == null) return; + _context.MediaError.Remove(error); + } + + public Task Find(string filename) + { + return _context.MediaError.Where(e => e.FilePath == filename).SingleOrDefaultAsync(); + } + + public Task> GetAllErrorDtosAsync(UserParams userParams) + { + var query = _context.MediaError + .OrderByDescending(m => m.Created) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking(); + return PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + public IEnumerable GetAllErrorDtosAsync() + { + var query = _context.MediaError + .OrderByDescending(m => m.Created) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking(); + return query.AsEnumerable(); + } + + public Task ExistsAsync(MediaError error) + { + return _context.MediaError.AnyAsync(m => m.FilePath.Equals(error.FilePath) + && m.Comment.Equals(error.Comment) + && m.Details.Equals(error.Details) + ); + } + + public async Task DeleteAll() + { + _context.MediaError.RemoveRange(await _context.MediaError.ToListAsync()); + await _context.SaveChangesAsync(); + } +} diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 02a089eca..7c98d37dd 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -25,6 +25,7 @@ public interface IUnitOfWork ISiteThemeRepository SiteThemeRepository { get; } IMangaFileRepository MangaFileRepository { get; } IDeviceRepository DeviceRepository { get; } + IMediaErrorRepository MediaErrorRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); @@ -62,6 +63,7 @@ public class UnitOfWork : IUnitOfWork public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper); public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context); public IDeviceRepository DeviceRepository => new DeviceRepository(_context, _mapper); + public IMediaErrorRepository MediaErrorRepository => new MediaErrorRepository(_context, _mapper); /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/Entities/MediaError.cs b/API/Entities/MediaError.cs new file mode 100644 index 000000000..0f2f49da0 --- /dev/null +++ b/API/Entities/MediaError.cs @@ -0,0 +1,36 @@ +using System; +using API.Entities.Interfaces; + +namespace API.Entities; + +/// +/// Represents issues found during scanning or interacting with media. For example) Can't open file, corrupt media, missing content in epub. +/// +public class MediaError : IEntityDate +{ + public int Id { get; set; } + /// + /// Format Type (RAR, ZIP, 7Zip, Epub, PDF) + /// + public required string Extension { get; set; } + /// + /// Full Filepath to the file that has some issue + /// + public required string FilePath { get; set; } + /// + /// Developer defined string + /// + public string Comment { get; set; } + /// + /// Exception message + /// + public string Details { get; set; } + /// + /// Was the file imported or not + /// + //public bool Imported { get; set; } + public DateTime Created { get; set; } + public DateTime LastModified { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime LastModifiedUtc { get; set; } +} diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 765c6e120..d9c5c3d06 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -49,6 +49,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 3fd23b709..fc3116fdf 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -4,6 +4,7 @@ using API.DTOs; using API.DTOs.Account; using API.DTOs.CollectionTags; using API.DTOs.Device; +using API.DTOs.MediaErrors; using API.DTOs.Metadata; using API.DTOs.Reader; using API.DTOs.ReadingLists; @@ -33,6 +34,7 @@ public class AutoMapperProfiles : Profile CreateMap(); CreateMap(); CreateMap(); + CreateMap(); CreateMap() .ForMember(dest => dest.PageNum, diff --git a/API/Helpers/Builders/MediaErrorBuilder.cs b/API/Helpers/Builders/MediaErrorBuilder.cs new file mode 100644 index 000000000..56b19ba33 --- /dev/null +++ b/API/Helpers/Builders/MediaErrorBuilder.cs @@ -0,0 +1,31 @@ +using System.IO; +using API.Entities; + +namespace API.Helpers.Builders; + +public class MediaErrorBuilder : IEntityBuilder +{ + private readonly MediaError _mediaError; + public MediaError Build() => _mediaError; + + public MediaErrorBuilder(string filePath) + { + _mediaError = new MediaError() + { + FilePath = filePath, + Extension = Path.GetExtension(filePath).Replace(".", string.Empty).ToUpperInvariant() + }; + } + + public MediaErrorBuilder WithComment(string comment) + { + _mediaError.Comment = comment.Trim(); + return this; + } + + public MediaErrorBuilder WithDetails(string details) + { + _mediaError.Details = details.Trim(); + return this; + } +} diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index d07e6e9a4..ad9d42139 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -44,13 +44,16 @@ public class ArchiveService : IArchiveService private readonly ILogger _logger; private readonly IDirectoryService _directoryService; private readonly IImageService _imageService; + private readonly IMediaErrorService _mediaErrorService; private const string ComicInfoFilename = "ComicInfo.xml"; - public ArchiveService(ILogger logger, IDirectoryService directoryService, IImageService imageService) + public ArchiveService(ILogger logger, IDirectoryService directoryService, + IImageService imageService, IMediaErrorService mediaErrorService) { _logger = logger; _directoryService = directoryService; _imageService = imageService; + _mediaErrorService = mediaErrorService; } /// @@ -120,6 +123,8 @@ public class ArchiveService : IArchiveService catch (Exception ex) { _logger.LogWarning(ex, "[GetNumberOfPagesFromArchive] There was an exception when reading archive stream: {ArchivePath}. Defaulting to 0 pages", archivePath); + _mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService, + "This archive cannot be read or not supported", ex); return 0; } } @@ -238,6 +243,8 @@ public class ArchiveService : IArchiveService catch (Exception ex) { _logger.LogWarning(ex, "[GetCoverImage] There was an exception when reading archive stream: {ArchivePath}. Defaulting to no cover image", archivePath); + _mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService, + "This archive cannot be read or not supported", ex); } return string.Empty; @@ -403,6 +410,8 @@ public class ArchiveService : IArchiveService catch (Exception ex) { _logger.LogWarning(ex, "[GetComicInfo] There was an exception when reading archive stream: {Filepath}", archivePath); + _mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService, + "This archive cannot be read or not supported", ex); } return null; @@ -485,9 +494,11 @@ public class ArchiveService : IArchiveService } } - catch (Exception e) + catch (Exception ex) { - _logger.LogWarning(e, "[ExtractArchive] There was a problem extracting {ArchivePath} to {ExtractPath}",archivePath, extractPath); + _logger.LogWarning(ex, "[ExtractArchive] There was a problem extracting {ArchivePath} to {ExtractPath}",archivePath, extractPath); + _mediaErrorService.ReportMediaIssue(archivePath, MediaErrorProducer.ArchiveService, + "This archive cannot be read or not supported", ex); throw new KavitaException( $"There was an error when extracting {archivePath}. Check the file exists, has read permissions or the server OS can support all path characters."); } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 58bd80793..205fcde94 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -60,6 +60,7 @@ public class BookService : IBookService private readonly ILogger _logger; private readonly IDirectoryService _directoryService; private readonly IImageService _imageService; + private readonly IMediaErrorService _mediaErrorService; private readonly StylesheetParser _cssParser = new (); private static readonly RecyclableMemoryStreamManager StreamManager = new (); private const string CssScopeClass = ".book-content"; @@ -72,11 +73,12 @@ public class BookService : IBookService } }; - public BookService(ILogger logger, IDirectoryService directoryService, IImageService imageService) + public BookService(ILogger logger, IDirectoryService directoryService, IImageService imageService, IMediaErrorService mediaErrorService) { _logger = logger; _directoryService = directoryService; _imageService = imageService; + _mediaErrorService = mediaErrorService; } private static bool HasClickableHrefPart(HtmlNode anchor) @@ -394,6 +396,8 @@ public class BookService : IBookService catch (Exception ex) { _logger.LogError(ex, "There was an error reading css file for inlining likely due to a key mismatch in metadata"); + await _mediaErrorService.ReportMediaIssueAsync(book.FilePath, MediaErrorProducer.BookService, + "There was an error reading css file for inlining likely due to a key mismatch in metadata", ex); } } } @@ -480,7 +484,9 @@ public class BookService : IBookService } catch (Exception ex) { - _logger.LogWarning(ex, "[GetComicInfo] There was an exception getting metadata"); + _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing metadata"); + _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, + "There was an exception parsing metadata", ex); } return null; @@ -553,6 +559,8 @@ public class BookService : IBookService catch (Exception ex) { _logger.LogWarning(ex, "[BookService] There was an exception getting number of pages, defaulting to 0"); + _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, + "There was an exception getting number of pages, defaulting to 0", ex); } return 0; @@ -697,6 +705,8 @@ public class BookService : IBookService catch (Exception ex) { _logger.LogWarning(ex, "[BookService] There was an exception when opening epub book: {FileName}", filePath); + _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, + "There was an exception when opening epub book", ex); } return null; @@ -916,8 +926,9 @@ public class BookService : IBookService } } catch (Exception ex) { - // NOTE: We can log this to media analysis service _logger.LogError(ex, "There was an issue reading one of the pages for {Book}", book.FilePath); + await _mediaErrorService.ReportMediaIssueAsync(book.FilePath, MediaErrorProducer.BookService, + "There was an issue reading one of the pages for", ex); } throw new KavitaException("Could not find the appropriate html for that page"); @@ -990,6 +1001,8 @@ public class BookService : IBookService catch (Exception ex) { _logger.LogWarning(ex, "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", fileFilePath); + _mediaErrorService.ReportMediaIssue(fileFilePath, MediaErrorProducer.BookService, + "There was a critical error and prevented thumbnail generation", ex); } return string.Empty; @@ -1014,6 +1027,8 @@ public class BookService : IBookService _logger.LogWarning(ex, "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", fileFilePath); + _mediaErrorService.ReportMediaIssue(fileFilePath, MediaErrorProducer.BookService, + "There was a critical error and prevented thumbnail generation", ex); } return string.Empty; diff --git a/API/Services/MediaErrorService.cs b/API/Services/MediaErrorService.cs new file mode 100644 index 000000000..6615dab7a --- /dev/null +++ b/API/Services/MediaErrorService.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading.Tasks; +using API.Data; +using API.Helpers.Builders; +using Hangfire; + +namespace API.Services; + +public enum MediaErrorProducer +{ + BookService = 0, + ArchiveService = 1 + +} + +public interface IMediaErrorService +{ + Task ReportMediaIssueAsync(string filename, MediaErrorProducer producer, string errorMessage, string details); + void ReportMediaIssue(string filename, MediaErrorProducer producer, string errorMessage, string details); + Task ReportMediaIssueAsync(string filename, MediaErrorProducer producer, string errorMessage, Exception ex); + void ReportMediaIssue(string filename, MediaErrorProducer producer, string errorMessage, Exception ex); +} + +public class MediaErrorService : IMediaErrorService +{ + private readonly IUnitOfWork _unitOfWork; + + public MediaErrorService(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task ReportMediaIssueAsync(string filename, MediaErrorProducer producer, string errorMessage, Exception ex) + { + await ReportMediaIssueAsync(filename, producer, errorMessage, ex.Message); + } + + public void ReportMediaIssue(string filename, MediaErrorProducer producer, string errorMessage, Exception ex) + { + // To avoid overhead on commits, do async. We don't need to wait. + BackgroundJob.Enqueue(() => ReportMediaIssueAsync(filename, producer, errorMessage, ex.Message)); + } + + public void ReportMediaIssue(string filename, MediaErrorProducer producer, string errorMessage, string details) + { + // To avoid overhead on commits, do async. We don't need to wait. + BackgroundJob.Enqueue(() => ReportMediaIssueAsync(filename, producer, errorMessage, details)); + } + + public async Task ReportMediaIssueAsync(string filename, MediaErrorProducer producer, string errorMessage, string details) + { + var error = new MediaErrorBuilder(filename) + .WithComment(errorMessage) + .WithDetails(details) + .Build(); + + if (await _unitOfWork.MediaErrorRepository.ExistsAsync(error)) + { + return; + } + + + _unitOfWork.MediaErrorRepository.Attach(error); + await _unitOfWork.CommitAsync(); + } + +} diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 4a3bfad93..dfe9c4cac 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -399,6 +399,7 @@ public class TaskScheduler : ITaskScheduler var scheduledJobs = JobStorage.Current.GetMonitoringApi().ScheduledJobs(0, int.MaxValue); ret = scheduledJobs.Any(j => + j.Value.Job != null && j.Value.Job.Method.DeclaringType != null && j.Value.Job.Args.SequenceEqual(args) && j.Value.Job.Method.Name.Equals(methodName) && j.Value.Job.Method.DeclaringType.Name.Equals(className)); diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index f50fd778f..dfa74c7ed 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -657,19 +657,13 @@ public class ProcessSeries : IProcessSeries } } - public void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? info) + public void UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicInfo) { + if (comicInfo == null) return; var firstFile = chapter.Files.MinBy(x => x.Chapter); if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, firstFile)) return; - var comicInfo = info; - if (info == null) - { - comicInfo = _readingItemService.GetComicInfo(firstFile.FilePath); - } - - if (comicInfo == null) return; _logger.LogTrace("[ScannerService] Read ComicInfo for {File}", firstFile.FilePath); chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating); @@ -807,11 +801,15 @@ public class ProcessSeries : IProcessSeries private static IList GetTagValues(string comicInfoTagSeparatedByComma) { // TODO: Move this to an extension and test it - if (!string.IsNullOrEmpty(comicInfoTagSeparatedByComma)) + if (string.IsNullOrEmpty(comicInfoTagSeparatedByComma)) { - return comicInfoTagSeparatedByComma.Split(",").Select(s => s.Trim()).DistinctBy(Parser.Parser.Normalize).ToList(); + return ImmutableList.Empty; } - return ImmutableList.Empty; + + return comicInfoTagSeparatedByComma.Split(",") + .Select(s => s.Trim()) + .DistinctBy(Parser.Parser.Normalize) + .ToList(); } /// diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index 6775a9c47..c8baf8548 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -4,6 +4,7 @@ import { environment } from 'src/environments/environment'; import { ServerInfo } from '../admin/_models/server-info'; import { UpdateVersionEvent } from '../_models/events/update-version-event'; import { Job } from '../_models/job/job'; +import { KavitaMediaError } from '../admin/_models/media-error'; @Injectable({ providedIn: 'root' @@ -61,4 +62,12 @@ export class ServerService { convertCovers() { return this.httpClient.post(this.baseUrl + 'server/convert-covers', {}); } + + getMediaErrors() { + return this.httpClient.get>(this.baseUrl + 'server/media-errors', {}); + } + + clearMediaAlerts() { + return this.httpClient.post(this.baseUrl + 'server/clear-media-alerts', {}); + } } diff --git a/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts b/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts index a5f19f59a..231d03284 100644 --- a/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts +++ b/UI/Web/src/app/_single-module/table/_directives/sortable-header.directive.ts @@ -18,6 +18,7 @@ export interface SortEvent { '(click)': 'rotate()', }, }) +// eslint-disable-next-line @angular-eslint/directive-class-suffix export class SortableHeader { @Input() sortable: SortColumn = ''; @Input() direction: SortDirection = ''; diff --git a/UI/Web/src/app/admin/_models/media-error.ts b/UI/Web/src/app/admin/_models/media-error.ts new file mode 100644 index 000000000..ee4ecd966 --- /dev/null +++ b/UI/Web/src/app/admin/_models/media-error.ts @@ -0,0 +1,8 @@ +export interface KavitaMediaError { + extension: string; + filePath: string; + comment: string; + details: string; + created: string; + createdUtc: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/admin/admin.module.ts b/UI/Web/src/app/admin/admin.module.ts index a70ffd69f..fa0861d1f 100644 --- a/UI/Web/src/app/admin/admin.module.ts +++ b/UI/Web/src/app/admin/admin.module.ts @@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { AdminRoutingModule } from './admin-routing.module'; import { DashboardComponent } from './dashboard/dashboard.component'; -import { NgbDropdownModule, NgbNavModule, NgbTooltipModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbAccordionModule, NgbDropdownModule, NgbNavModule, NgbTooltipModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; import { ManageLibraryComponent } from './manage-library/manage-library.component'; import { ManageUsersComponent } from './manage-users/manage-users.component'; import { SharedModule } from '../shared/shared.module'; @@ -25,6 +25,7 @@ import { ManageTasksSettingsComponent } from './manage-tasks-settings/manage-tas import { ManageLogsComponent } from './manage-logs/manage-logs.component'; import { VirtualScrollerModule } from '@iharbeck/ngx-virtual-scroller'; import { StatisticsModule } from '../statistics/statistics.module'; +import { ManageAlertsComponent } from './manage-alerts/manage-alerts.component'; @@ -47,6 +48,7 @@ import { StatisticsModule } from '../statistics/statistics.module'; ManageEmailSettingsComponent, ManageTasksSettingsComponent, ManageLogsComponent, + ManageAlertsComponent, ], imports: [ CommonModule, @@ -57,6 +59,7 @@ import { StatisticsModule } from '../statistics/statistics.module'; NgbTooltipModule, NgbTypeaheadModule, // Directory Picker NgbDropdownModule, + NgbAccordionModule, SharedModule, PipeModule, SidenavModule, diff --git a/UI/Web/src/app/admin/manage-alerts/manage-alerts.component.html b/UI/Web/src/app/admin/manage-alerts/manage-alerts.component.html new file mode 100644 index 000000000..d317a7806 --- /dev/null +++ b/UI/Web/src/app/admin/manage-alerts/manage-alerts.component.html @@ -0,0 +1,39 @@ +

This table contains issues found during scan or reading of your media. This list is non-managed. You can clear it at any time and use Library (Force) Scan to perform analysis.

+ + + + + + + + + + + + + + + + + + + + + +
+ Extension + + File + + Comment + + Details +
No issues
+ {{item.extension}} + + {{item.filePath}} + + {{item.comment}} + + {{item.details}} +
\ No newline at end of file diff --git a/UI/Web/src/app/admin/manage-alerts/manage-alerts.component.scss b/UI/Web/src/app/admin/manage-alerts/manage-alerts.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/admin/manage-alerts/manage-alerts.component.ts b/UI/Web/src/app/admin/manage-alerts/manage-alerts.component.ts new file mode 100644 index 000000000..5e164348c --- /dev/null +++ b/UI/Web/src/app/admin/manage-alerts/manage-alerts.component.ts @@ -0,0 +1,75 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, OnInit, QueryList, ViewChildren, inject } from '@angular/core'; +import { BehaviorSubject, Observable, Subject, combineLatest, filter, map, shareReplay, takeUntil } from 'rxjs'; +import { SortEvent, SortableHeader, compare } from 'src/app/_single-module/table/_directives/sortable-header.directive'; +import { KavitaMediaError } from '../_models/media-error'; +import { ServerService } from 'src/app/_services/server.service'; +import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service'; + +@Component({ + selector: 'app-manage-alerts', + templateUrl: './manage-alerts.component.html', + styleUrls: ['./manage-alerts.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ManageAlertsComponent implements OnInit { + + @ViewChildren(SortableHeader) headers!: QueryList>; + private readonly serverService = inject(ServerService); + private readonly messageHub = inject(MessageHubService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly onDestroy = new Subject(); + + messageHubUpdate$ = this.messageHub.messages$.pipe(takeUntil(this.onDestroy), filter(m => m.event === EVENTS.ScanSeries), shareReplay()); + currentSort = new BehaviorSubject>({column: 'extension', direction: 'asc'}); + currentSort$: Observable> = this.currentSort.asObservable(); + + data: Array = []; + isLoading = true; + + + constructor() {} + + ngOnInit(): void { + + this.loadData(); + + this.messageHubUpdate$.subscribe(_ => this.loadData()); + + this.currentSort$.subscribe(sortConfig => { + this.data = (sortConfig.column) ? this.data.sort((a: KavitaMediaError, b: KavitaMediaError) => { + if (sortConfig.column === '') return 0; + const res = compare(a[sortConfig.column], b[sortConfig.column]); + return sortConfig.direction === 'asc' ? res : -res; + }) : this.data; + this.cdRef.markForCheck(); + }); + } + + onSort(evt: any) { + //SortEvent + this.currentSort.next(evt); + + // Must clear out headers here + this.headers.forEach((header) => { + if (header.sortable !== evt.column) { + header.direction = ''; + } + }); + } + + loadData() { + this.isLoading = true; + this.cdRef.markForCheck(); + this.serverService.getMediaErrors().subscribe(d => { + this.data = d; + this.isLoading = false; + console.log(this.data) + console.log(this.isLoading) + this.cdRef.detectChanges(); + }); + } + + clear() { + this.serverService.clearMediaAlerts().subscribe(_ => this.loadData()); + } +} diff --git a/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html b/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html index cb0997bc0..6f6d529a8 100644 --- a/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html +++ b/UI/Web/src/app/admin/manage-media-settings/manage-media-settings.component.html @@ -1,5 +1,5 @@
-
+

WebP can drastically reduce space requirements for files. WebP is not supported on all browsers or versions. To learn if these settings are appropriate for your setup, visit Can I Use.

@@ -48,6 +48,14 @@
- - + + + + Media Issues + + + + + +
diff --git a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html index fc9dbc36d..5d235d4ea 100644 --- a/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html +++ b/UI/Web/src/app/reading-list/_components/draggable-ordered-list/draggable-ordered-list.component.html @@ -1,28 +1,28 @@ -
- -
-
-
-
- - -
+
+ +
+
+
+
+ +
- - - -
+ + + +
- -
+
+ +
diff --git a/openapi.json b/openapi.json index 82be404f9..dceb1c419 100644 --- a/openapi.json +++ b/openapi.json @@ -7638,6 +7638,58 @@ } } }, + "/api/Server/media-errors": { + "get": { + "tags": [ + "Server" + ], + "summary": "Returns a list of issues found during scanning or reading in which files may have corruption or bad metadata (structural metadata)", + "responses": { + "200": { + "description": "Success", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaErrorDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaErrorDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MediaErrorDto" + } + } + } + } + } + } + } + }, + "/api/Server/clear-media-alerts": { + "post": { + "tags": [ + "Server" + ], + "summary": "Deletes all media errors", + "responses": { + "200": { + "description": "Success" + } + } + } + }, "/api/Settings/base-url": { "get": { "tags": [ @@ -12231,6 +12283,40 @@ "additionalProperties": false, "description": "This is used for bulk updating a set of volume and or chapters in one go" }, + "MediaErrorDto": { + "type": "object", + "properties": { + "extension": { + "type": "string", + "description": "Format Type (RAR, ZIP, 7Zip, Epub, PDF)", + "nullable": true + }, + "filePath": { + "type": "string", + "description": "Full Filepath to the file that has some issue", + "nullable": true + }, + "comment": { + "type": "string", + "description": "Developer defined string", + "nullable": true + }, + "details": { + "type": "string", + "description": "Exception message", + "nullable": true + }, + "created": { + "type": "string", + "format": "date-time" + }, + "createdUtc": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, "MemberDto": { "type": "object", "properties": { @@ -15566,4 +15652,4 @@ "description": "Responsible for all things Want To Read" } ] -} +} \ No newline at end of file