diff --git a/API.Tests/Repository/GenreRepositoryTests.cs b/API.Tests/Repository/GenreRepositoryTests.cs new file mode 100644 index 000000000..d197a91ba --- /dev/null +++ b/API.Tests/Repository/GenreRepositoryTests.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata.Browse; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Helpers; +using API.Helpers.Builders; +using Xunit; + +namespace API.Tests.Repository; + +public class GenreRepositoryTests : AbstractDbTest +{ + private AppUser _fullAccess; + private AppUser _restrictedAccess; + private AppUser _restrictedAgeAccess; + + protected override async Task ResetDb() + { + Context.Genre.RemoveRange(Context.Genre); + Context.Library.RemoveRange(Context.Library); + await Context.SaveChangesAsync(); + } + + private TestGenreSet CreateTestGenres() + { + return new TestGenreSet + { + SharedSeriesChaptersGenre = new GenreBuilder("Shared Series Chapter Genre").Build(), + SharedSeriesGenre = new GenreBuilder("Shared Series Genre").Build(), + SharedChaptersGenre = new GenreBuilder("Shared Chapters Genre").Build(), + Lib0SeriesChaptersGenre = new GenreBuilder("Lib0 Series Chapter Genre").Build(), + Lib0SeriesGenre = new GenreBuilder("Lib0 Series Genre").Build(), + Lib0ChaptersGenre = new GenreBuilder("Lib0 Chapters Genre").Build(), + Lib1SeriesChaptersGenre = new GenreBuilder("Lib1 Series Chapter Genre").Build(), + Lib1SeriesGenre = new GenreBuilder("Lib1 Series Genre").Build(), + Lib1ChaptersGenre = new GenreBuilder("Lib1 Chapters Genre").Build(), + Lib1ChapterAgeGenre = new GenreBuilder("Lib1 Chapter Age Genre").Build() + }; + } + + private async Task SeedDbWithGenres(TestGenreSet genres) + { + await CreateTestUsers(); + await AddGenresToContext(genres); + await CreateLibrariesWithGenres(genres); + await AssignLibrariesToUsers(); + } + + private async Task CreateTestUsers() + { + _fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build(); + _restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build(); + _restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build(); + _restrictedAgeAccess.AgeRestriction = AgeRating.Teen; + _restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true; + + Context.Users.Add(_fullAccess); + Context.Users.Add(_restrictedAccess); + Context.Users.Add(_restrictedAgeAccess); + await Context.SaveChangesAsync(); + } + + private async Task AddGenresToContext(TestGenreSet genres) + { + var allGenres = genres.GetAllGenres(); + Context.Genre.AddRange(allGenres); + await Context.SaveChangesAsync(); + } + + private async Task CreateLibrariesWithGenres(TestGenreSet genres) + { + var lib0 = new LibraryBuilder("lib0") + .WithSeries(new SeriesBuilder("lib0-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib0SeriesChaptersGenre, genres.Lib0SeriesGenre]) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib0SeriesChaptersGenre, genres.Lib0ChaptersGenre]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .Build()) + .Build()) + .Build(); + + var lib1 = new LibraryBuilder("lib1") + .WithSeries(new SeriesBuilder("lib1-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1SeriesGenre]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre, genres.Lib1ChapterAgeGenre]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("lib1-s1") + .WithMetadata(new SeriesMetadataBuilder() + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedSeriesGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1SeriesGenre]) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithGenres([genres.SharedSeriesChaptersGenre, genres.SharedChaptersGenre, genres.Lib1SeriesChaptersGenre, genres.Lib1ChaptersGenre]) + .Build()) + .Build()) + .Build()) + .Build(); + + Context.Library.Add(lib0); + Context.Library.Add(lib1); + await Context.SaveChangesAsync(); + } + + private async Task AssignLibrariesToUsers() + { + var lib0 = Context.Library.First(l => l.Name == "lib0"); + var lib1 = Context.Library.First(l => l.Name == "lib1"); + + _fullAccess.Libraries.Add(lib0); + _fullAccess.Libraries.Add(lib1); + _restrictedAccess.Libraries.Add(lib1); + _restrictedAgeAccess.Libraries.Add(lib1); + + await Context.SaveChangesAsync(); + } + + private static Predicate ContainsGenreCheck(Genre genre) + { + return g => g.Id == genre.Id; + } + + private static void AssertGenrePresent(IEnumerable genres, Genre expectedGenre) + { + Assert.Contains(genres, ContainsGenreCheck(expectedGenre)); + } + + private static void AssertGenreNotPresent(IEnumerable genres, Genre expectedGenre) + { + Assert.DoesNotContain(genres, ContainsGenreCheck(expectedGenre)); + } + + private static BrowseGenreDto GetGenreDto(IEnumerable genres, Genre genre) + { + return genres.First(dto => dto.Id == genre.Id); + } + + [Fact] + public async Task GetBrowseableGenre_FullAccess_ReturnsAllGenresWithCorrectCounts() + { + // Arrange + await ResetDb(); + var genres = CreateTestGenres(); + await SeedDbWithGenres(genres); + + // Act + var fullAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_fullAccess.Id, new UserParams()); + + // Assert + Assert.Equal(genres.GetAllGenres().Count, fullAccessGenres.TotalCount); + + foreach (var genre in genres.GetAllGenres()) + { + AssertGenrePresent(fullAccessGenres, genre); + } + + // Verify counts - 1 lib0 series, 2 lib1 series = 3 total series + Assert.Equal(3, GetGenreDto(fullAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount); + Assert.Equal(6, GetGenreDto(fullAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount); + Assert.Equal(1, GetGenreDto(fullAccessGenres, genres.Lib0SeriesGenre).SeriesCount); + } + + [Fact] + public async Task GetBrowseableGenre_RestrictedAccess_ReturnsOnlyAccessibleGenres() + { + // Arrange + await ResetDb(); + var genres = CreateTestGenres(); + await SeedDbWithGenres(genres); + + // Act + var restrictedAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_restrictedAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 4 library 1 specific = 7 genres + Assert.Equal(7, restrictedAccessGenres.TotalCount); + + // Verify shared and Library 1 genres are present + AssertGenrePresent(restrictedAccessGenres, genres.SharedSeriesChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.SharedSeriesGenre); + AssertGenrePresent(restrictedAccessGenres, genres.SharedChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1SeriesChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1SeriesGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1ChaptersGenre); + AssertGenrePresent(restrictedAccessGenres, genres.Lib1ChapterAgeGenre); + + // Verify Library 0 specific genres are not present + AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0SeriesChaptersGenre); + AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0SeriesGenre); + AssertGenreNotPresent(restrictedAccessGenres, genres.Lib0ChaptersGenre); + + // Verify counts - 2 lib1 series + Assert.Equal(2, GetGenreDto(restrictedAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount); + Assert.Equal(4, GetGenreDto(restrictedAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount); + Assert.Equal(2, GetGenreDto(restrictedAccessGenres, genres.Lib1SeriesGenre).SeriesCount); + Assert.Equal(4, GetGenreDto(restrictedAccessGenres, genres.Lib1ChaptersGenre).ChapterCount); + Assert.Equal(1, GetGenreDto(restrictedAccessGenres, genres.Lib1ChapterAgeGenre).ChapterCount); + } + + [Fact] + public async Task GetBrowseableGenre_RestrictedAgeAccess_FiltersAgeRestrictedContent() + { + // Arrange + await ResetDb(); + var genres = CreateTestGenres(); + await SeedDbWithGenres(genres); + + // Act + var restrictedAgeAccessGenres = await UnitOfWork.GenreRepository.GetBrowseableGenre(_restrictedAgeAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 3 lib1 specific = 6 genres (age-restricted genre filtered out) + Assert.Equal(6, restrictedAgeAccessGenres.TotalCount); + + // Verify accessible genres are present + AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedSeriesGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.SharedChaptersGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1SeriesChaptersGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1SeriesGenre); + AssertGenrePresent(restrictedAgeAccessGenres, genres.Lib1ChaptersGenre); + + // Verify age-restricted genre is filtered out + AssertGenreNotPresent(restrictedAgeAccessGenres, genres.Lib1ChapterAgeGenre); + + // Verify counts - 1 series lib1 (age-restricted series filtered out) + Assert.Equal(1, GetGenreDto(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre).SeriesCount); + Assert.Equal(1, GetGenreDto(restrictedAgeAccessGenres, genres.Lib1SeriesGenre).SeriesCount); + + // These values represent a bug - chapters are not properly filtered when their series is age-restricted + // Should be 2, but currently returns 3 due to the filtering issue + Assert.Equal(3, GetGenreDto(restrictedAgeAccessGenres, genres.SharedSeriesChaptersGenre).ChapterCount); + Assert.Equal(3, GetGenreDto(restrictedAgeAccessGenres, genres.Lib1ChaptersGenre).ChapterCount); + } + + private class TestGenreSet + { + public Genre SharedSeriesChaptersGenre { get; set; } + public Genre SharedSeriesGenre { get; set; } + public Genre SharedChaptersGenre { get; set; } + public Genre Lib0SeriesChaptersGenre { get; set; } + public Genre Lib0SeriesGenre { get; set; } + public Genre Lib0ChaptersGenre { get; set; } + public Genre Lib1SeriesChaptersGenre { get; set; } + public Genre Lib1SeriesGenre { get; set; } + public Genre Lib1ChaptersGenre { get; set; } + public Genre Lib1ChapterAgeGenre { get; set; } + + public List GetAllGenres() + { + return + [ + SharedSeriesChaptersGenre, SharedSeriesGenre, SharedChaptersGenre, + Lib0SeriesChaptersGenre, Lib0SeriesGenre, Lib0ChaptersGenre, + Lib1SeriesChaptersGenre, Lib1SeriesGenre, Lib1ChaptersGenre, Lib1ChapterAgeGenre + ]; + } + } +} diff --git a/API.Tests/Repository/PersonRepositoryTests.cs b/API.Tests/Repository/PersonRepositoryTests.cs new file mode 100644 index 000000000..a2b19cc0c --- /dev/null +++ b/API.Tests/Repository/PersonRepositoryTests.cs @@ -0,0 +1,342 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata.Browse; +using API.DTOs.Metadata.Browse.Requests; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Person; +using API.Helpers; +using API.Helpers.Builders; +using Xunit; + +namespace API.Tests.Repository; + +public class PersonRepositoryTests : AbstractDbTest +{ + private AppUser _fullAccess; + private AppUser _restrictedAccess; + private AppUser _restrictedAgeAccess; + + protected override async Task ResetDb() + { + Context.Person.RemoveRange(Context.Person.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); + Context.AppUser.RemoveRange(Context.AppUser.ToList()); + await UnitOfWork.CommitAsync(); + } + + private async Task SeedDb() + { + _fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build(); + _restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build(); + _restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build(); + _restrictedAgeAccess.AgeRestriction = AgeRating.Teen; + _restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true; + + Context.AppUser.Add(_fullAccess); + Context.AppUser.Add(_restrictedAccess); + Context.AppUser.Add(_restrictedAgeAccess); + await Context.SaveChangesAsync(); + + var people = CreateTestPeople(); + Context.Person.AddRange(people); + await Context.SaveChangesAsync(); + + var libraries = CreateTestLibraries(people); + Context.Library.AddRange(libraries); + await Context.SaveChangesAsync(); + + _fullAccess.Libraries.Add(libraries[0]); // lib0 + _fullAccess.Libraries.Add(libraries[1]); // lib1 + _restrictedAccess.Libraries.Add(libraries[1]); // lib1 only + _restrictedAgeAccess.Libraries.Add(libraries[1]); // lib1 only + + await Context.SaveChangesAsync(); + } + + private static List CreateTestPeople() + { + return new List + { + new PersonBuilder("Shared Series Chapter Person").Build(), + new PersonBuilder("Shared Series Person").Build(), + new PersonBuilder("Shared Chapters Person").Build(), + new PersonBuilder("Lib0 Series Chapter Person").Build(), + new PersonBuilder("Lib0 Series Person").Build(), + new PersonBuilder("Lib0 Chapters Person").Build(), + new PersonBuilder("Lib1 Series Chapter Person").Build(), + new PersonBuilder("Lib1 Series Person").Build(), + new PersonBuilder("Lib1 Chapters Person").Build(), + new PersonBuilder("Lib1 Chapter Age Person").Build() + }; + } + + private static List CreateTestLibraries(List people) + { + var lib0 = new LibraryBuilder("lib0") + .WithSeries(new SeriesBuilder("lib0-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Writer) + .WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Writer) + .WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Writer) + .WithPerson(GetPersonByName(people, "Lib0 Series Person"), PersonRole.Writer) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Colorist) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Colorist) + .WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Colorist) + .WithPerson(GetPersonByName(people, "Lib0 Chapters Person"), PersonRole.Colorist) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Editor) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Editor) + .WithPerson(GetPersonByName(people, "Lib0 Series Chapter Person"), PersonRole.Editor) + .WithPerson(GetPersonByName(people, "Lib0 Chapters Person"), PersonRole.Editor) + .Build()) + .Build()) + .Build()) + .Build(); + + var lib1 = new LibraryBuilder("lib1") + .WithSeries(new SeriesBuilder("lib1-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Letterer) + .WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Letterer) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Letterer) + .WithPerson(GetPersonByName(people, "Lib1 Series Person"), PersonRole.Letterer) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Imprint) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Imprint) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Imprint) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Imprint) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.CoverArtist) + .WithPerson(GetPersonByName(people, "Lib1 Chapter Age Person"), PersonRole.CoverArtist) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("lib1-s1") + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Inker) + .WithPerson(GetPersonByName(people, "Shared Series Person"), PersonRole.Inker) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Inker) + .WithPerson(GetPersonByName(people, "Lib1 Series Person"), PersonRole.Inker) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Team) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Team) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Team) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Team) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithPerson(GetPersonByName(people, "Shared Series Chapter Person"), PersonRole.Translator) + .WithPerson(GetPersonByName(people, "Shared Chapters Person"), PersonRole.Translator) + .WithPerson(GetPersonByName(people, "Lib1 Series Chapter Person"), PersonRole.Translator) + .WithPerson(GetPersonByName(people, "Lib1 Chapters Person"), PersonRole.Translator) + .Build()) + .Build()) + .Build()) + .Build(); + + return new List { lib0, lib1 }; + } + + private static Person GetPersonByName(List people, string name) + { + return people.First(p => p.Name == name); + } + + private Person GetPersonByName(string name) + { + return Context.Person.First(p => p.Name == name); + } + + private static Predicate ContainsPersonCheck(Person person) + { + return p => p.Id == person.Id; + } + + [Fact] + public async Task GetBrowsePersonDtos() + { + await ResetDb(); + await SeedDb(); + + // Get people from database for assertions + var sharedSeriesChaptersPerson = GetPersonByName("Shared Series Chapter Person"); + var lib0SeriesPerson = GetPersonByName("Lib0 Series Person"); + var lib1SeriesPerson = GetPersonByName("Lib1 Series Person"); + var lib1ChapterAgePerson = GetPersonByName("Lib1 Chapter Age Person"); + var allPeople = Context.Person.ToList(); + + var fullAccessPeople = + await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_fullAccess.Id, new BrowsePersonFilterDto(), + new UserParams()); + Assert.Equal(allPeople.Count, fullAccessPeople.TotalCount); + + foreach (var person in allPeople) + Assert.Contains(fullAccessPeople, ContainsPersonCheck(person)); + + // 1 series in lib0, 2 series in lib1 + Assert.Equal(3, fullAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).SeriesCount); + // 3 series with each 2 chapters + Assert.Equal(6, fullAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).ChapterCount); + // 1 series in lib0 + Assert.Equal(1, fullAccessPeople.First(dto => dto.Id == lib0SeriesPerson.Id).SeriesCount); + // 2 series in lib1 + Assert.Equal(2, fullAccessPeople.First(dto => dto.Id == lib1SeriesPerson.Id).SeriesCount); + + var restrictedAccessPeople = + await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_restrictedAccess.Id, new BrowsePersonFilterDto(), + new UserParams()); + + Assert.Equal(7, restrictedAccessPeople.TotalCount); + + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Series Chapter Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Series Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Shared Chapters Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Series Chapter Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Series Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Chapters Person"))); + Assert.Contains(restrictedAccessPeople, ContainsPersonCheck(GetPersonByName("Lib1 Chapter Age Person"))); + + // 2 series in lib1, no series in lib0 + Assert.Equal(2, restrictedAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).SeriesCount); + // 2 series with each 2 chapters + Assert.Equal(4, restrictedAccessPeople.First(dto => dto.Id == sharedSeriesChaptersPerson.Id).ChapterCount); + // 2 series in lib1 + Assert.Equal(2, restrictedAccessPeople.First(dto => dto.Id == lib1SeriesPerson.Id).SeriesCount); + + var restrictedAgeAccessPeople = await UnitOfWork.PersonRepository.GetBrowsePersonDtos(_restrictedAgeAccess.Id, + new BrowsePersonFilterDto(), new UserParams()); + + // Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up + Assert.Equal(6, restrictedAgeAccessPeople.TotalCount); + + // No access to the age restricted chapter + Assert.DoesNotContain(restrictedAgeAccessPeople, ContainsPersonCheck(lib1ChapterAgePerson)); + } + + [Fact] + public async Task GetRolesForPersonByName() + { + await ResetDb(); + await SeedDb(); + + var sharedSeriesPerson = GetPersonByName("Shared Series Person"); + var sharedChaptersPerson = GetPersonByName("Shared Chapters Person"); + var lib1ChapterAgePerson = GetPersonByName("Lib1 Chapter Age Person"); + + var sharedSeriesRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _fullAccess.Id); + var chapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _fullAccess.Id); + var ageChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _fullAccess.Id); + Assert.Equal(3, sharedSeriesRoles.Count()); + Assert.Equal(6, chapterRoles.Count()); + Assert.Single(ageChapterRoles); + + var restrictedRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _restrictedAccess.Id); + var restrictedChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _restrictedAccess.Id); + var restrictedAgePersonChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _restrictedAccess.Id); + Assert.Equal(2, restrictedRoles.Count()); + Assert.Equal(4, restrictedChapterRoles.Count()); + Assert.Single(restrictedAgePersonChapterRoles); + + var restrictedAgeRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedSeriesPerson.Id, _restrictedAgeAccess.Id); + var restrictedAgeChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(sharedChaptersPerson.Id, _restrictedAgeAccess.Id); + var restrictedAgeAgePersonChapterRoles = await UnitOfWork.PersonRepository.GetRolesForPersonByName(lib1ChapterAgePerson.Id, _restrictedAgeAccess.Id); + Assert.Single(restrictedAgeRoles); + Assert.Equal(2, restrictedAgeChapterRoles.Count()); + // Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up + Assert.Empty(restrictedAgeAgePersonChapterRoles); + } + + [Fact] + public async Task GetPersonDtoByName() + { + await ResetDb(); + await SeedDb(); + + var allPeople = Context.Person.ToList(); + + foreach (var person in allPeople) + { + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName(person.Name, _fullAccess.Id)); + } + + Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib0 Chapters Person", _restrictedAccess.Id)); + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Shared Series Person", _restrictedAccess.Id)); + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Series Person", _restrictedAccess.Id)); + + Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib0 Chapters Person", _restrictedAgeAccess.Id)); + Assert.NotNull(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Series Person", _restrictedAgeAccess.Id)); + // Note: There is a potential bug here where a person in a different chapter of an age restricted series will show up + Assert.Null(await UnitOfWork.PersonRepository.GetPersonDtoByName("Lib1 Chapter Age Person", _restrictedAgeAccess.Id)); + } + + [Fact] + public async Task GetSeriesKnownFor() + { + await ResetDb(); + await SeedDb(); + + var sharedSeriesPerson = GetPersonByName("Shared Series Person"); + var lib1SeriesPerson = GetPersonByName("Lib1 Series Person"); + + var series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _fullAccess.Id); + Assert.Equal(3, series.Count()); + + series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _restrictedAccess.Id); + Assert.Equal(2, series.Count()); + + series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(sharedSeriesPerson.Id, _restrictedAgeAccess.Id); + Assert.Single(series); + + series = await UnitOfWork.PersonRepository.GetSeriesKnownFor(lib1SeriesPerson.Id, _restrictedAgeAccess.Id); + Assert.Single(series); + } + + [Fact] + public async Task GetChaptersForPersonByRole() + { + await ResetDb(); + await SeedDb(); + + var sharedChaptersPerson = GetPersonByName("Shared Chapters Person"); + + // Lib0 + var chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Colorist); + var restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Colorist); + var restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Colorist); + Assert.Single(chapters); + Assert.Empty(restrictedChapters); + Assert.Empty(restrictedAgeChapters); + + // Lib1 - age restricted series + chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Imprint); + restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Imprint); + restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Imprint); + Assert.Single(chapters); + Assert.Single(restrictedChapters); + Assert.Empty(restrictedAgeChapters); + + // Lib1 - not age restricted series + chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _fullAccess.Id, PersonRole.Team); + restrictedChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAccess.Id, PersonRole.Team); + restrictedAgeChapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(sharedChaptersPerson.Id, _restrictedAgeAccess.Id, PersonRole.Team); + Assert.Single(chapters); + Assert.Single(restrictedChapters); + Assert.Single(restrictedAgeChapters); + } +} diff --git a/API.Tests/Repository/TagRepositoryTests.cs b/API.Tests/Repository/TagRepositoryTests.cs new file mode 100644 index 000000000..229082eb6 --- /dev/null +++ b/API.Tests/Repository/TagRepositoryTests.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.DTOs.Metadata.Browse; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; +using API.Helpers; +using API.Helpers.Builders; +using Xunit; + +namespace API.Tests.Repository; + +public class TagRepositoryTests : AbstractDbTest +{ + private AppUser _fullAccess; + private AppUser _restrictedAccess; + private AppUser _restrictedAgeAccess; + + protected override async Task ResetDb() + { + Context.Tag.RemoveRange(Context.Tag); + Context.Library.RemoveRange(Context.Library); + await Context.SaveChangesAsync(); + } + + private TestTagSet CreateTestTags() + { + return new TestTagSet + { + SharedSeriesChaptersTag = new TagBuilder("Shared Series Chapter Tag").Build(), + SharedSeriesTag = new TagBuilder("Shared Series Tag").Build(), + SharedChaptersTag = new TagBuilder("Shared Chapters Tag").Build(), + Lib0SeriesChaptersTag = new TagBuilder("Lib0 Series Chapter Tag").Build(), + Lib0SeriesTag = new TagBuilder("Lib0 Series Tag").Build(), + Lib0ChaptersTag = new TagBuilder("Lib0 Chapters Tag").Build(), + Lib1SeriesChaptersTag = new TagBuilder("Lib1 Series Chapter Tag").Build(), + Lib1SeriesTag = new TagBuilder("Lib1 Series Tag").Build(), + Lib1ChaptersTag = new TagBuilder("Lib1 Chapters Tag").Build(), + Lib1ChapterAgeTag = new TagBuilder("Lib1 Chapter Age Tag").Build() + }; + } + + private async Task SeedDbWithTags(TestTagSet tags) + { + await CreateTestUsers(); + await AddTagsToContext(tags); + await CreateLibrariesWithTags(tags); + await AssignLibrariesToUsers(); + } + + private async Task CreateTestUsers() + { + _fullAccess = new AppUserBuilder("amelia", "amelia@example.com").Build(); + _restrictedAccess = new AppUserBuilder("mila", "mila@example.com").Build(); + _restrictedAgeAccess = new AppUserBuilder("eva", "eva@example.com").Build(); + _restrictedAgeAccess.AgeRestriction = AgeRating.Teen; + _restrictedAgeAccess.AgeRestrictionIncludeUnknowns = true; + + Context.Users.Add(_fullAccess); + Context.Users.Add(_restrictedAccess); + Context.Users.Add(_restrictedAgeAccess); + await Context.SaveChangesAsync(); + } + + private async Task AddTagsToContext(TestTagSet tags) + { + var allTags = tags.GetAllTags(); + Context.Tag.AddRange(allTags); + await Context.SaveChangesAsync(); + } + + private async Task CreateLibrariesWithTags(TestTagSet tags) + { + var lib0 = new LibraryBuilder("lib0") + .WithSeries(new SeriesBuilder("lib0-s0") + .WithMetadata(new SeriesMetadata + { + Tags = [tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib0SeriesChaptersTag, tags.Lib0SeriesTag] + }) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib0SeriesChaptersTag, tags.Lib0ChaptersTag]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .Build()) + .Build()) + .Build()) + .Build(); + + var lib1 = new LibraryBuilder("lib1") + .WithSeries(new SeriesBuilder("lib1-s0") + .WithMetadata(new SeriesMetadataBuilder() + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib1SeriesChaptersTag, tags.Lib1SeriesTag]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag, tags.Lib1ChapterAgeTag]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .WithSeries(new SeriesBuilder("lib1-s1") + .WithMetadata(new SeriesMetadataBuilder() + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedSeriesTag, tags.Lib1SeriesChaptersTag, tags.Lib1SeriesTag]) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .Build()) + .WithChapter(new ChapterBuilder("2") + .WithTags([tags.SharedSeriesChaptersTag, tags.SharedChaptersTag, tags.Lib1SeriesChaptersTag, tags.Lib1ChaptersTag]) + .WithAgeRating(AgeRating.Mature17Plus) + .Build()) + .Build()) + .Build()) + .Build(); + + Context.Library.Add(lib0); + Context.Library.Add(lib1); + await Context.SaveChangesAsync(); + } + + private async Task AssignLibrariesToUsers() + { + var lib0 = Context.Library.First(l => l.Name == "lib0"); + var lib1 = Context.Library.First(l => l.Name == "lib1"); + + _fullAccess.Libraries.Add(lib0); + _fullAccess.Libraries.Add(lib1); + _restrictedAccess.Libraries.Add(lib1); + _restrictedAgeAccess.Libraries.Add(lib1); + + await Context.SaveChangesAsync(); + } + + private static Predicate ContainsTagCheck(Tag tag) + { + return t => t.Id == tag.Id; + } + + private static void AssertTagPresent(IEnumerable tags, Tag expectedTag) + { + Assert.Contains(tags, ContainsTagCheck(expectedTag)); + } + + private static void AssertTagNotPresent(IEnumerable tags, Tag expectedTag) + { + Assert.DoesNotContain(tags, ContainsTagCheck(expectedTag)); + } + + private static BrowseTagDto GetTagDto(IEnumerable tags, Tag tag) + { + return tags.First(dto => dto.Id == tag.Id); + } + + [Fact] + public async Task GetBrowseableTag_FullAccess_ReturnsAllTagsWithCorrectCounts() + { + // Arrange + await ResetDb(); + var tags = CreateTestTags(); + await SeedDbWithTags(tags); + + // Act + var fullAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_fullAccess.Id, new UserParams()); + + // Assert + Assert.Equal(tags.GetAllTags().Count, fullAccessTags.TotalCount); + + foreach (var tag in tags.GetAllTags()) + { + AssertTagPresent(fullAccessTags, tag); + } + + // Verify counts - 1 series lib0, 2 series lib1 = 3 total series + Assert.Equal(3, GetTagDto(fullAccessTags, tags.SharedSeriesChaptersTag).SeriesCount); + Assert.Equal(6, GetTagDto(fullAccessTags, tags.SharedSeriesChaptersTag).ChapterCount); + Assert.Equal(1, GetTagDto(fullAccessTags, tags.Lib0SeriesTag).SeriesCount); + } + + [Fact] + public async Task GetBrowseableTag_RestrictedAccess_ReturnsOnlyAccessibleTags() + { + // Arrange + await ResetDb(); + var tags = CreateTestTags(); + await SeedDbWithTags(tags); + + // Act + var restrictedAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_restrictedAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 4 library 1 specific = 7 tags + Assert.Equal(7, restrictedAccessTags.TotalCount); + + // Verify shared and Library 1 tags are present + AssertTagPresent(restrictedAccessTags, tags.SharedSeriesChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.SharedSeriesTag); + AssertTagPresent(restrictedAccessTags, tags.SharedChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1SeriesChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1SeriesTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1ChaptersTag); + AssertTagPresent(restrictedAccessTags, tags.Lib1ChapterAgeTag); + + // Verify Library 0 specific tags are not present + AssertTagNotPresent(restrictedAccessTags, tags.Lib0SeriesChaptersTag); + AssertTagNotPresent(restrictedAccessTags, tags.Lib0SeriesTag); + AssertTagNotPresent(restrictedAccessTags, tags.Lib0ChaptersTag); + + // Verify counts - 2 series lib1 + Assert.Equal(2, GetTagDto(restrictedAccessTags, tags.SharedSeriesChaptersTag).SeriesCount); + Assert.Equal(4, GetTagDto(restrictedAccessTags, tags.SharedSeriesChaptersTag).ChapterCount); + Assert.Equal(2, GetTagDto(restrictedAccessTags, tags.Lib1SeriesTag).SeriesCount); + Assert.Equal(4, GetTagDto(restrictedAccessTags, tags.Lib1ChaptersTag).ChapterCount); + } + + [Fact] + public async Task GetBrowseableTag_RestrictedAgeAccess_FiltersAgeRestrictedContent() + { + // Arrange + await ResetDb(); + var tags = CreateTestTags(); + await SeedDbWithTags(tags); + + // Act + var restrictedAgeAccessTags = await UnitOfWork.TagRepository.GetBrowseableTag(_restrictedAgeAccess.Id, new UserParams()); + + // Assert - Should see: 3 shared + 3 lib1 specific = 6 tags (age-restricted tag filtered out) + Assert.Equal(6, restrictedAgeAccessTags.TotalCount); + + // Verify accessible tags are present + AssertTagPresent(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag); + AssertTagPresent(restrictedAgeAccessTags, tags.SharedSeriesTag); + AssertTagPresent(restrictedAgeAccessTags, tags.SharedChaptersTag); + AssertTagPresent(restrictedAgeAccessTags, tags.Lib1SeriesChaptersTag); + AssertTagPresent(restrictedAgeAccessTags, tags.Lib1SeriesTag); + AssertTagPresent(restrictedAgeAccessTags, tags.Lib1ChaptersTag); + + // Verify age-restricted tag is filtered out + AssertTagNotPresent(restrictedAgeAccessTags, tags.Lib1ChapterAgeTag); + + // Verify counts - 1 series lib1 (age-restricted series filtered out) + Assert.Equal(1, GetTagDto(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag).SeriesCount); + Assert.Equal(2, GetTagDto(restrictedAgeAccessTags, tags.SharedSeriesChaptersTag).ChapterCount); + Assert.Equal(1, GetTagDto(restrictedAgeAccessTags, tags.Lib1SeriesTag).SeriesCount); + Assert.Equal(2, GetTagDto(restrictedAgeAccessTags, tags.Lib1ChaptersTag).ChapterCount); + } + + private class TestTagSet + { + public Tag SharedSeriesChaptersTag { get; set; } + public Tag SharedSeriesTag { get; set; } + public Tag SharedChaptersTag { get; set; } + public Tag Lib0SeriesChaptersTag { get; set; } + public Tag Lib0SeriesTag { get; set; } + public Tag Lib0ChaptersTag { get; set; } + public Tag Lib1SeriesChaptersTag { get; set; } + public Tag Lib1SeriesTag { get; set; } + public Tag Lib1ChaptersTag { get; set; } + public Tag Lib1ChapterAgeTag { get; set; } + + public List GetAllTags() + { + return + [ + SharedSeriesChaptersTag, SharedSeriesTag, SharedChaptersTag, + Lib0SeriesChaptersTag, Lib0SeriesTag, Lib0ChaptersTag, + Lib1SeriesChaptersTag, Lib1SeriesTag, Lib1ChaptersTag, Lib1ChapterAgeTag + ]; + } + } +} diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs index 8278f3b1a..973b7c6df 100644 --- a/API.Tests/Services/ExternalMetadataServiceTests.cs +++ b/API.Tests/Services/ExternalMetadataServiceTests.cs @@ -15,6 +15,7 @@ using API.Entities.Person; using API.Helpers.Builders; using API.Services.Plus; using API.Services.Tasks.Metadata; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; using Hangfire; using Microsoft.EntityFrameworkCore; @@ -881,6 +882,217 @@ public class ExternalMetadataServiceTests : AbstractDbTest } + [Fact] + public void IsSeriesCompleted_ExactMatch() + { + const string seriesName = "Test - Exact Match"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(5) + .WithTotalCount(5) + .Build()) + .Build(); + + var chapters = new List(); + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 5, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.True(result); + } + + [Fact] + public void IsSeriesCompleted_Volumes_DecimalVolumes() + { + const string seriesName = "Test - Volume Complete"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(3) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .WithVolume(new VolumeBuilder("2.5").WithNumber(2.5f).Build()) + .Build(); + + var chapters = new List(); + // External metadata includes decimal volume 2.5 + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.True(result); + Assert.Equal(3, series.Metadata.MaxCount); + Assert.Equal(3, series.Metadata.TotalCount); + } + + /// + /// This is validating that we get a completed even though we have a special chapter and AL doesn't count it + /// + [Fact] + public void IsSeriesCompleted_Volumes_HasSpecialAndDecimal_ExternalNoSpecial() + { + const string seriesName = "Test - Volume Complete"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(3) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("1.5").WithNumber(1.5f).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .WithVolume(new VolumeBuilder(Parser.SpecialVolume).Build()) + .Build(); + + var chapters = new List(); + // External metadata includes volume 1.5, but not the special + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.True(result); + Assert.Equal(3, series.Metadata.MaxCount); + Assert.Equal(3, series.Metadata.TotalCount); + } + + /// + /// This unit test also illustrates the bug where you may get a false positive if you had Volumes 1,2, and 2.1. While + /// missing volume 3. With the external metadata expecting non-decimal volumes. + /// i.e. it would fail if we only had one decimal volume + /// + [Fact] + public void IsSeriesCompleted_Volumes_TooManyDecimalVolumes() + { + const string seriesName = "Test - Volume Complete"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(3) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .WithVolume(new VolumeBuilder("2.1").WithNumber(2.1f).Build()) + .WithVolume(new VolumeBuilder("2.2").WithNumber(2.2f).Build()) + .Build(); + + var chapters = new List(); + // External metadata includes no special or decimals. There are 3 volumes. And we're missing volume 3 + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 3 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.False(result); + } + + [Fact] + public void IsSeriesCompleted_NoVolumes_GEQChapterCheck() + { + // We own 11 chapters, the external metadata expects 10 + const string seriesName = "Test - Chapter MaxCount, no volumes"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(11) + .WithTotalCount(10) + .Build()) + .Build(); + + var chapters = new List(); + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.True(result); + Assert.Equal(11, series.Metadata.TotalCount); + Assert.Equal(11, series.Metadata.MaxCount); + } + + [Fact] + public void IsSeriesCompleted_NoVolumes_IncludeAllChaptersCheck() + { + const string seriesName = "Test - Chapter Count"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(7) + .WithTotalCount(10) + .Build()) + .Build(); + + var chapters = new List + { + new ChapterBuilder("0").Build(), + new ChapterBuilder("2").Build(), + new ChapterBuilder("3").Build(), + new ChapterBuilder("4").Build(), + new ChapterBuilder("5").Build(), + new ChapterBuilder("6").Build(), + new ChapterBuilder("7").Build(), + new ChapterBuilder("7.1").Build(), + new ChapterBuilder("7.2").Build(), + new ChapterBuilder("7.3").Build() + }; + // External metadata includes prologues (0) and extra's (7.X) + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.True(result); + Assert.Equal(10, series.Metadata.TotalCount); + Assert.Equal(10, series.Metadata.MaxCount); + } + + [Fact] + public void IsSeriesCompleted_NotEnoughVolumes() + { + const string seriesName = "Test - Incomplete Volume"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(2) + .WithTotalCount(5) + .Build()) + .WithVolume(new VolumeBuilder("1").WithNumber(1).Build()) + .WithVolume(new VolumeBuilder("2").WithNumber(2).Build()) + .Build(); + + var chapters = new List(); + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 0, Volumes = 5 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, 2); + + Assert.False(result); + } + + [Fact] + public void IsSeriesCompleted_NoVolumes_NotEnoughChapters() + { + const string seriesName = "Test - Incomplete Chapter"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .WithMaxCount(5) + .WithTotalCount(8) + .Build()) + .Build(); + + var chapters = new List + { + new ChapterBuilder("1").Build(), + new ChapterBuilder("2").Build(), + new ChapterBuilder("3").Build() + }; + var externalMetadata = new ExternalSeriesDetailDto { Chapters = 10, Volumes = 0 }; + + var result = ExternalMetadataService.IsSeriesCompleted(series, chapters, externalMetadata, Parser.DefaultChapterNumber); + + Assert.False(result); + } + #endregion diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index bf3cc1814..7328ff954 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -185,7 +185,7 @@ public class PersonController : BaseApiController [HttpGet("series-known-for")] public async Task>> GetKnownSeries(int personId) { - return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId)); + return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId, User.GetUserId())); } /// @@ -206,6 +206,7 @@ public class PersonController : BaseApiController /// /// [HttpPost("merge")] + [Authorize("RequireAdminRole")] public async Task> MergePeople(PersonMergeDto dto) { var dst = await _unitOfWork.PersonRepository.GetPersonById(dto.DestId, PersonIncludes.All); diff --git a/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs index a3cd378b2..6704bf697 100644 --- a/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs +++ b/API/DTOs/KavitaPlus/Metadata/ExternalSeriesDetailDto.cs @@ -29,7 +29,9 @@ public sealed record ExternalSeriesDetailDto public DateTime? StartDate { get; set; } public DateTime? EndDate { get; set; } public int AverageScore { get; set; } + /// AniList returns the total count of unique chapters, includes 1.1 for example public int Chapters { get; set; } + /// AniList returns the total count of unique volumes, includes 1.1 for example public int Volumes { get; set; } public IList? Relations { get; set; } = []; public IList? Characters { get; set; } = []; diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index 3e645cb2e..d3baa4de6 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -173,20 +173,30 @@ public class GenreRepository : IGenreRepository { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var allLibrariesCount = await _context.Library.CountAsync(); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + var seriesIds = await _context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id).ToListAsync(); + var query = _context.Genre .RestrictAgainstAgeRestriction(ageRating) + .WhereIf(allLibrariesCount != userLibs.Count, + genre => genre.Chapters.Any(cp => seriesIds.Contains(cp.Volume.SeriesId)) || + genre.SeriesMetadatas.Any(sm => seriesIds.Contains(sm.SeriesId))) .Select(g => new BrowseGenreDto { Id = g.Id, Title = g.Title, SeriesCount = g.SeriesMetadatas - .Select(sm => sm.Id) + .Where(sm => allLibrariesCount == userLibs.Count || seriesIds.Contains(sm.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) .Distinct() .Count(), ChapterCount = g.Chapters - .Select(ch => ch.Id) + .Where(cp => allLibrariesCount == userLibs.Count || seriesIds.Contains(cp.Volume.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) .Distinct() - .Count() + .Count(), }) .OrderBy(g => g.Title); diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index 6954ccf03..26045c74c 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -63,7 +63,7 @@ public interface IPersonRepository Task GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases); Task IsNameUnique(string name); - Task> GetSeriesKnownFor(int personId); + Task> GetSeriesKnownFor(int personId, int userId); Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role); /// /// Returns all people with a matching name, or alias @@ -179,20 +179,25 @@ public class PersonRepository : IPersonRepository public async Task> GetRolesForPersonByName(int personId, int userId) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); // Query roles from ChapterPeople var chapterRoles = await _context.Person .Where(p => p.Id == personId) + .SelectMany(p => p.ChapterPeople) .RestrictAgainstAgeRestriction(ageRating) - .SelectMany(p => p.ChapterPeople.Select(cp => cp.Role)) + .RestrictByLibrary(userLibs) + .Select(cp => cp.Role) .Distinct() .ToListAsync(); // Query roles from SeriesMetadataPeople var seriesRoles = await _context.Person .Where(p => p.Id == personId) + .SelectMany(p => p.SeriesMetadataPeople) .RestrictAgainstAgeRestriction(ageRating) - .SelectMany(p => p.SeriesMetadataPeople.Select(smp => smp.Role)) + .RestrictByLibrary(userLibs) + .Select(smp => smp.Role) .Distinct() .ToListAsync(); @@ -204,44 +209,53 @@ public class PersonRepository : IPersonRepository { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); - var query = CreateFilteredPersonQueryable(userId, filter, ageRating); + var query = await CreateFilteredPersonQueryable(userId, filter, ageRating); return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } - private IQueryable CreateFilteredPersonQueryable(int userId, BrowsePersonFilterDto filter, AgeRestriction ageRating) + private async Task> CreateFilteredPersonQueryable(int userId, BrowsePersonFilterDto filter, AgeRestriction ageRating) { + var allLibrariesCount = await _context.Library.CountAsync(); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + var seriesIds = await _context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id).ToListAsync(); + var query = _context.Person.AsNoTracking(); // Apply filtering based on statements query = BuildPersonFilterQuery(userId, filter, query); - // Apply age restriction - query = query.RestrictAgainstAgeRestriction(ageRating); + // Apply restrictions + query = query.RestrictAgainstAgeRestriction(ageRating) + .WhereIf(allLibrariesCount != userLibs.Count, + person => person.ChapterPeople.Any(cp => seriesIds.Contains(cp.Chapter.Volume.SeriesId)) || + person.SeriesMetadataPeople.Any(smp => seriesIds.Contains(smp.SeriesMetadata.SeriesId))); // Apply sorting and limiting var sortedQuery = query.SortBy(filter.SortOptions); var limitedQuery = ApplyPersonLimit(sortedQuery, filter.LimitTo); - // Project to DTO - var projectedQuery = limitedQuery.Select(p => new BrowsePersonDto + return limitedQuery.Select(p => new BrowsePersonDto { Id = p.Id, Name = p.Name, Description = p.Description, CoverImage = p.CoverImage, SeriesCount = p.SeriesMetadataPeople - .Select(smp => smp.SeriesMetadata.SeriesId) + .Select(smp => smp.SeriesMetadata) + .Where(sm => allLibrariesCount == userLibs.Count || seriesIds.Contains(sm.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) .Distinct() .Count(), ChapterCount = p.ChapterPeople - .Select(cp => cp.Chapter.Id) + .Select(chp => chp.Chapter) + .Where(ch => allLibrariesCount == userLibs.Count || seriesIds.Contains(ch.Volume.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) .Distinct() - .Count() + .Count(), }); - - return projectedQuery; } private static IQueryable BuildPersonFilterQuery(int userId, BrowsePersonFilterDto filterDto, IQueryable query) @@ -287,11 +301,13 @@ public class PersonRepository : IPersonRepository { var normalized = name.ToNormalized(); var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); return await _context.Person .Where(p => p.NormalizedName == normalized) .Includes(includes) .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) .ProjectTo(_mapper.ConfigurationProvider) .FirstOrDefaultAsync(); } @@ -313,14 +329,18 @@ public class PersonRepository : IPersonRepository .AnyAsync(p => p.Name == name || p.Aliases.Any(pa => pa.Alias == name))); } - public async Task> GetSeriesKnownFor(int personId) + public async Task> GetSeriesKnownFor(int personId, int userId) { - List notValidRoles = [PersonRole.Location, PersonRole.Team, PersonRole.Other, PersonRole.Publisher, PersonRole.Translator]; + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + return await _context.Person .Where(p => p.Id == personId) - .SelectMany(p => p.SeriesMetadataPeople.Where(smp => !notValidRoles.Contains(smp.Role))) + .SelectMany(p => p.SeriesMetadataPeople) .Select(smp => smp.SeriesMetadata) .Select(sm => sm.Series) + .RestrictAgainstAgeRestriction(ageRating) + .Where(s => userLibs.Contains(s.LibraryId)) .Distinct() .OrderByDescending(s => s.ExternalSeriesMetadata.AverageExternalRating) .Take(20) @@ -331,11 +351,13 @@ public class PersonRepository : IPersonRepository public async Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); return await _context.ChapterPeople .Where(cp => cp.PersonId == personId && cp.Role == role) .Select(cp => cp.Chapter) .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) .OrderBy(ch => ch.SortOrder) .Take(20) .ProjectTo(_mapper.ConfigurationProvider) @@ -386,27 +408,31 @@ public class PersonRepository : IPersonRepository .ToListAsync(); } - public async Task> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.Aliases) + public async Task> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.None) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); return await _context.Person .Includes(includes) - .OrderBy(p => p.Name) .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) + .OrderBy(p => p.Name) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } - public async Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.Aliases) + public async Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.None) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var userLibs = _context.Library.GetUserLibraries(userId); return await _context.Person .Where(p => p.SeriesMetadataPeople.Any(smp => smp.Role == role) || p.ChapterPeople.Any(cp => cp.Role == role)) // Filter by role in both series and chapters .Includes(includes) - .OrderBy(p => p.Name) .RestrictAgainstAgeRestriction(ageRating) + .RestrictByLibrary(userLibs) + .OrderBy(p => p.Name) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index ea39d2b0d..40d40a675 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -111,18 +111,28 @@ public class TagRepository : ITagRepository { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + var allLibrariesCount = await _context.Library.CountAsync(); + var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); + + var seriesIds = _context.Series.Where(s => userLibs.Contains(s.LibraryId)).Select(s => s.Id); + var query = _context.Tag .RestrictAgainstAgeRestriction(ageRating) + .WhereIf(userLibs.Count != allLibrariesCount, + tag => tag.Chapters.Any(cp => seriesIds.Contains(cp.Volume.SeriesId)) || + tag.SeriesMetadatas.Any(sm => seriesIds.Contains(sm.SeriesId))) .Select(g => new BrowseTagDto { Id = g.Id, Title = g.Title, SeriesCount = g.SeriesMetadatas - .Select(sm => sm.Id) + .Where(sm => allLibrariesCount == userLibs.Count || seriesIds.Contains(sm.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) .Distinct() .Count(), ChapterCount = g.Chapters - .Select(ch => ch.Id) + .Where(ch => allLibrariesCount == userLibs.Count || seriesIds.Contains(ch.Volume.SeriesId)) + .RestrictAgainstAgeRestriction(ageRating) .Distinct() .Count() }) diff --git a/API/Extensions/EnumerableExtensions.cs b/API/Extensions/EnumerableExtensions.cs index 8beec88ca..9bc06bab4 100644 --- a/API/Extensions/EnumerableExtensions.cs +++ b/API/Extensions/EnumerableExtensions.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using API.Data.Misc; +using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; @@ -55,4 +56,16 @@ public static class EnumerableExtensions return q; } + + 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/QueryExtensions/RestrictByAgeExtensions.cs b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs index 350372e5b..e0738bdf3 100644 --- a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs @@ -27,6 +27,19 @@ public static class RestrictByAgeExtensions return q; } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(s => s.SeriesMetadata.AgeRating <= restriction.AgeRating); + + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.SeriesMetadata.AgeRating != AgeRating.Unknown); + } + + return q; + } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { @@ -41,6 +54,19 @@ public static class RestrictByAgeExtensions return q; } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(cp => cp.Chapter.Volume.Series.Metadata.AgeRating <= restriction.AgeRating); + + if (!restriction.IncludeUnknowns) + { + return q.Where(cp => cp.Chapter.Volume.Series.Metadata.AgeRating != AgeRating.Unknown); + } + + return q; + } + public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { diff --git a/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs b/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs index e69de29bb..9ec1b8621 100644 --- a/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs @@ -0,0 +1,31 @@ +using System.Linq; +using API.Entities; +using API.Entities.Person; + +namespace API.Extensions.QueryExtensions; + +public static class RestrictByLibraryExtensions +{ + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(p => + p.ChapterPeople.Any(cp => userLibs.Contains(cp.Chapter.Volume.Series.LibraryId)) || + p.SeriesMetadataPeople.Any(sm => userLibs.Contains(sm.SeriesMetadata.Series.LibraryId))); + } + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(cp => userLibs.Contains(cp.Volume.Series.LibraryId)); + } + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(sm => userLibs.Contains(sm.SeriesMetadata.Series.LibraryId)); + } + + public static IQueryable RestrictByLibrary(this IQueryable query, IQueryable userLibs) + { + return query.Where(cp => userLibs.Contains(cp.Chapter.Volume.Series.LibraryId)); + } +} diff --git a/API/Helpers/Builders/ChapterBuilder.cs b/API/Helpers/Builders/ChapterBuilder.cs index f85c21595..d9976d92a 100644 --- a/API/Helpers/Builders/ChapterBuilder.cs +++ b/API/Helpers/Builders/ChapterBuilder.cs @@ -156,4 +156,24 @@ public class ChapterBuilder : IEntityBuilder return this; } + + public ChapterBuilder WithTags(IList tags) + { + _chapter.Tags ??= []; + foreach (var tag in tags) + { + _chapter.Tags.Add(tag); + } + return this; + } + + public ChapterBuilder WithGenres(IList genres) + { + _chapter.Genres ??= []; + foreach (var genre in genres) + { + _chapter.Genres.Add(genre); + } + return this; + } } diff --git a/API/Helpers/Builders/SeriesMetadataBuilder.cs b/API/Helpers/Builders/SeriesMetadataBuilder.cs index 8ceb16d95..462bc4455 100644 --- a/API/Helpers/Builders/SeriesMetadataBuilder.cs +++ b/API/Helpers/Builders/SeriesMetadataBuilder.cs @@ -108,4 +108,23 @@ public class SeriesMetadataBuilder : IEntityBuilder _seriesMetadata.TagsLocked = lockStatus; return this; } + + public SeriesMetadataBuilder WithTags(List tags, bool lockStatus = false) + { + _seriesMetadata.Tags = tags; + _seriesMetadata.TagsLocked = lockStatus; + return this; + } + + public SeriesMetadataBuilder WithMaxCount(int count) + { + _seriesMetadata.MaxCount = count; + return this; + } + + public SeriesMetadataBuilder WithTotalCount(int count) + { + _seriesMetadata.TotalCount = count; + return this; + } } diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 1db334b91..3c8023671 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -1057,6 +1057,7 @@ public class ExternalMetadataService : IExternalMetadataService var status = DeterminePublicationStatus(series, chapters, externalMetadata); series.Metadata.PublicationStatus = status; + series.Metadata.PublicationStatusLocked = true; return true; } catch (Exception ex) @@ -1188,32 +1189,39 @@ public class ExternalMetadataService : IExternalMetadataService #region Rating - var averageCriticRating = metadata.CriticReviews.Average(r => r.Rating); - var averageUserRating = metadata.UserReviews.Average(r => r.Rating); + // C# can't make the implicit conversation here + float? averageCriticRating = metadata.CriticReviews.Count > 0 ? metadata.CriticReviews.Average(r => r.Rating) : null; + float? averageUserRating = metadata.UserReviews.Count > 0 ? metadata.UserReviews.Average(r => r.Rating) : null; var existingRatings = await _unitOfWork.ChapterRepository.GetExternalChapterRatings(chapter.Id); _unitOfWork.ExternalSeriesMetadataRepository.Remove(existingRatings); - chapter.ExternalRatings = - [ - new ExternalRating + chapter.ExternalRatings = []; + + if (averageUserRating != null) + { + chapter.ExternalRatings.Add(new ExternalRating { AverageScore = (int) averageUserRating, Provider = ScrobbleProvider.Cbr, Authority = RatingAuthority.User, ProviderUrl = metadata.IssueUrl, - }, - new ExternalRating + + }); + chapter.AverageExternalRating = averageUserRating.Value; + } + + if (averageCriticRating != null) + { + chapter.ExternalRatings.Add(new ExternalRating { AverageScore = (int) averageCriticRating, Provider = ScrobbleProvider.Cbr, Authority = RatingAuthority.Critic, ProviderUrl = metadata.IssueUrl, - }, - ]; - - chapter.AverageExternalRating = averageUserRating; + }); + } madeModification = averageUserRating > 0f || averageCriticRating > 0f || madeModification; @@ -1563,16 +1571,16 @@ public class ExternalMetadataService : IExternalMetadataService var maxVolume = (int)(nonSpecialVolumes.Count != 0 ? nonSpecialVolumes.Max(v => v.MaxNumber) : 0); var maxChapter = (int)chapters.Max(c => c.MaxNumber); - if (series.Format == MangaFormat.Epub || series.Format == MangaFormat.Pdf && chapters.Count == 1) + if (series.Format is MangaFormat.Epub or MangaFormat.Pdf && chapters.Count == 1) { series.Metadata.MaxCount = 1; } - else if (series.Metadata.TotalCount <= 1 && chapters.Count == 1 && chapters[0].IsSpecial) + else if (series.Metadata.TotalCount <= 1 && chapters is [{ IsSpecial: true }]) { series.Metadata.MaxCount = series.Metadata.TotalCount; } else if ((maxChapter == Parser.DefaultChapterNumber || maxChapter > series.Metadata.TotalCount) && - maxVolume <= series.Metadata.TotalCount) + maxVolume <= series.Metadata.TotalCount && maxVolume != Parser.DefaultChapterNumber) { series.Metadata.MaxCount = maxVolume; } @@ -1593,8 +1601,7 @@ public class ExternalMetadataService : IExternalMetadataService { status = PublicationStatus.Ended; - // Check if all volumes/chapters match the total count - if (series.Metadata.MaxCount == series.Metadata.TotalCount && series.Metadata.TotalCount > 0) + if (IsSeriesCompleted(series, chapters, externalMetadata, maxVolume)) { status = PublicationStatus.Completed; } @@ -1610,6 +1617,68 @@ public class ExternalMetadataService : IExternalMetadataService return PublicationStatus.OnGoing; } + /// + /// Returns true if the series should be marked as completed, checks loosey with chapter and series numbers. + /// Respects Specials to reach the required amount. + /// + /// + /// + /// + /// + /// + /// Updates MaxCount and TotalCount if a loosey check is used to set as completed + public static bool IsSeriesCompleted(Series series, List chapters, ExternalSeriesDetailDto externalMetadata, int maxVolumes) + { + // A series is completed if exactly the amount is found + if (series.Metadata.MaxCount == series.Metadata.TotalCount && series.Metadata.TotalCount > 0) + { + return true; + } + + // If volumes are collected, check if we reach the required volumes by including specials, and decimal volumes + // + // TODO BUG: If the series has specials, that are not included in the external count. But you do own them + // This may mark the series as completed pre-maturely + // Note: I've currently opted to keep this an equals to prevent the above bug from happening + // We *could* change this to >= in the future in case this is reported by users + // If we do; test IsSeriesCompleted_Volumes_TooManySpecials needs to be updated + if (maxVolumes != Parser.DefaultChapterNumber && externalMetadata.Volumes == series.Volumes.Count) + { + series.Metadata.MaxCount = series.Volumes.Count; + series.Metadata.TotalCount = series.Volumes.Count; + return true; + } + + // Note: If Kavita has specials, we should be lenient and ignore for the volume check + var volumeModifier = series.Volumes.Any(v => v.Name == Parser.SpecialVolume) ? 1 : 0; + var modifiedMinVolumeCount = series.Volumes.Count - volumeModifier; + if (maxVolumes != Parser.DefaultChapterNumber && externalMetadata.Volumes == modifiedMinVolumeCount) + { + series.Metadata.MaxCount = modifiedMinVolumeCount; + series.Metadata.TotalCount = modifiedMinVolumeCount; + return true; + } + + // If no volumes are collected, the series is completed if we reach or exceed the external chapters + if (maxVolumes == Parser.DefaultChapterNumber && series.Metadata.MaxCount >= externalMetadata.Chapters) + { + series.Metadata.TotalCount = series.Metadata.MaxCount; + return true; + } + + // If no volumes are collected, the series is complete if we reach or exceed the external chapters while including + // prologues, and extra chapters + if (maxVolumes == Parser.DefaultChapterNumber && chapters.Count >= externalMetadata.Chapters) + { + series.Metadata.TotalCount = chapters.Count; + series.Metadata.MaxCount = chapters.Count; + return true; + } + + + return false; + } + private static Dictionary> ApplyFieldMappings(IEnumerable values, MetadataFieldType sourceType, List mappings) { var result = new Dictionary>(); diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index e73d82b1f..575f89b3b 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -215,9 +215,9 @@ public class TaskScheduler : ITaskScheduler RecurringJob.AddOrUpdate(LicenseCheckId, () => _licenseService.GetLicenseInfo(false), LicenseService.Cron, RecurringJobOptions); - // KavitaPlus Scrobbling (every hour) + // KavitaPlus Scrobbling (every hour) - randomise minutes to spread requests out for K+ RecurringJob.AddOrUpdate(ProcessScrobblingEventsId, () => _scrobblingService.ProcessUpdatesSinceLastSync(), - "0 */1 * * *", RecurringJobOptions); + Cron.Hourly(Rnd.Next(0, 60)), RecurringJobOptions); RecurringJob.AddOrUpdate(ProcessProcessedScrobblingEventsId, () => _scrobblingService.ClearProcessedEvents(), Cron.Daily, RecurringJobOptions); diff --git a/UI/Web/src/app/_services/nav.service.ts b/UI/Web/src/app/_services/nav.service.ts index 65d9fca17..0aad76ef7 100644 --- a/UI/Web/src/app/_services/nav.service.ts +++ b/UI/Web/src/app/_services/nav.service.ts @@ -9,6 +9,24 @@ import {AccountService} from "./account.service"; import {map} from "rxjs/operators"; import {NavigationEnd, Router} from "@angular/router"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component"; +import {WikiLink} from "../_models/wiki"; + +/** + * NavItem used to construct the dropdown or NavLinkModal on mobile + * Priority construction + * @param routerLink A link to a page on the web app, takes priority + * @param fragment Optional fragment for routerLink + * @param href A link to an external page, must set noopener noreferrer + * @param click Callback, lowest priority. Should only be used if routerLink and href or not set + */ +interface NavItem { + transLocoKey: string; + href?: string; + fragment?: string; + routerLink?: string; + click?: () => void; +} @Injectable({ providedIn: 'root' @@ -21,6 +39,33 @@ export class NavService { public localStorageSideNavKey = 'kavita--sidenav--expanded'; + public navItems: NavItem[] = [ + { + transLocoKey: 'all-filters', + routerLink: '/all-filters/', + }, + { + transLocoKey: 'browse-genres', + routerLink: '/browse/genres', + }, + { + transLocoKey: 'browse-tags', + routerLink: '/browse/tags', + }, + { + transLocoKey: 'announcements', + routerLink: '/announcements/', + }, + { + transLocoKey: 'help', + href: WikiLink.Guides, + }, + { + transLocoKey: 'logout', + click: () => this.logout(), + } + ] + private navbarVisibleSource = new ReplaySubject(1); /** * If the top Nav bar is rendered or not @@ -127,6 +172,13 @@ export class NavService { }, 10); } + logout() { + this.accountService.logout(); + this.hideNavBar(); + this.hideSideNav(); + this.router.navigateByUrl('/login'); + } + /** * Shows the side nav. When being visible, the side nav will automatically return to previous collapsed state. */ diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html index 96fd71b95..f5f4e1e26 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html @@ -43,7 +43,7 @@ [sorts]="[{prop: 'createdUtc', dir: 'desc'}]" > - +
m.sourceValue.length > 0); + }).filter(m => m.sourceValue.length > 0 && m.destinationValue.length > 0); // Translate blacklist string -> Array return { @@ -231,15 +231,6 @@ export class ManageMetadataSettingsComponent implements OnInit { excludeFromSource: [mapping?.excludeFromSource || false] }); - // Autofill destination value if empty when source value loses focus - mappingGroup.get('sourceValue')?.valueChanges - .pipe( - filter(() => !mappingGroup.get('destinationValue')?.value) - ) - .subscribe(sourceValue => { - mappingGroup.get('destinationValue')?.setValue(sourceValue); - }); - //@ts-ignore this.fieldMappings.push(mappingGroup); } diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html index 089262875..d2e94d906 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.html @@ -205,12 +205,15 @@ {{user.username | sentenceCase}}
- {{t('all-filters')}} - {{t('browse-genres')}} - {{t('browse-tags')}} - {{t('announcements')}} - {{t('help')}} - {{t('logout')}} + @for (navItem of navService.navItems; track $index) { + @if (navItem.routerLink) { + {{t(navItem.transLocoKey)}} + } @else if (navItem.href) { + {{t(navItem.transLocoKey)}} + } @else if (navItem.click) { + {{t(navItem.transLocoKey)}} + } + }
} diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts index fd4af01f0..11b1f3307 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts @@ -134,13 +134,6 @@ export class NavHeaderComponent implements OnInit { this.cdRef.markForCheck(); } - logout() { - this.accountService.logout(); - this.navService.hideNavBar(); - this.navService.hideSideNav(); - this.router.navigateByUrl('/login'); - } - moveFocus() { this.document.getElementById('content')?.focus(); } @@ -253,7 +246,6 @@ export class NavHeaderComponent implements OnInit { openLinkSelectionMenu() { const ref = this.modalService.open(NavLinkModalComponent, {fullscreen: 'sm'}); - ref.componentInstance.logoutFn = this.logout.bind(this); } } diff --git a/UI/Web/src/app/nav/_components/nav-link-modal/nav-link-modal.component.html b/UI/Web/src/app/nav/_components/nav-link-modal/nav-link-modal.component.html index 6d94f0ed5..48c93f410 100644 --- a/UI/Web/src/app/nav/_components/nav-link-modal/nav-link-modal.component.html +++ b/UI/Web/src/app/nav/_components/nav-link-modal/nav-link-modal.component.html @@ -6,21 +6,22 @@