A bunch of bug fixes and some enhancements (#3871)

Co-authored-by: Joseph Milazzo <josephmajora@gmail.com>
This commit is contained in:
Fesaa 2025-06-25 17:04:26 +02:00 committed by GitHub
parent 62231d3c4e
commit 6fa1cf994e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1464 additions and 91 deletions

View File

@ -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<BrowseGenreDto> ContainsGenreCheck(Genre genre)
{
return g => g.Id == genre.Id;
}
private static void AssertGenrePresent(IEnumerable<BrowseGenreDto> genres, Genre expectedGenre)
{
Assert.Contains(genres, ContainsGenreCheck(expectedGenre));
}
private static void AssertGenreNotPresent(IEnumerable<BrowseGenreDto> genres, Genre expectedGenre)
{
Assert.DoesNotContain(genres, ContainsGenreCheck(expectedGenre));
}
private static BrowseGenreDto GetGenreDto(IEnumerable<BrowseGenreDto> 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<Genre> GetAllGenres()
{
return
[
SharedSeriesChaptersGenre, SharedSeriesGenre, SharedChaptersGenre,
Lib0SeriesChaptersGenre, Lib0SeriesGenre, Lib0ChaptersGenre,
Lib1SeriesChaptersGenre, Lib1SeriesGenre, Lib1ChaptersGenre, Lib1ChapterAgeGenre
];
}
}
}

View File

@ -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<Person> CreateTestPeople()
{
return new List<Person>
{
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<Library> CreateTestLibraries(List<Person> 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<Library> { lib0, lib1 };
}
private static Person GetPersonByName(List<Person> 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<BrowsePersonDto> 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);
}
}

View File

@ -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<BrowseTagDto> ContainsTagCheck(Tag tag)
{
return t => t.Id == tag.Id;
}
private static void AssertTagPresent(IEnumerable<BrowseTagDto> tags, Tag expectedTag)
{
Assert.Contains(tags, ContainsTagCheck(expectedTag));
}
private static void AssertTagNotPresent(IEnumerable<BrowseTagDto> tags, Tag expectedTag)
{
Assert.DoesNotContain(tags, ContainsTagCheck(expectedTag));
}
private static BrowseTagDto GetTagDto(IEnumerable<BrowseTagDto> 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<Tag> GetAllTags()
{
return
[
SharedSeriesChaptersTag, SharedSeriesTag, SharedChaptersTag,
Lib0SeriesChaptersTag, Lib0SeriesTag, Lib0ChaptersTag,
Lib1SeriesChaptersTag, Lib1SeriesTag, Lib1ChaptersTag, Lib1ChapterAgeTag
];
}
}
}

View File

@ -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<Chapter>();
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<Chapter>();
// 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);
}
/// <summary>
/// This is validating that we get a completed even though we have a special chapter and AL doesn't count it
/// </summary>
[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<Chapter>();
// 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);
}
/// <remarks>
/// 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
/// </remarks>
[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<Chapter>();
// 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<Chapter>();
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<Chapter>
{
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<Chapter>();
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<Chapter>
{
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

View File

@ -185,7 +185,7 @@ public class PersonController : BaseApiController
[HttpGet("series-known-for")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetKnownSeries(int personId)
{
return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId));
return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId, User.GetUserId()));
}
/// <summary>
@ -206,6 +206,7 @@ public class PersonController : BaseApiController
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("merge")]
[Authorize("RequireAdminRole")]
public async Task<ActionResult<PersonDto>> MergePeople(PersonMergeDto dto)
{
var dst = await _unitOfWork.PersonRepository.GetPersonById(dto.DestId, PersonIncludes.All);

View File

@ -29,7 +29,9 @@ public sealed record ExternalSeriesDetailDto
public DateTime? StartDate { get; set; }
public DateTime? EndDate { get; set; }
public int AverageScore { get; set; }
/// <remarks>AniList returns the total count of unique chapters, includes 1.1 for example</remarks>
public int Chapters { get; set; }
/// <remarks>AniList returns the total count of unique volumes, includes 1.1 for example</remarks>
public int Volumes { get; set; }
public IList<SeriesRelationship>? Relations { get; set; } = [];
public IList<SeriesCharacter>? Characters { get; set; } = [];

View File

@ -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);

View File

