From b802e1e1b0e62b33e71cbf05f3a908d915519c88 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Tue, 18 Oct 2022 16:53:17 -0700 Subject: [PATCH] Release Shakeout Day 1 (#1591) * Fixed an issue where reading list were not able to update their summary due to a duplicate title check. * Misc code smell cleanup * Updated .net dependencies and removed unneeded ones * Fixed an issue where removing a series from want to read list page wouldn't update the page correctly * Fixed age restriction not applied to Recommended page * Ensure that Genres and Tags are age restricted gated * Persons are now age gated as well * When you choose a cover, the new cover will properly be selected and will focus on it, in the cases there are many other covers available. * Fixed caching profiles * Added in a special hook when deleting a library to clear all series Relations before we delete --- API.Tests/API.Tests.csproj | 4 +- .../Extensions/QueryableExtensionsTests.cs | 153 ++++++++++++++++++ API/API.csproj | 19 ++- API/Controllers/LibraryController.cs | 9 ++ API/Controllers/MetadataController.cs | 18 ++- API/Controllers/ReadingListController.cs | 23 ++- API/Controllers/RecommendedController.cs | 3 +- API/Data/Repositories/GenreRepository.cs | 21 ++- API/Data/Repositories/PersonRepository.cs | 26 +-- API/Data/Repositories/SeriesRepository.cs | 91 ++++++----- API/Data/Repositories/TagRepository.cs | 21 ++- API/Extensions/QueryableExtensions.cs | 65 ++++++++ API/Helpers/GenreHelper.cs | 10 -- API/Services/MetadataService.cs | 1 + .../Tasks/Scanner/ParseScannedFiles.cs | 2 + API/Services/Tasks/Scanner/Parser/Parser.cs | 16 +- API/Services/Tasks/ScannerService.cs | 2 +- API/Services/TokenService.cs | 2 +- API/SignalR/Presence/PresenceTracker.cs | 1 - API/Startup.cs | 13 +- Kavita.Common/EnvironmentInfo/IOsInfo.cs | 2 +- Kavita.Common/Kavita.Common.csproj | 2 +- UI/Web/src/app/_services/metadata.service.ts | 2 - .../cover-image-chooser.component.ts | 7 +- .../series-card/series-card.component.ts | 15 +- .../app/dashboard/dashboard.component.html | 2 +- .../library-recommended.component.ts | 4 +- .../want-to-read/want-to-read.component.html | 5 +- .../want-to-read/want-to-read.component.ts | 18 +++ 29 files changed, 404 insertions(+), 153 deletions(-) diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index efe321347..6380fc95f 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/API.Tests/Extensions/QueryableExtensionsTests.cs b/API.Tests/Extensions/QueryableExtensionsTests.cs index 87d7f5b83..ee1ada416 100644 --- a/API.Tests/Extensions/QueryableExtensionsTests.cs +++ b/API.Tests/Extensions/QueryableExtensionsTests.cs @@ -100,6 +100,159 @@ public class QueryableExtensionsTests Assert.Equal(expectedCount, filtered.Count()); } + [Theory] + [InlineData(true, 2)] + [InlineData(false, 1)] + public void RestrictAgainstAgeRestriction_Genre_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + { + var items = new List() + { + new Genre() + { + SeriesMetadatas = new List() + { + new SeriesMetadata() + { + AgeRating = AgeRating.Teen, + } + } + }, + new Genre() + { + SeriesMetadatas = new List() + { + new SeriesMetadata() + { + AgeRating = AgeRating.Unknown, + }, + new SeriesMetadata() + { + AgeRating = AgeRating.Teen, + } + } + }, + new Genre() + { + 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_Tag_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + { + var items = new List() + { + new Tag() + { + SeriesMetadatas = new List() + { + new SeriesMetadata() + { + AgeRating = AgeRating.Teen, + } + } + }, + new Tag() + { + SeriesMetadatas = new List() + { + new SeriesMetadata() + { + AgeRating = AgeRating.Unknown, + }, + new SeriesMetadata() + { + AgeRating = AgeRating.Teen, + } + } + }, + new Tag() + { + 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_Person_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) + { + var items = new List() + { + new Person() + { + SeriesMetadatas = new List() + { + new SeriesMetadata() + { + AgeRating = AgeRating.Teen, + } + } + }, + new Person() + { + SeriesMetadatas = new List() + { + new SeriesMetadata() + { + AgeRating = AgeRating.Unknown, + }, + new SeriesMetadata() + { + AgeRating = AgeRating.Teen, + } + } + }, + new Person() + { + 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)] diff --git a/API/API.csproj b/API/API.csproj index e38ee8b77..4504e7804 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -47,7 +47,7 @@ - + @@ -59,20 +59,19 @@ - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - @@ -85,13 +84,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 73be21dd7..618c345da 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -11,6 +11,7 @@ using API.DTOs.Search; using API.DTOs.System; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Extensions; using API.Services; using API.Services.Tasks.Scanner; @@ -251,6 +252,14 @@ public class LibraryController : BaseApiController return BadRequest( "You cannot delete a library while a scan is in progress. Please wait for scan to continue then try to delete"); } + + // Due to a bad schema that I can't figure out how to fix, we need to erase all RelatedSeries before we delete the library + foreach (var s in await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id)) + { + s.Relations = new List(); + _unitOfWork.SeriesRepository.Update(s); + } + _unitOfWork.LibraryRepository.Delete(library); await _unitOfWork.CommitAsync(); diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index 34525a9fc..b0c9b62be 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -8,6 +8,7 @@ using API.DTOs; using API.DTOs.Filtering; using API.DTOs.Metadata; using API.Entities.Enums; +using API.Extensions; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Mvc; @@ -31,15 +32,18 @@ public class MetadataController : BaseApiController [HttpGet("genres")] public async Task>> GetAllGenres(string? libraryIds) { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); if (ids != null && ids.Count > 0) { - return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids)); + return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosForLibrariesAsync(ids, userId)); } - return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync()); + return Ok(await _unitOfWork.GenreRepository.GetAllGenreDtosAsync(userId)); } + + /// /// Fetches people from the instance /// @@ -48,12 +52,13 @@ public class MetadataController : BaseApiController [HttpGet("people")] public async Task>> GetAllPeople(string? libraryIds) { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); if (ids != null && ids.Count > 0) { - return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids)); + return Ok(await _unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(ids, userId)); } - return Ok(await _unitOfWork.PersonRepository.GetAllPeople()); + return Ok(await _unitOfWork.PersonRepository.GetAllPersonDtosAsync(userId)); } /// @@ -64,12 +69,13 @@ public class MetadataController : BaseApiController [HttpGet("tags")] public async Task>> GetAllTags(string? libraryIds) { + var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); var ids = libraryIds?.Split(",").Select(int.Parse).ToList(); if (ids != null && ids.Count > 0) { - return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids)); + return Ok(await _unitOfWork.TagRepository.GetAllTagDtosForLibrariesAsync(ids, userId)); } - return Ok(await _unitOfWork.TagRepository.GetAllTagDtosAsync()); + return Ok(await _unitOfWork.TagRepository.GetAllTagDtosAsync(userId)); } /// diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 0e4970a3e..1428e81f9 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -21,7 +21,6 @@ public class ReadingListController : BaseApiController private readonly IUnitOfWork _unitOfWork; private readonly IEventHub _eventHub; private readonly IReadingListService _readingListService; - private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub, IReadingListService readingListService) { @@ -219,22 +218,22 @@ public class ReadingListController : BaseApiController dto.Title = dto.Title.Trim(); if (!string.IsNullOrEmpty(dto.Title)) - { - var hasExisting = user.ReadingLists.Any(l => l.Title.Equals(dto.Title)); - if (hasExisting) - { - return BadRequest("A list of this name already exists"); - } - readingList.Title = dto.Title; - readingList.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(readingList.Title); - } - if (!string.IsNullOrEmpty(dto.Title)) { readingList.Summary = dto.Summary; + + if (!readingList.Title.Equals(dto.Title)) + { + var hasExisting = user.ReadingLists.Any(l => l.Title.Equals(dto.Title)); + if (hasExisting) + { + return BadRequest("A list of this name already exists"); + } + readingList.Title = dto.Title; + readingList.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(readingList.Title); + } } readingList.Promoted = dto.Promoted; - readingList.CoverImageLocked = dto.CoverImageLocked; if (!dto.CoverImageLocked) diff --git a/API/Controllers/RecommendedController.cs b/API/Controllers/RecommendedController.cs index 215b55397..893cb852a 100644 --- a/API/Controllers/RecommendedController.cs +++ b/API/Controllers/RecommendedController.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using System.Threading.Tasks; +using System.Threading.Tasks; using API.Data; using API.DTOs; using API.Extensions; diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index 7457adb24..df7fb5069 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Data.Misc; using API.DTOs.Metadata; using API.Entities; +using API.Extensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -15,9 +17,9 @@ public interface IGenreRepository void Remove(Genre genre); Task FindByNameAsync(string genreName); Task> GetAllGenresAsync(); - Task> GetAllGenreDtosAsync(); + Task> GetAllGenreDtosAsync(int userId); Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false); - Task> GetAllGenreDtosForLibrariesAsync(IList libraryIds); + Task> GetAllGenreDtosForLibrariesAsync(IList libraryIds, int userId); Task GetCountAsync(); } @@ -63,10 +65,18 @@ public class GenreRepository : IGenreRepository await _context.SaveChangesAsync(); } - public async Task> GetAllGenreDtosForLibrariesAsync(IList libraryIds) + /// + /// Returns a set of Genre tags for a set of library Ids. UserId will restrict returned Genres based on user's age restriction. + /// + /// + /// + /// + public async Task> GetAllGenreDtosForLibrariesAsync(IList libraryIds, int userId) { + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) + .RestrictAgainstAgeRestriction(userRating) .SelectMany(s => s.Metadata.Genres) .AsSplitQuery() .Distinct() @@ -75,6 +85,7 @@ public class GenreRepository : IGenreRepository .ToListAsync(); } + public async Task GetCountAsync() { return await _context.Genre.CountAsync(); @@ -85,9 +96,11 @@ public class GenreRepository : IGenreRepository return await _context.Genre.ToListAsync(); } - public async Task> GetAllGenreDtosAsync() + public async Task> GetAllGenreDtosAsync(int userId) { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Genre + .RestrictAgainstAgeRestriction(ageRating) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index 83aa18f62..7eea282a7 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using API.DTOs; using API.Entities; +using API.Extensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -14,8 +15,9 @@ public interface IPersonRepository void Attach(Person person); void Remove(Person person); Task> GetAllPeople(); + Task> GetAllPersonDtosAsync(int userId); Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false); - Task> GetAllPeopleDtosForLibrariesAsync(List libraryIds); + Task> GetAllPeopleDtosForLibrariesAsync(List libraryIds, int userId); Task GetCountAsync(); } @@ -40,14 +42,6 @@ public class PersonRepository : IPersonRepository _context.Person.Remove(person); } - public async Task FindByNameAsync(string name) - { - var normalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(name); - return await _context.Person - .Where(p => normalizedName.Equals(p.NormalizedName)) - .SingleOrDefaultAsync(); - } - public async Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false) { var peopleWithNoConnections = await _context.Person @@ -62,10 +56,12 @@ public class PersonRepository : IPersonRepository await _context.SaveChangesAsync(); } - public async Task> GetAllPeopleDtosForLibrariesAsync(List libraryIds) + public async Task> GetAllPeopleDtosForLibrariesAsync(List libraryIds, int userId) { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) + .RestrictAgainstAgeRestriction(ageRating) .SelectMany(s => s.Metadata.People) .Distinct() .OrderBy(p => p.Name) @@ -87,4 +83,14 @@ public class PersonRepository : IPersonRepository .OrderBy(p => p.Name) .ToListAsync(); } + + public async Task> GetAllPersonDtosAsync(int userId) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + return await _context.Person + .OrderBy(p => p.Name) + .RestrictAgainstAgeRestriction(ageRating) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 0c287a9f9..e04a18b6c 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -291,7 +291,7 @@ public class SeriesRepository : ISeriesRepository const int maxRecords = 15; var result = new SearchResultGroupDto(); var searchQueryNormalized = Services.Tasks.Scanner.Parser.Parser.Normalize(searchQuery); - var userRating = await GetUserAgeRestriction(userId); + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); var seriesIds = _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) @@ -723,7 +723,7 @@ public class SeriesRepository : ISeriesRepository private async Task> CreateFilteredSearchQueryable(int userId, int libraryId, FilterDto filter) { var userLibraries = await GetUserLibraries(libraryId, userId); - var userRating = await GetUserAgeRestriction(userId); + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); var formats = ExtractFilters(libraryId, userId, filter, ref userLibraries, out var allPeopleIds, out var hasPeopleFilter, out var hasGenresFilter, @@ -1027,59 +1027,46 @@ public class SeriesRepository : ISeriesRepository public async Task> GetRecentlyUpdatedSeries(int userId, int pageSize = 30) { var seriesMap = new Dictionary(); - var index = 0; - var userRating = await GetUserAgeRestriction(userId); + var index = 0; + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); - var items = (await GetRecentlyAddedChaptersQuery(userId)); - if (userRating.AgeRating != AgeRating.NotApplicable) + var items = (await GetRecentlyAddedChaptersQuery(userId)); + if (userRating.AgeRating != AgeRating.NotApplicable) + { + items = items.RestrictAgainstAgeRestriction(userRating); + } + foreach (var item in items) + { + if (seriesMap.Keys.Count == pageSize) break; + + if (seriesMap.ContainsKey(item.SeriesName)) { - items = items.RestrictAgainstAgeRestriction(userRating); + seriesMap[item.SeriesName].Count += 1; } - foreach (var item in items) + else { - if (seriesMap.Keys.Count == pageSize) break; - - if (seriesMap.ContainsKey(item.SeriesName)) + seriesMap[item.SeriesName] = new GroupedSeriesDto() { - seriesMap[item.SeriesName].Count += 1; - } - else - { - seriesMap[item.SeriesName] = new GroupedSeriesDto() - { - LibraryId = item.LibraryId, - LibraryType = item.LibraryType, - SeriesId = item.SeriesId, - SeriesName = item.SeriesName, - Created = item.Created, - Id = index, - Format = item.Format, - Count = 1, - }; - index += 1; - } + LibraryId = item.LibraryId, + LibraryType = item.LibraryType, + SeriesId = item.SeriesId, + SeriesName = item.SeriesName, + Created = item.Created, + Id = index, + Format = item.Format, + Count = 1, + }; + index += 1; } + } - return seriesMap.Values.AsEnumerable(); - } - - 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(); + return seriesMap.Values.AsEnumerable(); } public async Task> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind) { var libraryIds = GetLibraryIdsForUser(userId); - var userRating = await GetUserAgeRestriction(userId); + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); var usersSeriesIds = _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) @@ -1108,9 +1095,14 @@ public class SeriesRepository : ISeriesRepository var libraryIds = GetLibraryIdsForUser(userId, libraryId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); + // Because this can be called from an API, we need to provide an additional check if the genre has anything the + // user with age restrictions can access + var query = _context.Series .Where(s => s.Metadata.Genres.Select(g => g.Id).Contains(genreId)) .Where(s => usersSeriesIds.Contains(s.Id)) + .RestrictAgainstAgeRestriction(userRating) .AsSplitQuery() .ProjectTo(_mapper.ConfigurationProvider); @@ -1147,7 +1139,7 @@ public class SeriesRepository : ISeriesRepository public async Task GetSeriesForMangaFile(int mangaFileId, int userId) { var libraryIds = GetLibraryIdsForUser(userId); - var userRating = await GetUserAgeRestriction(userId); + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.MangaFile .Where(m => m.Id == mangaFileId) @@ -1164,7 +1156,7 @@ public class SeriesRepository : ISeriesRepository public async Task GetSeriesForChapter(int chapterId, int userId) { var libraryIds = GetLibraryIdsForUser(userId); - var userRating = await GetUserAgeRestriction(userId); + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Chapter .Where(m => m.Id == chapterId) .AsSplitQuery() @@ -1321,9 +1313,11 @@ public class SeriesRepository : ISeriesRepository .Where(s => usersSeriesIds.Contains(s.SeriesId) && s.Rating > 4) .Select(p => p.SeriesId) .Distinct(); + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); var query = _context.Series .Where(s => distinctSeriesIdsWithHighRating.Contains(s.Id)) + .RestrictAgainstAgeRestriction(userRating) .AsSplitQuery() .OrderByDescending(s => _context.AppUserRating.Where(r => r.SeriesId == s.Id).Select(r => r.Rating).Average()) .ProjectTo(_mapper.ConfigurationProvider); @@ -1340,6 +1334,7 @@ public class SeriesRepository : ISeriesRepository .Where(s => usersSeriesIds.Contains(s.SeriesId)) .Select(p => p.SeriesId) .Distinct(); + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); var query = _context.Series @@ -1349,6 +1344,7 @@ public class SeriesRepository : ISeriesRepository && !distinctSeriesIdsWithProgress.Contains(s.Id) && usersSeriesIds.Contains(s.Id)) .Where(s => s.Metadata.PublicationStatus != PublicationStatus.OnGoing) + .RestrictAgainstAgeRestriction(userRating) .AsSplitQuery() .ProjectTo(_mapper.ConfigurationProvider); @@ -1365,6 +1361,8 @@ public class SeriesRepository : ISeriesRepository .Select(p => p.SeriesId) .Distinct(); + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); + var query = _context.Series .Where(s => ( @@ -1373,6 +1371,7 @@ public class SeriesRepository : ISeriesRepository && !distinctSeriesIdsWithProgress.Contains(s.Id) && usersSeriesIds.Contains(s.Id)) .Where(s => s.Metadata.PublicationStatus == PublicationStatus.OnGoing) + .RestrictAgainstAgeRestriction(userRating) .AsSplitQuery() .ProjectTo(_mapper.ConfigurationProvider); @@ -1406,7 +1405,7 @@ public class SeriesRepository : ISeriesRepository { var libraryIds = GetLibraryIdsForUser(userId); var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds); - var userRating = await GetUserAgeRestriction(userId); + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); return new RelatedSeriesDto() { diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index 8faf0440b..e4e3987d0 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using API.DTOs.Metadata; using API.Entities; +using API.Extensions; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -13,11 +14,10 @@ public interface ITagRepository { void Attach(Tag tag); void Remove(Tag tag); - Task FindByNameAsync(string tagName); Task> GetAllTagsAsync(); - Task> GetAllTagDtosAsync(); + Task> GetAllTagDtosAsync(int userId); Task RemoveAllTagNoLongerAssociated(bool removeExternal = false); - Task> GetAllTagDtosForLibrariesAsync(IList libraryIds); + Task> GetAllTagDtosForLibrariesAsync(IList libraryIds, int userId); } public class TagRepository : ITagRepository @@ -41,13 +41,6 @@ public class TagRepository : ITagRepository _context.Tag.Remove(tag); } - public async Task FindByNameAsync(string tagName) - { - var normalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(tagName); - return await _context.Tag - .FirstOrDefaultAsync(g => g.NormalizedTitle.Equals(normalizedName)); - } - public async Task RemoveAllTagNoLongerAssociated(bool removeExternal = false) { var tagsWithNoConnections = await _context.Tag @@ -62,10 +55,12 @@ public class TagRepository : ITagRepository await _context.SaveChangesAsync(); } - public async Task> GetAllTagDtosForLibrariesAsync(IList libraryIds) + public async Task> GetAllTagDtosForLibrariesAsync(IList libraryIds, int userId) { + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) + .RestrictAgainstAgeRestriction(userRating) .SelectMany(s => s.Metadata.Tags) .AsSplitQuery() .Distinct() @@ -80,10 +75,12 @@ public class TagRepository : ITagRepository return await _context.Tag.ToListAsync(); } - public async Task> GetAllTagDtosAsync() + public async Task> GetAllTagDtosAsync(int userId) { + var userRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Tag .AsNoTracking() + .RestrictAgainstAgeRestriction(userRating) .OrderBy(t => t.Title) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); diff --git a/API/Extensions/QueryableExtensions.cs b/API/Extensions/QueryableExtensions.cs index 426c84ce7..ec0b81257 100644 --- a/API/Extensions/QueryableExtensions.cs +++ b/API/Extensions/QueryableExtensions.cs @@ -1,7 +1,9 @@ using System.Linq; +using System.Threading.Tasks; using API.Data.Misc; using API.Entities; using API.Entities.Enums; +using Microsoft.EntityFrameworkCore; namespace API.Extensions; @@ -33,6 +35,48 @@ public static class QueryableExtensions sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + 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, AgeRestriction restriction) + { + 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, AgeRestriction restriction) + { + 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, AgeRestriction restriction) { if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; @@ -45,4 +89,25 @@ public static class QueryableExtensions return q; } + + public static Task GetUserAgeRestriction(this DbSet queryable, int userId) + { + if (userId < 1) + { + return Task.FromResult(new AgeRestriction() + { + AgeRating = AgeRating.NotApplicable, + IncludeUnknowns = true + }); + } + return queryable + .AsNoTracking() + .Where(u => u.Id == userId) + .Select(u => + new AgeRestriction(){ + AgeRating = u.AgeRestriction, + IncludeUnknowns = u.AgeRestrictionIncludeUnknowns + }) + .SingleAsync(); + } } diff --git a/API/Helpers/GenreHelper.cs b/API/Helpers/GenreHelper.cs index 5eadea8fa..631baf85c 100644 --- a/API/Helpers/GenreHelper.cs +++ b/API/Helpers/GenreHelper.cs @@ -63,14 +63,4 @@ public static class GenreHelper metadataGenres.Add(genre); } } - - public static void AddGenreIfNotExists(BlockingCollection metadataGenres, Genre genre) - { - var existingGenre = metadataGenres.FirstOrDefault(p => - p.NormalizedTitle == Services.Tasks.Scanner.Parser.Parser.Normalize(genre.Title)); - if (existingGenre == null) - { - metadataGenres.Add(genre); - } - } } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 20d6239e8..6be15bf8e 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -8,6 +8,7 @@ using API.Data; using API.Data.Metadata; using API.Data.Repositories; using API.Data.Scanner; +using API.DTOs.Metadata; using API.Entities; using API.Entities.Enums; using API.Extensions; diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index aa76f36af..b80d2d9b5 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -68,6 +68,7 @@ public class ParseScannedFiles /// This will Scan all files in a folder path. For each folder within the folderPath, FolderAction will be invoked for all files contained /// /// Scan directory by directory and for each, call folderAction + /// A dictionary mapping a normalized path to a list of to help scanner skip I/O /// A library folder or series folder /// A callback async Task to be called once all files for each folder path are found /// If we should bypass any folder last write time checks on the scan and force I/O @@ -215,6 +216,7 @@ public class ParseScannedFiles /// Using a normalized name from the passed ParserInfo, this checks against all found series so far and if an existing one exists with /// same normalized name, it merges into the existing one. This is important as some manga may have a slight difference with punctuation or capitalization. /// + /// /// /// Series Name to group this info into private string MergeName(ConcurrentDictionary> scannedSeries, ParserInfo info) diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 79d1f675a..8a7e16933 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -749,12 +749,12 @@ public static class Parser foreach (var regex in MangaChapterRegex) { var matches = regex.Matches(filename); - foreach (Match match in matches) + foreach (var groups in matches.Select(match => match.Groups)) { - if (!match.Groups["Chapter"].Success || match.Groups["Chapter"] == Match.Empty) continue; + if (!groups["Chapter"].Success || groups["Chapter"] == Match.Empty) continue; - var value = match.Groups["Chapter"].Value; - var hasPart = match.Groups["Part"].Success; + var value = groups["Chapter"].Value; + var hasPart = groups["Part"].Success; return FormatValue(value, hasPart); } @@ -778,11 +778,11 @@ public static class Parser foreach (var regex in ComicChapterRegex) { var matches = regex.Matches(filename); - foreach (Match match in matches) + foreach (var groups in matches.Select(match => match.Groups)) { - if (!match.Groups["Chapter"].Success || match.Groups["Chapter"] == Match.Empty) continue; - var value = match.Groups["Chapter"].Value; - var hasPart = match.Groups["Part"].Success; + if (!groups["Chapter"].Success || groups["Chapter"] == Match.Empty) continue; + var value = groups["Chapter"].Value; + var hasPart = groups["Part"].Success; return FormatValue(value, hasPart); } diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 04c41535e..8f61452bf 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -428,7 +428,7 @@ public class ScannerService : IScannerService /// Defaults to false [Queue(TaskScheduler.ScanQueue)] [DisableConcurrentExecution(60 * 60 * 60)] - [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] + [AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)] public async Task ScanLibrary(int libraryId, bool forceUpdate = false) { var sw = Stopwatch.StartNew(); diff --git a/API/Services/TokenService.cs b/API/Services/TokenService.cs index 2c8e9926e..927b15907 100644 --- a/API/Services/TokenService.cs +++ b/API/Services/TokenService.cs @@ -75,7 +75,7 @@ public class TokenService : ITokenService var username = tokenContent.Claims.FirstOrDefault(q => q.Type == JwtRegisteredClaimNames.NameId)?.Value; var user = await _userManager.FindByNameAsync(username); if (user == null) return null; // This forces a logout - var isValid = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "RefreshToken", request.RefreshToken); + await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "RefreshToken", request.RefreshToken); await _userManager.UpdateSecurityStampAsync(user); diff --git a/API/SignalR/Presence/PresenceTracker.cs b/API/SignalR/Presence/PresenceTracker.cs index eb21a528c..5cf847c6e 100644 --- a/API/SignalR/Presence/PresenceTracker.cs +++ b/API/SignalR/Presence/PresenceTracker.cs @@ -90,7 +90,6 @@ public class PresenceTracker : IPresenceTracker public Task GetOnlineAdmins() { - // TODO: This might end in stale data, we want to get the online users, query against DB to check if they are admins then return string[] onlineUsers; lock (OnlineUsers) { diff --git a/API/Startup.cs b/API/Startup.cs index a2fe63153..a608563a4 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -74,21 +74,21 @@ public class Startup new CacheProfile() { Duration = 60 * 10, - Location = ResponseCacheLocation.Any, + Location = ResponseCacheLocation.None, NoStore = false }); options.CacheProfiles.Add("5Minute", new CacheProfile() { Duration = 60 * 5, - Location = ResponseCacheLocation.Any, + Location = ResponseCacheLocation.None, }); // Instant is a very quick cache, because we can't bust based on the query params, but rather body options.CacheProfiles.Add("Instant", new CacheProfile() { Duration = 30, - Location = ResponseCacheLocation.Any, + Location = ResponseCacheLocation.None, }); }); services.Configure(options => @@ -300,13 +300,6 @@ public class Startup app.Use(async (context, next) => { - // Note: I removed this as I caught Chrome caching api responses when it shouldn't have - // context.Response.GetTypedHeaders().CacheControl = - // new CacheControlHeaderValue() - // { - // Public = false, - // MaxAge = TimeSpan.FromSeconds(10), - // }; context.Response.Headers[HeaderNames.Vary] = new[] { "Accept-Encoding" }; diff --git a/Kavita.Common/EnvironmentInfo/IOsInfo.cs b/Kavita.Common/EnvironmentInfo/IOsInfo.cs index 5bb9dcf3d..e3453c3d6 100644 --- a/Kavita.Common/EnvironmentInfo/IOsInfo.cs +++ b/Kavita.Common/EnvironmentInfo/IOsInfo.cs @@ -11,7 +11,7 @@ public class OsInfo : IOsInfo public static Os Os { get; } public static bool IsNotWindows => !IsWindows; - public static bool IsLinux => Os == Os.Linux || Os == Os.LinuxMusl || Os == Os.Bsd; + public static bool IsLinux => Os is Os.Linux or Os.LinuxMusl or Os.Bsd; public static bool IsOsx => Os == Os.Osx; public static bool IsWindows => Os == Os.Windows; diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index f8cb2d386..259ec9818 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -14,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts index a8b1e9b3e..c2ec18320 100644 --- a/UI/Web/src/app/_services/metadata.service.ts +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -86,8 +86,6 @@ export class MetadataService { return of(this.validLanguages); } return this.httpClient.get>(this.baseUrl + 'metadata/all-languages').pipe(map(l => this.validLanguages = l)); - - //return this.httpClient.get>(this.baseUrl + 'metadata/all-languages').pipe(); } getAllPeople(libraries?: Array) { diff --git a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts index 91a637eae..7c443c757 100644 --- a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts +++ b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts @@ -190,9 +190,12 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy { this.imageUrls.push(e.target.result); // This is base64 already this.imageUrlsChange.emit(this.imageUrls); - this.selectedIndex += 1; + this.selectedIndex = this.imageUrls.length - 1; this.imageSelected.emit(this.selectedIndex); // Auto select newly uploaded image this.selectedBase64Url.emit(e.target.result); + setTimeout(() => { + (this.document.querySelector('div.image-card[aria-label="Image ' + this.selectedIndex + '"]') as HTMLElement).focus(); + }) this.cdRef.markForCheck(); } @@ -209,7 +212,7 @@ export class CoverImageChooserComponent implements OnInit, OnDestroy { setTimeout(() => { // Auto select newly uploaded image and tell parent of new base64 url - this.selectImage(this.selectedIndex + 1); + this.selectImage(index >= 0 ? index : this.imageUrls.length - 1); }); } diff --git a/UI/Web/src/app/cards/series-card/series-card.component.ts b/UI/Web/src/app/cards/series-card/series-card.component.ts index 007040cc5..182634869 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.ts +++ b/UI/Web/src/app/cards/series-card/series-card.component.ts @@ -1,10 +1,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core'; -import { NavigationStart, Router } from '@angular/router'; +import { Router } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; -import { filter, take } from 'rxjs/operators'; import { Series } from 'src/app/_models/series'; -import { AccountService } from 'src/app/_services/account.service'; import { ImageService } from 'src/app/_services/image.service'; import { ActionFactoryService, Action, ActionItem } from 'src/app/_services/action-factory.service'; import { SeriesService } from 'src/app/_services/series.service'; @@ -37,7 +35,10 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { @Input() relation: RelationKind | undefined = undefined; @Output() clicked = new EventEmitter(); - @Output() reload = new EventEmitter(); + /** + * Emits when a reload needs to occur and the id of the entity + */ + @Output() reload = new EventEmitter(); @Output() dataChanged = new EventEmitter(); /** * When the card is selected. @@ -103,7 +104,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { case Action.RemoveFromWantToReadList: this.actionService.removeMultipleSeriesFromWantToReadList([series.id]); if (this.router.url.startsWith('/want-to-read')) { - this.reload.emit(true); + this.reload.emit(series.id); } break; case(Action.AddToCollection): @@ -125,7 +126,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { this.seriesService.getSeries(data.id).subscribe(series => { this.data = series; this.cdRef.markForCheck(); - this.reload.emit(true); + this.reload.emit(series.id); this.dataChanged.emit(series); }); } @@ -145,7 +146,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { async deleteSeries(series: Series) { this.actionService.deleteSeries(series, (result: boolean) => { if (result) { - this.reload.emit(true); + this.reload.emit(series.id); } }); } diff --git a/UI/Web/src/app/dashboard/dashboard.component.html b/UI/Web/src/app/dashboard/dashboard.component.html index 132d55086..abc0c52d5 100644 --- a/UI/Web/src/app/dashboard/dashboard.component.html +++ b/UI/Web/src/app/dashboard/dashboard.component.html @@ -16,7 +16,7 @@ - + diff --git a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts index a1203cd35..76415028d 100644 --- a/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts +++ b/UI/Web/src/app/library-detail/library-recommended/library-recommended.component.ts @@ -66,8 +66,8 @@ export class LibraryRecommendedComponent implements OnInit, OnDestroy { } - reloadInProgress(series: Series | boolean) { - if (series === true || series === false) { + reloadInProgress(series: Series | number) { + if (Number.isInteger(series)) { if (!series) {return;} } // If the update to Series doesn't affect the requirement to be in this stream, then ignore update request diff --git a/UI/Web/src/app/want-to-read/want-to-read/want-to-read.component.html b/UI/Web/src/app/want-to-read/want-to-read/want-to-read.component.html index f29596aab..eb1997852 100644 --- a/UI/Web/src/app/want-to-read/want-to-read/want-to-read.component.html +++ b/UI/Web/src/app/want-to-read/want-to-read/want-to-read.component.html @@ -23,8 +23,9 @@ [refresh]="refresh" (applyFilter)="updateFilter($event)"> - diff --git a/UI/Web/src/app/want-to-read/want-to-read/want-to-read.component.ts b/UI/Web/src/app/want-to-read/want-to-read/want-to-read.component.ts index 6bf90d725..09a184755 100644 --- a/UI/Web/src/app/want-to-read/want-to-read/want-to-read.component.ts +++ b/UI/Web/src/app/want-to-read/want-to-read/want-to-read.component.ts @@ -61,6 +61,7 @@ export class WantToReadComponent implements OnInit, OnDestroy { break; } } + collectionTag: any; tagImage: any; @@ -164,6 +165,23 @@ export class WantToReadComponent implements OnInit, OnDestroy { if (!data.isFirst) this.filterUtilityService.updateUrlFromFilter(this.seriesPagination, this.filter); this.loadPage(); } + + handleAction(action: ActionItem, series: Series) { + // let lib: Partial = library; + // if (library === undefined) { + // lib = {id: this.libraryId, name: this.libraryName}; + // } + // switch (action.action) { + // case(Action.Scan): + // this.actionService.scanLibrary(lib); + // break; + // case(Action.RefreshMetadata): + // this.actionService.refreshMetadata(lib); + // break; + // default: + // break; + // } + } }