From 9149c4cbcac0e6bbe3fdbb72b4729a8c146871b9 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Mon, 17 Oct 2022 15:33:18 -0700 Subject: [PATCH] Release Polish (#1586) * Fixed a scaling issue in the epub reader, where images could scale when they shouldn't. * Removed some caching on library/ api and added more output for a foreign key constraint * Hooked in Restricted Profile stat collection * Added a new boolean on age restrictions to explicitly allow unknowns or not. Since unknown is the default state of metadata, if users are allowed access to Unknown, age restricted content could leak. * Fixed a bug where sometimes series cover generation could fail under conditions where only specials existed. * Fixed foreign constraint issue when cleaning up series not seen at end of scan loop * Removed an additional epub parse when scanning and handled merging differently * Code smell --- .../Extensions/EnumerableExtensionsTests.cs | 34 +- .../Extensions/QueryableExtensionsTests.cs | 131 ++ API.Tests/Extensions/SeriesExtensionsTests.cs | 283 ++- API.Tests/Repository/SeriesRepositoryTests.cs | 6 +- API.Tests/Services/CleanupServiceTests.cs | 4 +- API/Controllers/AccountController.cs | 11 +- API/Controllers/LibraryController.cs | 1 - API/Controllers/ServerController.cs | 2 +- API/DTOs/Account/AgeRestrictionDto.cs | 16 + API/DTOs/Account/InviteUserDto.cs | 2 +- API/DTOs/Account/UpdateAgeRestrictionDto.cs | 4 +- API/DTOs/Account/UpdateUserDto.cs | 2 +- API/DTOs/MemberDto.cs | 8 +- API/DTOs/Stats/ServerInfoDto.cs | 5 + API/DTOs/UserDto.cs | 6 +- ...20221017131711_IncludeUnknowns.Designer.cs | 1673 +++++++++++++++++ .../20221017131711_IncludeUnknowns.cs | 26 + .../Migrations/DataContextModelSnapshot.cs | 3 + API/Data/Misc/AgeRestriction.cs | 9 + API/Data/Misc/RecentlyAddedSeries.cs | 22 + .../Repositories/CollectionTagRepository.cs | 18 +- API/Data/Repositories/SeriesRepository.cs | 87 +- API/Data/Repositories/UserRepository.cs | 13 +- API/Entities/AppUser.cs | 4 + API/Extensions/EnumerableExtensions.cs | 14 + API/Extensions/QueryableExtensions.cs | 37 +- API/Extensions/SeriesExtensions.cs | 23 + API/Helpers/AutoMapperProfiles.cs | 18 +- API/Services/MetadataService.cs | 11 +- API/Services/ReadingItemService.cs | 3 +- API/Services/TaskScheduler.cs | 2 +- API/Services/Tasks/CleanupService.cs | 10 +- .../Tasks/Scanner/ParseScannedFiles.cs | 6 - API/Services/Tasks/ScannerService.cs | 29 +- API/Services/Tasks/StatsService.cs | 6 + UI/Web/src/app/_models/age-restriction.ts | 6 + UI/Web/src/app/_models/member.ts | 7 +- UI/Web/src/app/_models/user.ts | 4 +- UI/Web/src/app/_services/account.service.ts | 9 +- .../admin/edit-user/edit-user.component.ts | 12 +- .../invite-user/invite-user.component.ts | 9 +- .../book-reader/book-reader.component.scss | 3 +- .../change-age-restriction.component.html | 6 +- .../change-age-restriction.component.ts | 23 +- .../restriction-selector.component.html | 12 + .../restriction-selector.component.ts | 29 +- 46 files changed, 2504 insertions(+), 145 deletions(-) create mode 100644 API.Tests/Extensions/QueryableExtensionsTests.cs create mode 100644 API/DTOs/Account/AgeRestrictionDto.cs create mode 100644 API/Data/Migrations/20221017131711_IncludeUnknowns.Designer.cs create mode 100644 API/Data/Migrations/20221017131711_IncludeUnknowns.cs create mode 100644 API/Data/Misc/AgeRestriction.cs create mode 100644 API/Data/Misc/RecentlyAddedSeries.cs create mode 100644 UI/Web/src/app/_models/age-restriction.ts 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(); }