@ -63,7 +63,7 @@ public interface IPersonRepository
Task<Person?> GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases);
Task<bool> IsNameUnique(string name);
Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId);
Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId, int userId);
Task<IEnumerable<StandaloneChapterDto>> GetChaptersForPersonByRole(int personId, int userId, PersonRole role);
/// <summary>
/// Returns all people with a matching name, or alias
@ -179,20 +179,25 @@ public class PersonRepository : IPersonRepository
public async Task<IEnumerable<PersonRole>> 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<BrowsePersonDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
private IQueryable<BrowsePersonDto> CreateFilteredPersonQueryable(int userId, BrowsePersonFilterDto filter, AgeRestriction ageRating)
private async Task<IQueryable<BrowsePersonDto>> 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<Person> BuildPersonFilterQuery(int userId, BrowsePersonFilterDto filterDto, IQueryable<Person> 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<PersonDto>(_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<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId)
public async Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId, int userId)
{
List<PersonRole> 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<IEnumerable<StandaloneChapterDto>> 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<StandaloneChapterDto>(_mapper.ConfigurationProvider)
@ -386,27 +408,31 @@ public class PersonRepository : IPersonRepository
.ToListAsync();
}
public async Task<IList<PersonDto>> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.Aliases)
public async Task<IList<PersonDto>> 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<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.Aliases)
public async Task<IList<PersonDto>> 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<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}

View File

@ -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()
})

View File

@ -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<Chapter> RestrictAgainstAgeRestriction(this IEnumerable<Chapter> 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;
}
}

View File

@ -27,6 +27,19 @@ public static class RestrictByAgeExtensions
return q;
}
public static IQueryable<SeriesMetadataPeople> RestrictAgainstAgeRestriction(this IQueryable<SeriesMetadataPeople> 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<Chapter> RestrictAgainstAgeRestriction(this IQueryable<Chapter> queryable, AgeRestriction restriction)
{
@ -41,6 +54,19 @@ public static class RestrictByAgeExtensions
return q;
}
public static IQueryable<ChapterPeople> RestrictAgainstAgeRestriction(this IQueryable<ChapterPeople> 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<AppUserCollection> RestrictAgainstAgeRestriction(this IQueryable<AppUserCollection> queryable, AgeRestriction restriction)
{

View File

@ -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<Person> RestrictByLibrary(this IQueryable<Person> query, IQueryable<int> 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<Chapter> RestrictByLibrary(this IQueryable<Chapter> query, IQueryable<int> userLibs)
{
return query.Where(cp => userLibs.Contains(cp.Volume.Series.LibraryId));
}
public static IQueryable<SeriesMetadataPeople> RestrictByLibrary(this IQueryable<SeriesMetadataPeople> query, IQueryable<int> userLibs)
{
return query.Where(sm => userLibs.Contains(sm.SeriesMetadata.Series.LibraryId));
}
public static IQueryable<ChapterPeople> RestrictByLibrary(this IQueryable<ChapterPeople> query, IQueryable<int> userLibs)
{
return query.Where(cp => userLibs.Contains(cp.Chapter.Volume.Series.LibraryId));
}
}

View File

@ -156,4 +156,24 @@ public class ChapterBuilder : IEntityBuilder<Chapter>
return this;
}
public ChapterBuilder WithTags(IList<Tag> tags)
{
_chapter.Tags ??= [];
foreach (var tag in tags)
{
_chapter.Tags.Add(tag);
}
return this;
}
public ChapterBuilder WithGenres(IList<Genre> genres)
{
_chapter.Genres ??= [];
foreach (var genre in genres)
{
_chapter.Genres.Add(genre);
}
return this;
}
}

View File

@ -108,4 +108,23 @@ public class SeriesMetadataBuilder : IEntityBuilder<SeriesMetadata>
_seriesMetadata.TagsLocked = lockStatus;
return this;
}
public SeriesMetadataBuilder WithTags(List<Tag> 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;
}
}

View File

@ -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;
}
/// <summary>
/// Returns true if the series should be marked as completed, checks loosey with chapter and series numbers.
/// Respects Specials to reach the required amount.
/// </summary>
/// <param name="series"></param>
/// <param name="chapters"></param>
/// <param name="externalMetadata"></param>
/// <param name="maxVolumes"></param>
/// <returns></returns>
/// <remarks>Updates MaxCount and TotalCount if a loosey check is used to set as completed</remarks>
public static bool IsSeriesCompleted(Series series, List<Chapter> 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<MetadataFieldType, List<string>> ApplyFieldMappings(IEnumerable<string> values, MetadataFieldType sourceType, List<MetadataFieldMappingDto> mappings)
{
var result = new Dictionary<MetadataFieldType, List<string>>();

View File

@ -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);

View File

@ -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<boolean>(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.
*/

View File

@ -43,7 +43,7 @@
[sorts]="[{prop: 'createdUtc', dir: 'desc'}]"
>
<ngx-datatable-column prop="select" [sortable]="false" [draggable]="false" [resizeable]="false" [width]="50">
<ngx-datatable-column prop="select" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
<div class="form-check">
<input id="select-all" type="checkbox" class="form-check-input"

View File

@ -190,7 +190,7 @@ export class ManageMetadataSettingsComponent implements OnInit {
destinationValue: value.destinationValue,
excludeFromSource: value.excludeFromSource
}
}).filter(m => m.sourceValue.length > 0);
}).filter(m => m.sourceValue.length > 0 && m.destinationValue.length > 0);
// Translate blacklist string -> Array<string>
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);
}

