diff --git a/API.Tests/Extensions/EnumerableExtensionsTests.cs b/API.Tests/Extensions/EnumerableExtensionsTests.cs index 0f04ac9d7..e115d45f3 100644 --- a/API.Tests/Extensions/EnumerableExtensionsTests.cs +++ b/API.Tests/Extensions/EnumerableExtensionsTests.cs @@ -1,4 +1,7 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; +using API.Data.Misc; +using API.Entities.Enums; using API.Extensions; using Xunit; @@ -132,4 +135,33 @@ public class EnumerableExtensionsTests i++; } } + + [Theory] + [InlineData(true, 2)] + [InlineData(false, 1)] + public void RestrictAgainstAgeRestriction_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + { + var items = new List() + { + new RecentlyAddedSeries() + { + AgeRating = AgeRating.Teen, + }, + new RecentlyAddedSeries() + { + AgeRating = AgeRating.Unknown, + }, + new RecentlyAddedSeries() + { + AgeRating = AgeRating.X18Plus, + }, + }; + + var filtered = items.RestrictAgainstAgeRestriction(new AgeRestriction() + { + AgeRating = AgeRating.Teen, + IncludeUnknowns = includeUnknowns + }); + Assert.Equal(expectedCount, filtered.Count()); + } } diff --git a/API.Tests/Extensions/QueryableExtensionsTests.cs b/API.Tests/Extensions/QueryableExtensionsTests.cs new file mode 100644 index 000000000..87d7f5b83 --- /dev/null +++ b/API.Tests/Extensions/QueryableExtensionsTests.cs @@ -0,0 +1,131 @@ +using System.Collections.Generic; +using System.Linq; +using API.Data.Misc; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Extensions; +using Xunit; + +namespace API.Tests.Extensions; + +public class QueryableExtensionsTests +{ + [Theory] + [InlineData(true, 2)] + [InlineData(false, 1)] + public void RestrictAgainstAgeRestriction_Series_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + { + var items = new List() + { + new Series() + { + Metadata = new SeriesMetadata() + { + AgeRating = AgeRating.Teen, + } + }, + new Series() + { + Metadata = new SeriesMetadata() + { + AgeRating = AgeRating.Unknown, + } + }, + new Series() + { + Metadata = new SeriesMetadata() + { + AgeRating = AgeRating.X18Plus, + } + }, + }; + + var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction() + { + AgeRating = AgeRating.Teen, + IncludeUnknowns = includeUnknowns + }); + Assert.Equal(expectedCount, filtered.Count()); + } + + [Theory] + [InlineData(true, 2)] + [InlineData(false, 1)] + public void RestrictAgainstAgeRestriction_CollectionTag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + { + var items = new List() + { + new CollectionTag() + { + SeriesMetadatas = new List() + { + new SeriesMetadata() + { + AgeRating = AgeRating.Teen, + } + } + }, + new CollectionTag() + { + SeriesMetadatas = new List() + { + new SeriesMetadata() + { + AgeRating = AgeRating.Unknown, + }, + new SeriesMetadata() + { + AgeRating = AgeRating.Teen, + } + } + }, + new CollectionTag() + { + SeriesMetadatas = new List() + { + new SeriesMetadata() + { + AgeRating = AgeRating.X18Plus, + } + } + }, + }; + + var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction() + { + AgeRating = AgeRating.Teen, + IncludeUnknowns = includeUnknowns + }); + Assert.Equal(expectedCount, filtered.Count()); + } + + [Theory] + [InlineData(true, 2)] + [InlineData(false, 1)] + public void RestrictAgainstAgeRestriction_ReadingList_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + { + var items = new List() + { + new ReadingList() + { + AgeRating = AgeRating.Teen, + }, + new ReadingList() + { + AgeRating = AgeRating.Unknown, + }, + new ReadingList() + { + AgeRating = AgeRating.X18Plus + }, + }; + + var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction() + { + AgeRating = AgeRating.Teen, + IncludeUnknowns = includeUnknowns + }); + Assert.Equal(expectedCount, filtered.Count()); + } +} diff --git a/API.Tests/Extensions/SeriesExtensionsTests.cs b/API.Tests/Extensions/SeriesExtensionsTests.cs index 6825ad61a..f8dce8876 100644 --- a/API.Tests/Extensions/SeriesExtensionsTests.cs +++ b/API.Tests/Extensions/SeriesExtensionsTests.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; +using API.Comparators; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; @@ -81,11 +83,286 @@ public class SeriesExtensionsTests NormalizedName = seriesInput.Length == 4 ? seriesInput[3] : API.Services.Tasks.Scanner.Parser.Parser.Normalize(seriesInput[0]), Metadata = new SeriesMetadata() }; - var info = new ParserInfo(); - info.Series = parserSeries; + var info = new ParserInfo + { + Series = parserSeries + }; Assert.Equal(expected, series.NameInParserInfo(info)); } + [Fact] + public void GetCoverImage_MultipleSpecials_Comics() + { + var series = new Series() + { + Format = MangaFormat.Archive, + Volumes = new List() + { + new Volume() + { + Number = 0, + Name = API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume, + Chapters = new List() + { + new Chapter() + { + IsSpecial = true, + Number = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, + CoverImage = "Special 1", + }, + new Chapter() + { + IsSpecial = true, + Number = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, + CoverImage = "Special 2", + } + }, + } + } + }; + + Assert.Equal("Special 1", series.GetCoverImage()); + + } + + [Fact] + public void GetCoverImage_MultipleSpecials_Books() + { + var series = new Series() + { + Format = MangaFormat.Epub, + Volumes = new List() + { + new Volume() + { + Number = 0, + Name = API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume, + Chapters = new List() + { + new Chapter() + { + IsSpecial = true, + Number = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, + CoverImage = "Special 1", + }, + new Chapter() + { + IsSpecial = true, + Number = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, + CoverImage = "Special 2", + } + }, + } + } + }; + + Assert.Equal("Special 1", series.GetCoverImage()); + } + + [Fact] + public void GetCoverImage_JustChapters_Comics() + { + var series = new Series() + { + Format = MangaFormat.Archive, + Volumes = new List() + { + new Volume() + { + Number = 0, + Name = API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume, + Chapters = new List() + { + new Chapter() + { + IsSpecial = false, + Number = "2.5", + CoverImage = "Special 1", + }, + new Chapter() + { + IsSpecial = false, + Number = "2", + CoverImage = "Special 2", + } + }, + } + } + }; + + foreach (var vol in series.Volumes) + { + vol.CoverImage = vol.Chapters.MinBy(x => double.Parse(x.Number), ChapterSortComparerZeroFirst.Default)?.CoverImage; + } + + Assert.Equal("Special 2", series.GetCoverImage()); + } + + [Fact] + public void GetCoverImage_JustChaptersAndSpecials_Comics() + { + var series = new Series() + { + Format = MangaFormat.Archive, + Volumes = new List() + { + new Volume() + { + Number = 0, + Name = API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume, + Chapters = new List() + { + new Chapter() + { + IsSpecial = false, + Number = "2.5", + CoverImage = "Special 1", + }, + new Chapter() + { + IsSpecial = false, + Number = "2", + CoverImage = "Special 2", + }, + new Chapter() + { + IsSpecial = true, + Number = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, + CoverImage = "Special 3", + } + }, + } + } + }; + + foreach (var vol in series.Volumes) + { + vol.CoverImage = vol.Chapters.MinBy(x => double.Parse(x.Number), ChapterSortComparerZeroFirst.Default)?.CoverImage; + } + + Assert.Equal("Special 2", series.GetCoverImage()); + } + + [Fact] + public void GetCoverImage_VolumesChapters_Comics() + { + var series = new Series() + { + Format = MangaFormat.Archive, + Volumes = new List() + { + new Volume() + { + Number = 0, + Name = API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume, + Chapters = new List() + { + new Chapter() + { + IsSpecial = false, + Number = "2.5", + CoverImage = "Special 1", + }, + new Chapter() + { + IsSpecial = false, + Number = "2", + CoverImage = "Special 2", + }, + new Chapter() + { + IsSpecial = true, + Number = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, + CoverImage = "Special 3", + } + }, + }, + new Volume() + { + Number = 1, + Name = "1", + Chapters = new List() + { + new Chapter() + { + IsSpecial = false, + Number = "0", + CoverImage = "Volume 1", + }, + + }, + } + } + }; + + foreach (var vol in series.Volumes) + { + vol.CoverImage = vol.Chapters.MinBy(x => double.Parse(x.Number), ChapterSortComparerZeroFirst.Default)?.CoverImage; + } + + Assert.Equal("Volume 1", series.GetCoverImage()); + } + + [Fact] + public void GetCoverImage_VolumesChaptersAndSpecials_Comics() + { + var series = new Series() + { + Format = MangaFormat.Archive, + Volumes = new List() + { + new Volume() + { + Number = 0, + Name = API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume, + Chapters = new List() + { + new Chapter() + { + IsSpecial = false, + Number = "2.5", + CoverImage = "Special 1", + }, + new Chapter() + { + IsSpecial = false, + Number = "2", + CoverImage = "Special 2", + }, + new Chapter() + { + IsSpecial = true, + Number = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, + CoverImage = "Special 3", + } + }, + }, + new Volume() + { + Number = 1, + Name = "1", + Chapters = new List() + { + new Chapter() + { + IsSpecial = false, + Number = "0", + CoverImage = "Volume 1", + }, + + }, + } + } + }; + + foreach (var vol in series.Volumes) + { + vol.CoverImage = vol.Chapters.MinBy(x => double.Parse(x.Number), ChapterSortComparerZeroFirst.Default)?.CoverImage; + } + + Assert.Equal("Volume 1", series.GetCoverImage()); + } + } diff --git a/API.Tests/Repository/SeriesRepositoryTests.cs b/API.Tests/Repository/SeriesRepositoryTests.cs index 65491d333..fe285641e 100644 --- a/API.Tests/Repository/SeriesRepositoryTests.cs +++ b/API.Tests/Repository/SeriesRepositoryTests.cs @@ -140,12 +140,12 @@ public class SeriesRepositoryTests [InlineData("Heion Sedai no Idaten-tachi", "", MangaFormat.Archive, "The Idaten Deities Know Only Peace")] // Matching on localized name in DB [InlineData("Heion Sedai no Idaten-tachi", "", MangaFormat.Pdf, null)] - public async Task GetFullSeriesByAnyName_Should(string seriesName, string localizedName, string? expected) + public async Task GetFullSeriesByAnyName_Should(string seriesName, MangaFormat format, string localizedName, string? expected) { var firstSeries = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); var series = await _unitOfWork.SeriesRepository.GetFullSeriesByAnyName(seriesName, localizedName, - 1, MangaFormat.Unknown); + 1, format); if (expected == null) { Assert.Null(series); @@ -157,4 +157,6 @@ public class SeriesRepositoryTests } } + + //public async Task } diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 26216cd0a..5c60baf4d 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -351,7 +351,7 @@ public class CleanupServiceTests var ds = new DirectoryService(Substitute.For>(), filesystem); var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, ds); - cleanupService.CleanupCacheDirectory(); + cleanupService.CleanupCacheAndTempDirectories(); Assert.Empty(ds.GetFiles(CacheDirectory, searchOption: SearchOption.AllDirectories)); } @@ -365,7 +365,7 @@ public class CleanupServiceTests var ds = new DirectoryService(Substitute.For>(), filesystem); var cleanupService = new CleanupService(_logger, _unitOfWork, _messageHub, ds); - cleanupService.CleanupCacheDirectory(); + cleanupService.CleanupCacheAndTempDirectories(); Assert.Empty(ds.GetFiles(CacheDirectory, searchOption: SearchOption.AllDirectories)); } diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index fc72a5111..b0d6c43ba 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -366,7 +366,9 @@ public class AccountController : BaseApiController var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); - user.AgeRestriction = isAdmin ? AgeRating.NotApplicable : dto.AgeRestriction; + user.AgeRestriction = isAdmin ? AgeRating.NotApplicable : dto.AgeRating; + user.AgeRestrictionIncludeUnknowns = isAdmin || dto.IncludeUnknowns; + _unitOfWork.UserRepository.Update(user); if (!_unitOfWork.HasChanges()) return Ok(); @@ -455,7 +457,9 @@ public class AccountController : BaseApiController lib.AppUsers.Add(user); } - user.AgeRestriction = hasAdminRole ? AgeRating.NotApplicable : dto.AgeRestriction; + user.AgeRestriction = hasAdminRole ? AgeRating.NotApplicable : dto.AgeRestriction.AgeRating; + user.AgeRestrictionIncludeUnknowns = hasAdminRole || dto.AgeRestriction.IncludeUnknowns; + _unitOfWork.UserRepository.Update(user); if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync()) @@ -570,7 +574,8 @@ public class AccountController : BaseApiController lib.AppUsers.Add(user); } - user.AgeRestriction = hasAdminRole ? AgeRating.NotApplicable : dto.AgeRestriction; + user.AgeRestriction = hasAdminRole ? AgeRating.NotApplicable : dto.AgeRestriction.AgeRating; + user.AgeRestrictionIncludeUnknowns = hasAdminRole || dto.AgeRestriction.IncludeUnknowns; var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); if (string.IsNullOrEmpty(token)) diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index f62326651..73be21dd7 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -113,7 +113,6 @@ public class LibraryController : BaseApiController } - [ResponseCache(CacheProfileName = "10Minute")] [HttpGet] public async Task>> GetLibraries() { diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 13e991065..f43bcf271 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -72,7 +72,7 @@ public class ServerController : BaseApiController public ActionResult ClearCache() { _logger.LogInformation("{UserName} is clearing cache of server from admin dashboard", User.GetUsername()); - _cleanupService.CleanupCacheDirectory(); + _cleanupService.CleanupCacheAndTempDirectories(); return Ok(); } diff --git a/API/DTOs/Account/AgeRestrictionDto.cs b/API/DTOs/Account/AgeRestrictionDto.cs new file mode 100644 index 000000000..ad4534b35 --- /dev/null +++ b/API/DTOs/Account/AgeRestrictionDto.cs @@ -0,0 +1,16 @@ +using API.Entities.Enums; + +namespace API.DTOs.Account; + +public class AgeRestrictionDto +{ + /// + /// The maximum age rating a user has access to. -1 if not applicable + /// + public AgeRating AgeRating { get; set; } = AgeRating.NotApplicable; + /// + /// Are Unknowns explicitly allowed against age rating + /// + /// Unknown is always lowest and default age rating. Setting this to false will ensure Teen age rating applies and unknowns are still filtered + public bool IncludeUnknowns { get; set; } = false; +} diff --git a/API/DTOs/Account/InviteUserDto.cs b/API/DTOs/Account/InviteUserDto.cs index 86bed4476..9532b86dd 100644 --- a/API/DTOs/Account/InviteUserDto.cs +++ b/API/DTOs/Account/InviteUserDto.cs @@ -20,5 +20,5 @@ public class InviteUserDto /// /// An Age Rating which will limit the account to seeing everything equal to or below said rating. /// - public AgeRating AgeRestriction { get; set; } + public AgeRestrictionDto AgeRestriction { get; set; } } diff --git a/API/DTOs/Account/UpdateAgeRestrictionDto.cs b/API/DTOs/Account/UpdateAgeRestrictionDto.cs index edd1be9af..ef6be1bba 100644 --- a/API/DTOs/Account/UpdateAgeRestrictionDto.cs +++ b/API/DTOs/Account/UpdateAgeRestrictionDto.cs @@ -6,5 +6,7 @@ namespace API.DTOs.Account; public class UpdateAgeRestrictionDto { [Required] - public AgeRating AgeRestriction { get; set; } + public AgeRating AgeRating { get; set; } + [Required] + public bool IncludeUnknowns { get; set; } } diff --git a/API/DTOs/Account/UpdateUserDto.cs b/API/DTOs/Account/UpdateUserDto.cs index 6bf880074..7a928690c 100644 --- a/API/DTOs/Account/UpdateUserDto.cs +++ b/API/DTOs/Account/UpdateUserDto.cs @@ -19,6 +19,6 @@ public record UpdateUserDto /// /// An Age Rating which will limit the account to seeing everything equal to or below said rating. /// - public AgeRating AgeRestriction { get; init; } + public AgeRestrictionDto AgeRestriction { get; init; } } diff --git a/API/DTOs/MemberDto.cs b/API/DTOs/MemberDto.cs index d2218c9ba..1805c1d24 100644 --- a/API/DTOs/MemberDto.cs +++ b/API/DTOs/MemberDto.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using API.Data.Misc; +using API.DTOs.Account; using API.Entities.Enums; namespace API.DTOs; @@ -12,11 +14,7 @@ public class MemberDto public int Id { get; init; } public string Username { get; init; } public string Email { get; init; } - - /// - /// The maximum age rating a user has access to. -1 if not applicable - /// - public AgeRating AgeRestriction { get; init; } = AgeRating.NotApplicable; + public AgeRestrictionDto AgeRestriction { get; init; } public DateTime Created { get; init; } public DateTime LastActive { get; init; } public IEnumerable Libraries { get; init; } diff --git a/API/DTOs/Stats/ServerInfoDto.cs b/API/DTOs/Stats/ServerInfoDto.cs index 955a73f4b..58700a770 100644 --- a/API/DTOs/Stats/ServerInfoDto.cs +++ b/API/DTOs/Stats/ServerInfoDto.cs @@ -140,4 +140,9 @@ public class ServerInfoDto /// /// Introduced in v0.6.0 public IEnumerable FileFormats { get; set; } + /// + /// If there is at least one user that is using an age restricted profile on the instance + /// + /// Introduced in v0.6.0 + public bool UsingRestrictedProfiles { get; set; } } diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index 210eefa09..1e9cba267 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -1,4 +1,5 @@  +using API.DTOs.Account; using API.Entities.Enums; namespace API.DTOs; @@ -11,8 +12,5 @@ public class UserDto public string RefreshToken { get; set; } public string ApiKey { get; init; } public UserPreferencesDto Preferences { get; set; } - /// - /// The highest age rating the user has access to. Not applicable for admins - /// - public AgeRating AgeRestriction { get; set; } = AgeRating.NotApplicable; + public AgeRestrictionDto AgeRestriction { get; init; } } diff --git a/API/Data/Migrations/20221017131711_IncludeUnknowns.Designer.cs b/API/Data/Migrations/20221017131711_IncludeUnknowns.Designer.cs new file mode 100644 index 000000000..9ad6b3542 --- /dev/null +++ b/API/Data/Migrations/20221017131711_IncludeUnknowns.Designer.cs @@ -0,0 +1,1673 @@ +// +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("20221017131711_IncludeUnknowns")] + partial class IncludeUnknowns + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.9"); + + 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("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", (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("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .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("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + 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("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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + 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("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("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .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("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("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .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("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .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("Created") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + 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("LastModified") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .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("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .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.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .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("LastModified") + .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.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.ClientCascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.ClientCascade) + .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"); + }); + + 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/20221017131711_IncludeUnknowns.cs b/API/Data/Migrations/20221017131711_IncludeUnknowns.cs new file mode 100644 index 000000000..34c0dfd9e --- /dev/null +++ b/API/Data/Migrations/20221017131711_IncludeUnknowns.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class IncludeUnknowns : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AgeRestrictionIncludeUnknowns", + table: "AspNetUsers", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AgeRestrictionIncludeUnknowns", + table: "AspNetUsers"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 80b841b8b..5ff8b98d0 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -56,6 +56,9 @@ namespace API.Data.Migrations b.Property("AgeRestriction") .HasColumnType("INTEGER"); + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + b.Property("ApiKey") .HasColumnType("TEXT"); diff --git a/API/Data/Misc/AgeRestriction.cs b/API/Data/Misc/AgeRestriction.cs new file mode 100644 index 000000000..90c3c5888 --- /dev/null +++ b/API/Data/Misc/AgeRestriction.cs @@ -0,0 +1,9 @@ +using API.Entities.Enums; + +namespace API.Data.Misc; + +public class AgeRestriction +{ + public AgeRating AgeRating { get; set; } + public bool IncludeUnknowns { get; set; } +} diff --git a/API/Data/Misc/RecentlyAddedSeries.cs b/API/Data/Misc/RecentlyAddedSeries.cs new file mode 100644 index 000000000..24100ca0f --- /dev/null +++ b/API/Data/Misc/RecentlyAddedSeries.cs @@ -0,0 +1,22 @@ +using System; +using API.Entities.Enums; + +namespace API.Data.Misc; + +public class RecentlyAddedSeries +{ + public int LibraryId { get; init; } + public LibraryType LibraryType { get; init; } + public DateTime Created { get; init; } + public int SeriesId { get; init; } + public string SeriesName { get; init; } + public MangaFormat Format { get; init; } + public int ChapterId { get; init; } + public int VolumeId { get; init; } + public string ChapterNumber { get; init; } + public string ChapterRange { get; init; } + public string ChapterTitle { get; init; } + public bool IsSpecial { get; init; } + public int VolumeNumber { get; init; } + public AgeRating AgeRating { get; init; } +} diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index a88a98adf..a5ea582f3 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -2,6 +2,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using API.Data.Misc; using API.DTOs.CollectionTags; using API.Entities; using API.Extensions; @@ -96,7 +97,7 @@ public class CollectionTagRepository : ICollectionTagRepository public async Task> GetAllPromotedTagDtosAsync(int userId) { - var userRating = (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction; + var userRating = await GetUserAgeRestriction(userId); return await _context.CollectionTag .Where(c => c.Promoted) .RestrictAgainstAgeRestriction(userRating) @@ -122,9 +123,22 @@ public class CollectionTagRepository : ICollectionTagRepository .SingleOrDefaultAsync(); } + private async Task GetUserAgeRestriction(int userId) + { + return await _context.AppUser + .AsNoTracking() + .Where(u => u.Id == userId) + .Select(u => + new AgeRestriction(){ + AgeRating = u.AgeRestriction, + IncludeUnknowns = u.AgeRestrictionIncludeUnknowns + }) + .SingleAsync(); + } + public async Task> SearchTagDtosAsync(string searchQuery, int userId) { - var userRating = (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction; + var userRating = await GetUserAgeRestriction(userId); return await _context.CollectionTag .Where(s => EF.Functions.Like(s.Title, $"%{searchQuery}%") || EF.Functions.Like(s.NormalizedTitle, $"%{searchQuery}%")) diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 21de38ce2..0c287a9f9 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using API.Data.Misc; using API.Data.Scanner; using API.DTOs; using API.DTOs.CollectionTags; @@ -36,24 +37,6 @@ public enum SeriesIncludes Library = 16, } -internal class RecentlyAddedSeries -{ - public int LibraryId { get; init; } - public LibraryType LibraryType { get; init; } - public DateTime Created { get; init; } - public int SeriesId { get; init; } - public string SeriesName { get; init; } - public MangaFormat Format { get; init; } - public int ChapterId { get; init; } - public int VolumeId { get; init; } - public string ChapterNumber { get; init; } - public string ChapterRange { get; init; } - public string ChapterTitle { get; init; } - public bool IsSpecial { get; init; } - public int VolumeNumber { get; init; } - public AgeRating AgeRating { get; init; } -} - public interface ISeriesRepository { void Add(Series series); @@ -121,7 +104,7 @@ public interface ISeriesRepository Task> GetWantToReadForUserAsync(int userId, UserParams userParams, FilterDto filter); Task GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None); Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true); - Task> RemoveSeriesNotInList(IList seenSeries, int libraryId); + Task> RemoveSeriesNotInList(IList seenSeries, int libraryId); Task>> GetFolderPathMap(int libraryId); Task GetMaxAgeRatingFromSeriesAsync(IEnumerable seriesIds); } @@ -767,7 +750,7 @@ public class SeriesRepository : ISeriesRepository EF.Functions.Like(s.Name, $"%{filter.SeriesNameQuery}%") || EF.Functions.Like(s.OriginalName, $"%{filter.SeriesNameQuery}%") || EF.Functions.Like(s.LocalizedName, $"%{filter.SeriesNameQuery}%")); - if (userRating != AgeRating.NotApplicable) + if (userRating.AgeRating != AgeRating.NotApplicable) { query = query.RestrictAgainstAgeRestriction(userRating); } @@ -1048,9 +1031,9 @@ public class SeriesRepository : ISeriesRepository var userRating = await GetUserAgeRestriction(userId); var items = (await GetRecentlyAddedChaptersQuery(userId)); - if (userRating != AgeRating.NotApplicable) + if (userRating.AgeRating != AgeRating.NotApplicable) { - items = items.Where(c => c.AgeRating <= userRating); + items = items.RestrictAgainstAgeRestriction(userRating); } foreach (var item in items) { @@ -1080,9 +1063,17 @@ public class SeriesRepository : ISeriesRepository return seriesMap.Values.AsEnumerable(); } - private async Task GetUserAgeRestriction(int userId) + private async Task GetUserAgeRestriction(int userId) { - return (await _context.AppUser.SingleAsync(u => u.Id == userId)).AgeRestriction; + return await _context.AppUser + .AsNoTracking() + .Where(u => u.Id == userId) + .Select(u => + new AgeRestriction(){ + AgeRating = u.AgeRestriction, + IncludeUnknowns = u.AgeRestrictionIncludeUnknowns + }) + .SingleAsync(); } public async Task> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind) @@ -1267,20 +1258,39 @@ public class SeriesRepository : ISeriesRepository /// /// /// - public async Task> RemoveSeriesNotInList(IList seenSeries, int libraryId) + public async Task> RemoveSeriesNotInList(IList seenSeries, int libraryId) { - if (seenSeries.Count == 0) return new List(); + if (seenSeries.Count == 0) return Array.Empty(); + var ids = new List(); foreach (var parsedSeries in seenSeries) { - var series = await _context.Series - .Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName && - s.LibraryId == libraryId) - .Select(s => s.Id) - .SingleOrDefaultAsync(); - if (series > 0) + try { - ids.Add(series); + var seriesId = await _context.Series + .Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName && + s.LibraryId == libraryId) + .Select(s => s.Id) + .SingleOrDefaultAsync(); + if (seriesId > 0) + { + ids.Add(seriesId); + } + } + catch (Exception) + { + // This is due to v0.5.6 introducing bugs where we could have multiple series get duplicated and no way to delete them + // This here will delete the 2nd one as the first is the one to likely be used. + var sId = _context.Series + .Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName && + s.LibraryId == libraryId) + .Select(s => s.Id) + .OrderBy(s => s) + .Last(); + if (sId > 0) + { + ids.Add(sId); + } } } @@ -1289,6 +1299,15 @@ public class SeriesRepository : ISeriesRepository .Where(s => !ids.Contains(s.Id)) .ToListAsync(); + // If the series to remove has Relation (related series), we must manually unlink due to the DB not being + // setup correctly (if this is not done, a foreign key constraint will be thrown) + + foreach (var sr in seriesToRemove) + { + sr.Relations = new List(); + Update(sr); + } + _context.Series.RemoveRange(seriesToRemove); return seriesToRemove; @@ -1427,7 +1446,7 @@ public class SeriesRepository : ISeriesRepository .Select(s => s.Id); } - private async Task> GetRelatedSeriesQuery(int seriesId, IEnumerable usersSeriesIds, RelationKind kind, AgeRating userRating) + private async Task> GetRelatedSeriesQuery(int seriesId, IEnumerable usersSeriesIds, RelationKind kind, AgeRestriction userRating) { return await _context.Series.SelectMany(s => s.Relations.Where(sr => sr.RelationKind == kind && sr.SeriesId == seriesId && usersSeriesIds.Contains(sr.TargetSeriesId)) diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 62d802f3a..c7115081b 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using API.Constants; using API.DTOs; +using API.DTOs.Account; using API.DTOs.Filtering; using API.DTOs.Reader; using API.Entities; @@ -396,7 +397,11 @@ public class UserRepository : IUserRepository Created = u.Created, LastActive = u.LastActive, Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), - AgeRestriction = u.AgeRestriction, + AgeRestriction = new AgeRestrictionDto() + { + AgeRating = u.AgeRestriction, + IncludeUnknowns = u.AgeRestrictionIncludeUnknowns + }, Libraries = u.Libraries.Select(l => new LibraryDto { Name = l.Name, @@ -430,7 +435,11 @@ public class UserRepository : IUserRepository Created = u.Created, LastActive = u.LastActive, Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), - AgeRestriction = u.AgeRestriction, + AgeRestriction = new AgeRestrictionDto() + { + AgeRating = u.AgeRestriction, + IncludeUnknowns = u.AgeRestrictionIncludeUnknowns + }, Libraries = u.Libraries.Select(l => new LibraryDto { Name = l.Name, diff --git a/API/Entities/AppUser.cs b/API/Entities/AppUser.cs index 53cdefce6..8a603ba57 100644 --- a/API/Entities/AppUser.cs +++ b/API/Entities/AppUser.cs @@ -45,6 +45,10 @@ public class AppUser : IdentityUser, IHasConcurrencyToken /// The highest age rating the user has access to. Not applicable for admins /// public AgeRating AgeRestriction { get; set; } = AgeRating.NotApplicable; + /// + /// If an age rating restriction is applied to the account, if Unknowns should be allowed for the user. Defaults to false. + /// + public bool AgeRestrictionIncludeUnknowns { get; set; } = false; /// [ConcurrencyCheck] diff --git a/API/Extensions/EnumerableExtensions.cs b/API/Extensions/EnumerableExtensions.cs index 5b39ca024..679136efb 100644 --- a/API/Extensions/EnumerableExtensions.cs +++ b/API/Extensions/EnumerableExtensions.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using API.Data.Misc; +using API.Entities.Enums; namespace API.Extensions; @@ -27,4 +29,16 @@ public static class EnumerableExtensions return list.OrderBy(i => Regex.Replace(selector(i), match => match.Value.PadLeft(maxDigits, '0')), stringComparer ?? StringComparer.CurrentCulture); } + + public static IEnumerable RestrictAgainstAgeRestriction(this IEnumerable items, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return items; + var q = items.Where(s => s.AgeRating <= restriction.AgeRating); + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.AgeRating != AgeRating.Unknown); + } + + return q; + } } diff --git a/API/Extensions/QueryableExtensions.cs b/API/Extensions/QueryableExtensions.cs index d92406613..426c84ce7 100644 --- a/API/Extensions/QueryableExtensions.cs +++ b/API/Extensions/QueryableExtensions.cs @@ -1,4 +1,5 @@ using System.Linq; +using API.Data.Misc; using API.Entities; using API.Entities.Enums; @@ -6,18 +7,42 @@ namespace API.Extensions; public static class QueryableExtensions { - public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRating rating) + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { - return queryable.Where(s => rating == AgeRating.NotApplicable || s.Metadata.AgeRating <= rating); + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(s => s.Metadata.AgeRating <= restriction.AgeRating); + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.Metadata.AgeRating != AgeRating.Unknown); + } + + return q; } - public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRating rating) + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { - return queryable.Where(c => c.SeriesMetadatas.All(sm => sm.AgeRating <= rating)); + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + + if (restriction.IncludeUnknowns) + { + return queryable.Where(c => c.SeriesMetadatas.All(sm => + sm.AgeRating <= restriction.AgeRating)); + } + + return queryable.Where(c => c.SeriesMetadatas.All(sm => + sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); } - public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRating rating) + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { - return queryable.Where(rl => rl.AgeRating <= rating); + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(rl => rl.AgeRating <= restriction.AgeRating); + + if (!restriction.IncludeUnknowns) + { + return q.Where(rl => rl.AgeRating != AgeRating.Unknown); + } + + return q; } } diff --git a/API/Extensions/SeriesExtensions.cs b/API/Extensions/SeriesExtensions.cs index bfb999d10..ad5ec3130 100644 --- a/API/Extensions/SeriesExtensions.cs +++ b/API/Extensions/SeriesExtensions.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using API.Comparators; using API.Entities; using API.Parser; using API.Services.Tasks.Scanner; @@ -45,4 +46,26 @@ public static class SeriesExtensions || info.Series == series.Name || info.Series == series.LocalizedName || info.Series == series.OriginalName || Services.Tasks.Scanner.Parser.Parser.Normalize(info.Series) == Services.Tasks.Scanner.Parser.Parser.Normalize(series.OriginalName); } + + /// + /// Calculates the Cover Image for the Series + /// + /// + /// + /// This is under the assumption that the Volume already has a Cover Image calculated and set + public static string GetCoverImage(this Series series) + { + var volumes = series.Volumes ?? new List(); + var firstVolume = volumes.GetCoverImage(series.Format); + string coverImage = null; + + var chapters = firstVolume.Chapters.OrderBy(c => double.Parse(c.Number), ChapterSortComparerZeroFirst.Default).ToList(); + if (chapters.Count > 1 && chapters.Any(c => c.IsSpecial)) + { + coverImage = chapters.FirstOrDefault(c => !c.IsSpecial)?.CoverImage ?? chapters.First().CoverImage; + firstVolume = null; + } + + return firstVolume?.CoverImage ?? coverImage; + } } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 2a0295a7e..d89a3f9e0 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -1,13 +1,13 @@ using System.Collections.Generic; using System.Linq; using API.DTOs; +using API.DTOs.Account; using API.DTOs.CollectionTags; using API.DTOs.Device; using API.DTOs.Metadata; using API.DTOs.Reader; using API.DTOs.ReadingLists; using API.DTOs.Search; -using API.DTOs.SeriesDetail; using API.DTOs.Settings; using API.DTOs.Theme; using API.Entities; @@ -98,7 +98,14 @@ public class AutoMapperProfiles : Profile opt => opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); - CreateMap(); + CreateMap() + .ForMember(dest => dest.AgeRestriction, + opt => + opt.MapFrom(src => new AgeRestrictionDto() + { + AgeRating = src.AgeRestriction, + IncludeUnknowns = src.AgeRestrictionIncludeUnknowns + })); CreateMap(); CreateMap() .ForMember(dest => dest.Theme, @@ -130,6 +137,13 @@ public class AutoMapperProfiles : Profile opt.MapFrom(src => src.Folders.Select(x => x.Path).ToList())); CreateMap() + .ForMember(dest => dest.AgeRestriction, + opt => + opt.MapFrom(src => new AgeRestrictionDto() + { + AgeRating = src.AgeRestriction, + IncludeUnknowns = src.AgeRestrictionIncludeUnknowns + })) .AfterMap((ps, pst, context) => context.Mapper.Map(ps.Libraries, pst.Libraries)); CreateMap(); diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 767655698..20d6239e8 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -130,17 +130,8 @@ public class MetadataService : IMetadataService return Task.CompletedTask; series.Volumes ??= new List(); - var firstCover = series.Volumes.GetCoverImage(series.Format); - string coverImage = null; + series.CoverImage = series.GetCoverImage(); - var chapters = firstCover.Chapters.OrderBy(c => double.Parse(c.Number), ChapterSortComparerZeroFirst.Default).ToList(); - if (chapters.Count > 1 && chapters.Any(c => c.IsSpecial)) - { - coverImage = chapters.First(c => !c.IsSpecial).CoverImage ?? chapters.First().CoverImage; - firstCover = null; - } - - series.CoverImage = firstCover?.CoverImage ?? coverImage; _updateEvents.Add(MessageFactory.CoverUpdateEvent(series.Id, MessageFactoryEntityTypes.Series)); return Task.CompletedTask; } diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index 4c4933aed..3f2122a08 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -72,8 +72,7 @@ public class ReadingItemService : IReadingItemService // This catches when original library type is Manga/Comic and when parsing with non if (Tasks.Scanner.Parser.Parser.IsEpub(path) && Tasks.Scanner.Parser.Parser.ParseVolume(info.Series) != Tasks.Scanner.Parser.Parser.DefaultVolume) // Shouldn't this be info.Volume != DefaultVolume? { - info = _defaultParser.Parse(path, rootPath, LibraryType.Book); - var info2 = Parse(path, rootPath, type); + var info2 = _defaultParser.Parse(path, rootPath, LibraryType.Book); info.Merge(info2); } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index f5efa18cf..d225b3b99 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -239,7 +239,7 @@ public class TaskScheduler : ITaskScheduler _logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId); BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, force)); // When we do a scan, force cache to re-unpack in case page numbers change - BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory()); + BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheAndTempDirectories()); } public void CleanupChapters(int[] chapterIds) diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index bf39d8ad8..0cc4d7c98 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -20,7 +20,7 @@ public interface ICleanupService { Task Cleanup(); Task CleanupDbEntries(); - void CleanupCacheDirectory(); + void CleanupCacheAndTempDirectories(); Task DeleteSeriesCoverImages(); Task DeleteChapterCoverImages(); Task DeleteTagCoverImages(); @@ -65,7 +65,7 @@ public class CleanupService : ICleanupService _logger.LogInformation("Cleaning temp directory"); _directoryService.ClearDirectory(_directoryService.TempDirectory); await SendProgress(0.1F, "Cleaning temp directory"); - CleanupCacheDirectory(); + CleanupCacheAndTempDirectories(); await SendProgress(0.25F, "Cleaning old database backups"); _logger.LogInformation("Cleaning old database backups"); await CleanupBackups(); @@ -143,9 +143,9 @@ public class CleanupService : ICleanupService /// /// Removes all files and directories in the cache and temp directory /// - public void CleanupCacheDirectory() + public void CleanupCacheAndTempDirectories() { - _logger.LogInformation("Performing cleanup of Cache directory"); + _logger.LogInformation("Performing cleanup of Cache & Temp directories"); _directoryService.ExistOrCreate(_directoryService.CacheDirectory); _directoryService.ExistOrCreate(_directoryService.TempDirectory); @@ -159,7 +159,7 @@ public class CleanupService : ICleanupService _logger.LogError(ex, "There was an issue deleting one or more folders/files during cleanup"); } - _logger.LogInformation("Cache directory purged"); + _logger.LogInformation("Cache and temp directory purged"); } /// diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 44be4c02b..aa76f36af 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -29,12 +29,6 @@ public class ParsedSeries public MangaFormat Format { get; init; } } -public enum Modified -{ - Modified = 1, - NotModified = 2 -} - public class SeriesModified { public string FolderPath { get; set; } diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index f4c64c96b..04c41535e 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -506,11 +506,6 @@ public class ScannerService : IScannerService library.LastScanned = time; - // Could I delete anything in a Library's Series where the LastScan date is before scanStart? - // NOTE: This implementation is expensive - _logger.LogDebug("Removing Series that were not found during the scan"); - var removedSeries = await _unitOfWork.SeriesRepository.RemoveSeriesNotInList(seenSeries, library.Id); - _logger.LogDebug("Removing Series that were not found during the scan - complete"); _unitOfWork.LibraryRepository.Update(library); if (await _unitOfWork.CommitAsync()) @@ -528,10 +523,27 @@ public class ScannerService : IScannerService totalFiles, seenSeries.Count, sw.ElapsedMilliseconds, library.Name); } - foreach (var s in removedSeries) + try { - await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved, - MessageFactory.SeriesRemovedEvent(s.Id, s.Name, s.LibraryId), false); + // Could I delete anything in a Library's Series where the LastScan date is before scanStart? + // NOTE: This implementation is expensive + _logger.LogDebug("[ScannerService] Removing Series that were not found during the scan"); + var removedSeries = await _unitOfWork.SeriesRepository.RemoveSeriesNotInList(seenSeries, library.Id); + _logger.LogDebug("[ScannerService] Found {Count} series that needs to be removed: {SeriesList}", + removedSeries.Count, removedSeries.Select(s => s.Name)); + _logger.LogDebug("[ScannerService] Removing Series that were not found during the scan - complete"); + + await _unitOfWork.CommitAsync(); + + foreach (var s in removedSeries) + { + await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved, + MessageFactory.SeriesRemovedEvent(s.Id, s.Name, s.LibraryId), false); + } + } + catch (Exception ex) + { + _logger.LogCritical(ex, "[ScannerService] There was an issue deleting series for cleanup. Please check logs and rescan"); } } else @@ -584,4 +596,5 @@ public class ScannerService : IScannerService { return existingSeries.Where(es => !ParserInfoHelpers.SeriesHasMatchingParserInfoFormat(es, parsedSeries)); } + } diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 21af4a46b..0f1653a7c 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -134,6 +134,7 @@ public class StatsService : IStatsService MangaReaderPageSplittingModes = await AllMangaReaderPageSplitting(), MangaReaderLayoutModes = await AllMangaReaderLayoutModes(), FileFormats = AllFormats(), + UsingRestrictedProfiles = await GetUsingRestrictedProfiles(), }; var usersWithPref = (await _unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.UserPreferences)).ToList(); @@ -261,4 +262,9 @@ public class StatsService : IStatsService return results; } + + private Task GetUsingRestrictedProfiles() + { + return _context.Users.AnyAsync(u => u.AgeRestriction > AgeRating.NotApplicable); + } } diff --git a/UI/Web/src/app/_models/age-restriction.ts b/UI/Web/src/app/_models/age-restriction.ts new file mode 100644 index 000000000..e5be030b1 --- /dev/null +++ b/UI/Web/src/app/_models/age-restriction.ts @@ -0,0 +1,6 @@ +import { AgeRating } from "./metadata/age-rating"; + +export interface AgeRestriction { + ageRating: AgeRating; + includeUnknowns: boolean; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/member.ts b/UI/Web/src/app/_models/member.ts index eb584b340..adfbd9d93 100644 --- a/UI/Web/src/app/_models/member.ts +++ b/UI/Web/src/app/_models/member.ts @@ -1,5 +1,5 @@ +import { AgeRestriction } from './age-restriction'; import { Library } from './library'; -import { AgeRating } from './metadata/age-rating'; export interface Member { id: number; @@ -9,8 +9,5 @@ export interface Member { created: string; // datetime roles: string[]; libraries: Library[]; - /** - * If not applicable, will store a -1 - */ - ageRestriction: AgeRating; + ageRestriction: AgeRestriction; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/user.ts b/UI/Web/src/app/_models/user.ts index 1271b1a17..8aa1467bc 100644 --- a/UI/Web/src/app/_models/user.ts +++ b/UI/Web/src/app/_models/user.ts @@ -1,4 +1,4 @@ -import { AgeRating } from './metadata/age-rating'; +import { AgeRestriction } from './age-restriction'; import { Preferences } from './preferences/preferences'; // This interface is only used for login and storing/retreiving JWT from local storage @@ -10,5 +10,5 @@ export interface User { preferences: Preferences; apiKey: string; email: string; - ageRestriction: AgeRating; + ageRestriction: AgeRestriction; } \ No newline at end of file diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 5990fe6d4..618f23e86 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -12,6 +12,7 @@ import { InviteUserResponse } from '../_models/invite-user-response'; import { UserUpdateEvent } from '../_models/events/user-update-event'; import { UpdateEmailResponse } from '../_models/email/update-email-response'; import { AgeRating } from '../_models/metadata/age-rating'; +import { AgeRestriction } from '../_models/age-restriction'; export enum Role { Admin = 'Admin', @@ -161,7 +162,7 @@ export class AccountService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'}); } - inviteUser(model: {email: string, roles: Array, libraries: Array, ageRestriction: AgeRating}) { + inviteUser(model: {email: string, roles: Array, libraries: Array, ageRestriction: AgeRestriction}) { return this.httpClient.post(this.baseUrl + 'account/invite', model); } @@ -198,7 +199,7 @@ export class AccountService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password, oldPassword}, {responseType: 'json' as 'text'}); } - update(model: {email: string, roles: Array, libraries: Array, userId: number, ageRestriction: AgeRating}) { + update(model: {email: string, roles: Array, libraries: Array, userId: number, ageRestriction: AgeRestriction}) { return this.httpClient.post(this.baseUrl + 'account/update', model); } @@ -206,8 +207,8 @@ export class AccountService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'account/update/email', {email}); } - updateAgeRestriction(ageRating: AgeRating) { - return this.httpClient.post(this.baseUrl + 'account/update/age-restriction', {ageRating}); + updateAgeRestriction(ageRating: AgeRating, includeUnknowns: boolean) { + return this.httpClient.post(this.baseUrl + 'account/update/age-restriction', {ageRating, includeUnknowns}); } /** diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.ts b/UI/Web/src/app/admin/edit-user/edit-user.component.ts index c884f3d80..a57bd6703 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.ts +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.ts @@ -1,12 +1,11 @@ import { Component, Input, OnInit } from '@angular/core'; import { FormGroup, FormControl, Validators } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { AgeRestriction } from 'src/app/_models/age-restriction'; import { Library } from 'src/app/_models/library'; import { Member } from 'src/app/_models/member'; -import { AgeRating } from 'src/app/_models/metadata/age-rating'; import { AccountService } from 'src/app/_services/account.service'; -// TODO: Rename this to EditUserModal @Component({ selector: 'app-edit-user', templateUrl: './edit-user.component.html', @@ -18,7 +17,7 @@ export class EditUserComponent implements OnInit { selectedRoles: Array = []; selectedLibraries: Array = []; - selectedRating: AgeRating = AgeRating.NotApplicable; + selectedRestriction!: AgeRestriction; isSaving: boolean = false; userForm: FormGroup = new FormGroup({}); @@ -41,8 +40,8 @@ export class EditUserComponent implements OnInit { this.selectedRoles = roles; } - updateRestrictionSelection(rating: AgeRating) { - this.selectedRating = rating; + updateRestrictionSelection(restriction: AgeRestriction) { + this.selectedRestriction = restriction; } updateLibrarySelection(libraries: Array) { @@ -58,8 +57,7 @@ export class EditUserComponent implements OnInit { model.userId = this.member.id; model.roles = this.selectedRoles; model.libraries = this.selectedLibraries; - model.ageRestriction = this.selectedRating || AgeRating.NotApplicable; - console.log('rating: ', this.selectedRating); + model.ageRestriction = this.selectedRestriction; this.accountService.update(model).subscribe(() => { this.modal.close(true); }); diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.ts b/UI/Web/src/app/admin/invite-user/invite-user.component.ts index f031b3b8c..ddbc8fea6 100644 --- a/UI/Web/src/app/admin/invite-user/invite-user.component.ts +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { FormControl, FormGroup, Validators } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; +import { AgeRestriction } from 'src/app/_models/age-restriction'; import { InviteUserResponse } from 'src/app/_models/invite-user-response'; import { Library } from 'src/app/_models/library'; import { AgeRating } from 'src/app/_models/metadata/age-rating'; @@ -21,7 +22,7 @@ export class InviteUserComponent implements OnInit { inviteForm: FormGroup = new FormGroup({}); selectedRoles: Array = []; selectedLibraries: Array = []; - selectedRating: AgeRating = AgeRating.NotApplicable; + selectedRestriction: AgeRestriction = {ageRating: AgeRating.NotApplicable, includeUnknowns: false}; emailLink: string = ''; makeLink: (val: string) => string = (val: string) => {return this.emailLink}; @@ -48,7 +49,7 @@ export class InviteUserComponent implements OnInit { email, libraries: this.selectedLibraries, roles: this.selectedRoles, - ageRestriction: this.selectedRating + ageRestriction: this.selectedRestriction }).subscribe((data: InviteUserResponse) => { this.emailLink = data.emailLink; this.isSending = false; @@ -69,8 +70,8 @@ export class InviteUserComponent implements OnInit { this.selectedLibraries = libraries.map(l => l.id); } - updateRestrictionSelection(rating: AgeRating) { - this.selectedRating = rating; + updateRestrictionSelection(restriction: AgeRestriction) { + this.selectedRestriction = restriction; } } diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss index ad9cbcb47..8b834a2ff 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss @@ -264,8 +264,7 @@ $action-bar-height: 38px; // This is applied to images in the backend ::ng-deep .kavita-scale-width-container { width: auto; - // * 4 is just for extra buffer which is needed based on testing. --book-reader-content-max-height is set by us on calculation of columnHeight - max-height: calc(var(--book-reader-content-max-height) - ($action-bar-height * 4)), calc((var(--vh)*100) - ($action-bar-height * 4)) !important; + max-height: calc(var(--book-reader-content-max-height) - ($action-bar-height)) !important; } // This is applied to images in the backend diff --git a/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.html b/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.html index b5475df75..fd99fba37 100644 --- a/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.html +++ b/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.html @@ -10,7 +10,11 @@ - {{user?.ageRestriction | ageRating | async}} + {{user?.ageRestriction?.ageRating| ageRating | async}} + + + Unknowns + +
diff --git a/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.ts b/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.ts index 5c4958775..2169d4cd1 100644 --- a/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.ts +++ b/UI/Web/src/app/user-settings/change-age-restriction/change-age-restriction.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, OnInit } from '@angular/core'; import { ToastrService } from 'ngx-toastr'; import { Observable, of, Subject, takeUntil, shareReplay, map, take } from 'rxjs'; +import { AgeRestriction } from 'src/app/_models/age-restriction'; import { AgeRating } from 'src/app/_models/metadata/age-rating'; import { User } from 'src/app/_models/user'; import { AccountService } from 'src/app/_services/account.service'; @@ -16,9 +17,9 @@ export class ChangeAgeRestrictionComponent implements OnInit { user: User | undefined = undefined; hasChangeAgeRestrictionAbility: Observable = of(false); isViewMode: boolean = true; - selectedRating: AgeRating = AgeRating.NotApplicable; - originalRating!: AgeRating; - reset: EventEmitter = new EventEmitter(); + selectedRestriction!: AgeRestriction; + originalRestriction!: AgeRestriction; + reset: EventEmitter = new EventEmitter(); get AgeRating() { return AgeRating; } @@ -28,8 +29,9 @@ export class ChangeAgeRestrictionComponent implements OnInit { ngOnInit(): void { this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), take(1)).subscribe(user => { + if (!user) return; this.user = user; - this.originalRating = this.user?.ageRestriction || AgeRating.NotApplicable; + this.originalRestriction = this.user.ageRestriction; this.cdRef.markForCheck(); }); @@ -39,8 +41,8 @@ export class ChangeAgeRestrictionComponent implements OnInit { this.cdRef.markForCheck(); } - updateRestrictionSelection(rating: AgeRating) { - this.selectedRating = rating; + updateRestrictionSelection(restriction: AgeRestriction) { + this.selectedRestriction = restriction; } ngOnDestroy() { @@ -50,18 +52,19 @@ export class ChangeAgeRestrictionComponent implements OnInit { resetForm() { if (!this.user) return; - this.reset.emit(this.originalRating); + this.reset.emit(this.originalRestriction); this.cdRef.markForCheck(); } saveForm() { if (this.user === undefined) { return; } - this.accountService.updateAgeRestriction(this.selectedRating).subscribe(() => { + this.accountService.updateAgeRestriction(this.selectedRestriction.ageRating, this.selectedRestriction.includeUnknowns).subscribe(() => { this.toastr.success('Age Restriction has been updated'); - this.originalRating = this.selectedRating; + this.originalRestriction = this.selectedRestriction; if (this.user) { - this.user.ageRestriction = this.selectedRating; + this.user.ageRestriction.ageRating = this.selectedRestriction.ageRating; + this.user.ageRestriction.includeUnknowns = this.selectedRestriction.includeUnknowns; } this.resetForm(); this.isViewMode = true; diff --git a/UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.html b/UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.html index 757504e8a..e32593998 100644 --- a/UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.html +++ b/UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.html @@ -15,5 +15,17 @@
+ +
+
+ + +
+ + If true, Unknowns will be allowed with Age Restrcition. This could lead to untagged media leaking to users with Age restrictions. + If true, Unknowns will be allowed with Age Restrcition. This could lead to untagged media leaking to users with Age restrictions. +
+ + \ No newline at end of file diff --git a/UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.ts b/UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.ts index f141721e2..0a49a0211 100644 --- a/UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.ts +++ b/UI/Web/src/app/user-settings/restriction-selector/restriction-selector.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; +import { AgeRestriction } from 'src/app/_models/age-restriction'; import { Member } from 'src/app/_models/member'; import { AgeRating } from 'src/app/_models/metadata/age-rating'; import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto'; @@ -20,8 +21,8 @@ export class RestrictionSelectorComponent implements OnInit, OnChanges { * Show labels and description around the form */ @Input() showContext: boolean = true; - @Input() reset: EventEmitter | undefined; - @Output() selected: EventEmitter = new EventEmitter(); + @Input() reset: EventEmitter | undefined; + @Output() selected: EventEmitter = new EventEmitter(); ageRatings: Array = []; @@ -32,22 +33,36 @@ export class RestrictionSelectorComponent implements OnInit, OnChanges { ngOnInit(): void { this.restrictionForm = new FormGroup({ - 'ageRating': new FormControl(this.member?.ageRestriction || AgeRating.NotApplicable, []) + 'ageRating': new FormControl(this.member?.ageRestriction.ageRating || AgeRating.NotApplicable || AgeRating.NotApplicable, []), + 'ageRestrictionIncludeUnknowns': new FormControl(this.member?.ageRestriction.includeUnknowns, []), + }); if (this.isAdmin) { this.restrictionForm.get('ageRating')?.disable(); + this.restrictionForm.get('ageRestrictionIncludeUnknowns')?.disable(); } if (this.reset) { this.reset.subscribe(e => { - this.restrictionForm?.get('ageRating')?.setValue(e); + this.restrictionForm?.get('ageRating')?.setValue(e.ageRating); + this.restrictionForm?.get('ageRestrictionIncludeUnknowns')?.setValue(e.includeUnknowns); this.cdRef.markForCheck(); }); } this.restrictionForm.get('ageRating')?.valueChanges.subscribe(e => { - this.selected.emit(parseInt(e, 10)); + this.selected.emit({ + ageRating: parseInt(e, 10), + includeUnknowns: this.restrictionForm?.get('ageRestrictionIncludeUnknowns')?.value + }); + }); + + this.restrictionForm.get('ageRestrictionIncludeUnknowns')?.valueChanges.subscribe(e => { + this.selected.emit({ + ageRating: parseInt(this.restrictionForm?.get('ageRating')?.value, 10), + includeUnknowns: e + }); }); this.metadataService.getAllAgeRatings().subscribe(ratings => { @@ -60,8 +75,8 @@ export class RestrictionSelectorComponent implements OnInit, OnChanges { ngOnChanges() { if (!this.member) return; - console.log('changes: '); - this.restrictionForm?.get('ageRating')?.setValue(this.member?.ageRestriction || AgeRating.NotApplicable); + this.restrictionForm?.get('ageRating')?.setValue(this.member?.ageRestriction.ageRating || AgeRating.NotApplicable); + this.restrictionForm?.get('ageRestrictionIncludeUnknowns')?.setValue(this.member?.ageRestriction.includeUnknowns); this.cdRef.markForCheck(); }