View File

@ -205,12 +205,15 @@
<span class="d-none d-xs-none d-sm-none d-md-inline-block fw-bold">{{user.username | sentenceCase}}</span>
</button>
<div ngbDropdownMenu>
<a ngbDropdownItem routerLink="/all-filters/">{{t('all-filters')}}</a>
<a ngbDropdownItem routerLink="/browse/genres">{{t('browse-genres')}}</a>
<a ngbDropdownItem routerLink="/browse/tags">{{t('browse-tags')}}</a>
<a ngbDropdownItem routerLink="/announcements/">{{t('announcements')}}</a>
<a ngbDropdownItem [href]="WikiLink.Guides" rel="noopener noreferrer" target="_blank">{{t('help')}}</a>
<a ngbDropdownItem (click)="logout()">{{t('logout')}}</a>
@for (navItem of navService.navItems; track $index) {
@if (navItem.routerLink) {
<a ngbDropdownItem [routerLink]="navItem.routerLink" [fragment]="navItem.fragment">{{t(navItem.transLocoKey)}}</a>
} @else if (navItem.href) {
<a ngbDropdownItem [href]="navItem.href" rel="noopener noreferrer" target="_blank">{{t(navItem.transLocoKey)}}</a>
} @else if (navItem.click) {
<a ngbDropdownItem (click)="navItem.click()">{{t(navItem.transLocoKey)}}</a>
}
}
</div>
</div>
}

View File

@ -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);
}
}

View File

@ -6,21 +6,22 @@
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<a routerLink="/settings" [fragment]="SettingsTabId.Preferences" (click)="closeIfOnSettings()" [title]="t('settings')">{{t('settings')}}</a>
</div>
@for (navItem of navService.navItems; track $index) {
<div class="mb-3">
<a routerLink="/all-filters/">{{t('all-filters')}}</a>
</div>
<div class="mb-3">
<a routerLink="/announcements/">{{t('announcements')}}</a>
</div>
<div class="mb-3">
<a [href]="WikiLink.Guides" rel="noopener noreferrer" target="_blank">{{t('help')}}</a>
</div>
<div class="mb-3">
<a href="javascript:void(0);" (click)="logout()">{{t('logout')}}</a>
@if (navItem.routerLink) {
<a [routerLink]="navItem.routerLink" [fragment]="navItem.fragment">{{t(navItem.transLocoKey)}}</a>
} @else if (navItem.href) {
<a [href]="navItem.href" rel="noopener noreferrer" target="_blank">{{t(navItem.transLocoKey)}}</a>
} @else if (navItem.click) {
<a href="javascript:void(0);" (click)="navItem.click()">{{t(navItem.transLocoKey)}}</a>
}
</div>
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" (click)="close()">{{t('close')}}</button>

View File

@ -5,6 +5,7 @@ import {Router, RouterLink} from "@angular/router";
import {ReactiveFormsModule} from "@angular/forms";
import {TranslocoDirective} from "@jsverse/transloco";
import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.component";
import {NavService} from "../../../_services/nav.service";
@Component({
selector: 'app-nav-link-modal',
@ -25,17 +26,12 @@ export class NavLinkModalComponent {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly modal = inject(NgbActiveModal);
private readonly router = inject(Router);
@Input({required: true}) logoutFn!: () => void;
protected readonly navService = inject(NavService);
close() {
this.modal.close();
}
logout() {
this.logoutFn();
}
closeIfOnSettings() {
setTimeout(() => {
const currentUrl = this.router.url;

View File

@ -187,9 +187,8 @@ export class PersonDetailComponent implements OnInit {
filter.combination = FilterCombination.Or;
filter.limitTo = 20;
// I might want to use roles$ to do all this
allPeople.forEach(f => {
filter.statements.push({comparison: FilterComparison.Contains, value: this.person!.id + '', field: f});
roles.forEach(pr => {
filter.statements.push({comparison: FilterComparison.Contains, value: this.person!.id + '', field: personRoleForFilterField(pr)});
});
return filter;