From 7ce36bfc440caf76c11bbdcabd182a9635464c5a Mon Sep 17 00:00:00 2001 From: Fesaa <77553571+Fesaa@users.noreply.github.com> Date: Sat, 10 May 2025 00:18:13 +0200 Subject: [PATCH 01/47] People Aliases and Merging (#3795) Co-authored-by: Joseph Milazzo --- API.Tests/Helpers/PersonHelperTests.cs | 335 +- .../Services/ExternalMetadataServiceTests.cs | 124 + API.Tests/Services/PersonServiceTests.cs | 286 ++ API.Tests/Services/SeriesServiceTests.cs | 1 + API/Controllers/MetadataController.cs | 3 +- API/Controllers/OPDSController.cs | 1 + API/Controllers/PersonController.cs | 61 +- API/Controllers/ReadingListController.cs | 2 +- API/Controllers/SearchController.cs | 1 + API/DTOs/ChapterDto.cs | 1 + API/DTOs/Metadata/ChapterMetadataDto.cs | 1 + API/DTOs/Person/BrowsePersonDto.cs | 4 +- API/DTOs/Person/PersonDto.cs | 5 +- API/DTOs/Person/PersonMergeDto.cs | 17 + API/DTOs/Person/UpdatePersonDto.cs | 4 +- API/DTOs/ReadingLists/ReadingListCast.cs | 1 + API/DTOs/Search/SearchResultGroupDto.cs | 1 + API/DTOs/SeriesMetadataDto.cs | 1 + API/DTOs/UpdateChapterDto.cs | 1 + API/Data/DataContext.cs | 1 + .../20250507221026_PersonAliases.Designer.cs | 3571 +++++++++++++++++ .../20250507221026_PersonAliases.cs | 47 + .../Migrations/DataContextModelSnapshot.cs | 35 + API/Data/Repositories/PersonRepository.cs | 105 +- .../Repositories/ReadingListRepository.cs | 2 +- API/Data/Repositories/SeriesRepository.cs | 18 +- API/Entities/Person/Person.cs | 7 +- API/Entities/Person/PersonAlias.cs | 11 + .../ApplicationServiceExtensions.cs | 1 + .../Filtering/SearchQueryableExtensions.cs | 22 +- .../QueryExtensions/IncludesExtensions.cs | 23 +- API/Helpers/AutoMapperProfiles.cs | 4 +- API/Helpers/Builders/PersonAliasBuilder.cs | 19 + API/Helpers/Builders/PersonBuilder.cs | 18 +- API/Helpers/PersonHelper.cs | 35 +- API/I18N/en.json | 1 + API/Services/PersonService.cs | 147 + API/Services/Plus/ExternalMetadataService.cs | 52 +- API/Services/SeriesService.cs | 4 +- API/Services/Tasks/Metadata/CoverDbService.cs | 2 +- API/SignalR/MessageFactory.cs | 18 + UI/Web/src/_series-detail-common.scss | 2 +- UI/Web/src/app/_models/library/library.ts | 3 + UI/Web/src/app/_models/metadata/person.ts | 1 + .../app/_services/action-factory.service.ts | 14 +- .../src/app/_services/message-hub.service.ts | 13 +- UI/Web/src/app/_services/person.service.ts | 21 +- .../edit-chapter-modal.component.ts | 2 +- .../edit-series-modal.component.ts | 2 +- .../nav-header/nav-header.component.html | 11 +- .../nav-header/nav-header.component.scss | 4 + .../edit-person-modal.component.html | 13 + .../edit-person-modal.component.ts | 35 +- .../merge-person-modal.component.html | 65 + .../merge-person-modal.component.scss | 0 .../merge-person-modal.component.ts | 101 + .../person-detail.component.html | 42 +- .../person-detail/person-detail.component.ts | 152 +- .../series-detail.component.html | 2 +- .../series-detail.component.scss | 4 + .../badge-expander.component.scss | 9 +- .../shared/edit-list/edit-list.component.html | 26 +- .../shared/edit-list/edit-list.component.ts | 12 +- .../library-settings-modal.component.ts | 3 +- .../_components/typeahead.component.ts | 11 + UI/Web/src/assets/langs/en.json | 28 +- UI/Web/src/theme/themes/dark.scss | 3 + 67 files changed, 5288 insertions(+), 284 deletions(-) create mode 100644 API.Tests/Services/PersonServiceTests.cs create mode 100644 API/DTOs/Person/PersonMergeDto.cs create mode 100644 API/Data/Migrations/20250507221026_PersonAliases.Designer.cs create mode 100644 API/Data/Migrations/20250507221026_PersonAliases.cs create mode 100644 API/Entities/Person/PersonAlias.cs create mode 100644 API/Helpers/Builders/PersonAliasBuilder.cs create mode 100644 API/Services/PersonService.cs create mode 100644 UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.html create mode 100644 UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.scss create mode 100644 UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.ts diff --git a/API.Tests/Helpers/PersonHelperTests.cs b/API.Tests/Helpers/PersonHelperTests.cs index 66713e17c..47dab48da 100644 --- a/API.Tests/Helpers/PersonHelperTests.cs +++ b/API.Tests/Helpers/PersonHelperTests.cs @@ -1,5 +1,10 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using API.Entities.Enums; +using API.Helpers; +using API.Helpers.Builders; +using Xunit; namespace API.Tests.Helpers; @@ -7,127 +12,215 @@ public class PersonHelperTests : AbstractDbTest { protected override async Task ResetDb() { + Context.Series.RemoveRange(Context.Series.ToList()); + Context.Person.RemoveRange(Context.Person.ToList()); + Context.Library.RemoveRange(Context.Library.ToList()); Context.Series.RemoveRange(Context.Series.ToList()); await Context.SaveChangesAsync(); } - // - // // 1. Test adding new people and keeping existing ones - // [Fact] - // public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained() - // { - // var existingPerson = new PersonBuilder("Joe Shmo").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // // Create an existing person and assign them to the series with a role - // var series = new SeriesBuilder("Test 1") - // .WithFormat(MangaFormat.Archive) - // .WithMetadata(new SeriesMetadataBuilder() - // .WithPerson(existingPerson, PersonRole.Editor) - // .Build()) - // .WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Call UpdateChapterPeopleAsync with one existing and one new person - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo", "New Person" }, PersonRole.Editor, _unitOfWork); - // - // // Assert existing person retained and new person added - // var people = await _unitOfWork.PersonRepository.GetAllPeople(); - // Assert.Contains(people, p => p.Name == "Joe Shmo"); - // Assert.Contains(people, p => p.Name == "New Person"); - // - // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); - // Assert.Contains("Joe Shmo", chapterPeople); - // Assert.Contains("New Person", chapterPeople); - // } - // - // // 2. Test removing a person no longer in the list - // [Fact] - // public async Task UpdateChapterPeopleAsync_RemovePeople() - // { - // var existingPerson1 = new PersonBuilder("Joe Shmo").Build(); - // var existingPerson2 = new PersonBuilder("Jane Doe").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // var series = new SeriesBuilder("Test 1") - // .WithVolume(new VolumeBuilder("1") - // .WithChapter(new ChapterBuilder("1") - // .WithPerson(existingPerson1, PersonRole.Editor) - // .WithPerson(existingPerson2, PersonRole.Editor) - // .Build()) - // .Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Call UpdateChapterPeopleAsync with only one person - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); - // - // var people = await _unitOfWork.PersonRepository.GetAllPeople(); - // Assert.DoesNotContain(people, p => p.Name == "Jane Doe"); - // - // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); - // Assert.Contains("Joe Shmo", chapterPeople); - // Assert.DoesNotContain("Jane Doe", chapterPeople); - // } - // - // // 3. Test no changes when the list of people is the same - // [Fact] - // public async Task UpdateChapterPeopleAsync_NoChanges() - // { - // var existingPerson = new PersonBuilder("Joe Shmo").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // var series = new SeriesBuilder("Test 1") - // .WithVolume(new VolumeBuilder("1") - // .WithChapter(new ChapterBuilder("1") - // .WithPerson(existingPerson, PersonRole.Editor) - // .Build()) - // .Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Call UpdateChapterPeopleAsync with the same list - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); - // - // var people = await _unitOfWork.PersonRepository.GetAllPeople(); - // Assert.Contains(people, p => p.Name == "Joe Shmo"); - // - // var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); - // Assert.Contains("Joe Shmo", chapterPeople); - // Assert.Single(chapter.People); // No duplicate entries - // } - // - // // 4. Test multiple roles for a person - // [Fact] - // public async Task UpdateChapterPeopleAsync_MultipleRoles() - // { - // var person = new PersonBuilder("Joe Shmo").Build(); - // var chapter = new ChapterBuilder("1").Build(); - // - // var series = new SeriesBuilder("Test 1") - // .WithVolume(new VolumeBuilder("1") - // .WithChapter(new ChapterBuilder("1") - // .WithPerson(person, PersonRole.Writer) // Assign person as Writer - // .Build()) - // .Build()) - // .Build(); - // - // _unitOfWork.SeriesRepository.Add(series); - // await _unitOfWork.CommitAsync(); - // - // // Add same person as Editor - // await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, _unitOfWork); - // - // // Ensure that the same person is assigned with two roles - // var chapterPeople = chapter.People.Where(cp => cp.Person.Name == "Joe Shmo").ToList(); - // Assert.Equal(2, chapterPeople.Count); // One for each role - // Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer); - // Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor); - // } + + // 1. Test adding new people and keeping existing ones + [Fact] + public async Task UpdateChapterPeopleAsync_AddNewPeople_ExistingPersonRetained() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var existingPerson = new PersonBuilder("Joe Shmo").Build(); + var chapter = new ChapterBuilder("1").Build(); + + // Create an existing person and assign them to the series with a role + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithFormat(MangaFormat.Archive) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(existingPerson, PersonRole.Editor) + .Build()) + .WithVolume(new VolumeBuilder("1").WithChapter(chapter).Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Call UpdateChapterPeopleAsync with one existing and one new person + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo", "New Person" }, PersonRole.Editor, UnitOfWork); + + // Assert existing person retained and new person added + var people = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Contains(people, p => p.Name == "Joe Shmo"); + Assert.Contains(people, p => p.Name == "New Person"); + + var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + Assert.Contains("Joe Shmo", chapterPeople); + Assert.Contains("New Person", chapterPeople); + } + + // 2. Test removing a person no longer in the list + [Fact] + public async Task UpdateChapterPeopleAsync_RemovePeople() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var existingPerson1 = new PersonBuilder("Joe Shmo").Build(); + var existingPerson2 = new PersonBuilder("Jane Doe").Build(); + var chapter = new ChapterBuilder("1") + .WithPerson(existingPerson1, PersonRole.Editor) + .WithPerson(existingPerson2, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Call UpdateChapterPeopleAsync with only one person + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, UnitOfWork); + + // PersonHelper does not remove the Person from the global DbSet itself + await UnitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); + + var people = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.DoesNotContain(people, p => p.Name == "Jane Doe"); + + var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + Assert.Contains("Joe Shmo", chapterPeople); + Assert.DoesNotContain("Jane Doe", chapterPeople); + } + + // 3. Test no changes when the list of people is the same + [Fact] + public async Task UpdateChapterPeopleAsync_NoChanges() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var existingPerson = new PersonBuilder("Joe Shmo").Build(); + var chapter = new ChapterBuilder("1").WithPerson(existingPerson, PersonRole.Editor).Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Call UpdateChapterPeopleAsync with the same list + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, UnitOfWork); + + var people = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Contains(people, p => p.Name == "Joe Shmo"); + + var chapterPeople = chapter.People.Select(cp => cp.Person.Name).ToList(); + Assert.Contains("Joe Shmo", chapterPeople); + Assert.Single(chapter.People); // No duplicate entries + } + + // 4. Test multiple roles for a person + [Fact] + public async Task UpdateChapterPeopleAsync_MultipleRoles() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var person = new PersonBuilder("Joe Shmo").Build(); + var chapter = new ChapterBuilder("1").WithPerson(person, PersonRole.Writer).Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Add same person as Editor + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Shmo" }, PersonRole.Editor, UnitOfWork); + + // Ensure that the same person is assigned with two roles + var chapterPeople = chapter + .People + .Where(cp => + cp.Person.Name == "Joe Shmo") + .ToList(); + Assert.Equal(2, chapterPeople.Count); // One for each role + Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Writer); + Assert.Contains(chapterPeople, cp => cp.Role == PersonRole.Editor); + } + + [Fact] + public async Task UpdateChapterPeopleAsync_MatchOnAlias_NoChanges() + { + await ResetDb(); + + var library = new LibraryBuilder("My Library") + .Build(); + + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var person = new PersonBuilder("Joe Doe") + .WithAlias("Jonny Doe") + .Build(); + + var chapter = new ChapterBuilder("1") + .WithPerson(person, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + await UnitOfWork.CommitAsync(); + + // Add on Name + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Joe Doe" }, PersonRole.Editor, UnitOfWork); + await UnitOfWork.CommitAsync(); + + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + + // Add on alias + await PersonHelper.UpdateChapterPeopleAsync(chapter, new List { "Jonny Doe" }, PersonRole.Editor, UnitOfWork); + await UnitOfWork.CommitAsync(); + + allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + } + + // TODO: Unit tests for series } diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs index c2c226538..8310ed269 100644 --- a/API.Tests/Services/ExternalMetadataServiceTests.cs +++ b/API.Tests/Services/ExternalMetadataServiceTests.cs @@ -1678,6 +1678,130 @@ public class ExternalMetadataServiceTests : AbstractDbTest #endregion + #region People Alias + + [Fact] + public async Task PeopleAliasing_AddAsAlias() + { + await ResetDb(); + + const string seriesName = "Test - People - Add as Alias"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + Context.Person.Add(new PersonBuilder("John Doe").Build()); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("Doe", "John", "Story")] + }, 1); + + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + + var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList(); + Assert.Single(allWriters); + + var johnDoe = allWriters[0].Person; + + Assert.Contains("Doe John", johnDoe.Aliases.Select(pa => pa.Alias)); + } + + [Fact] + public async Task PeopleAliasing_AddOnAlias() + { + await ResetDb(); + + const string seriesName = "Test - People - Add as Alias"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + + Context.Person.Add(new PersonBuilder("John Doe").WithAlias("Doe John").Build()); + + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("Doe", "John", "Story")] + }, 1); + + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + + var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList(); + Assert.Single(allWriters); + + var johnDoe = allWriters[0].Person; + + Assert.Contains("Doe John", johnDoe.Aliases.Select(pa => pa.Alias)); + } + + [Fact] + public async Task PeopleAliasing_DontAddAsAlias_SameButNotSwitched() + { + await ResetDb(); + + const string seriesName = "Test - People - Add as Alias"; + var series = new SeriesBuilder(seriesName) + .WithLibraryId(1) + .WithMetadata(new SeriesMetadataBuilder() + .Build()) + .Build(); + Context.Series.Attach(series); + await Context.SaveChangesAsync(); + + var metadataSettings = await UnitOfWork.SettingsRepository.GetMetadataSettings(); + metadataSettings.Enabled = true; + metadataSettings.EnablePeople = true; + metadataSettings.FirstLastPeopleNaming = true; + metadataSettings.Overrides = [MetadataSettingField.People]; + metadataSettings.PersonRoles = [PersonRole.Writer]; + Context.MetadataSettings.Update(metadataSettings); + await Context.SaveChangesAsync(); + + await _externalMetadataService.WriteExternalMetadataToSeries(new ExternalSeriesDetailDto() + { + Name = seriesName, + Staff = [CreateStaff("John", "Doe Doe", "Story"), CreateStaff("Doe", "John Doe", "Story")] + }, 1); + + var postSeries = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Metadata); + Assert.NotNull(postSeries); + + var allWriters = postSeries.Metadata.People.Where(p => p.Role == PersonRole.Writer).ToList(); + Assert.Equal(2, allWriters.Count); + } + + #endregion + #region People - Characters [Fact] diff --git a/API.Tests/Services/PersonServiceTests.cs b/API.Tests/Services/PersonServiceTests.cs new file mode 100644 index 000000000..5c1929b1c --- /dev/null +++ b/API.Tests/Services/PersonServiceTests.cs @@ -0,0 +1,286 @@ +using System.Linq; +using System.Threading.Tasks; +using API.Data.Repositories; +using API.Entities; +using API.Entities.Enums; +using API.Entities.Person; +using API.Extensions; +using API.Helpers.Builders; +using API.Services; +using Xunit; + +namespace API.Tests.Services; + +public class PersonServiceTests: AbstractDbTest +{ + + [Fact] + public async Task PersonMerge_KeepNonEmptyMetadata() + { + var ps = new PersonService(UnitOfWork); + + var person1 = new Person + { + Name = "Casey Delores", + NormalizedName = "Casey Delores".ToNormalized(), + HardcoverId = "ANonEmptyId", + MalId = 12, + }; + + var person2 = new Person + { + Name= "Delores Casey", + NormalizedName = "Delores Casey".ToNormalized(), + Description = "Hi, I'm Delores Casey!", + Aliases = [new PersonAliasBuilder("Casey, Delores").Build()], + AniListId = 27, + }; + + UnitOfWork.PersonRepository.Attach(person1); + UnitOfWork.PersonRepository.Attach(person2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person1); + + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + + var person = allPeople[0]; + Assert.Equal("Casey Delores", person.Name); + Assert.NotEmpty(person.Description); + Assert.Equal(27, person.AniListId); + Assert.NotNull(person.HardcoverId); + Assert.NotEmpty(person.HardcoverId); + Assert.Contains(person.Aliases, pa => pa.Alias == "Delores Casey"); + Assert.Contains(person.Aliases, pa => pa.Alias == "Casey, Delores"); + } + + [Fact] + public async Task PersonMerge_MergedPersonDestruction() + { + var ps = new PersonService(UnitOfWork); + + var person1 = new Person + { + Name = "Casey Delores", + NormalizedName = "Casey Delores".ToNormalized(), + }; + + var person2 = new Person + { + Name = "Delores Casey", + NormalizedName = "Delores Casey".ToNormalized(), + }; + + UnitOfWork.PersonRepository.Attach(person1); + UnitOfWork.PersonRepository.Attach(person2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person1); + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + } + + [Fact] + public async Task PersonMerge_RetentionChapters() + { + var ps = new PersonService(UnitOfWork); + + var library = new LibraryBuilder("My Library").Build(); + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var user = new AppUserBuilder("Amelia", "amelia@localhost") + .WithLibrary(library).Build(); + UnitOfWork.UserRepository.Add(user); + + var person = new PersonBuilder("Jillian Cowan").Build(); + + var person2 = new PersonBuilder("Cowan Jillian").Build(); + + var chapter = new ChapterBuilder("1") + .WithPerson(person, PersonRole.Editor) + .Build(); + + var chapter2 = new ChapterBuilder("2") + .WithPerson(person2, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + var series2 = new SeriesBuilder("Test 2") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("2") + .WithChapter(chapter2) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + UnitOfWork.SeriesRepository.Add(series2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person); + + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + var mergedPerson = allPeople[0]; + + Assert.Equal("Jillian Cowan", mergedPerson.Name); + + var chapters = await UnitOfWork.PersonRepository.GetChaptersForPersonByRole(1, 1, PersonRole.Editor); + Assert.Equal(2, chapters.Count()); + + chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(1, ChapterIncludes.People); + Assert.NotNull(chapter); + Assert.Single(chapter.People); + + chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(2, ChapterIncludes.People); + Assert.NotNull(chapter2); + Assert.Single(chapter2.People); + + Assert.Equal(chapter.People.First().PersonId, chapter2.People.First().PersonId); + } + + [Fact] + public async Task PersonMerge_NoDuplicateChaptersOrSeries() + { + await ResetDb(); + + var ps = new PersonService(UnitOfWork); + + var library = new LibraryBuilder("My Library").Build(); + UnitOfWork.LibraryRepository.Add(library); + await UnitOfWork.CommitAsync(); + + var user = new AppUserBuilder("Amelia", "amelia@localhost") + .WithLibrary(library).Build(); + UnitOfWork.UserRepository.Add(user); + + var person = new PersonBuilder("Jillian Cowan").Build(); + + var person2 = new PersonBuilder("Cowan Jillian").Build(); + + var chapter = new ChapterBuilder("1") + .WithPerson(person, PersonRole.Editor) + .WithPerson(person2, PersonRole.Colorist) + .Build(); + + var chapter2 = new ChapterBuilder("2") + .WithPerson(person2, PersonRole.Editor) + .WithPerson(person, PersonRole.Editor) + .Build(); + + var series = new SeriesBuilder("Test 1") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(person, PersonRole.Editor) + .WithPerson(person2, PersonRole.Editor) + .Build()) + .Build(); + + var series2 = new SeriesBuilder("Test 2") + .WithLibraryId(library.Id) + .WithVolume(new VolumeBuilder("2") + .WithChapter(chapter2) + .Build()) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(person, PersonRole.Editor) + .WithPerson(person2, PersonRole.Colorist) + .Build()) + .Build(); + + UnitOfWork.SeriesRepository.Add(series); + UnitOfWork.SeriesRepository.Add(series2); + await UnitOfWork.CommitAsync(); + + await ps.MergePeopleAsync(person2, person); + var allPeople = await UnitOfWork.PersonRepository.GetAllPeople(); + Assert.Single(allPeople); + + var mergedPerson = await UnitOfWork.PersonRepository.GetPersonById(person.Id, PersonIncludes.All); + Assert.NotNull(mergedPerson); + Assert.Equal(3, mergedPerson.ChapterPeople.Count); + Assert.Equal(3, mergedPerson.SeriesMetadataPeople.Count); + + chapter = await UnitOfWork.ChapterRepository.GetChapterAsync(chapter.Id, ChapterIncludes.People); + Assert.NotNull(chapter); + Assert.Equal(2, chapter.People.Count); + Assert.Single(chapter.People.Select(p => p.Person.Id).Distinct()); + Assert.Contains(chapter.People, p => p.Role == PersonRole.Editor); + Assert.Contains(chapter.People, p => p.Role == PersonRole.Colorist); + + chapter2 = await UnitOfWork.ChapterRepository.GetChapterAsync(chapter2.Id, ChapterIncludes.People); + Assert.NotNull(chapter2); + Assert.Single(chapter2.People); + Assert.Contains(chapter2.People, p => p.Role == PersonRole.Editor); + Assert.DoesNotContain(chapter2.People, p => p.Role == PersonRole.Colorist); + + series = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series.Id, SeriesIncludes.Metadata); + Assert.NotNull(series); + Assert.Single(series.Metadata.People); + Assert.Contains(series.Metadata.People, p => p.Role == PersonRole.Editor); + Assert.DoesNotContain(series.Metadata.People, p => p.Role == PersonRole.Colorist); + + series2 = await UnitOfWork.SeriesRepository.GetSeriesByIdAsync(series2.Id, SeriesIncludes.Metadata); + Assert.NotNull(series2); + Assert.Equal(2, series2.Metadata.People.Count); + Assert.Contains(series2.Metadata.People, p => p.Role == PersonRole.Editor); + Assert.Contains(series2.Metadata.People, p => p.Role == PersonRole.Colorist); + + + } + + [Fact] + public async Task PersonAddAlias_NoOverlap() + { + await ResetDb(); + + UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jillian Cowan").Build()); + UnitOfWork.PersonRepository.Attach(new PersonBuilder("Jilly Cowan").WithAlias("Jolly Cowan").Build()); + await UnitOfWork.CommitAsync(); + + var ps = new PersonService(UnitOfWork); + + var person1 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jillian Cowan"); + var person2 = await UnitOfWork.PersonRepository.GetPersonByNameOrAliasAsync("Jilly Cowan"); + Assert.NotNull(person1); + Assert.NotNull(person2); + + // Overlap on Name + var success = await ps.UpdatePersonAliasesAsync(person1, ["Jilly Cowan"]); + Assert.False(success); + + // Overlap on alias + success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan"]); + Assert.False(success); + + // No overlap + success = await ps.UpdatePersonAliasesAsync(person2, ["Jilly Joy Cowan"]); + Assert.True(success); + + // Some overlap + success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]); + Assert.False(success); + + // Some overlap + success = await ps.UpdatePersonAliasesAsync(person1, ["Jolly Cowan", "Jilly Joy Cowan"]); + Assert.False(success); + + Assert.Single(person2.Aliases); + } + + protected override async Task ResetDb() + { + Context.Person.RemoveRange(Context.Person.ToList()); + + await Context.SaveChangesAsync(); + } +} diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 4bf0e6782..55babf815 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -8,6 +8,7 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index b08ac1f38..10a5f393a 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -6,9 +6,9 @@ using System.Threading.Tasks; using API.Constants; using API.Data; using API.Data.Repositories; -using API.DTOs; using API.DTOs.Filtering; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.Recommendation; using API.DTOs.SeriesDetail; using API.Entities.Enums; @@ -74,6 +74,7 @@ public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService loc { return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId(), ids)); } + return Ok(await unitOfWork.PersonRepository.GetAllPeopleDtosForLibrariesAsync(User.GetUserId())); } diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index fcc4ca58f..6e96c3063 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -15,6 +15,7 @@ using API.DTOs.CollectionTags; using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.OPDS; +using API.DTOs.Person; using API.DTOs.Progress; using API.DTOs.Search; using API.Entities; diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index 1094a1137..a2ab3bf88 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -1,7 +1,10 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using API.Data; +using API.Data.Repositories; using API.DTOs; +using API.DTOs.Person; using API.Entities.Enums; using API.Extensions; using API.Helpers; @@ -24,9 +27,10 @@ public class PersonController : BaseApiController private readonly ICoverDbService _coverDbService; private readonly IImageService _imageService; private readonly IEventHub _eventHub; + private readonly IPersonService _personService; public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper, - ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub) + ICoverDbService coverDbService, IImageService imageService, IEventHub eventHub, IPersonService personService) { _unitOfWork = unitOfWork; _localizationService = localizationService; @@ -34,6 +38,7 @@ public class PersonController : BaseApiController _coverDbService = coverDbService; _imageService = imageService; _eventHub = eventHub; + _personService = personService; } @@ -43,6 +48,17 @@ public class PersonController : BaseApiController return Ok(await _unitOfWork.PersonRepository.GetPersonDtoByName(name, User.GetUserId())); } + /// + /// Find a person by name or alias against a query string + /// + /// + /// + [HttpGet("search")] + public async Task>> SearchPeople([FromQuery] string queryString) + { + return Ok(await _unitOfWork.PersonRepository.SearchPeople(queryString)); + } + /// /// Returns all roles for a Person /// @@ -54,6 +70,7 @@ public class PersonController : BaseApiController return Ok(await _unitOfWork.PersonRepository.GetRolesForPersonByName(personId, User.GetUserId())); } + /// /// Returns a list of authors and artists for browsing /// @@ -78,7 +95,7 @@ public class PersonController : BaseApiController public async Task> UpdatePerson(UpdatePersonDto dto) { // This needs to get all people and update them equally - var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id); + var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id, PersonIncludes.Aliases); if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); if (string.IsNullOrEmpty(dto.Name)) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-required")); @@ -90,6 +107,10 @@ public class PersonController : BaseApiController return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-name-unique")); } + var success = await _personService.UpdatePersonAliasesAsync(person, dto.Aliases); + if (!success) return BadRequest(await _localizationService.Translate(User.GetUserId(), "aliases-have-overlap")); + + person.Name = dto.Name?.Trim(); person.Description = dto.Description ?? string.Empty; person.CoverImageLocked = dto.CoverImageLocked; @@ -173,5 +194,41 @@ public class PersonController : BaseApiController return Ok(await _unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, User.GetUserId(), role)); } + /// + /// Merges Persons into one, this action is irreversible + /// + /// + /// + [HttpPost("merge")] + public async Task> MergePeople(PersonMergeDto dto) + { + var dst = await _unitOfWork.PersonRepository.GetPersonById(dto.DestId, PersonIncludes.All); + if (dst == null) return BadRequest(); + + var src = await _unitOfWork.PersonRepository.GetPersonById(dto.SrcId, PersonIncludes.All); + if (src == null) return BadRequest(); + + await _personService.MergePeopleAsync(src, dst); + await _eventHub.SendMessageAsync(MessageFactory.PersonMerged, MessageFactory.PersonMergedMessage(dst, src)); + + return Ok(_mapper.Map(dst)); + } + + /// + /// Ensure the alias is valid to be added. For example, the alias cannot be on another person or be the same as the current person name/alias. + /// + /// + /// + /// + [HttpGet("valid-alias")] + public async Task> IsValidAlias(int personId, string alias) + { + var person = await _unitOfWork.PersonRepository.GetPersonById(personId, PersonIncludes.Aliases); + if (person == null) return NotFound(); + + var existingAlias = await _unitOfWork.PersonRepository.AnyAliasExist(alias); + return Ok(!existingAlias && person.NormalizedName != alias.ToNormalized()); + } + } diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 6c9be6c75..1187992bc 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -4,7 +4,7 @@ using System.Threading.Tasks; using API.Constants; using API.Data; using API.Data.Repositories; -using API.DTOs; +using API.DTOs.Person; using API.DTOs.ReadingLists; using API.Entities.Enums; using API.Extensions; diff --git a/API/Controllers/SearchController.cs b/API/Controllers/SearchController.cs index 5aa54d1db..cc89a124e 100644 --- a/API/Controllers/SearchController.cs +++ b/API/Controllers/SearchController.cs @@ -63,6 +63,7 @@ public class SearchController : BaseApiController var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); if (user == null) return Unauthorized(); + var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList(); if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(User.GetUserId(), "libraries-restricted")); diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 70fb12e85..85624b51c 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using API.DTOs.Metadata; +using API.DTOs.Person; using API.Entities.Enums; using API.Entities.Interfaces; diff --git a/API/DTOs/Metadata/ChapterMetadataDto.cs b/API/DTOs/Metadata/ChapterMetadataDto.cs index 1adc52cd1..c79436e24 100644 --- a/API/DTOs/Metadata/ChapterMetadataDto.cs +++ b/API/DTOs/Metadata/ChapterMetadataDto.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using API.DTOs.Person; using API.Entities.Enums; namespace API.DTOs.Metadata; diff --git a/API/DTOs/Person/BrowsePersonDto.cs b/API/DTOs/Person/BrowsePersonDto.cs index 8d6999973..c7d318e79 100644 --- a/API/DTOs/Person/BrowsePersonDto.cs +++ b/API/DTOs/Person/BrowsePersonDto.cs @@ -1,4 +1,6 @@ -namespace API.DTOs; +using API.DTOs.Person; + +namespace API.DTOs; /// /// Used to browse writers and click in to see their series diff --git a/API/DTOs/Person/PersonDto.cs b/API/DTOs/Person/PersonDto.cs index 511317f2a..db152e3b1 100644 --- a/API/DTOs/Person/PersonDto.cs +++ b/API/DTOs/Person/PersonDto.cs @@ -1,6 +1,6 @@ -using System.Runtime.Serialization; +using System.Collections.Generic; -namespace API.DTOs; +namespace API.DTOs.Person; #nullable enable public class PersonDto @@ -13,6 +13,7 @@ public class PersonDto public string? SecondaryColor { get; set; } public string? CoverImage { get; set; } + public List Aliases { get; set; } = []; public string? Description { get; set; } /// diff --git a/API/DTOs/Person/PersonMergeDto.cs b/API/DTOs/Person/PersonMergeDto.cs new file mode 100644 index 000000000..b5dc23375 --- /dev/null +++ b/API/DTOs/Person/PersonMergeDto.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs; + +public sealed record PersonMergeDto +{ + /// + /// The id of the person being merged into + /// + [Required] + public int DestId { get; init; } + /// + /// The id of the person being merged. This person will be removed, and become an alias of + /// + [Required] + public int SrcId { get; init; } +} diff --git a/API/DTOs/Person/UpdatePersonDto.cs b/API/DTOs/Person/UpdatePersonDto.cs index 29190151f..b43a45e88 100644 --- a/API/DTOs/Person/UpdatePersonDto.cs +++ b/API/DTOs/Person/UpdatePersonDto.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; namespace API.DTOs; #nullable enable @@ -11,6 +12,7 @@ public sealed record UpdatePersonDto public bool CoverImageLocked { get; set; } [Required] public string Name {get; set;} + public IList Aliases { get; set; } = []; public string? Description { get; set; } public int? AniListId { get; set; } diff --git a/API/DTOs/ReadingLists/ReadingListCast.cs b/API/DTOs/ReadingLists/ReadingListCast.cs index 8f2587426..855bb12b7 100644 --- a/API/DTOs/ReadingLists/ReadingListCast.cs +++ b/API/DTOs/ReadingLists/ReadingListCast.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using API.DTOs.Person; namespace API.DTOs.ReadingLists; diff --git a/API/DTOs/Search/SearchResultGroupDto.cs b/API/DTOs/Search/SearchResultGroupDto.cs index 20a53f853..11c4bdc08 100644 --- a/API/DTOs/Search/SearchResultGroupDto.cs +++ b/API/DTOs/Search/SearchResultGroupDto.cs @@ -2,6 +2,7 @@ using API.DTOs.Collection; using API.DTOs.CollectionTags; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.Reader; using API.DTOs.ReadingLists; diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs index 701034d80..fa745148e 100644 --- a/API/DTOs/SeriesMetadataDto.cs +++ b/API/DTOs/SeriesMetadataDto.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using API.DTOs.Metadata; +using API.DTOs.Person; using API.Entities.Enums; namespace API.DTOs; diff --git a/API/DTOs/UpdateChapterDto.cs b/API/DTOs/UpdateChapterDto.cs index ec2f1cf62..9ead8adc8 100644 --- a/API/DTOs/UpdateChapterDto.cs +++ b/API/DTOs/UpdateChapterDto.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using API.DTOs.Metadata; +using API.DTOs.Person; using API.Entities.Enums; namespace API.DTOs; diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 714e29fdf..ce35ba7ec 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -49,6 +49,7 @@ public sealed class DataContext : IdentityDbContext ReadingList { get; set; } = null!; public DbSet ReadingListItem { get; set; } = null!; public DbSet Person { get; set; } = null!; + public DbSet PersonAlias { get; set; } = null!; public DbSet Genre { get; set; } = null!; public DbSet Tag { get; set; } = null!; public DbSet SiteTheme { get; set; } = null!; diff --git a/API/Data/Migrations/20250507221026_PersonAliases.Designer.cs b/API/Data/Migrations/20250507221026_PersonAliases.Designer.cs new file mode 100644 index 000000000..5d76571e1 --- /dev/null +++ b/API/Data/Migrations/20250507221026_PersonAliases.Designer.cs @@ -0,0 +1,3571 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250507221026_PersonAliases")] + partial class PersonAliases + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250507221026_PersonAliases.cs b/API/Data/Migrations/20250507221026_PersonAliases.cs new file mode 100644 index 000000000..cb046a131 --- /dev/null +++ b/API/Data/Migrations/20250507221026_PersonAliases.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PersonAliases : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PersonAlias", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Alias = table.Column(type: "TEXT", nullable: true), + NormalizedAlias = table.Column(type: "TEXT", nullable: true), + PersonId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PersonAlias", x => x.Id); + table.ForeignKey( + name: "FK_PersonAlias_Person_PersonId", + column: x => x.PersonId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PersonAlias_PersonId", + table: "PersonAlias", + column: "PersonId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PersonAlias"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index a66568dcc..bdeb3d7c4 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -1836,6 +1836,28 @@ namespace API.Data.Migrations b.ToTable("Person"); }); + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => { b.Property("SeriesMetadataId") @@ -3082,6 +3104,17 @@ namespace API.Data.Migrations b.Navigation("Person"); }); + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => { b.HasOne("API.Entities.Person.Person", "Person") @@ -3496,6 +3529,8 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.Person.Person", b => { + b.Navigation("Aliases"); + b.Navigation("ChapterPeople"); b.Navigation("SeriesMetadataPeople"); diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index db66ecd79..dce3f86ef 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -1,7 +1,9 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.DTOs; +using API.DTOs.Person; using API.Entities.Enums; using API.Entities.Person; using API.Extensions; @@ -14,6 +16,17 @@ using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories; #nullable enable +[Flags] +public enum PersonIncludes +{ + None = 1 << 0, + Aliases = 1 << 1, + ChapterPeople = 1 << 2, + SeriesPeople = 1 << 3, + + All = Aliases | ChapterPeople | SeriesPeople, +} + public interface IPersonRepository { void Attach(Person person); @@ -23,24 +36,41 @@ public interface IPersonRepository void Remove(SeriesMetadataPeople person); void Update(Person person); - Task> GetAllPeople(); - Task> GetAllPersonDtosAsync(int userId); - Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role); + Task> GetAllPeople(PersonIncludes includes = PersonIncludes.Aliases); + Task> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.None); + Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.None); Task RemoveAllPeopleNoLongerAssociated(); - Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null); + Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null, PersonIncludes includes = PersonIncludes.None); Task GetCoverImageAsync(int personId); Task GetCoverImageByNameAsync(string name); Task> GetRolesForPersonByName(int personId, int userId); Task> GetAllWritersAndSeriesCount(int userId, UserParams userParams); - Task GetPersonById(int personId); - Task GetPersonDtoByName(string name, int userId); + Task GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None); + Task GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases); + /// + /// Returns a person matched on normalized name or alias + /// + /// + /// + /// + Task GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases); Task IsNameUnique(string name); Task> GetSeriesKnownFor(int personId); Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role); - Task> GetPeopleByNames(List normalizedNames); - Task GetPersonByAniListId(int aniListId); + /// + /// Returns all people with a matching name, or alias + /// + /// + /// + /// + Task> GetPeopleByNames(List normalizedNames, PersonIncludes includes = PersonIncludes.Aliases); + Task GetPersonByAniListId(int aniListId, PersonIncludes includes = PersonIncludes.Aliases); + + Task> SearchPeople(string searchQuery, PersonIncludes includes = PersonIncludes.Aliases); + + Task AnyAliasExist(string alias); } public class PersonRepository : IPersonRepository @@ -99,7 +129,7 @@ public class PersonRepository : IPersonRepository } - public async Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null) + public async Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null, PersonIncludes includes = PersonIncludes.Aliases) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); var userLibs = await _context.Library.GetUserLibraries(userId).ToListAsync(); @@ -113,6 +143,7 @@ public class PersonRepository : IPersonRepository .Where(s => userLibs.Contains(s.LibraryId)) .RestrictAgainstAgeRestriction(ageRating) .SelectMany(s => s.Metadata.People.Select(p => p.Person)) + .Includes(includes) .Distinct() .OrderBy(p => p.Name) .AsNoTracking() @@ -193,27 +224,41 @@ public class PersonRepository : IPersonRepository return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); } - public async Task GetPersonById(int personId) + public async Task GetPersonById(int personId, PersonIncludes includes = PersonIncludes.None) { return await _context.Person.Where(p => p.Id == personId) + .Includes(includes) .FirstOrDefaultAsync(); } - public async Task GetPersonDtoByName(string name, int userId) + public async Task GetPersonDtoByName(string name, int userId, PersonIncludes includes = PersonIncludes.Aliases) { var normalized = name.ToNormalized(); var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Person .Where(p => p.NormalizedName == normalized) + .Includes(includes) .RestrictAgainstAgeRestriction(ageRating) .ProjectTo(_mapper.ConfigurationProvider) .FirstOrDefaultAsync(); } + public Task GetPersonByNameOrAliasAsync(string name, PersonIncludes includes = PersonIncludes.Aliases) + { + var normalized = name.ToNormalized(); + return _context.Person + .Includes(includes) + .Where(p => p.NormalizedName == normalized || p.Aliases.Any(pa => pa.NormalizedAlias == normalized)) + .FirstOrDefaultAsync(); + } + public async Task IsNameUnique(string name) { - return !(await _context.Person.AnyAsync(p => p.Name == name)); + // Should this use Normalized to check? + return !(await _context.Person + .Includes(PersonIncludes.Aliases) + .AnyAsync(p => p.Name == name || p.Aliases.Any(pa => pa.Alias == name))); } public async Task> GetSeriesKnownFor(int personId) @@ -245,45 +290,69 @@ public class PersonRepository : IPersonRepository .ToListAsync(); } - public async Task> GetPeopleByNames(List normalizedNames) + public async Task> GetPeopleByNames(List normalizedNames, PersonIncludes includes = PersonIncludes.Aliases) { return await _context.Person - .Where(p => normalizedNames.Contains(p.NormalizedName)) + .Includes(includes) + .Where(p => normalizedNames.Contains(p.NormalizedName) || p.Aliases.Any(pa => normalizedNames.Contains(pa.NormalizedAlias))) .OrderBy(p => p.Name) .ToListAsync(); } - public async Task GetPersonByAniListId(int aniListId) + public async Task GetPersonByAniListId(int aniListId, PersonIncludes includes = PersonIncludes.Aliases) { return await _context.Person .Where(p => p.AniListId == aniListId) + .Includes(includes) .FirstOrDefaultAsync(); } - public async Task> GetAllPeople() + public async Task> SearchPeople(string searchQuery, PersonIncludes includes = PersonIncludes.Aliases) + { + searchQuery = searchQuery.ToNormalized(); + + return await _context.Person + .Includes(includes) + .Where(p => EF.Functions.Like(p.Name, $"%{searchQuery}%") + || p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%"))) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + + public async Task AnyAliasExist(string alias) + { + return await _context.PersonAlias.AnyAsync(pa => pa.NormalizedAlias == alias.ToNormalized()); + } + + + public async Task> GetAllPeople(PersonIncludes includes = PersonIncludes.Aliases) { return await _context.Person + .Includes(includes) .OrderBy(p => p.Name) .ToListAsync(); } - public async Task> GetAllPersonDtosAsync(int userId) + public async Task> GetAllPersonDtosAsync(int userId, PersonIncludes includes = PersonIncludes.Aliases) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); return await _context.Person + .Includes(includes) .OrderBy(p => p.Name) .RestrictAgainstAgeRestriction(ageRating) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } - public async Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role) + public async Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role, PersonIncludes includes = PersonIncludes.Aliases) { var ageRating = await _context.AppUser.GetUserAgeRestriction(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) .ProjectTo(_mapper.ConfigurationProvider) diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 6d4a14bd9..6992b2950 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data.Misc; -using API.DTOs; +using API.DTOs.Person; using API.DTOs.ReadingLists; using API.Entities; using API.Entities.Enums; diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index d9c78c770..e04c944e3 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -15,6 +15,7 @@ using API.DTOs.Filtering; using API.DTOs.Filtering.v2; using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.ReadingLists; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; @@ -455,11 +456,18 @@ public class SeriesRepository : ISeriesRepository .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Persons = await _context.SeriesMetadata + // I can't work out how to map people in DB layer + var personIds = await _context.SeriesMetadata .SearchPeople(searchQuery, seriesIds) - .Take(maxRecords) - .OrderBy(t => t.NormalizedName) + .Select(p => p.Id) .Distinct() + .OrderBy(id => id) + .Take(maxRecords) + .ToListAsync(); + + result.Persons = await _context.Person + .Where(p => personIds.Contains(p.Id)) + .OrderBy(p => p.NormalizedName) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -475,8 +483,8 @@ public class SeriesRepository : ISeriesRepository .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Files = new List(); - result.Chapters = new List(); + result.Files = []; + result.Chapters = (List) []; if (includeChapterAndFiles) diff --git a/API/Entities/Person/Person.cs b/API/Entities/Person/Person.cs index 8eed08f5c..ed57fd6d3 100644 --- a/API/Entities/Person/Person.cs +++ b/API/Entities/Person/Person.cs @@ -8,8 +8,7 @@ public class Person : IHasCoverImage public int Id { get; set; } public required string Name { get; set; } public required string NormalizedName { get; set; } - - //public ICollection Aliases { get; set; } = default!; + public ICollection Aliases { get; set; } = []; public string? CoverImage { get; set; } public bool CoverImageLocked { get; set; } @@ -47,8 +46,8 @@ public class Person : IHasCoverImage //public long MetronId { get; set; } = 0; // Relationships - public ICollection ChapterPeople { get; set; } = new List(); - public ICollection SeriesMetadataPeople { get; set; } = new List(); + public ICollection ChapterPeople { get; set; } = []; + public ICollection SeriesMetadataPeople { get; set; } = []; public void ResetColorScape() diff --git a/API/Entities/Person/PersonAlias.cs b/API/Entities/Person/PersonAlias.cs new file mode 100644 index 000000000..f053f608d --- /dev/null +++ b/API/Entities/Person/PersonAlias.cs @@ -0,0 +1,11 @@ +namespace API.Entities.Person; + +public class PersonAlias +{ + public int Id { get; set; } + public required string Alias { get; set; } + public required string NormalizedAlias { get; set; } + + public int PersonId { get; set; } + public Person Person { get; set; } +} diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index e004fcc25..e95c4f65e 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -53,6 +53,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs index cc40491d0..d7acf9381 100644 --- a/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using API.Data.Misc; using API.Data.Repositories; @@ -49,23 +50,26 @@ public static class SearchQueryableExtensions // Get people from SeriesMetadata var peopleFromSeriesMetadata = queryable .Where(sm => seriesIds.Contains(sm.SeriesId)) - .SelectMany(sm => sm.People) - .Where(p => p.Person.Name != null && EF.Functions.Like(p.Person.Name, $"%{searchQuery}%")) - .Select(p => p.Person); + .SelectMany(sm => sm.People.Select(sp => sp.Person)) + .Where(p => + EF.Functions.Like(p.Name, $"%{searchQuery}%") || + p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%")) + ); - // Get people from ChapterPeople by navigating through Volume -> Series var peopleFromChapterPeople = queryable .Where(sm => seriesIds.Contains(sm.SeriesId)) .SelectMany(sm => sm.Series.Volumes) .SelectMany(v => v.Chapters) - .SelectMany(ch => ch.People) - .Where(cp => cp.Person.Name != null && EF.Functions.Like(cp.Person.Name, $"%{searchQuery}%")) - .Select(cp => cp.Person); + .SelectMany(ch => ch.People.Select(cp => cp.Person)) + .Where(p => + EF.Functions.Like(p.Name, $"%{searchQuery}%") || + p.Aliases.Any(pa => EF.Functions.Like(pa.Alias, $"%{searchQuery}%")) + ); // Combine both queries and ensure distinct results return peopleFromSeriesMetadata .Union(peopleFromChapterPeople) - .Distinct() + .Select(p => p) .OrderBy(p => p.NormalizedName); } diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index 864c4e5a1..bfc585455 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -1,7 +1,7 @@ using System.Linq; using API.Data.Repositories; using API.Entities; -using API.Entities.Metadata; +using API.Entities.Person; using Microsoft.EntityFrameworkCore; namespace API.Extensions.QueryExtensions; @@ -321,4 +321,25 @@ public static class IncludesExtensions return query.AsSplitQuery(); } + + public static IQueryable Includes(this IQueryable queryable, PersonIncludes includeFlags) + { + + if (includeFlags.HasFlag(PersonIncludes.Aliases)) + { + queryable = queryable.Include(p => p.Aliases); + } + + if (includeFlags.HasFlag(PersonIncludes.ChapterPeople)) + { + queryable = queryable.Include(p => p.ChapterPeople); + } + + if (includeFlags.HasFlag(PersonIncludes.SeriesPeople)) + { + queryable = queryable.Include(p => p.SeriesMetadataPeople); + } + + return queryable; + } } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 334403ab3..75183fdcd 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -15,6 +15,7 @@ using API.DTOs.KavitaPlus.Manage; using API.DTOs.KavitaPlus.Metadata; using API.DTOs.MediaErrors; using API.DTOs.Metadata; +using API.DTOs.Person; using API.DTOs.Progress; using API.DTOs.Reader; using API.DTOs.ReadingLists; @@ -68,7 +69,8 @@ public class AutoMapperProfiles : Profile CreateMap() .ForMember(dest => dest.Owner, opt => opt.MapFrom(src => src.AppUser.UserName)) .ForMember(dest => dest.ItemCount, opt => opt.MapFrom(src => src.Items.Count)); - CreateMap(); + CreateMap() + .ForMember(dest => dest.Aliases, opt => opt.MapFrom(src => src.Aliases.Select(s => s.Alias))); CreateMap(); CreateMap(); CreateMap(); diff --git a/API/Helpers/Builders/PersonAliasBuilder.cs b/API/Helpers/Builders/PersonAliasBuilder.cs new file mode 100644 index 000000000..e54ea8975 --- /dev/null +++ b/API/Helpers/Builders/PersonAliasBuilder.cs @@ -0,0 +1,19 @@ +using API.Entities.Person; +using API.Extensions; + +namespace API.Helpers.Builders; + +public class PersonAliasBuilder : IEntityBuilder +{ + private readonly PersonAlias _alias; + public PersonAlias Build() => _alias; + + public PersonAliasBuilder(string name) + { + _alias = new PersonAlias() + { + Alias = name.Trim(), + NormalizedAlias = name.ToNormalized(), + }; + } +} diff --git a/API/Helpers/Builders/PersonBuilder.cs b/API/Helpers/Builders/PersonBuilder.cs index 492d79e17..afd0c84af 100644 --- a/API/Helpers/Builders/PersonBuilder.cs +++ b/API/Helpers/Builders/PersonBuilder.cs @@ -1,7 +1,5 @@ using System.Collections.Generic; -using API.Entities; -using API.Entities.Enums; -using API.Entities.Metadata; +using System.Linq; using API.Entities.Person; using API.Extensions; @@ -34,6 +32,20 @@ public class PersonBuilder : IEntityBuilder return this; } + public PersonBuilder WithAlias(string alias) + { + if (_person.Aliases.Any(a => a.NormalizedAlias.Equals(alias.ToNormalized()))) + { + return this; + } + + _person.Aliases.Add(new PersonAliasBuilder(alias).Build()); + + return this; + } + + + public PersonBuilder WithSeriesMetadata(SeriesMetadataPeople seriesMetadataPeople) { _person.SeriesMetadataPeople.Add(seriesMetadataPeople); diff --git a/API/Helpers/PersonHelper.cs b/API/Helpers/PersonHelper.cs index 07161e418..b71ff2c1a 100644 --- a/API/Helpers/PersonHelper.cs +++ b/API/Helpers/PersonHelper.cs @@ -17,6 +17,20 @@ namespace API.Helpers; public static class PersonHelper { + public static Dictionary ConstructNameAndAliasDictionary(IList people) + { + var dict = new Dictionary(); + foreach (var person in people) + { + dict.TryAdd(person.NormalizedName, person); + foreach (var alias in person.Aliases) + { + dict.TryAdd(alias.NormalizedAlias, person); + } + } + return dict; + } + public static async Task UpdateSeriesMetadataPeopleAsync(SeriesMetadata metadata, ICollection metadataPeople, IEnumerable chapterPeople, PersonRole role, IUnitOfWork unitOfWork) { @@ -38,7 +52,9 @@ public static class PersonHelper // Identify people to remove from metadataPeople var peopleToRemove = existingMetadataPeople - .Where(person => !peopleToAddSet.Contains(person.Person.NormalizedName)) + .Where(person => + !peopleToAddSet.Contains(person.Person.NormalizedName) && + !person.Person.Aliases.Any(pa => peopleToAddSet.Contains(pa.NormalizedAlias))) .ToList(); // Remove identified people from metadataPeople @@ -53,11 +69,7 @@ public static class PersonHelper .GetPeopleByNames(peopleToAdd.Select(p => p.NormalizedName).ToList()); // Prepare a dictionary for quick lookup of existing people by normalized name - var existingPeopleDict = new Dictionary(); - foreach (var person in existingPeopleInDb) - { - existingPeopleDict.TryAdd(person.NormalizedName, person); - } + var existingPeopleDict = ConstructNameAndAliasDictionary(existingPeopleInDb); // Track the people to attach (newly created people) var peopleToAttach = new List(); @@ -129,15 +141,12 @@ public static class PersonHelper var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedPeople); // Prepare a dictionary for quick lookup by normalized name - var existingPeopleDict = new Dictionary(); - foreach (var person in existingPeople) - { - existingPeopleDict.TryAdd(person.NormalizedName, person); - } + var existingPeopleDict = ConstructNameAndAliasDictionary(existingPeople); // Identify people to remove (those present in ChapterPeople but not in the new list) - foreach (var existingChapterPerson in existingChapterPeople - .Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName))) + var toRemove = existingChapterPeople + .Where(existingChapterPerson => !normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName)); + foreach (var existingChapterPerson in toRemove) { chapter.People.Remove(existingChapterPerson); unitOfWork.PersonRepository.Remove(existingChapterPerson); diff --git a/API/I18N/en.json b/API/I18N/en.json index 6e37a3cd9..5916bc63e 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -212,6 +212,7 @@ "user-no-access-library-from-series": "User does not have access to the library this series belongs to", "series-restricted-age-restriction": "User is not allowed to view this series due to age restrictions", "kavitaplus-restricted": "This is restricted to Kavita+ only", + "aliases-have-overlap": "One or more of the aliases have overlap with other people, cannot update", "volume-num": "Volume {0}", "book-num": "Book {0}", diff --git a/API/Services/PersonService.cs b/API/Services/PersonService.cs new file mode 100644 index 000000000..ff0049cbe --- /dev/null +++ b/API/Services/PersonService.cs @@ -0,0 +1,147 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Data; +using API.Entities.Person; +using API.Extensions; +using API.Helpers.Builders; + +namespace API.Services; + +public interface IPersonService +{ + /// + /// Adds src as an alias to dst, this is a destructive operation + /// + /// Merged person + /// Remaining person + /// The entities passed as arguments **must** include all relations + /// + Task MergePeopleAsync(Person src, Person dst); + + /// + /// Adds the alias to the person, requires that the aliases are not shared with anyone else + /// + /// This method does NOT commit changes + /// + /// + /// + Task UpdatePersonAliasesAsync(Person person, IList aliases); +} + +public class PersonService(IUnitOfWork unitOfWork): IPersonService +{ + + public async Task MergePeopleAsync(Person src, Person dst) + { + if (dst.Id == src.Id) return; + + if (string.IsNullOrWhiteSpace(dst.Description) && !string.IsNullOrWhiteSpace(src.Description)) + { + dst.Description = src.Description; + } + + if (dst.MalId == 0 && src.MalId != 0) + { + dst.MalId = src.MalId; + } + + if (dst.AniListId == 0 && src.AniListId != 0) + { + dst.AniListId = src.AniListId; + } + + if (dst.HardcoverId == null && src.HardcoverId != null) + { + dst.HardcoverId = src.HardcoverId; + } + + if (dst.Asin == null && src.Asin != null) + { + dst.Asin = src.Asin; + } + + if (dst.CoverImage == null && src.CoverImage != null) + { + dst.CoverImage = src.CoverImage; + } + + MergeChapterPeople(dst, src); + MergeSeriesMetadataPeople(dst, src); + + dst.Aliases.Add(new PersonAliasBuilder(src.Name).Build()); + + foreach (var alias in src.Aliases) + { + dst.Aliases.Add(alias); + } + + unitOfWork.PersonRepository.Remove(src); + unitOfWork.PersonRepository.Update(dst); + await unitOfWork.CommitAsync(); + } + + private static void MergeChapterPeople(Person dst, Person src) + { + + foreach (var chapter in src.ChapterPeople) + { + var alreadyPresent = dst.ChapterPeople + .Any(x => x.ChapterId == chapter.ChapterId && x.Role == chapter.Role); + + if (alreadyPresent) continue; + + dst.ChapterPeople.Add(new ChapterPeople + { + Role = chapter.Role, + ChapterId = chapter.ChapterId, + Person = dst, + KavitaPlusConnection = chapter.KavitaPlusConnection, + OrderWeight = chapter.OrderWeight, + }); + } + } + + private static void MergeSeriesMetadataPeople(Person dst, Person src) + { + foreach (var series in src.SeriesMetadataPeople) + { + var alreadyPresent = dst.SeriesMetadataPeople + .Any(x => x.SeriesMetadataId == series.SeriesMetadataId && x.Role == series.Role); + + if (alreadyPresent) continue; + + dst.SeriesMetadataPeople.Add(new SeriesMetadataPeople + { + SeriesMetadataId = series.SeriesMetadataId, + Role = series.Role, + Person = dst, + KavitaPlusConnection = series.KavitaPlusConnection, + OrderWeight = series.OrderWeight, + }); + } + } + + public async Task UpdatePersonAliasesAsync(Person person, IList aliases) + { + var normalizedAliases = aliases + .Select(a => a.ToNormalized()) + .Where(a => !string.IsNullOrEmpty(a) && a != person.NormalizedName) + .ToList(); + + if (normalizedAliases.Count == 0) + { + person.Aliases = []; + return true; + } + + var others = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedAliases); + others = others.Where(p => p.Id != person.Id).ToList(); + + if (others.Count != 0) return false; + + person.Aliases = aliases.Select(a => new PersonAliasBuilder(a).Build()).ToList(); + + return true; + } +} diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index f9af923a2..a0c88b16d 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -10,6 +10,7 @@ using API.DTOs.Collection; using API.DTOs.KavitaPlus.ExternalMetadata; using API.DTOs.KavitaPlus.Metadata; using API.DTOs.Metadata.Matching; +using API.DTOs.Person; using API.DTOs.Recommendation; using API.DTOs.Scrobbling; using API.DTOs.SeriesDetail; @@ -17,8 +18,10 @@ using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; using API.Entities.MetadataMatching; +using API.Entities.Person; using API.Extensions; using API.Helpers; +using API.Helpers.Builders; using API.Services.Tasks.Metadata; using API.Services.Tasks.Scanner.Parser; using API.SignalR; @@ -614,12 +617,8 @@ public class ExternalMetadataService : IExternalMetadataService madeModification = await UpdateTags(series, settings, externalMetadata, processedTags) || madeModification; madeModification = UpdateAgeRating(series, settings, processedGenres.Concat(processedTags)) || madeModification; - var staff = (externalMetadata.Staff ?? []).Select(s => - { - s.Name = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}"; + var staff = await SetNameAndAddAliases(settings, externalMetadata.Staff); - return s; - }).ToList(); madeModification = await UpdateWriters(series, settings, staff) || madeModification; madeModification = await UpdateArtists(series, settings, staff) || madeModification; madeModification = await UpdateCharacters(series, settings, externalMetadata.Characters) || madeModification; @@ -632,6 +631,49 @@ public class ExternalMetadataService : IExternalMetadataService return madeModification; } + private async Task> SetNameAndAddAliases(MetadataSettingsDto settings, IList? staff) + { + if (staff == null || staff.Count == 0) return []; + + var nameMappings = staff.Select(s => new + { + Staff = s, + PreferredName = settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}", + AlternativeName = !settings.FirstLastPeopleNaming ? $"{s.FirstName} {s.LastName}" : $"{s.LastName} {s.FirstName}" + }).ToList(); + + var preferredNames = nameMappings.Select(n => n.PreferredName.ToNormalized()).Distinct().ToList(); + var alternativeNames = nameMappings.Select(n => n.AlternativeName.ToNormalized()).Distinct().ToList(); + + var existingPeople = await _unitOfWork.PersonRepository.GetPeopleByNames(preferredNames.Union(alternativeNames).ToList()); + var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople); + + var modified = false; + foreach (var mapping in nameMappings) + { + mapping.Staff.Name = mapping.PreferredName; + + if (existingPeopleDictionary.ContainsKey(mapping.PreferredName.ToNormalized())) + { + continue; + } + + + if (existingPeopleDictionary.TryGetValue(mapping.AlternativeName.ToNormalized(), out var person)) + { + modified = true; + person.Aliases.Add(new PersonAliasBuilder(mapping.PreferredName).Build()); + } + } + + if (modified) + { + await _unitOfWork.CommitAsync(); + } + + return [.. staff]; + } + private static void GenerateGenreAndTagLists(ExternalSeriesDetailDto externalMetadata, MetadataSettingsDto settings, ref List processedTags, ref List processedGenres) { diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 805b3b06f..426a8de3f 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -7,6 +7,7 @@ using API.Comparators; using API.Data; using API.Data.Repositories; using API.DTOs; +using API.DTOs.Person; using API.DTOs.SeriesDetail; using API.Entities; using API.Entities.Enums; @@ -361,8 +362,7 @@ public class SeriesService : ISeriesService var existingPeople = await unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames); // Use a dictionary for quick lookups - var existingPeopleDictionary = existingPeople.DistinctBy(p => p.NormalizedName) - .ToDictionary(p => p.NormalizedName, p => p); + var existingPeopleDictionary = PersonHelper.ConstructNameAndAliasDictionary(existingPeople); // List to track people that will be added to the metadata var peopleToAdd = new List(); diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index cebf08b97..d58b225a5 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -579,7 +579,7 @@ public class CoverDbService : ICoverDbService else { _directoryService.DeleteFiles([tempFullPath]); - series.CoverImage = Path.GetFileName(existingPath); + return; } } catch (Exception ex) diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index de9818b79..ba967d8a6 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -1,5 +1,6 @@ using System; using API.DTOs.Update; +using API.Entities.Person; using API.Extensions; using API.Services.Plus; @@ -147,6 +148,10 @@ public static class MessageFactory /// Volume is removed from server /// public const string VolumeRemoved = "VolumeRemoved"; + /// + /// A Person merged has been merged into another + /// + public const string PersonMerged = "PersonMerged"; public static SignalRMessage DashboardUpdateEvent(int userId) { @@ -661,4 +666,17 @@ public static class MessageFactory EventType = ProgressEventType.Single, }; } + + public static SignalRMessage PersonMergedMessage(Person dst, Person src) + { + return new SignalRMessage() + { + Name = PersonMerged, + Body = new + { + srcId = src.Id, + dstName = dst.Name, + }, + }; + } } diff --git a/UI/Web/src/_series-detail-common.scss b/UI/Web/src/_series-detail-common.scss index f043dec17..efb54f860 100644 --- a/UI/Web/src/_series-detail-common.scss +++ b/UI/Web/src/_series-detail-common.scss @@ -13,7 +13,7 @@ } .subtitle { - color: lightgrey; + color: var(--detail-subtitle-color); font-weight: bold; font-size: 0.8rem; } diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index 74cabc658..06ba86cf2 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -6,6 +6,9 @@ export enum LibraryType { Book = 2, Images = 3, LightNovel = 4, + /** + * Comic (Legacy) + */ ComicVine = 5 } diff --git a/UI/Web/src/app/_models/metadata/person.ts b/UI/Web/src/app/_models/metadata/person.ts index c8a4c566e..6b098de19 100644 --- a/UI/Web/src/app/_models/metadata/person.ts +++ b/UI/Web/src/app/_models/metadata/person.ts @@ -22,6 +22,7 @@ export interface Person extends IHasCover { id: number; name: string; description: string; + aliases: Array; coverImage?: string; coverImageLocked: boolean; malId?: number; diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 6d2f7053e..0fef35b0e 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -116,7 +116,11 @@ export enum Action { /** * Match an entity with an upstream system */ - Match = 28 + Match = 28, + /** + * Merge two (or more?) entities + */ + Merge = 29, } /** @@ -819,6 +823,14 @@ export class ActionFactoryService { callback: this.dummyCallback, requiresAdmin: true, children: [], + }, + { + action: Action.Merge, + title: 'merge', + description: 'merge-person-tooltip', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], } ]; diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index ea1819bd7..67f07f32e 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -109,7 +109,11 @@ export enum EVENTS { /** * A Progress event when a smart collection is synchronizing */ - SmartCollectionSync = 'SmartCollectionSync' + SmartCollectionSync = 'SmartCollectionSync', + /** + * A Person merged has been merged into another + */ + PersonMerged = 'PersonMerged', } export interface Message { @@ -336,6 +340,13 @@ export class MessageHubService { payload: resp.body }); }); + + this.hubConnection.on(EVENTS.PersonMerged, resp => { + this.messagesSource.next({ + event: EVENTS.PersonMerged, + payload: resp.body + }); + }) } stopHubConnection() { diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts index 676aa6e71..0ac58b178 100644 --- a/UI/Web/src/app/_services/person.service.ts +++ b/UI/Web/src/app/_services/person.service.ts @@ -1,14 +1,12 @@ -import { Injectable } from '@angular/core'; -import { HttpClient, HttpParams } from "@angular/common/http"; +import {Injectable} from '@angular/core'; +import {HttpClient, HttpParams} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {Person, PersonRole} from "../_models/metadata/person"; -import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; import {PaginatedResult} from "../_models/pagination"; import {Series} from "../_models/series"; import {map} from "rxjs/operators"; import {UtilityService} from "../shared/_services/utility.service"; import {BrowsePerson} from "../_models/person/browse-person"; -import {Chapter} from "../_models/chapter"; import {StandaloneChapter} from "../_models/standalone-chapter"; import {TextResonse} from "../_types/text-response"; @@ -29,6 +27,10 @@ export class PersonService { return this.httpClient.get(this.baseUrl + `person?name=${name}`); } + searchPerson(name: string) { + return this.httpClient.get>(this.baseUrl + `person/search?queryString=${encodeURIComponent(name)}`); + } + getRolesForPerson(personId: number) { return this.httpClient.get>(this.baseUrl + `person/roles?personId=${personId}`); } @@ -55,4 +57,15 @@ export class PersonService { downloadCover(personId: number) { return this.httpClient.post(this.baseUrl + 'person/fetch-cover?personId=' + personId, {}, TextResonse); } + + isValidAlias(personId: number, alias: string) { + return this.httpClient.get(this.baseUrl + `person/valid-alias?personId=${personId}&alias=${alias}`, TextResonse).pipe( + map(valid => valid + '' === 'true') + ); + } + + mergePerson(destId: number, srcId: number) { + return this.httpClient.post(this.baseUrl + 'person/merge', {destId, srcId}); + } + } diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts index 467452a9f..bda048341 100644 --- a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts @@ -483,7 +483,7 @@ export class EditChapterModalComponent implements OnInit { }; personSettings.addTransformFn = ((title: string) => { - return {id: 0, name: title, role: role, description: '', coverImage: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' }; + return {id: 0, name: title, aliases: [], role: role, description: '', coverImage: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' }; }); personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + ''); diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts index c00d65c2a..623b13ec5 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.ts @@ -521,7 +521,7 @@ export class EditSeriesModalComponent implements OnInit { }; personSettings.addTransformFn = ((title: string) => { - return {id: 0, name: title, description: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' }; + return {id: 0, name: title, aliases: [], description: '', coverImageLocked: false, primaryColor: '', secondaryColor: '' }; }); personSettings.trackByIdentityFn = (index, value) => value.name + (value.id + ''); 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 7c49b2934..9e0f26a0a 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 @@ -118,7 +118,14 @@ width="24px" [imageUrl]="imageService.getPersonImage(item.id)" [errorImage]="imageService.noPersonImage">
-
{{item.name}}
+
+ {{item.name}} +
+ @if (item.aliases.length > 0) { + + {{t('person-aka-status')}} + + }
@@ -206,7 +213,7 @@ } } - + diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.scss b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.scss index 296e9de45..19a2ef5c4 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.scss +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.scss @@ -138,3 +138,7 @@ } } } + +.small-text { + font-size: 0.8rem; +} diff --git a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html index 4782529b3..f6ae1c6ae 100644 --- a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html +++ b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html @@ -96,6 +96,19 @@ +
  • + {{t(TabID.Aliases)}} + +
    {{t('aliases-label')}}
    +
    {{t('aliases-tooltip')}}
    + +
    +
  • +
  • {{t(TabID.CoverImage)}} diff --git a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts index 7db41ce13..74a20e951 100644 --- a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts +++ b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts @@ -1,6 +1,14 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service"; -import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; +import { + AbstractControl, + AsyncValidatorFn, + FormControl, + FormGroup, + ReactiveFormsModule, + ValidationErrors, + Validators +} from "@angular/forms"; import {Person} from "../../../_models/metadata/person"; import { NgbActiveModal, @@ -14,14 +22,16 @@ import { import {PersonService} from "../../../_services/person.service"; import {translate, TranslocoDirective} from '@jsverse/transloco'; import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component"; -import {forkJoin} from "rxjs"; +import {forkJoin, map, of} from "rxjs"; import {UploadService} from "../../../_services/upload.service"; import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component"; import {AccountService} from "../../../_services/account.service"; import {ToastrService} from "ngx-toastr"; +import {EditListComponent} from "../../../shared/edit-list/edit-list.component"; enum TabID { General = 'general-tab', + Aliases = 'aliases-tab', CoverImage = 'cover-image-tab', } @@ -37,7 +47,8 @@ enum TabID { NgbNavOutlet, CoverImageChooserComponent, SettingItemComponent, - NgbNavLink + NgbNavLink, + EditListComponent ], templateUrl: './edit-person-modal.component.html', styleUrl: './edit-person-modal.component.scss', @@ -117,6 +128,7 @@ export class EditPersonModalComponent implements OnInit { // @ts-ignore malId: this.editForm.get('malId')!.value === '' ? null : parseInt(this.editForm.get('malId').value, 10), hardcoverId: this.editForm.get('hardcoverId')!.value || '', + aliases: this.person.aliases, }; apis.push(this.personService.updatePerson(person)); @@ -165,4 +177,21 @@ export class EditPersonModalComponent implements OnInit { }); } + aliasValidator(): AsyncValidatorFn { + return (control: AbstractControl) => { + const name = control.value; + if (!name || name.trim().length === 0) { + return of(null); + } + + return this.personService.isValidAlias(this.person.id, name).pipe(map(valid => { + if (valid) { + return null; + } + + return { 'invalidAlias': {'alias': name} } as ValidationErrors; + })); + } + } + } diff --git a/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.html b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.html new file mode 100644 index 000000000..2f4cb8b42 --- /dev/null +++ b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.html @@ -0,0 +1,65 @@ + + + + + + + + diff --git a/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.scss b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.ts b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.ts new file mode 100644 index 000000000..865db0590 --- /dev/null +++ b/UI/Web/src/app/person-detail/_modal/merge-person-modal/merge-person-modal.component.ts @@ -0,0 +1,101 @@ +import {Component, DestroyRef, EventEmitter, inject, Input, OnInit} from '@angular/core'; +import {Person} from "../../../_models/metadata/person"; +import {PersonService} from "../../../_services/person.service"; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {ToastrService} from "ngx-toastr"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {TypeaheadComponent} from "../../../typeahead/_components/typeahead.component"; +import {TypeaheadSettings} from "../../../typeahead/_models/typeahead-settings"; +import {map} from "rxjs/operators"; +import {UtilityService} from "../../../shared/_services/utility.service"; +import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component"; +import {BadgeExpanderComponent} from "../../../shared/badge-expander/badge-expander.component"; +import {FilterField} from "../../../_models/metadata/v2/filter-field"; +import {Observable, of} from "rxjs"; +import {Series} from "../../../_models/series"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {AsyncPipe} from "@angular/common"; + +@Component({ + selector: 'app-merge-person-modal', + imports: [ + TranslocoDirective, + TypeaheadComponent, + SettingItemComponent, + BadgeExpanderComponent, + AsyncPipe + ], + templateUrl: './merge-person-modal.component.html', + styleUrl: './merge-person-modal.component.scss' +}) +export class MergePersonModalComponent implements OnInit { + + private readonly personService = inject(PersonService); + public readonly utilityService = inject(UtilityService); + private readonly destroyRef = inject(DestroyRef); + private readonly modal = inject(NgbActiveModal); + protected readonly toastr = inject(ToastrService); + + typeAheadSettings!: TypeaheadSettings; + typeAheadUnfocus = new EventEmitter(); + + @Input({required: true}) person!: Person; + + mergee: Person | null = null; + knownFor$: Observable | null = null; + + save() { + if (!this.mergee) { + this.close(); + return; + } + + this.personService.mergePerson(this.person.id, this.mergee.id).subscribe(person => { + this.modal.close({success: true, person: person}); + }) + } + + close() { + this.modal.close({success: false, person: this.person}); + } + + ngOnInit(): void { + this.typeAheadSettings = new TypeaheadSettings(); + this.typeAheadSettings.minCharacters = 0; + this.typeAheadSettings.multiple = false; + this.typeAheadSettings.addIfNonExisting = false; + this.typeAheadSettings.id = "merge-person-modal-typeahead"; + this.typeAheadSettings.compareFn = (options: Person[], filter: string) => { + return options.filter(m => this.utilityService.filter(m.name, filter)); + } + this.typeAheadSettings.selectionCompareFn = (a: Person, b: Person) => { + return a.name == b.name; + } + this.typeAheadSettings.fetchFn = (filter: string) => { + if (filter.length == 0) return of([]); + + return this.personService.searchPerson(filter).pipe(map(people => { + return people.filter(p => this.utilityService.filter(p.name, filter) && p.id != this.person.id); + })); + }; + + this.typeAheadSettings.trackByIdentityFn = (index, value) => `${value.name}_${value.id}`; + } + + updatePerson(people: Person[]) { + if (people.length == 0) return; + + this.typeAheadUnfocus.emit(this.typeAheadSettings.id); + this.mergee = people[0]; + this.knownFor$ = this.personService.getSeriesMostKnownFor(this.mergee.id) + .pipe(takeUntilDestroyed(this.destroyRef)); + } + + protected readonly FilterField = FilterField; + + allNewAliases() { + if (!this.mergee) return []; + + return [this.mergee.name, ...this.mergee.aliases] + } +} diff --git a/UI/Web/src/app/person-detail/person-detail.component.html b/UI/Web/src/app/person-detail/person-detail.component.html index 507521642..1b99611e1 100644 --- a/UI/Web/src/app/person-detail/person-detail.component.html +++ b/UI/Web/src/app/person-detail/person-detail.component.html @@ -43,15 +43,43 @@
    - + + + + @if (person.aliases.length > 0) { + {{t('aka-title')}} +
    + + + {{item}} + + +
    + } + @if (roles$ | async; as roles) { -
    -
    {{t('all-roles')}}
    - @for(role of roles; track role) { - {{role | personRole}} - } -
    + @if (roles.length > 0) { + {{t('all-roles')}} +
    + + + {{item | personRole}} + + +
    + } + + + + + + + + + }
    diff --git a/UI/Web/src/app/person-detail/person-detail.component.ts b/UI/Web/src/app/person-detail/person-detail.component.ts index e2dc192cb..2a3f1d63c 100644 --- a/UI/Web/src/app/person-detail/person-detail.component.ts +++ b/UI/Web/src/app/person-detail/person-detail.component.ts @@ -1,31 +1,31 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, - Component, DestroyRef, + Component, + DestroyRef, ElementRef, - Inject, - inject, OnInit, + inject, + OnInit, ViewChild } from '@angular/core'; import {ActivatedRoute, Router} from "@angular/router"; import {PersonService} from "../_services/person.service"; import {BehaviorSubject, EMPTY, Observable, switchMap, tap} from "rxjs"; import {Person, PersonRole} from "../_models/metadata/person"; -import {AsyncPipe, NgStyle} from "@angular/common"; +import {AsyncPipe} from "@angular/common"; import {ImageComponent} from "../shared/image/image.component"; import {ImageService} from "../_services/image.service"; import { SideNavCompanionBarComponent } from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; import {ReadMoreComponent} from "../shared/read-more/read-more.component"; -import {TagBadgeComponent, TagBadgeCursor} from "../shared/tag-badge/tag-badge.component"; +import {TagBadgeCursor} from "../shared/tag-badge/tag-badge.component"; import {PersonRolePipe} from "../_pipes/person-role.pipe"; import {CarouselReelComponent} from "../carousel/_components/carousel-reel/carousel-reel.component"; -import {SeriesCardComponent} from "../cards/series-card/series-card.component"; import {FilterComparison} from "../_models/metadata/v2/filter-comparison"; import {FilterUtilitiesService} from "../shared/_services/filter-utilities.service"; import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2"; -import {allPeople, personRoleForFilterField} from "../_models/metadata/v2/filter-field"; +import {allPeople, FilterField, personRoleForFilterField} from "../_models/metadata/v2/filter-field"; import {Series} from "../_models/series"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {FilterCombination} from "../_models/metadata/v2/filter-combination"; @@ -42,28 +42,38 @@ import {DefaultModalOptions} from "../_models/default-modal-options"; import {ToastrService} from "ngx-toastr"; import {LicenseService} from "../_services/license.service"; import {SafeUrlPipe} from "../_pipes/safe-url.pipe"; +import {MergePersonModalComponent} from "./_modal/merge-person-modal/merge-person-modal.component"; +import {EVENTS, MessageHubService} from "../_services/message-hub.service"; +import {BadgeExpanderComponent} from "../shared/badge-expander/badge-expander.component"; + +interface PersonMergeEvent { + srcId: number, + dstId: number, + dstName: number, +} + @Component({ - selector: 'app-person-detail', - imports: [ - AsyncPipe, - ImageComponent, - SideNavCompanionBarComponent, - ReadMoreComponent, - TagBadgeComponent, - PersonRolePipe, - CarouselReelComponent, - CardItemComponent, - CardActionablesComponent, - TranslocoDirective, - ChapterCardComponent, - SafeUrlPipe - ], - templateUrl: './person-detail.component.html', - styleUrl: './person-detail.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'app-person-detail', + imports: [ + AsyncPipe, + ImageComponent, + SideNavCompanionBarComponent, + ReadMoreComponent, + PersonRolePipe, + CarouselReelComponent, + CardItemComponent, + CardActionablesComponent, + TranslocoDirective, + ChapterCardComponent, + SafeUrlPipe, + BadgeExpanderComponent + ], + templateUrl: './person-detail.component.html', + styleUrl: './person-detail.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush }) -export class PersonDetailComponent { +export class PersonDetailComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly filterUtilityService = inject(FilterUtilitiesService); @@ -77,6 +87,7 @@ export class PersonDetailComponent { protected readonly licenseService = inject(LicenseService); private readonly themeService = inject(ThemeService); private readonly toastr = inject(ToastrService); + private readonly messageHubService = inject(MessageHubService) protected readonly TagBadgeCursor = TagBadgeCursor; @@ -88,11 +99,11 @@ export class PersonDetailComponent { roles$: Observable | null = null; roles: PersonRole[] | null = null; works$: Observable | null = null; - defaultSummaryText = 'No information about this Person'; filter: SeriesFilterV2 | null = null; personActions: Array> = this.actionService.getPersonActions(this.handleAction.bind(this)); chaptersByRole: any = {}; anilistUrl: string = ''; + private readonly personSubject = new BehaviorSubject(null); protected readonly person$ = this.personSubject.asObservable().pipe(tap(p => { if (p?.aniListId) { @@ -118,43 +129,58 @@ export class PersonDetailComponent { return this.personService.get(personName); }), tap((person) => { - if (person == null) { this.toastr.error(translate('toasts.unauthorized-1')); this.router.navigateByUrl('/home'); return; } - this.person = person; - this.personSubject.next(person); // emit the person data for subscribers - this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor); - - // Fetch roles and process them - this.roles$ = this.personService.getRolesForPerson(this.person.id).pipe( - tap(roles => { - this.roles = roles; - this.filter = this.createFilter(roles); - this.chaptersByRole = {}; // Reset chaptersByRole for each person - - // Populate chapters by role - roles.forEach(role => { - this.chaptersByRole[role] = this.personService.getChaptersByRole(person.id, role) - .pipe(takeUntilDestroyed(this.destroyRef)); - }); - this.cdRef.markForCheck(); - }), - takeUntilDestroyed(this.destroyRef) - ); - - // Fetch series known for this person - this.works$ = this.personService.getSeriesMostKnownFor(person.id).pipe( - takeUntilDestroyed(this.destroyRef) - ); + this.setPerson(person); }), takeUntilDestroyed(this.destroyRef) ).subscribe(); } + ngOnInit(): void { + this.messageHubService.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => { + if (message.event !== EVENTS.PersonMerged) return; + + const event = message.payload as PersonMergeEvent; + if (event.srcId !== this.person?.id) return; + + this.router.navigate(['person', event.dstName]); + }); + } + + private setPerson(person: Person) { + this.person = person; + this.personSubject.next(person); // emit the person data for subscribers + this.themeService.setColorScape(person.primaryColor || '', person.secondaryColor); + + // Fetch roles and process them + this.roles$ = this.personService.getRolesForPerson(this.person.id).pipe( + tap(roles => { + this.roles = roles; + this.filter = this.createFilter(roles); + this.chaptersByRole = {}; // Reset chaptersByRole for each person + + // Populate chapters by role + roles.forEach(role => { + this.chaptersByRole[role] = this.personService.getChaptersByRole(person.id, role) + .pipe(takeUntilDestroyed(this.destroyRef)); + }); + this.cdRef.markForCheck(); + }), + takeUntilDestroyed(this.destroyRef) + ); + + // Fetch series known for this person + this.works$ = this.personService.getSeriesMostKnownFor(person.id).pipe( + takeUntilDestroyed(this.destroyRef) + ); + + } + createFilter(roles: PersonRole[]) { const filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter(); filter.combination = FilterCombination.Or; @@ -229,14 +255,34 @@ export class PersonDetailComponent { } }); break; + case (Action.Merge): + this.mergePersonAction(); + break; default: break; } } + private mergePersonAction() { + const ref = this.modalService.open(MergePersonModalComponent, DefaultModalOptions); + ref.componentInstance.person = this.person; + + ref.closed.subscribe(r => { + if (r.success) { + // Reload the person data, as relations may have changed + this.personService.get(r.person.name).subscribe(person => { + this.setPerson(person!); + this.cdRef.markForCheck(); + }) + } + }); + } + performAction(action: ActionItem) { if (typeof action.callback === 'function') { action.callback(action, this.person); } } + + protected readonly FilterField = FilterField; } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html index 6d39b0b28..1696aa5f0 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.html @@ -130,7 +130,7 @@ {{t('publication-status-title')}}
    @if (seriesMetadata.publicationStatus | publicationStatus; as pubStatus) { - {{pubStatus}} diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss index 26ef0aabc..158f2ce01 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.scss @@ -30,3 +30,7 @@ :host ::ng-deep .card-actions.btn-actions .btn { padding: 0.375rem 0.75rem; } + +.font-size { + font-size: 0.8rem; +} diff --git a/UI/Web/src/app/shared/badge-expander/badge-expander.component.scss b/UI/Web/src/app/shared/badge-expander/badge-expander.component.scss index cf2445645..342ab4431 100644 --- a/UI/Web/src/app/shared/badge-expander/badge-expander.component.scss +++ b/UI/Web/src/app/shared/badge-expander/badge-expander.component.scss @@ -5,4 +5,11 @@ .collapsed { height: 35px; overflow: hidden; -} \ No newline at end of file +} + +::ng-deep .badge-expander .content { + a, + span { + font-size: 0.8rem; + } +} diff --git a/UI/Web/src/app/shared/edit-list/edit-list.component.html b/UI/Web/src/app/shared/edit-list/edit-list.component.html index 0231252d6..930f9720a 100644 --- a/UI/Web/src/app/shared/edit-list/edit-list.component.html +++ b/UI/Web/src/app/shared/edit-list/edit-list.component.html @@ -1,6 +1,7 @@
    - @for(item of ItemsArray.controls; let i = $index; track i) { + + @for(item of ItemsArray.controls; let i = $index; track item; let last = $last) {
    @@ -11,21 +12,30 @@ [formControlName]="i" id="item--{{i}}" > + @if (item.dirty && item.touched && errorMessage) { + @if (item.status === "INVALID") { +
    + {{errorMessage}} +
    + } + }
    -
    - +
    + @if (last){ + + }
    } diff --git a/UI/Web/src/app/shared/edit-list/edit-list.component.ts b/UI/Web/src/app/shared/edit-list/edit-list.component.ts index 6d21549e8..c5b3d121e 100644 --- a/UI/Web/src/app/shared/edit-list/edit-list.component.ts +++ b/UI/Web/src/app/shared/edit-list/edit-list.component.ts @@ -9,7 +9,7 @@ import { OnInit, Output } from '@angular/core'; -import {FormArray, FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms"; +import {AsyncValidatorFn, FormArray, FormControl, FormGroup, ReactiveFormsModule, ValidatorFn} from "@angular/forms"; import {TranslocoDirective} from "@jsverse/transloco"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {debounceTime, distinctUntilChanged, tap} from "rxjs/operators"; @@ -28,6 +28,10 @@ export class EditListComponent implements OnInit { @Input({required: true}) items: Array = []; @Input({required: true}) label = ''; + @Input() validators: ValidatorFn[] = [] + @Input() asyncValidators: AsyncValidatorFn[] = []; + // TODO: Make this more dynamic based on which validator failed + @Input() errorMessage: string | null = null; @Output() updateItems = new EventEmitter>(); form: FormGroup = new FormGroup({items: new FormArray([])}); @@ -39,6 +43,9 @@ export class EditListComponent implements OnInit { ngOnInit() { this.items.forEach(item => this.addItem(item)); + if (this.items.length === 0) { + this.addItem(""); + } this.form.valueChanges.pipe( @@ -51,7 +58,7 @@ export class EditListComponent implements OnInit { } createItemControl(value: string = ''): FormControl { - return new FormControl(value, []); + return new FormControl(value, this.validators, this.asyncValidators); } add() { @@ -69,6 +76,7 @@ export class EditListComponent implements OnInit { if (this.ItemsArray.length === 1) { this.ItemsArray.at(0).setValue(''); this.emit(); + this.cdRef.markForCheck(); return; } diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts index d8a0ff752..ab8d46753 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts @@ -130,7 +130,8 @@ export class LibrarySettingsModalComponent implements OnInit { get IsMetadataDownloadEligible() { const libType = parseInt(this.libraryForm.get('type')?.value + '', 10) as LibraryType; - return libType === LibraryType.Manga || libType === LibraryType.LightNovel || libType === LibraryType.ComicVine; + return libType === LibraryType.Manga || libType === LibraryType.LightNovel + || libType === LibraryType.ComicVine || libType === LibraryType.Comic; } ngOnInit(): void { diff --git a/UI/Web/src/app/typeahead/_components/typeahead.component.ts b/UI/Web/src/app/typeahead/_components/typeahead.component.ts index 223676b3a..17dbc7b4c 100644 --- a/UI/Web/src/app/typeahead/_components/typeahead.component.ts +++ b/UI/Web/src/app/typeahead/_components/typeahead.component.ts @@ -72,6 +72,10 @@ export class TypeaheadComponent implements OnInit { * When triggered, will focus the input if the passed string matches the id */ @Input() focus: EventEmitter | undefined; + /** + * When triggered, will unfocus the input if the passed string matches the id + */ + @Input() unFocus: EventEmitter | undefined; @Output() selectedData = new EventEmitter(); @Output() newItemAdded = new EventEmitter(); // eslint-disable-next-line @angular-eslint/no-output-on-prefix @@ -113,6 +117,13 @@ export class TypeaheadComponent implements OnInit { }); } + if (this.unFocus) { + this.unFocus.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((id: string) => { + if (this.settings.id !== id) return; + this.hasFocus = false; + }); + } + this.init(); } diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index e460e3ffa..19d4443b6 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1003,7 +1003,7 @@ "save": "{{common.save}}", "no-results": "Unable to find a match. Try adding the url from a supported provider and retry.", "query-label": "Query", - "query-tooltip": "Enter series name, AniList/MyAnimeList url. Urls will use a direct lookup.", + "query-tooltip": "Enter series name, AniList/MyAnimeList/ComicBookRoundup url. Urls will use a direct lookup.", "dont-match-label": "Do not Match", "dont-match-tooltip": "Opt this series from matching and scrobbling", "search": "Search" @@ -1103,12 +1103,14 @@ }, "person-detail": { + "aka-title": "Also known as ", "known-for-title": "Known For", "individual-role-title": "As a {{role}}", "browse-person-title": "All Works of {{name}}", "browse-person-by-role-title": "All Works of {{name}} as a {{role}}", "all-roles": "Roles", - "anilist-url": "{{edit-person-modal.anilist-tooltip}}" + "anilist-url": "{{edit-person-modal.anilist-tooltip}}", + "no-info": "No information about this Person" }, "library-settings-modal": { @@ -1857,7 +1859,8 @@ "logout": "Logout", "all-filters": "Smart Filters", "nav-link-header": "Navigation Options", - "close": "{{common.close}}" + "close": "{{common.close}}", + "person-aka-status": "Matches an alias" }, "promoted-icon": { @@ -2246,6 +2249,7 @@ "title": "{{personName}} Details", "general-tab": "{{edit-series-modal.general-tab}}", "cover-image-tab": "{{edit-series-modal.cover-image-tab}}", + "aliases-tab": "Aliases", "loading": "{{common.loading}}", "close": "{{common.close}}", "name-label": "{{edit-series-modal.name-label}}", @@ -2263,7 +2267,20 @@ "cover-image-description": "{{edit-series-modal.cover-image-description}}", "cover-image-description-extra": "Alternatively you can download a cover from CoversDB if available.", "save": "{{common.save}}", - "download-coversdb": "Download from CoversDB" + "download-coversdb": "Download from CoversDB", + "aliases-label": "Edit aliases", + "alias-overlap": "This alias already points towards another person or is the name of this person, consider merging them.", + "aliases-tooltip": "When a series is tagged with an alias of a person, the person is assigned rather than creating a new person. When deleting an alias, you'll have to rescan the series for the change to be picked up." + }, + + "merge-person-modal": { + "title": "{{personName}}", + "close": "{{common.close}}", + "save": "{{common.save}}", + "src": "Merge Person", + "merge-warning": "If you proceed, the selected person will be removed. The selected person's name will be added as an alias, and all their roles will be transferred.", + "alias-title": "New aliases", + "known-for-title": "Known for" }, "day-breakdown": { @@ -2781,7 +2798,8 @@ "match-tooltip": "Match Series with Kavita+ manually", "reorder": "Reorder", "rename": "Rename", - "rename-tooltip": "Rename the Smart Filter" + "rename-tooltip": "Rename the Smart Filter", + "merge": "Merge" }, "preferences": { diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss index d06e4e4d0..f57c52f29 100644 --- a/UI/Web/src/theme/themes/dark.scss +++ b/UI/Web/src/theme/themes/dark.scss @@ -436,4 +436,7 @@ --login-input-font-family: 'League Spartan', sans-serif; --login-input-placeholder-opacity: 0.5; --login-input-placeholder-color: #fff; + + /** Series Detail **/ + --detail-subtitle-color: lightgrey; } From 005c1bf60b591245d625455ea4c57a08017beb27 Mon Sep 17 00:00:00 2001 From: majora2007 Date: Fri, 9 May 2025 22:18:55 +0000 Subject: [PATCH 02/47] Bump versions by dotnet-bump-version. --- Kavita.Common/Kavita.Common.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 2c9ab6dc0..029166254 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -3,7 +3,7 @@ net9.0 kavitareader.com Kavita - 0.8.6.8 + 0.8.6.9 en true From 574cf4b78e988c11cc753c41b609b147ea65a8ea Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 9 May 2025 22:20:05 +0000 Subject: [PATCH 03/47] Update OpenAPI documentation --- openapi.json | 229 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 227 insertions(+), 2 deletions(-) diff --git a/openapi.json b/openapi.json index 11a839f53..68a302fc7 100644 --- a/openapi.json +++ b/openapi.json @@ -2,12 +2,12 @@ "openapi": "3.0.4", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.7", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.8", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.8.6.7" + "version": "0.8.6.8" }, "servers": [ { @@ -5469,6 +5469,55 @@ } } }, + "/api/Person/search": { + "get": { + "tags": [ + "Person" + ], + "summary": "Find a person by name or alias against a query string", + "parameters": [ + { + "name": "queryString", + "in": "query", + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonDto" + } + } + } + } + } + } + } + }, "/api/Person/roles": { "get": { "tags": [ @@ -5844,6 +5893,105 @@ } } }, + "/api/Person/merge": { + "post": { + "tags": [ + "Person" + ], + "summary": "Merges Persons into one, this action is irreversible", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonMergeDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PersonMergeDto" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/PersonMergeDto" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/PersonDto" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonDto" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/PersonDto" + } + } + } + } + } + } + }, + "/api/Person/valid-alias": { + "get": { + "tags": [ + "Person" + ], + "summary": "Ensure the alias is valid to be added. For example, the alias cannot be on another person or be the same as the current person name/alias.", + "parameters": [ + { + "name": "personId", + "in": "query", + "description": "", + "schema": { + "type": "integer", + "format": "int32" + } + }, + { + "name": "alias", + "in": "query", + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "boolean" + } + }, + "application/json": { + "schema": { + "type": "boolean" + } + }, + "text/json": { + "schema": { + "type": "boolean" + } + } + } + } + } + } + }, "/api/Plugin/authenticate": { "post": { "tags": [ @@ -16660,6 +16808,13 @@ "type": "string", "nullable": true }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, "description": { "type": "string", "nullable": true @@ -20905,6 +21060,13 @@ "type": "string", "nullable": true }, + "aliases": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonAlias" + }, + "nullable": true + }, "coverImage": { "type": "string", "nullable": true @@ -20962,6 +21124,35 @@ }, "additionalProperties": false }, + "PersonAlias": { + "required": [ + "alias", + "normalizedAlias" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "alias": { + "type": "string", + "nullable": true + }, + "normalizedAlias": { + "type": "string", + "nullable": true + }, + "personId": { + "type": "integer", + "format": "int32" + }, + "person": { + "$ref": "#/components/schemas/Person" + } + }, + "additionalProperties": false + }, "PersonDto": { "required": [ "name" @@ -20991,6 +21182,13 @@ "type": "string", "nullable": true }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, "description": { "type": "string", "nullable": true @@ -21018,6 +21216,26 @@ }, "additionalProperties": false }, + "PersonMergeDto": { + "required": [ + "destId", + "srcId" + ], + "type": "object", + "properties": { + "destId": { + "type": "integer", + "description": "The id of the person being merged into", + "format": "int32" + }, + "srcId": { + "type": "integer", + "description": "The id of the person being merged. This person will be removed, and become an alias of API.DTOs.PersonMergeDto.DestId", + "format": "int32" + } + }, + "additionalProperties": false + }, "PersonalToCDto": { "required": [ "chapterId", @@ -25071,6 +25289,13 @@ "minLength": 1, "type": "string" }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, "description": { "type": "string", "nullable": true From 70f00895e82d41880f1f945c8c1f37176fccf04c Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sat, 10 May 2025 15:57:14 -0600 Subject: [PATCH 04/47] Random Stuff (#3798) --- API.Tests/API.Tests.csproj | 6 +- API/API.csproj | 31 +- .../ExternalMetadata/MatchSeriesRequestDto.cs | 10 +- API/Middleware/SecurityMiddleware.cs | 3 +- API/Program.cs | 36 ++- API/Services/Plus/ExternalMetadataService.cs | 10 +- API/Services/Tasks/Metadata/CoverDbService.cs | 3 +- API/Services/Tasks/Scanner/LibraryWatcher.cs | 2 +- API/Services/Tasks/VersionUpdaterService.cs | 20 +- API/Startup.cs | 18 +- Kavita.Common/Kavita.Common.csproj | 2 +- .../app/_services/action-factory.service.ts | 290 +++++++++++++----- UI/Web/src/app/_services/action.service.ts | 3 +- .../src/app/_services/statistics.service.ts | 42 +-- .../actionable-modal.component.html | 6 +- .../actionable-modal.component.ts | 7 +- .../card-actionables.component.html | 90 +++--- .../card-actionables.component.scss | 22 +- .../card-actionables.component.ts | 118 ++++--- .../manage-library.component.html | 22 +- .../manage-library.component.ts | 19 +- .../manage-logs/manage-logs.component.html | 11 - .../manage-logs/manage-logs.component.scss | 0 .../manage-logs/manage-logs.component.ts | 71 ----- .../update-section.component.ts | 2 - .../bulk-operations.component.html | 2 +- .../bulk-operations.component.ts | 31 +- .../card-detail-layout.component.html | 2 +- .../cards/card-item/card-item.component.html | 2 +- .../cards/card-item/card-item.component.ts | 4 - .../chapter-card/chapter-card.component.html | 2 +- .../chapter-card/chapter-card.component.ts | 46 +-- .../person-card/person-card.component.html | 2 +- .../person-card/person-card.component.ts | 21 +- .../series-card/series-card.component.html | 2 +- .../series-card/series-card.component.ts | 17 +- .../volume-card/volume-card.component.html | 2 +- .../volume-card/volume-card.component.ts | 29 +- .../carousel-reel.component.html | 2 +- .../chapter-detail.component.html | 2 +- .../chapter-detail.component.ts | 17 +- .../collection-detail.component.html | 8 +- .../collection-detail.component.ts | 10 +- .../library-detail.component.html | 3 +- .../library-detail.component.ts | 2 - .../grouped-typeahead.component.html | 4 + .../grouped-typeahead.component.scss | 11 + .../grouped-typeahead.component.ts | 25 +- .../person-detail.component.html | 2 +- .../person-detail/person-detail.component.ts | 10 +- .../reading-list-detail.component.html | 12 +- .../reading-list-detail.component.ts | 11 - .../reading-lists.component.html | 2 +- .../series-detail.component.html | 2 +- .../series-detail/series-detail.component.ts | 14 - .../app/shared/_services/utility.service.ts | 22 +- .../side-nav/side-nav.component.html | 6 +- .../side-nav/side-nav.component.ts | 14 +- .../library-settings-modal.component.ts | 4 +- .../volume-detail.component.html | 2 +- .../volume-detail/volume-detail.component.ts | 27 +- UI/Web/src/theme/themes/dark.scss | 4 + build.sh | 4 +- 63 files changed, 659 insertions(+), 567 deletions(-) delete mode 100644 UI/Web/src/app/admin/manage-logs/manage-logs.component.html delete mode 100644 UI/Web/src/app/admin/manage-logs/manage-logs.component.scss delete mode 100644 UI/Web/src/app/admin/manage-logs/manage-logs.component.ts diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 20e10e548..9e7fc3a02 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -9,10 +9,10 @@ - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/API/API.csproj b/API/API.csproj index 1ddb37d7f..f9a889d74 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -51,7 +51,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -66,7 +66,7 @@ - + @@ -78,7 +78,7 @@ - + @@ -87,20 +87,20 @@ - + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + - + @@ -111,17 +111,16 @@ - - - - - + + + + @@ -139,6 +138,7 @@ + @@ -188,7 +188,6 @@ - Always diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs index 6cd911700..fae674ded 100644 --- a/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs +++ b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs @@ -4,14 +4,18 @@ using API.DTOs.Scrobbling; namespace API.DTOs.KavitaPlus.ExternalMetadata; #nullable enable +/// +/// Represents a request to match some series from Kavita to an external id which K+ uses. +/// internal sealed record MatchSeriesRequestDto { - public string SeriesName { get; set; } - public ICollection AlternativeNames { get; set; } + public required string SeriesName { get; set; } + public ICollection AlternativeNames { get; set; } = []; public int Year { get; set; } = 0; - public string Query { get; set; } + public string? Query { get; set; } public int? AniListId { get; set; } public long? MalId { get; set; } public string? HardcoverId { get; set; } + public int? CbrId { get; set; } public PlusMediaFormat Format { get; set; } } diff --git a/API/Middleware/SecurityMiddleware.cs b/API/Middleware/SecurityMiddleware.cs index 61ca1c75d..67cb42d0c 100644 --- a/API/Middleware/SecurityMiddleware.cs +++ b/API/Middleware/SecurityMiddleware.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Net; using System.Text.Json; using System.Threading.Tasks; @@ -26,7 +27,7 @@ public class SecurityEventMiddleware(RequestDelegate next) } catch (KavitaUnauthenticatedUserException ex) { - var ipAddress = context.Connection.RemoteIpAddress?.ToString(); + var ipAddress = context.Request.Headers["X-Forwarded-For"].FirstOrDefault() ?? context.Connection.RemoteIpAddress?.ToString(); var requestMethod = context.Request.Method; var requestPath = context.Request.Path; var userAgent = context.Request.Headers.UserAgent; diff --git a/API/Program.cs b/API/Program.cs index 852844f2f..011a7de2a 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.IO.Abstractions; using System.Linq; using System.Security.Cryptography; @@ -48,15 +49,13 @@ public class Program var directoryService = new DirectoryService(null!, new FileSystem()); + + // Check if this is the first time running and if so, rename appsettings-init.json to appsettings.json + HandleFirstRunConfiguration(); + + // Before anything, check if JWT has been generated properly or if user still has default - if (!Configuration.CheckIfJwtTokenSet() && - Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != Environments.Development) - { - Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions..."); - var rBytes = new byte[256]; - RandomNumberGenerator.Create().GetBytes(rBytes); - Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty); - } + EnsureJwtTokenKey(); try { @@ -70,6 +69,7 @@ public class Program { var logger = services.GetRequiredService>(); var context = services.GetRequiredService(); + var pendingMigrations = await context.Database.GetPendingMigrationsAsync(); var isDbCreated = await context.Database.CanConnectAsync(); if (isDbCreated && pendingMigrations.Any()) @@ -157,6 +157,26 @@ public class Program } } + private static void EnsureJwtTokenKey() + { + if (Configuration.CheckIfJwtTokenSet() || Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development) return; + + Log.Logger.Information("Generating JWT TokenKey for encrypting user sessions..."); + var rBytes = new byte[256]; + RandomNumberGenerator.Create().GetBytes(rBytes); + Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty); + } + + private static void HandleFirstRunConfiguration() + { + var firstRunConfigFilePath = Path.Join(Directory.GetCurrentDirectory(), "config/appsettings-init.json"); + if (File.Exists(firstRunConfigFilePath) && + !File.Exists(Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json"))) + { + File.Move(firstRunConfigFilePath, Path.Join(Directory.GetCurrentDirectory(), "config/appsettings.json")); + } + } + private static async Task GetMigrationDirectory(DataContext context, IDirectoryService directoryService) { string? currentVersion = null; diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index a0c88b16d..a1e3750dd 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -226,7 +226,7 @@ public class ExternalMetadataService : IExternalMetadataService AlternativeNames = altNames.Where(s => !string.IsNullOrEmpty(s)).ToList(), Year = series.Metadata.ReleaseYear, AniListId = potentialAnilistId ?? ScrobblingService.GetAniListId(series), - MalId = potentialMalId ?? ScrobblingService.GetMalId(series), + MalId = potentialMalId ?? ScrobblingService.GetMalId(series) }; var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; @@ -792,7 +792,7 @@ public class ExternalMetadataService : IExternalMetadataService var characters = externalCharacters .Select(w => new PersonDto() { - Name = w.Name, + Name = w.Name.Trim(), AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListCharacterWebsite), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), }) @@ -873,7 +873,7 @@ public class ExternalMetadataService : IExternalMetadataService var artists = upstreamArtists .Select(w => new PersonDto() { - Name = w.Name, + Name = w.Name.Trim(), AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), }) @@ -929,7 +929,7 @@ public class ExternalMetadataService : IExternalMetadataService var writers = upstreamWriters .Select(w => new PersonDto() { - Name = w.Name, + Name = w.Name.Trim(), AniListId = ScrobblingService.ExtractId(w.Url, ScrobblingService.AniListStaffWebsite), Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))), }) @@ -1353,7 +1353,7 @@ public class ExternalMetadataService : IExternalMetadataService var people = staff! .Select(w => new PersonDto() { - Name = w, + Name = w.Trim(), }) .Concat(chapter.People .Where(p => p.Role == role) diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index d58b225a5..59f01de55 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -501,7 +501,7 @@ public class CoverDbService : ICoverDbService else { _directoryService.DeleteFiles([tempFullPath]); - person.CoverImage = Path.GetFileName(existingPath); + return; } } else @@ -651,6 +651,7 @@ public class CoverDbService : ICoverDbService else { _directoryService.DeleteFiles([tempFullPath]); + return; } chapter.CoverImage = finalFileName; diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index d2e6437a3..fec0304a8 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -310,7 +310,7 @@ public class LibraryWatcher : ILibraryWatcher if (rootFolder.Count == 0) return string.Empty; // Select the first folder and join with library folder, this should give us the folder to scan. - return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[rootFolder.Count - 1])); + return Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder[^1])); } diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index 123b610ff..4ccf79abb 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -52,6 +52,7 @@ public interface IVersionUpdaterService Task PushUpdate(UpdateNotificationDto update); Task> GetAllReleases(int count = 0); Task GetNumberOfReleasesBehind(bool stableOnly = false); + void BustGithubCache(); } @@ -384,7 +385,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService if (DateTime.UtcNow - fileInfo.LastWriteTimeUtc <= CacheDuration) { var cachedData = await File.ReadAllTextAsync(_cacheLatestReleaseFilePath); - return System.Text.Json.JsonSerializer.Deserialize(cachedData); + return JsonSerializer.Deserialize(cachedData); } return null; @@ -407,7 +408,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService { try { - var json = System.Text.Json.JsonSerializer.Serialize(update, JsonOptions); + var json = JsonSerializer.Serialize(update, JsonOptions); await File.WriteAllTextAsync(_cacheLatestReleaseFilePath, json); } catch (Exception ex) @@ -446,6 +447,21 @@ public partial class VersionUpdaterService : IVersionUpdaterService .Count(u => u.IsReleaseNewer); } + /// + /// Clears the Github cache + /// + public void BustGithubCache() + { + try + { + File.Delete(_cacheFilePath); + File.Delete(_cacheLatestReleaseFilePath); + } catch (Exception ex) + { + _logger.LogError(ex, "Failed to clear Github cache"); + } + } + private UpdateNotificationDto? CreateDto(GithubReleaseMetadata? update) { if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null; diff --git a/API/Startup.cs b/API/Startup.cs index 34af22154..cb32d1742 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -55,6 +55,9 @@ public class Startup { _config = config; _env = env; + + // Disable Hangfire Automatic Retry + GlobalJobFilters.Filters.Add(new AutomaticRetryAttribute { Attempts = 0 }); } // This method gets called by the runtime. Use this method to add services to the container. @@ -223,7 +226,7 @@ public class Startup // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env, IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider, ICacheService cacheService, - IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService) + IDirectoryService directoryService, IUnitOfWork unitOfWork, IBackupService backupService, IImageService imageService, IVersionUpdaterService versionService) { var logger = serviceProvider.GetRequiredService>(); @@ -235,9 +238,10 @@ public class Startup // Apply all migrations on startup var dataContext = serviceProvider.GetRequiredService(); - logger.LogInformation("Running Migrations"); + #region Migrations + // v0.7.9 await MigrateUserLibrarySideNavStream.Migrate(unitOfWork, dataContext, logger); @@ -289,13 +293,23 @@ public class Startup await ManualMigrateScrobbleSpecials.Migrate(dataContext, logger); await ManualMigrateScrobbleEventGen.Migrate(dataContext, logger); + #endregion + // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); + var isVersionDifferent = installVersion.Value != BuildInfo.Version.ToString(); installVersion.Value = BuildInfo.Version.ToString(); unitOfWork.SettingsRepository.Update(installVersion); await unitOfWork.CommitAsync(); logger.LogInformation("Running Migrations - complete"); + + if (isVersionDifferent) + { + // Clear the Github cache so update stuff shows correctly + versionService.BustGithubCache(); + } + }).GetAwaiter() .GetResult(); } diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 029166254..9e10f5ccf 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -14,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 0fef35b0e..61fee39ec 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -7,12 +7,13 @@ import {Library} from '../_models/library/library'; import {ReadingList} from '../_models/reading-list'; import {Series} from '../_models/series'; import {Volume} from '../_models/volume'; -import {AccountService} from './account.service'; +import {AccountService, Role} from './account.service'; import {DeviceService} from './device.service'; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {translate} from "@jsverse/transloco"; import {Person} from "../_models/metadata/person"; +import {User} from '../_models/user'; export enum Action { Submenu = -1, @@ -106,7 +107,7 @@ export enum Action { Promote = 24, UnPromote = 25, /** - * Invoke a refresh covers as false to generate colorscapes + * Invoke refresh covers as false to generate colorscapes */ GenerateColorScape = 26, /** @@ -126,14 +127,21 @@ export enum Action { /** * Callback for an action */ -export type ActionCallback = (action: ActionItem, data: T) => void; -export type ActionAllowedCallback = (action: ActionItem) => boolean; +export type ActionCallback = (action: ActionItem, entity: T) => void; +export type ActionShouldRenderFunc = (action: ActionItem, entity: T, user: User) => boolean; export interface ActionItem { title: string; description: string; action: Action; callback: ActionCallback; + /** + * Roles required to be present for ActionItem to show. If empty, assumes anyone can see. At least one needs to apply. + */ + requiredRoles: Role[]; + /** + * @deprecated Use required Roles instead + */ requiresAdmin: boolean; children: Array>; /** @@ -149,94 +157,98 @@ export interface ActionItem { * Extra data that needs to be sent back from the card item. Used mainly for dynamicList. This will be the item from dyanamicList return */ _extra?: {title: string, data: any}; + /** + * Will call on each action to determine if it should show for the appropriate entity based on state and user + */ + shouldRender: ActionShouldRenderFunc; } +/** + * Entities that can be actioned upon + */ +export type ActionableEntity = Volume | Series | Chapter | ReadingList | UserCollection | Person | Library | SideNavStream | SmartFilter | null; + @Injectable({ providedIn: 'root', }) export class ActionFactoryService { - libraryActions: Array> = []; - - seriesActions: Array> = []; - - volumeActions: Array> = []; - - chapterActions: Array> = []; - - collectionTagActions: Array> = []; - - readingListActions: Array> = []; - - bookmarkActions: Array> = []; - + private libraryActions: Array> = []; + private seriesActions: Array> = []; + private volumeActions: Array> = []; + private chapterActions: Array> = []; + private collectionTagActions: Array> = []; + private readingListActions: Array> = []; + private bookmarkActions: Array> = []; private personActions: Array> = []; - - sideNavStreamActions: Array> = []; - smartFilterActions: Array> = []; - - sideNavHomeActions: Array> = []; - - isAdmin = false; - + private sideNavStreamActions: Array> = []; + private smartFilterActions: Array> = []; + private sideNavHomeActions: Array> = []; constructor(private accountService: AccountService, private deviceService: DeviceService) { - this.accountService.currentUser$.subscribe((user) => { - if (user) { - this.isAdmin = this.accountService.hasAdminRole(user); - } else { - this._resetActions(); - return; // If user is logged out, we don't need to do anything - } - + this.accountService.currentUser$.subscribe((_) => { this._resetActions(); }); } - getLibraryActions(callback: ActionCallback) { - return this.applyCallbackToList(this.libraryActions, callback); + getLibraryActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.libraryActions, callback, shouldRenderFunc) as ActionItem[]; } - getSeriesActions(callback: ActionCallback) { - return this.applyCallbackToList(this.seriesActions, callback); + getSeriesActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList(this.seriesActions, callback, shouldRenderFunc); } - getSideNavStreamActions(callback: ActionCallback) { - return this.applyCallbackToList(this.sideNavStreamActions, callback); + getSideNavStreamActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.sideNavStreamActions, callback, shouldRenderFunc); } - getSmartFilterActions(callback: ActionCallback) { - return this.applyCallbackToList(this.smartFilterActions, callback); + getSmartFilterActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.smartFilterActions, callback, shouldRenderFunc); } - getVolumeActions(callback: ActionCallback) { - return this.applyCallbackToList(this.volumeActions, callback); + getVolumeActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList(this.volumeActions, callback, shouldRenderFunc); } - getChapterActions(callback: ActionCallback) { - return this.applyCallbackToList(this.chapterActions, callback); + getChapterActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.basicReadRender) { + return this.applyCallbackToList(this.chapterActions, callback, shouldRenderFunc); } - getCollectionTagActions(callback: ActionCallback) { - return this.applyCallbackToList(this.collectionTagActions, callback); + getCollectionTagActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.collectionTagActions, callback, shouldRenderFunc); } - getReadingListActions(callback: ActionCallback) { - return this.applyCallbackToList(this.readingListActions, callback); + getReadingListActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.readingListActions, callback, shouldRenderFunc); } - getBookmarkActions(callback: ActionCallback) { - return this.applyCallbackToList(this.bookmarkActions, callback); + getBookmarkActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.bookmarkActions, callback, shouldRenderFunc); } - getPersonActions(callback: ActionCallback) { - return this.applyCallbackToList(this.personActions, callback); + getPersonActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.personActions, callback, shouldRenderFunc); } - getSideNavHomeActions(callback: ActionCallback) { - return this.applyCallbackToList(this.sideNavHomeActions, callback); + getSideNavHomeActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { + return this.applyCallbackToList(this.sideNavHomeActions, callback, shouldRenderFunc); } - dummyCallback(action: ActionItem, data: any) {} + dummyCallback(action: ActionItem, entity: any) {} + dummyShouldRender(action: ActionItem, entity: any, user: User) {return true;} + basicReadRender(action: ActionItem, entity: any, user: User) { + if (entity === null || entity === undefined) return true; + if (!entity.hasOwnProperty('pagesRead') && !entity.hasOwnProperty('pages')) return true; + + switch (action.action) { + case(Action.MarkAsRead): + return entity.pagesRead < entity.pages; + case(Action.MarkAsUnread): + return entity.pagesRead !== 0; + default: + return true; + } + } filterSendToAction(actions: Array>, chapter: Chapter) { // if (chapter.files.filter(f => f.format === MangaFormat.EPUB || f.format === MangaFormat.PDF).length !== chapter.files.length) { @@ -279,7 +291,7 @@ export class ActionFactoryService { return tasks.filter(t => !blacklist.includes(t.action)); } - getBulkLibraryActions(callback: ActionCallback) { + getBulkLibraryActions(callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender) { // Scan is currently not supported due to the backend not being able to handle it yet const actions = this.flattenActions(this.libraryActions).filter(a => { @@ -293,11 +305,13 @@ export class ActionFactoryService { dynamicList: undefined, action: Action.CopySettings, callback: this.dummyCallback, + shouldRender: shouldRenderFunc, children: [], + requiredRoles: [Role.Admin], requiresAdmin: true, title: 'copy-settings' }) - return this.applyCallbackToList(actions, callback); + return this.applyCallbackToList(actions, callback, shouldRenderFunc) as ActionItem[]; } flattenActions(actions: Array>): Array> { @@ -323,7 +337,9 @@ export class ActionFactoryService { title: 'scan-library', description: 'scan-library-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -331,14 +347,18 @@ export class ActionFactoryService { title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [ { action: Action.RefreshMetadata, title: 'refresh-covers', description: 'refresh-covers-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -346,7 +366,9 @@ export class ActionFactoryService { title: 'generate-colorscape', description: 'generate-colorscape-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -354,7 +376,9 @@ export class ActionFactoryService { title: 'analyze-files', description: 'analyze-files-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -362,7 +386,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, ], @@ -372,7 +398,9 @@ export class ActionFactoryService { title: 'settings', description: 'settings-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, ]; @@ -383,7 +411,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -391,7 +421,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], class: 'danger', children: [], }, @@ -400,7 +432,9 @@ export class ActionFactoryService { title: 'promote', description: 'promote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -408,7 +442,9 @@ export class ActionFactoryService { title: 'unpromote', description: 'unpromote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -419,7 +455,9 @@ export class ActionFactoryService { title: 'mark-as-read', description: 'mark-as-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -427,7 +465,9 @@ export class ActionFactoryService { title: 'mark-as-unread', description: 'mark-as-unread-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -435,7 +475,9 @@ export class ActionFactoryService { title: 'scan-series', description: 'scan-series-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -443,14 +485,18 @@ export class ActionFactoryService { title: 'add-to', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToWantToReadList, title: 'add-to-want-to-read', description: 'add-to-want-to-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -458,7 +504,9 @@ export class ActionFactoryService { title: 'remove-from-want-to-read', description: 'remove-to-want-to-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -466,7 +514,9 @@ export class ActionFactoryService { title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -474,26 +524,11 @@ export class ActionFactoryService { title: 'add-to-collection', description: 'add-to-collection-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, - - // { - // action: Action.AddToScrobbleHold, - // title: 'add-to-scrobble-hold', - // description: 'add-to-scrobble-hold-tooltip', - // callback: this.dummyCallback, - // requiresAdmin: true, - // children: [], - // }, - // { - // action: Action.RemoveFromScrobbleHold, - // title: 'remove-from-scrobble-hold', - // description: 'remove-from-scrobble-hold-tooltip', - // callback: this.dummyCallback, - // requiresAdmin: true, - // children: [], - // }, ], }, { @@ -501,14 +536,18 @@ export class ActionFactoryService { title: 'send-to', description: 'send-to-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; }), shareReplay())), @@ -521,14 +560,18 @@ export class ActionFactoryService { title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [], children: [ { action: Action.RefreshMetadata, title: 'refresh-covers', description: 'refresh-covers-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -536,7 +579,9 @@ export class ActionFactoryService { title: 'generate-colorscape', description: 'generate-colorscape-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -544,7 +589,9 @@ export class ActionFactoryService { title: 'analyze-files', description: 'analyze-files-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -552,7 +599,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], class: 'danger', children: [], }, @@ -563,7 +612,9 @@ export class ActionFactoryService { title: 'match', description: 'match-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -571,7 +622,9 @@ export class ActionFactoryService { title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [Role.Download], children: [], }, { @@ -579,7 +632,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, ]; @@ -590,7 +645,9 @@ export class ActionFactoryService { title: 'read-incognito', description: 'read-incognito-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -598,7 +655,9 @@ export class ActionFactoryService { title: 'mark-as-read', description: 'mark-as-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -606,7 +665,9 @@ export class ActionFactoryService { title: 'mark-as-unread', description: 'mark-as-unread-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -614,14 +675,18 @@ export class ActionFactoryService { title: 'add-to', description: '=', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToReadingList, title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], } ] @@ -631,14 +696,18 @@ export class ActionFactoryService { title: 'send-to', description: 'send-to-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; }), shareReplay())), @@ -651,14 +720,18 @@ export class ActionFactoryService { title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.Delete, title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -666,7 +739,9 @@ export class ActionFactoryService { title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ] @@ -676,7 +751,9 @@ export class ActionFactoryService { title: 'details', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -687,7 +764,9 @@ export class ActionFactoryService { title: 'read-incognito', description: 'read-incognito-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -695,7 +774,9 @@ export class ActionFactoryService { title: 'mark-as-read', description: 'mark-as-read-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -703,7 +784,9 @@ export class ActionFactoryService { title: 'mark-as-unread', description: 'mark-as-unread-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -711,14 +794,18 @@ export class ActionFactoryService { title: 'add-to', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.AddToReadingList, title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], } ] @@ -728,14 +815,18 @@ export class ActionFactoryService { title: 'send-to', description: 'send-to-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.SendTo, title: '', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], dynamicList: this.deviceService.devices$.pipe(map((devices: Array) => devices.map(d => { return {'title': d.name, 'data': d}; }), shareReplay())), @@ -749,14 +840,18 @@ export class ActionFactoryService { title: 'others', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [ { action: Action.Delete, title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -764,7 +859,9 @@ export class ActionFactoryService { title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [Role.Download], children: [], }, ] @@ -774,7 +871,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -785,7 +884,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -793,7 +894,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], class: 'danger', children: [], }, @@ -802,7 +905,9 @@ export class ActionFactoryService { title: 'promote', description: 'promote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -810,7 +915,9 @@ export class ActionFactoryService { title: 'unpromote', description: 'unpromote-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -821,7 +928,9 @@ export class ActionFactoryService { title: 'edit', description: 'edit-person-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], }, { @@ -829,7 +938,9 @@ export class ActionFactoryService { title: 'merge', description: 'merge-person-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: true, + requiredRoles: [Role.Admin], children: [], } ]; @@ -840,7 +951,9 @@ export class ActionFactoryService { title: 'view-series', description: 'view-series-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -848,7 +961,9 @@ export class ActionFactoryService { title: 'download', description: 'download-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -856,8 +971,10 @@ export class ActionFactoryService { title: 'clear', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, class: 'danger', requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -868,7 +985,9 @@ export class ActionFactoryService { title: 'mark-visible', description: 'mark-visible-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -876,7 +995,9 @@ export class ActionFactoryService { title: 'mark-invisible', description: 'mark-invisible-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -887,7 +1008,9 @@ export class ActionFactoryService { title: 'rename', description: 'rename-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, { @@ -895,7 +1018,9 @@ export class ActionFactoryService { title: 'delete', description: 'delete-tooltip', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], }, ]; @@ -906,7 +1031,9 @@ export class ActionFactoryService { title: 'reorder', description: '', callback: this.dummyCallback, + shouldRender: this.dummyShouldRender, requiresAdmin: false, + requiredRoles: [], children: [], } ] @@ -914,21 +1041,24 @@ export class ActionFactoryService { } - private applyCallback(action: ActionItem, callback: (action: ActionItem, data: any) => void) { + private applyCallback(action: ActionItem, callback: ActionCallback, shouldRenderFunc: ActionShouldRenderFunc) { action.callback = callback; + action.shouldRender = shouldRenderFunc; if (action.children === null || action.children?.length === 0) return; action.children?.forEach((childAction) => { - this.applyCallback(childAction, callback); + this.applyCallback(childAction, callback, shouldRenderFunc); }); } - public applyCallbackToList(list: Array>, callback: (action: ActionItem, data: any) => void): Array> { + public applyCallbackToList(list: Array>, + callback: ActionCallback, + shouldRenderFunc: ActionShouldRenderFunc = this.dummyShouldRender): Array> { const actions = list.map((a) => { return { ...a }; }); - actions.forEach((action) => this.applyCallback(action, callback)); + actions.forEach((action) => this.applyCallback(action, callback, shouldRenderFunc)); return actions; } diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index fd24bd9ff..37826b0e2 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -473,8 +473,7 @@ export class ActionService { } async deleteMultipleVolumes(volumes: Array, callback?: BooleanActionCallback) { - // TODO: Change translation key back to "toasts.confirm-delete-multiple-volumes" - if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-chapters', {count: volumes.length}))) return; + if (!await this.confirmService.confirm(translate('toasts.confirm-delete-multiple-volumes', {count: volumes.length}))) return; this.volumeService.deleteMultipleVolumes(volumes.map(v => v.id)).subscribe((success) => { if (callback) { diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index f13b29c87..cf80765f2 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -1,20 +1,19 @@ -import { HttpClient } from '@angular/common/http'; +import {HttpClient, HttpParams} from '@angular/common/http'; import {Inject, inject, Injectable} from '@angular/core'; -import { environment } from 'src/environments/environment'; -import { UserReadStatistics } from '../statistics/_models/user-read-statistics'; -import { PublicationStatusPipe } from '../_pipes/publication-status.pipe'; -import {asyncScheduler, finalize, map, tap} from 'rxjs'; -import { MangaFormatPipe } from '../_pipes/manga-format.pipe'; -import { FileExtensionBreakdown } from '../statistics/_models/file-breakdown'; -import { TopUserRead } from '../statistics/_models/top-reads'; -import { ReadHistoryEvent } from '../statistics/_models/read-history-event'; -import { ServerStatistics } from '../statistics/_models/server-statistics'; -import { StatCount } from '../statistics/_models/stat-count'; -import { PublicationStatus } from '../_models/metadata/publication-status'; -import { MangaFormat } from '../_models/manga-format'; -import { TextResonse } from '../_types/text-response'; +import {environment} from 'src/environments/environment'; +import {UserReadStatistics} from '../statistics/_models/user-read-statistics'; +import {PublicationStatusPipe} from '../_pipes/publication-status.pipe'; +import {asyncScheduler, map} from 'rxjs'; +import {MangaFormatPipe} from '../_pipes/manga-format.pipe'; +import {FileExtensionBreakdown} from '../statistics/_models/file-breakdown'; +import {TopUserRead} from '../statistics/_models/top-reads'; +import {ReadHistoryEvent} from '../statistics/_models/read-history-event'; +import {ServerStatistics} from '../statistics/_models/server-statistics'; +import {StatCount} from '../statistics/_models/stat-count'; +import {PublicationStatus} from '../_models/metadata/publication-status'; +import {MangaFormat} from '../_models/manga-format'; +import {TextResonse} from '../_types/text-response'; import {TranslocoService} from "@jsverse/transloco"; -import {KavitaPlusMetadataBreakdown} from "../statistics/_models/kavitaplus-metadata-breakdown"; import {throttleTime} from "rxjs/operators"; import {DEBOUNCE_TIME} from "../shared/_services/download.service"; import {download} from "../shared/_models/download"; @@ -44,11 +43,14 @@ export class StatisticsService { constructor(private httpClient: HttpClient, @Inject(SAVER) private save: Saver) { } getUserStatistics(userId: number, libraryIds: Array = []) { - // TODO: Convert to httpParams object - let url = 'stats/user/' + userId + '/read'; - if (libraryIds.length > 0) url += '?libraryIds=' + libraryIds.join(','); + const url = `${this.baseUrl}stats/user/${userId}/read`; - return this.httpClient.get(this.baseUrl + url); + let params = new HttpParams(); + if (libraryIds.length > 0) { + params = params.set('libraryIds', libraryIds.join(',')); + } + + return this.httpClient.get(url, { params }); } getServerStatistics() { @@ -59,7 +61,7 @@ export class StatisticsService { return this.httpClient.get[]>(this.baseUrl + 'stats/server/count/year').pipe( map(spreads => spreads.map(spread => { return {name: spread.value + '', value: spread.count}; - }))); + }))); } getTopYears() { diff --git a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html index 067dc5fb2..caf8bf683 100644 --- a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html +++ b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html @@ -1,7 +1,9 @@
  • /// /// - /// + /// This is not in use /// [HttpPost("all-v2")] public async Task>> GetAllSeriesV2(FilterV2Dto filterDto, [FromQuery] UserParams userParams, @@ -321,8 +321,6 @@ public class SeriesController : BaseApiController await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, userParams, filterDto, context); // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-series")); - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 944ea987b..e5cfb626a 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -128,6 +128,7 @@ public class UsersController : BaseApiController existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize; existingPreferences.NoTransitions = preferencesDto.NoTransitions; existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate; + existingPreferences.AllowAutomaticWebtoonReaderDetection = preferencesDto.AllowAutomaticWebtoonReaderDetection; existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships; existingPreferences.ShareReviews = preferencesDto.ShareReviews; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 6b8cdc243..8e8576069 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -102,11 +102,22 @@ export class AccountService { return true; } + /** + * If the user has any role in the restricted roles array or is an Admin + * @param user + * @param roles + * @param restrictedRoles + */ hasAnyRole(user: User, roles: Array, restrictedRoles: Array = []) { if (!user || !user.roles) { return false; } + // If the user is an admin, they have the role + if (this.hasAdminRole(user)) { + return true; + } + // If restricted roles are provided and the user has any of them, deny access if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) { return false; diff --git a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html index caf8bf683..7573c554a 100644 --- a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html +++ b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.html @@ -15,7 +15,7 @@
    @for (action of currentItems; track action.title) { - @if (willRenderAction(action)) { + @if (willRenderAction(action, user!)) {
    - + + @if (layoutMode !== BookPageLayoutMode.Default) { + @let vp = getVirtualPage();
    {{t('page-label')}}
    -
    -
    {{vp[0]}}
    - +
    -
    {{vp[1]}}
    +
    {{vp[1]}}
    -
    - + }
    {{t('pagination-header')}}
    diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss index dcfa9ddcd..8f45302c3 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.scss @@ -277,9 +277,9 @@ $action-bar-height: 38px; } .virt-pagination-cont { - padding-bottom: 5px; - margin-bottom: 5px; - box-shadow: var(--drawer-pagination-horizontal-rule); + padding-bottom: 5px; + margin-bottom: 5px; + box-shadow: var(--drawer-pagination-horizontal-rule); } .bottom-bar { diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index 6abd619f8..002d769e8 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -63,6 +63,7 @@ import { PersonalToCEvent } from "../personal-table-of-contents/personal-table-of-contents.component"; import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {ConfirmService} from "../../../shared/confirm.service"; enum TabID { @@ -133,6 +134,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { private readonly utilityService = inject(UtilityService); private readonly libraryService = inject(LibraryService); private readonly themeService = inject(ThemeService); + private readonly confirmService = inject(ConfirmService); private readonly cdRef = inject(ChangeDetectorRef); protected readonly BookPageLayoutMode = BookPageLayoutMode; @@ -730,7 +732,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } @HostListener('window:keydown', ['$event']) - handleKeyPress(event: KeyboardEvent) { + async handleKeyPress(event: KeyboardEvent) { const activeElement = document.activeElement as HTMLElement; const isInputFocused = activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA'; if (isInputFocused) return; @@ -748,7 +750,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { event.stopPropagation(); event.preventDefault(); } else if (event.key === KEY_CODES.G) { - this.goToPage(); + await this.goToPage(); } else if (event.key === KEY_CODES.F) { this.toggleFullscreen() } @@ -905,33 +907,35 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } - promptForPage() { - const question = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages - 1}); - const goToPageNum = window.prompt(question, ''); + async promptForPage() { + const promptConfig = {...this.confirmService.defaultPrompt}; + // Pages are called sections in the UI, manga reader uses the go-to-page string so we use a different one here + promptConfig.header = translate('book-reader.go-to-section'); + promptConfig.content = translate('book-reader.go-to-section-prompt', {totalSections: this.maxPages - 1}); + + const goToPageNum = await this.confirmService.prompt(undefined, promptConfig); + if (goToPageNum === null || goToPageNum.trim().length === 0) { return null; } return goToPageNum; } - goToPage(pageNum?: number) { + async goToPage(pageNum?: number) { let page = pageNum; if (pageNum === null || pageNum === undefined) { - const goToPageNum = this.promptForPage(); + const goToPageNum = await this.promptForPage(); if (goToPageNum === null) { return; } + page = parseInt(goToPageNum.trim(), 10); } if (page === undefined || this.pageNum === page) { return; } - if (page > this.maxPages) { - page = this.maxPages; + if (page > this.maxPages - 1) { + page = this.maxPages - 1; } else if (page < 0) { page = 0; } - if (!(page === 0 || page === this.maxPages - 1)) { - page -= 1; - } - this.pageNum = page; this.loadPage(); } diff --git a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html index 585f3af42..ead8b3540 100644 --- a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html +++ b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.html @@ -17,12 +17,12 @@ } @else { @for (chapterGroup of chapters; track chapterGroup.title + chapterGroup.children.length) {
      -
    • +
    • {{chapterGroup.title}}
    • @for(chapter of chapterGroup.children; track chapter.title + chapter.part) { diff --git a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.scss b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.scss index e556f0e78..ca8e747f4 100644 --- a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.scss +++ b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.scss @@ -3,9 +3,10 @@ &.active { font-weight: bold; + color: var(--primary-color); } } .chapter-title { padding-inline-start: 1rem; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts index cb6417874..ce3a180ed 100644 --- a/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts +++ b/UI/Web/src/app/book-reader/_components/table-of-contents/table-of-contents.component.ts @@ -31,9 +31,8 @@ export class TableOfContentsComponent implements OnChanges { @Output() loadChapter: EventEmitter<{pageNum: number, part: string}> = new EventEmitter(); ngOnChanges(changes: SimpleChanges) { - console.log('Current Page: ', this.pageNum, this.currentPageAnchor); + //console.log('Current Page: ', this.pageNum, this.currentPageAnchor); this.cdRef.markForCheck(); - } cleanIdSelector(id: string) { @@ -47,4 +46,30 @@ export class TableOfContentsComponent implements OnChanges { loadChapterPage(pageNum: number, part: string) { this.loadChapter.emit({pageNum, part}); } + + isChapterSelected(chapterGroup: BookChapterItem) { + if (chapterGroup.page === this.pageNum) { + return true; + } + + const idx = this.chapters.indexOf(chapterGroup); + if (idx < 0) { + return false; // should never happen + } + + const nextIdx = idx + 1; + // Last chapter + if (nextIdx >= this.chapters.length) { + return chapterGroup.page < this.pageNum; + } + + // Passed chapter, and next chapter has not been reached + const next = this.chapters[nextIdx]; + return chapterGroup.page < this.pageNum && next.page > this.pageNum; + } + + isAnchorSelected(chapter: BookChapterItem) { + return this.cleanIdSelector(chapter.part) === this.currentPageAnchor + } + } diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index dc89c563c..c0a4c0890 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -94,7 +94,7 @@ @if (actions && actions.length > 0) { - + }
    diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index 6bdbcaf18..37de9ca13 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -26,7 +26,7 @@ import {Series} from 'src/app/_models/series'; import {User} from 'src/app/_models/user'; import {Volume} from 'src/app/_models/volume'; import {AccountService} from 'src/app/_services/account.service'; -import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; +import {Action, ActionableEntity, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service'; import {ImageService} from 'src/app/_services/image.service'; import {LibraryService} from 'src/app/_services/library.service'; import {EVENTS, MessageHubService} from 'src/app/_services/message-hub.service'; @@ -118,6 +118,10 @@ export class CardItemComponent implements OnInit { * This is the entity we are representing. It will be returned if an action is executed. */ @Input({required: true}) entity!: CardEntity; + /** + * An optional entity to be used in the action callback + */ + @Input() actionEntity: ActionableEntity | null = null; /** * If the entity is selected or not. */ diff --git a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html index 09923b239..599d1c156 100644 --- a/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html +++ b/UI/Web/src/app/collections/_components/all-collections/all-collections.component.html @@ -14,7 +14,7 @@ [trackByIdentity]="trackByIdentity" > - { if (!user) return; - this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this)) + this.collectionTagActions = this.actionFactoryService.getCollectionTagActions( + this.handleCollectionActionCallback.bind(this), this.shouldRenderCollection.bind(this)) .filter(action => this.collectionService.actionListFilter(action, user)); this.cdRef.markForCheck(); }); } + shouldRenderCollection(action: ActionItem, entity: UserCollection, user: User) { + switch (action.action) { + case Action.Promote: + return !entity.promoted; + case Action.UnPromote: + return entity.promoted; + default: + return true; + } + } + loadCollection(item: UserCollection) { this.router.navigate(['collections', item.id]); } diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html index 927316f99..1fad4b6e8 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.html @@ -11,7 +11,7 @@ } -
    {{t('item-count', {num: series.length})}}
    +
    {{t('item-count', {num: series.length})}}
    } diff --git a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts index ceb539718..d99626b64 100644 --- a/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/_components/collection-detail/collection-detail.component.ts @@ -207,7 +207,8 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(user => { if (!user) return; this.user = user; - this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this)) + this.collectionTagActions = this.actionFactoryService.getCollectionTagActions( + this.handleCollectionActionCallback.bind(this), this.shouldRenderCollection.bind(this)) .filter(action => this.collectionService.actionListFilter(action, user)); this.cdRef.markForCheck(); }); @@ -225,6 +226,17 @@ export class CollectionDetailComponent implements OnInit, AfterContentChecked { }); } + shouldRenderCollection(action: ActionItem, entity: UserCollection, user: User) { + switch (action.action) { + case Action.Promote: + return !entity.promoted; + case Action.UnPromote: + return entity.promoted; + default: + return true; + } + } + ngAfterContentChecked(): void { this.scrollService.setScrollContainer(this.scrollingBlock); } diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index 595ae6079..a7bbe2d90 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -70,6 +70,7 @@ import {LoadingComponent} from '../../../shared/loading/loading.component'; import {translate, TranslocoDirective} from "@jsverse/transloco"; import {shareReplay} from "rxjs/operators"; import {DblClickDirective} from "../../../_directives/dbl-click.directive"; +import {ConfirmService} from "../../../shared/confirm.service"; const PREFETCH_PAGES = 10; @@ -150,9 +151,11 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { private readonly modalService = inject(NgbModal); private readonly cdRef = inject(ChangeDetectorRef); private readonly toastr = inject(ToastrService); - public readonly readerService = inject(ReaderService); - public readonly utilityService = inject(UtilityService); - public readonly mangaReaderService = inject(MangaReaderService); + private readonly confirmService = inject(ConfirmService); + protected readonly readerService = inject(ReaderService); + protected readonly utilityService = inject(UtilityService); + protected readonly mangaReaderService = inject(MangaReaderService); + protected readonly KeyDirection = KeyDirection; protected readonly ReaderMode = ReaderMode; @@ -647,7 +650,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } @HostListener('window:keyup', ['$event']) - handleKeyPress(event: KeyboardEvent) { + async handleKeyPress(event: KeyboardEvent) { switch (this.readerMode) { case ReaderMode.LeftRight: if (event.key === KEY_CODES.RIGHT_ARROW) { @@ -682,7 +685,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } else if (event.key === KEY_CODES.SPACE) { this.toggleMenu(); } else if (event.key === KEY_CODES.G) { - const goToPageNum = this.promptForPage(); + const goToPageNum = await this.promptForPage(); if (goToPageNum === null) { return; } this.goToPage(parseInt(goToPageNum.trim(), 10)); } else if (event.key === KEY_CODES.B) { @@ -1593,9 +1596,16 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } // This is menu only code - promptForPage() { - const question = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages}); - const goToPageNum = window.prompt(question, ''); + async promptForPage() { + // const question = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages}); + // const goToPageNum = window.prompt(question, ''); + + const promptConfig = {...this.confirmService.defaultPrompt}; + promptConfig.header = translate('book-reader.go-to-page'); + promptConfig.content = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages}); + + const goToPageNum = await this.confirmService.prompt(undefined, promptConfig); + if (goToPageNum === null || goToPageNum.trim().length === 0) { return null; } return goToPageNum; } diff --git a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html index 6f36e9b5a..4a51435fc 100644 --- a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html +++ b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.html @@ -16,7 +16,7 @@ } } @else { -
    +
    Ctrl+K
    } diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html index 9f45cd55a..1d1ce4c7e 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html @@ -83,7 +83,7 @@ } -
    +
    diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts index 511811fe8..6e8e3b22a 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts @@ -58,6 +58,7 @@ import {DefaultValuePipe} from "../../../_pipes/default-value.pipe"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {DetailsTabComponent} from "../../../_single-module/details-tab/details-tab.component"; import {IHasCast} from "../../../_models/common/i-has-cast"; +import {User} from "../../../_models/user"; enum TabID { Storyline = 'storyline-tab', @@ -251,7 +252,8 @@ export class ReadingListDetailComponent implements OnInit { if (user) { this.isAdmin = this.accountService.hasAdminRole(user); - this.actions = this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) + this.actions = this.actionFactoryService + .getReadingListActions(this.handleReadingListActionCallback.bind(this), this.shouldRenderReadingListAction.bind(this)) .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin)); this.isOwnedReadingList = this.actions.filter(a => a.action === Action.Edit).length > 0; this.cdRef.markForCheck(); @@ -307,6 +309,17 @@ export class ReadingListDetailComponent implements OnInit { } } + shouldRenderReadingListAction(action: ActionItem, entity: ReadingList, user: User) { + switch (action.action) { + case Action.Promote: + return !entity.promoted; + case Action.UnPromote: + return entity.promoted; + default: + return true; + } + } + editReadingList(readingList: ReadingList) { this.actionService.editReadingList(readingList, (readingList: ReadingList) => { // Reload information around list diff --git a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html index dd7dcab9a..a66ec008f 100644 --- a/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-lists/reading-lists.component.html @@ -21,7 +21,7 @@ [trackByIdentity]="trackByIdentity" > - this.readingListService.actionListFilter(action, readingList, this.isAdmin || this.hasPromote)); - - return this.actionFactoryService.getReadingListActions(this.handleReadingListActionCallback.bind(this)) + return this.actionFactoryService + .getReadingListActions(this.handleReadingListActionCallback.bind(this), this.shouldRenderReadingListAction.bind(this)) .filter(action => this.readingListService.actionListFilter(action, readingList, this.isAdmin || this.hasPromote)); } @@ -172,4 +171,15 @@ export class ReadingListsComponent implements OnInit { break; } } + + shouldRenderReadingListAction(action: ActionItem, entity: ReadingList, user: User) { + switch (action.action) { + case Action.Promote: + return !entity.promoted; + case Action.UnPromote: + return entity.promoted; + default: + return true; + } + } } diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 05580bed0..6353664f3 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -551,7 +551,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.location.replaceState(newUrl) } - handleSeriesActionCallback(action: ActionItem, series: Series) { + async handleSeriesActionCallback(action: ActionItem, series: Series) { this.cdRef.markForCheck(); switch(action.action) { case(Action.MarkAsRead): @@ -565,16 +565,16 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { }); break; case(Action.Scan): - this.actionService.scanSeries(series); + await this.actionService.scanSeries(series); break; case(Action.RefreshMetadata): - this.actionService.refreshSeriesMetadata(series, undefined, true, false); + await this.actionService.refreshSeriesMetadata(series, undefined, true, false); break; case(Action.GenerateColorScape): - this.actionService.refreshSeriesMetadata(series, undefined, false, true); + await this.actionService.refreshSeriesMetadata(series, undefined, false, true); break; case(Action.Delete): - this.deleteSeries(series); + await this.deleteSeries(series); break; case(Action.AddToReadingList): this.actionService.addSeriesToReadingList(series); @@ -645,6 +645,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.actionService.sendToDevice(volume.chapters.map(c => c.id), device); break; } + case (Action.Download): + this.downloadService.download('volume', volume); + break; default: break; } @@ -679,6 +682,9 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { this.cdRef.markForCheck(); }); break; + case (Action.Download): + this.downloadService.download('chapter', chapter); + break; default: break; } diff --git a/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts b/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts index 481c9b48c..7cfd257e2 100644 --- a/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts +++ b/UI/Web/src/app/shared/confirm-dialog/_models/confirm-config.ts @@ -1,7 +1,7 @@ -import { ConfirmButton } from './confirm-button'; +import {ConfirmButton} from './confirm-button'; export class ConfirmConfig { - _type: 'confirm' | 'alert' | 'info' = 'confirm'; + _type: 'confirm' | 'alert' | 'info' | 'prompt' = 'confirm'; header: string = 'Confirm'; content: string = ''; buttons: Array = []; diff --git a/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html b/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html index 21b741cd3..213c80ceb 100644 --- a/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html +++ b/UI/Web/src/app/shared/confirm-dialog/confirm-dialog.component.html @@ -5,8 +5,18 @@ }
    - + + @if (config._type === 'prompt') { + + } @else { + + } +
    diff --git a/UI/Web/src/app/typeahead/_models/selection-model.ts b/UI/Web/src/app/typeahead/_models/selection-model.ts index c4b2ab18a..8493a4eed 100644 --- a/UI/Web/src/app/typeahead/_models/selection-model.ts +++ b/UI/Web/src/app/typeahead/_models/selection-model.ts @@ -70,6 +70,28 @@ export class SelectionModel { return (selectedCount !== this._data.length && selectedCount !== 0) } + /** + * @return If at least one item is selected + */ + hasAnySelected(): boolean { + for (const d of this._data) { + if (d.selected) { + return true; + } + } + return false; + } + + /** + * Marks every data entry has not selected + */ + clearSelected() { + this._data = this._data.map(d => { + d.selected = false; + return d; + }); + } + /** * * @returns All Selected items diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 2a2d40c4f..91a3dac9e 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -42,6 +42,8 @@ "series-header": "Series", "data-header": "Data", "is-processed-header": "Is Processed", + "select-all-label": "Select all", + "delete-selected-label": "Delete selected", "no-data": "{{common.no-data}}", "volume-and-chapter-num": "Volume {{v}} Chapter {{n}}", "volume-num": "Volume {{num}}", From 225572732f44aadbe05b65d14be0c4e6b2cc88a1 Mon Sep 17 00:00:00 2001 From: majora2007 Date: Fri, 20 Jun 2025 19:10:12 +0000 Subject: [PATCH 27/47] Bump versions by dotnet-bump-version. --- Kavita.Common/Kavita.Common.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 9b590f97c..081ab80ca 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -3,7 +3,7 @@ net9.0 kavitareader.com Kavita - 0.8.6.16 + 0.8.6.17 en true From fa8d778c8da2e9bad77336c5ac4c03eae43a3575 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 20 Jun 2025 19:11:24 +0000 Subject: [PATCH 28/47] Update OpenAPI documentation --- openapi.json | 55 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/openapi.json b/openapi.json index 209dfe2ef..5f50b88f7 100644 --- a/openapi.json +++ b/openapi.json @@ -2,12 +2,12 @@ "openapi": "3.0.4", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.15", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.16", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.8.6.15" + "version": "0.8.6.16" }, "servers": [ { @@ -10522,7 +10522,7 @@ "tags": [ "Scrobbling" ], - "summary": "Adds a hold against the Series for user's scrobbling", + "summary": "Remove a hold against the Series for user's scrobbling", "parameters": [ { "name": "seriesId", @@ -10571,6 +10571,51 @@ } } }, + "/api/Scrobbling/bulk-remove-events": { + "post": { + "tags": [ + "Scrobbling" + ], + "summary": "Delete the given scrobble events if they belong to that user", + "requestBody": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + }, + "text/json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + }, + "application/*+json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64" + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/Search/series-for-mangafile": { "get": { "tags": [ @@ -23505,6 +23550,10 @@ "ScrobbleEventDto": { "type": "object", "properties": { + "id": { + "type": "integer", + "format": "int64" + }, "seriesName": { "type": "string", "nullable": true From 36aa5f5c85cd49b13200c15f933f52548082a6e7 Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Mon, 23 Jun 2025 18:57:14 -0500 Subject: [PATCH 29/47] Ability to turn off Metadata Parsing (#3872) --- API.Benchmark/API.Benchmark.csproj | 4 +- API.Tests/API.Tests.csproj | 6 +- API.Tests/Parsers/ComicVineParserTests.cs | 8 +- API.Tests/Parsers/DefaultParserTests.cs | 20 +- API.Tests/Parsers/ImageParserTests.cs | 6 +- API.Tests/Parsers/PdfParserTests.cs | 2 +- API.Tests/Parsing/ImageParsingTests.cs | 6 +- API.Tests/Parsing/MangaParsingTests.cs | 2 - API.Tests/Services/BookServiceTests.cs | 2 +- API.Tests/Services/CacheServiceTests.cs | 4 +- .../Services/ExternalMetadataServiceTests.cs | 2 +- API.Tests/Services/ParseScannedFilesTests.cs | 16 +- API.Tests/Services/ScannerServiceTests.cs | 38 +- ...es with Localized No Metadata - Manga.json | 5 + API/API.csproj | 38 +- API/Controllers/LibraryController.cs | 1 + .../ExternalMetadataIdsDto.cs | 2 +- .../ExternalMetadata/MatchSeriesRequestDto.cs | 2 +- .../SeriesDetailPlusApiDto.cs | 2 +- .../KavitaPlus/Metadata/ExternalChapterDto.cs | 1 + API/DTOs/LibraryDto.cs | 4 + API/DTOs/UpdateLibraryDto.cs | 2 + API/Data/DataContext.cs | 3 + ...20215058_EnableMetadataLibrary.Designer.cs | 3709 +++++++++++++++++ .../20250620215058_EnableMetadataLibrary.cs | 29 + .../Migrations/DataContextModelSnapshot.cs | 7 +- API/Entities/Library.cs | 4 + .../RestrictByLibraryExtensions.cs | 0 API/Helpers/Builders/LibraryBuilder.cs | 6 + API/Services/Plus/ExternalMetadataService.cs | 47 +- API/Services/Plus/KavitaPlusApiService.cs | 53 +- API/Services/ReadingItemService.cs | 20 +- .../Tasks/Scanner/ParseScannedFiles.cs | 4 +- .../Tasks/Scanner/Parser/BasicParser.cs | 11 +- .../Tasks/Scanner/Parser/BookParser.cs | 4 +- .../Tasks/Scanner/Parser/ComicVineParser.cs | 7 +- .../Tasks/Scanner/Parser/DefaultParser.cs | 5 +- .../Tasks/Scanner/Parser/ImageParser.cs | 2 +- API/Services/Tasks/Scanner/Parser/Parser.cs | 4 +- .../Tasks/Scanner/Parser/PdfParser.cs | 16 +- API/Services/Tasks/ScannerService.cs | 5 + API/SignalR/MessageFactory.cs | 16 + Kavita.Common/Configuration.cs | 2 +- Kavita.Common/Kavita.Common.csproj | 8 +- UI/Web/src/_tag-card-common.scss | 5 + UI/Web/src/app/_helpers/form-debug.ts | 120 + .../external-match-rate-limit-error-event.ts | 4 + UI/Web/src/app/_models/library/library.ts | 1 + .../src/app/_services/message-hub.service.ts | 30 +- UI/Web/src/app/_services/reader.service.ts | 8 +- .../manage-matched-metadata.component.ts | 22 +- .../browse-genres.component.html | 2 +- .../browse-genres/browse-genres.component.ts | 10 +- .../browse-tags/browse-tags.component.html | 2 +- .../browse-tags/browse-tags.component.ts | 10 +- .../reading-list-detail.component.html | 9 +- .../reading-list-detail.component.ts | 10 +- .../reading-list-item.component.html | 8 +- .../reading-list-item.component.ts | 10 +- .../external-rating.component.ts | 5 +- .../library-settings-modal.component.html | 10 + .../library-settings-modal.component.ts | 37 +- UI/Web/src/assets/langs/en.json | 5 +- 63 files changed, 4257 insertions(+), 186 deletions(-) create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized No Metadata - Manga.json create mode 100644 API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs create mode 100644 API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs create mode 100644 API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs create mode 100644 UI/Web/src/app/_helpers/form-debug.ts create mode 100644 UI/Web/src/app/_models/events/external-match-rate-limit-error-event.ts diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj index d6fd4eb9f..ec9c1884f 100644 --- a/API.Benchmark/API.Benchmark.csproj +++ b/API.Benchmark/API.Benchmark.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 73b886e13..a571a6e72 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -6,13 +6,13 @@ - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/API.Tests/Parsers/ComicVineParserTests.cs b/API.Tests/Parsers/ComicVineParserTests.cs index f01e98afd..2f4fd568e 100644 --- a/API.Tests/Parsers/ComicVineParserTests.cs +++ b/API.Tests/Parsers/ComicVineParserTests.cs @@ -36,7 +36,7 @@ public class ComicVineParserTests public void Parse_SeriesWithComicInfo() { var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/", - RootDirectory, LibraryType.ComicVine, new ComicInfo() + RootDirectory, LibraryType.ComicVine, true, new ComicInfo() { Series = "Birds of Prey", Volume = "2002" @@ -54,7 +54,7 @@ public class ComicVineParserTests public void Parse_SeriesWithDirectoryNameAsSeriesYear() { var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/", - RootDirectory, LibraryType.ComicVine, null); + RootDirectory, LibraryType.ComicVine, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey (2002)", actual.Series); @@ -69,7 +69,7 @@ public class ComicVineParserTests public void Parse_SeriesWithADirectoryNameAsSeriesYear() { var actual = _parser.Parse("C:/Comics/DC Comics/Birds of Prey (1999)/Birds of Prey 001 (1999).cbz", "C:/Comics/DC Comics/", - RootDirectory, LibraryType.ComicVine, null); + RootDirectory, LibraryType.ComicVine, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey (1999)", actual.Series); @@ -84,7 +84,7 @@ public class ComicVineParserTests public void Parse_FallbackToDirectoryNameOnly() { var actual = _parser.Parse("C:/Comics/DC Comics/Blood Syndicate/Blood Syndicate 001 (1999).cbz", "C:/Comics/DC Comics/", - RootDirectory, LibraryType.ComicVine, null); + RootDirectory, LibraryType.ComicVine, true, null); Assert.NotNull(actual); Assert.Equal("Blood Syndicate", actual.Series); diff --git a/API.Tests/Parsers/DefaultParserTests.cs b/API.Tests/Parsers/DefaultParserTests.cs index 733b55d62..244c08b97 100644 --- a/API.Tests/Parsers/DefaultParserTests.cs +++ b/API.Tests/Parsers/DefaultParserTests.cs @@ -33,7 +33,7 @@ public class DefaultParserTests [InlineData("C:/", "C:/Something Random/Mujaki no Rakuen SP01.cbz", "Something Random")] public void ParseFromFallbackFolders_FallbackShouldParseSeries(string rootDir, string inputPath, string expectedSeries) { - var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, null); + var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, true, null); if (actual == null) { Assert.NotNull(actual); @@ -74,7 +74,7 @@ public class DefaultParserTests fs.AddFile(inputFile, new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), fs); var parser = new BasicParser(ds, new ImageParser(ds)); - var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null); + var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, true, null); _defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual); Assert.Equal(expectedParseInfo, actual.Series); } @@ -90,7 +90,7 @@ public class DefaultParserTests fs.AddFile(inputFile, new MockFileData("")); var ds = new DirectoryService(Substitute.For>(), fs); var parser = new BasicParser(ds, new ImageParser(ds)); - var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null); + var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, true, null); _defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual); Assert.Equal(expectedParseInfo, actual.Series); } @@ -251,7 +251,7 @@ public class DefaultParserTests foreach (var file in expected.Keys) { var expectedInfo = expected[file]; - var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, null); + var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, true, null); if (expectedInfo == null) { Assert.Null(actual); @@ -289,7 +289,7 @@ public class DefaultParserTests Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image, FullFilePath = filepath, IsSpecial = false }; - var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, null); + var actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Monster #8", "E:/Manga", LibraryType.Manga, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -315,7 +315,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, null); + actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga",LibraryType.Manga, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -341,7 +341,7 @@ public class DefaultParserTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, null); + actual2 = _defaultParser.Parse(filepath, @"E:/Manga/Extra layer for no reason/", "E:/Manga", LibraryType.Manga, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -383,7 +383,7 @@ public class DefaultParserTests FullFilePath = filepath }; - var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null); + var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, true, null); Assert.NotNull(actual); _testOutputHelper.WriteLine($"Validating {filepath}"); @@ -412,7 +412,7 @@ public class DefaultParserTests FullFilePath = filepath }; - actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null); + actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, true, null); Assert.NotNull(actual); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expected.Format, actual.Format); @@ -475,7 +475,7 @@ public class DefaultParserTests foreach (var file in expected.Keys) { var expectedInfo = expected[file]; - var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, null); + var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, true, null); if (expectedInfo == null) { Assert.Null(actual); diff --git a/API.Tests/Parsers/ImageParserTests.cs b/API.Tests/Parsers/ImageParserTests.cs index f95c98ddf..63df1926e 100644 --- a/API.Tests/Parsers/ImageParserTests.cs +++ b/API.Tests/Parsers/ImageParserTests.cs @@ -34,7 +34,7 @@ public class ImageParserTests public void Parse_SeriesWithDirectoryName() { var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01/01.jpg", "C:/Comics/Birds of Prey/", - RootDirectory, LibraryType.Image, null); + RootDirectory, LibraryType.Image, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey", actual.Series); @@ -48,7 +48,7 @@ public class ImageParserTests public void Parse_SeriesWithNoNestedChapter() { var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01 page 01.jpg", "C:/Comics/", - RootDirectory, LibraryType.Image, null); + RootDirectory, LibraryType.Image, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey", actual.Series); @@ -62,7 +62,7 @@ public class ImageParserTests public void Parse_SeriesWithLooseImages() { var actual = _parser.Parse("C:/Comics/Birds of Prey/page 01.jpg", "C:/Comics/", - RootDirectory, LibraryType.Image, null); + RootDirectory, LibraryType.Image, true, null); Assert.NotNull(actual); Assert.Equal("Birds of Prey", actual.Series); diff --git a/API.Tests/Parsers/PdfParserTests.cs b/API.Tests/Parsers/PdfParserTests.cs index 72088526d..08bf9f25d 100644 --- a/API.Tests/Parsers/PdfParserTests.cs +++ b/API.Tests/Parsers/PdfParserTests.cs @@ -35,7 +35,7 @@ public class PdfParserTests { var actual = _parser.Parse("C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/A Dictionary of Japanese Food - Ingredients and Culture.pdf", "C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/", - RootDirectory, LibraryType.Book, null); + RootDirectory, LibraryType.Book, true, null); Assert.NotNull(actual); Assert.Equal("A Dictionary of Japanese Food - Ingredients and Culture", actual.Series); diff --git a/API.Tests/Parsing/ImageParsingTests.cs b/API.Tests/Parsing/ImageParsingTests.cs index 3d78d9372..362b4b08c 100644 --- a/API.Tests/Parsing/ImageParsingTests.cs +++ b/API.Tests/Parsing/ImageParsingTests.cs @@ -34,7 +34,7 @@ public class ImageParsingTests Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image, FullFilePath = filepath, IsSpecial = false }; - var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, null); + var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -60,7 +60,7 @@ public class ImageParsingTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null); + actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); @@ -86,7 +86,7 @@ public class ImageParsingTests FullFilePath = filepath, IsSpecial = false }; - actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null); + actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, true, null); Assert.NotNull(actual2); _testOutputHelper.WriteLine($"Validating {filepath}"); Assert.Equal(expectedInfo2.Format, actual2.Format); diff --git a/API.Tests/Parsing/MangaParsingTests.cs b/API.Tests/Parsing/MangaParsingTests.cs index 8b93c5f90..53f2bc4c9 100644 --- a/API.Tests/Parsing/MangaParsingTests.cs +++ b/API.Tests/Parsing/MangaParsingTests.cs @@ -68,10 +68,8 @@ public class MangaParsingTests [InlineData("Манга Тома 1-4", "1-4")] [InlineData("Манга Том 1-4", "1-4")] [InlineData("조선왕조실톡 106화", "106")] - [InlineData("죽음 13회", "13")] [InlineData("동의보감 13장", "13")] [InlineData("몰?루 아카이브 7.5권", "7.5")] - [InlineData("주술회전 1.5권", "1.5")] [InlineData("63권#200", "63")] [InlineData("시즌34삽화2", "34")] [InlineData("Accel World Chapter 001 Volume 002", "2")] diff --git a/API.Tests/Services/BookServiceTests.cs b/API.Tests/Services/BookServiceTests.cs index a80c1ca01..5848c74ba 100644 --- a/API.Tests/Services/BookServiceTests.cs +++ b/API.Tests/Services/BookServiceTests.cs @@ -137,7 +137,7 @@ public class BookServiceTests var comicInfo = _bookService.GetComicInfo(filePath); Assert.NotNull(comicInfo); - var parserInfo = pdfParser.Parse(filePath, testDirectory, ds.GetParentDirectoryName(testDirectory), LibraryType.Book, comicInfo); + var parserInfo = pdfParser.Parse(filePath, testDirectory, ds.GetParentDirectoryName(testDirectory), LibraryType.Book, true, comicInfo); Assert.NotNull(parserInfo); Assert.Equal(parserInfo.Title, comicInfo.Title); Assert.Equal(parserInfo.Series, comicInfo.Title); diff --git a/API.Tests/Services/CacheServiceTests.cs b/API.Tests/Services/CacheServiceTests.cs index 5c1752cd8..caf1ae393 100644 --- a/API.Tests/Services/CacheServiceTests.cs +++ b/API.Tests/Services/CacheServiceTests.cs @@ -50,12 +50,12 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService throw new System.NotImplementedException(); } - public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true) { throw new System.NotImplementedException(); } - public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true) { throw new System.NotImplementedException(); } diff --git a/API.Tests/Services/ExternalMetadataServiceTests.cs b/API.Tests/Services/ExternalMetadataServiceTests.cs index 833e8fe5f..8278f3b1a 100644 --- a/API.Tests/Services/ExternalMetadataServiceTests.cs +++ b/API.Tests/Services/ExternalMetadataServiceTests.cs @@ -42,7 +42,7 @@ public class ExternalMetadataServiceTests : AbstractDbTest _externalMetadataService = new ExternalMetadataService(UnitOfWork, Substitute.For>(), Mapper, Substitute.For(), Substitute.For(), Substitute.For(), - Substitute.For()); + Substitute.For(), Substitute.For()); } #region Gloabl diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index f8714f69a..a732b2526 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -58,35 +58,35 @@ public class MockReadingItemService : IReadingItemService throw new NotImplementedException(); } - public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { if (_comicVineParser.IsApplicable(path, type)) { - return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _comicVineParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_imageParser.IsApplicable(path, type)) { - return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _imageParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_bookParser.IsApplicable(path, type)) { - return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _bookParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_pdfParser.IsApplicable(path, type)) { - return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _pdfParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_basicParser.IsApplicable(path, type)) { - return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _basicParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } return null; } - public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) + public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { - return Parse(path, rootPath, libraryRoot, type); + return Parse(path, rootPath, libraryRoot, type, enableMetadata); } } diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 2e812647b..acc0345b1 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -483,7 +483,7 @@ public class ScannerServiceTests : AbstractDbTest var infos = new Dictionary(); var library = await _scannerHelper.GenerateScannerData(testcase, infos); - library.LibraryExcludePatterns = [new LibraryExcludePattern() {Pattern = "**/Extra/*"}]; + library.LibraryExcludePatterns = [new LibraryExcludePattern() { Pattern = "**/Extra/*" }]; UnitOfWork.LibraryRepository.Update(library); await UnitOfWork.CommitAsync(); @@ -507,7 +507,7 @@ public class ScannerServiceTests : AbstractDbTest var infos = new Dictionary(); var library = await _scannerHelper.GenerateScannerData(testcase, infos); - library.LibraryExcludePatterns = [new LibraryExcludePattern() {Pattern = "**\\Extra\\*"}]; + library.LibraryExcludePatterns = [new LibraryExcludePattern() { Pattern = "**\\Extra\\*" }]; UnitOfWork.LibraryRepository.Update(library); await UnitOfWork.CommitAsync(); @@ -938,4 +938,38 @@ public class ScannerServiceTests : AbstractDbTest Assert.True(sortedChapters[1].SortOrder.Is(4f)); Assert.True(sortedChapters[2].SortOrder.Is(5f)); } + + + [Fact] + public async Task ScanLibrary_MetadataDisabled_NoOverrides() + { + const string testcase = "Series with Localized No Metadata - Manga.json"; + + // Get the first file and generate a ComicInfo + var infos = new Dictionary(); + infos.Add("Immoral Guild v01.cbz", new ComicInfo() + { + Series = "Immoral Guild", + LocalizedSeries = "Futoku no Guild" // Filename has a capital N and localizedSeries has lowercase + }); + + var library = await _scannerHelper.GenerateScannerData(testcase, infos); + + // Disable metadata + library.EnableMetadata = false; + UnitOfWork.LibraryRepository.Update(library); + await UnitOfWork.CommitAsync(); + + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + + var postLib = await UnitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + // Validate that there are 2 series + Assert.NotNull(postLib); + Assert.Equal(2, postLib.Series.Count); + + Assert.Contains(postLib.Series, x => x.Name == "Immoral Guild"); + Assert.Contains(postLib.Series, x => x.Name == "Futoku No Guild"); + } } diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized No Metadata - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized No Metadata - Manga.json new file mode 100644 index 000000000..d6e91183b --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Series with Localized No Metadata - Manga.json @@ -0,0 +1,5 @@ +[ + "Immoral Guild/Immoral Guild v01.cbz", + "Immoral Guild/Immoral Guild v02.cbz", + "Immoral Guild/Futoku No Guild - Vol. 12 Ch. 67 - Take Responsibility.cbz" +] diff --git a/API/API.csproj b/API/API.csproj index 4eed66f22..a7d1177dc 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -50,9 +50,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -62,25 +62,25 @@ - + - + - - - - - + + + + + - - - + + + @@ -89,16 +89,16 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 2f12aa1fe..c09011b77 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -623,6 +623,7 @@ public class LibraryController : BaseApiController library.ManageReadingLists = dto.ManageReadingLists; library.AllowScrobbling = dto.AllowScrobbling; library.AllowMetadataMatching = dto.AllowMetadataMatching; + library.EnableMetadata = dto.EnableMetadata; library.LibraryFileTypes = dto.FileGroupTypes .Select(t => new LibraryFileTypeGroup() {FileTypeGroup = t, LibraryId = library.Id}) .Distinct() diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs index 2b7dea8e6..c05ff0567 100644 --- a/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs +++ b/API/DTOs/KavitaPlus/ExternalMetadata/ExternalMetadataIdsDto.cs @@ -6,7 +6,7 @@ namespace API.DTOs.KavitaPlus.ExternalMetadata; /// /// Used for matching and fetching metadata on a series /// -internal sealed record ExternalMetadataIdsDto +public sealed record ExternalMetadataIdsDto { public long? MalId { get; set; } public int? AniListId { get; set; } diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs index fae674ded..a7359d69b 100644 --- a/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs +++ b/API/DTOs/KavitaPlus/ExternalMetadata/MatchSeriesRequestDto.cs @@ -7,7 +7,7 @@ namespace API.DTOs.KavitaPlus.ExternalMetadata; /// /// Represents a request to match some series from Kavita to an external id which K+ uses. /// -internal sealed record MatchSeriesRequestDto +public sealed record MatchSeriesRequestDto { public required string SeriesName { get; set; } public ICollection AlternativeNames { get; set; } = []; diff --git a/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs index d0cbb7bd3..84e9bbf3e 100644 --- a/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs +++ b/API/DTOs/KavitaPlus/ExternalMetadata/SeriesDetailPlusApiDto.cs @@ -6,7 +6,7 @@ using API.DTOs.SeriesDetail; namespace API.DTOs.KavitaPlus.ExternalMetadata; -internal sealed record SeriesDetailPlusApiDto +public sealed record SeriesDetailPlusApiDto { public IEnumerable Recommendations { get; set; } public IEnumerable Reviews { get; set; } diff --git a/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs b/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs index 1dcd8494c..add9ca723 100644 --- a/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs +++ b/API/DTOs/KavitaPlus/Metadata/ExternalChapterDto.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using API.DTOs.SeriesDetail; namespace API.DTOs.KavitaPlus.Metadata; +#nullable enable /// /// Information about an individual issue/chapter/book from Kavita+ diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index 8ba687346..7b38379c9 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -66,4 +66,8 @@ public sealed record LibraryDto /// This does not exclude the library from being linked to wrt Series Relationships /// Requires a valid LicenseKey public bool AllowMetadataMatching { get; set; } = true; + /// + /// Allow Kavita to read metadata (ComicInfo.xml, Epub, PDF) + /// + public bool EnableMetadata { get; set; } = true; } diff --git a/API/DTOs/UpdateLibraryDto.cs b/API/DTOs/UpdateLibraryDto.cs index 9bd47fd39..68d2417ec 100644 --- a/API/DTOs/UpdateLibraryDto.cs +++ b/API/DTOs/UpdateLibraryDto.cs @@ -28,6 +28,8 @@ public sealed record UpdateLibraryDto public bool AllowScrobbling { get; init; } [Required] public bool AllowMetadataMatching { get; init; } + [Required] + public bool EnableMetadata { get; init; } /// /// What types of files to allow the scanner to pickup /// diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 3bbf45e23..aa8b67283 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -147,6 +147,9 @@ public sealed class DataContext : IdentityDbContext() .Property(b => b.AllowMetadataMatching) .HasDefaultValue(true); + builder.Entity() + .Property(b => b.EnableMetadata) + .HasDefaultValue(true); builder.Entity() .Property(b => b.WebLinks) diff --git a/API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs new file mode 100644 index 000000000..c15f9f77b --- /dev/null +++ b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.Designer.cs @@ -0,0 +1,3709 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20250620215058_EnableMetadataLibrary")] + partial class EnableMetadataLibrary + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs new file mode 100644 index 000000000..f9e38c01d --- /dev/null +++ b/API/Data/Migrations/20250620215058_EnableMetadataLibrary.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class EnableMetadataLibrary : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EnableMetadata", + table: "Library", + type: "INTEGER", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EnableMetadata", + table: "Library"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index c9fb953df..eb786865b 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.4"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -1296,6 +1296,11 @@ namespace API.Data.Migrations b.Property("CreatedUtc") .HasColumnType("TEXT"); + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + b.Property("FolderWatching") .HasColumnType("INTEGER"); diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index abab81378..8dc386298 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -48,6 +48,10 @@ public class Library : IEntityDate, IHasCoverImage /// This does not exclude the library from being linked to wrt Series Relationships /// Requires a valid LicenseKey public bool AllowMetadataMatching { get; set; } = true; + /// + /// Should Kavita read metadata files from the library + /// + public bool EnableMetadata { get; set; } = true; public DateTime Created { get; set; } diff --git a/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs b/API/Extensions/QueryExtensions/RestrictByLibraryExtensions.cs new file mode 100644 index 000000000..e69de29bb diff --git a/API/Helpers/Builders/LibraryBuilder.cs b/API/Helpers/Builders/LibraryBuilder.cs index 30e6136a5..950c5d3d2 100644 --- a/API/Helpers/Builders/LibraryBuilder.cs +++ b/API/Helpers/Builders/LibraryBuilder.cs @@ -110,6 +110,12 @@ public class LibraryBuilder : IEntityBuilder return this; } + public LibraryBuilder WithEnableMetadata(bool enable) + { + _library.EnableMetadata = enable; + return this; + } + public LibraryBuilder WithAllowScrobbling(bool allowScrobbling) { _library.AllowScrobbling = allowScrobbling; diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 435727bda..1db334b91 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -67,6 +67,7 @@ public class ExternalMetadataService : IExternalMetadataService private readonly IScrobblingService _scrobblingService; private readonly IEventHub _eventHub; private readonly ICoverDbService _coverDbService; + private readonly IKavitaPlusApiService _kavitaPlusApiService; private readonly TimeSpan _externalSeriesMetadataCache = TimeSpan.FromDays(30); public static readonly HashSet NonEligibleLibraryTypes = [LibraryType.Comic, LibraryType.Book, LibraryType.Image]; @@ -82,7 +83,8 @@ public class ExternalMetadataService : IExternalMetadataService private static bool IsRomanCharacters(string input) => Regex.IsMatch(input, @"^[\p{IsBasicLatin}\p{IsLatin-1Supplement}]+$"); public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, - ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService) + ILicenseService licenseService, IScrobblingService scrobblingService, IEventHub eventHub, ICoverDbService coverDbService, + IKavitaPlusApiService kavitaPlusApiService) { _unitOfWork = unitOfWork; _logger = logger; @@ -91,6 +93,7 @@ public class ExternalMetadataService : IExternalMetadataService _scrobblingService = scrobblingService; _eventHub = eventHub; _coverDbService = coverDbService; + _kavitaPlusApiService = kavitaPlusApiService; FlurlConfiguration.ConfigureClientForUrl(Configuration.KavitaPlusApiUrl); } @@ -179,9 +182,7 @@ public class ExternalMetadataService : IExternalMetadataService _logger.LogDebug("Fetching Kavita+ for MAL Stacks for user {UserName}", user.MalUserName); var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var result = await ($"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stacks?username={user.MalUserName}") - .WithKavitaPlusHeaders(license) - .GetJsonAsync>(); + var result = await _kavitaPlusApiService.GetMalStacks(user.MalUserName, license); if (result == null) { @@ -207,7 +208,7 @@ public class ExternalMetadataService : IExternalMetadataService /// public async Task> MatchSeries(MatchSeriesDto dto) { - var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata | SeriesIncludes.Library); if (series == null) return []; @@ -239,14 +240,9 @@ public class ExternalMetadataService : IExternalMetadataService MalId = potentialMalId ?? ScrobblingService.GetMalId(series) }; - var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; - try { - var results = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/match-series") - .WithKavitaPlusHeaders(license, token) - .PostJsonAsync(matchRequest) - .ReceiveJson>(); + var results = await _kavitaPlusApiService.MatchSeries(matchRequest); // Some summaries can contain multiple
    s, we need to ensure it's only 1 foreach (var result in results) @@ -287,9 +283,7 @@ public class ExternalMetadataService : IExternalMetadataService } // This is for the Series drawer. We can get this extra information during the initial SeriesDetail call so it's all coming from the DB - - var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var details = await GetSeriesDetail(license, aniListId, malId, seriesId); + var details = await GetSeriesDetail(aniListId, malId, seriesId); return details; @@ -392,6 +386,9 @@ public class ExternalMetadataService : IExternalMetadataService { // We can't rethrow because Fix match is done in a background thread and Hangfire will requeue multiple times _logger.LogInformation(ex, "Rate limit hit for matching {SeriesName} with Kavita+", series.Name); + // Fire SignalR event about this + await _eventHub.SendMessageAsync(MessageFactory.ExternalMatchRateLimitError, + MessageFactory.ExternalMatchRateLimitErrorEvent(series.Id, series.Name)); } } @@ -442,16 +439,12 @@ public class ExternalMetadataService : IExternalMetadataService try { _logger.LogDebug("Fetching Kavita+ Series Detail data for {SeriesName}", string.IsNullOrEmpty(data.SeriesName) ? data.AniListId : data.SeriesName); - var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; - var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; SeriesDetailPlusApiDto? result = null; try { - result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") - .WithKavitaPlusHeaders(license, token) - .PostJsonAsync(data) - .ReceiveJson(); // This returns an AniListSeries and Match returns ExternalSeriesDto + // This returns an AniListSeries and Match returns ExternalSeriesDto + result = await _kavitaPlusApiService.GetSeriesDetail(data); } catch (FlurlHttpException ex) { @@ -466,11 +459,7 @@ public class ExternalMetadataService : IExternalMetadataService _logger.LogDebug("Hit rate limit, will retry in 3 seconds"); await Task.Delay(3000); - result = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") - .WithKavitaPlusHeaders(license, token) - .PostJsonAsync(data) - .ReceiveJson< - SeriesDetailPlusApiDto>(); + result = await _kavitaPlusApiService.GetSeriesDetail(data); } else if (errorMessage.Contains("Unknown Series")) { @@ -1777,7 +1766,7 @@ public class ExternalMetadataService : IExternalMetadataService /// /// /// - private async Task GetSeriesDetail(string license, int? aniListId, long? malId, int? seriesId) + private async Task GetSeriesDetail(int? aniListId, long? malId, int? seriesId) { var payload = new ExternalMetadataIdsDto() { @@ -1809,11 +1798,7 @@ public class ExternalMetadataService : IExternalMetadataService } try { - var token = (await _unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; - var ret = await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") - .WithKavitaPlusHeaders(license, token) - .PostJsonAsync(payload) - .ReceiveJson(); + var ret = await _kavitaPlusApiService.GetSeriesDetailById(payload); ret.Summary = StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(ret.Summary)); diff --git a/API/Services/Plus/KavitaPlusApiService.cs b/API/Services/Plus/KavitaPlusApiService.cs index cdf9471f8..ec4f414c3 100644 --- a/API/Services/Plus/KavitaPlusApiService.cs +++ b/API/Services/Plus/KavitaPlusApiService.cs @@ -1,6 +1,13 @@ #nullable enable +using System.Collections.Generic; using System.Threading.Tasks; +using API.Data; +using API.DTOs.Collection; +using API.DTOs.KavitaPlus.ExternalMetadata; +using API.DTOs.KavitaPlus.Metadata; +using API.DTOs.Metadata.Matching; using API.DTOs.Scrobbling; +using API.Entities.Enums; using API.Extensions; using Flurl.Http; using Kavita.Common; @@ -17,9 +24,13 @@ public interface IKavitaPlusApiService Task HasTokenExpired(string license, string token, ScrobbleProvider provider); Task GetRateLimit(string license, string token); Task PostScrobbleUpdate(ScrobbleDto data, string license); + Task> GetMalStacks(string malUsername, string license); + Task> MatchSeries(MatchSeriesRequestDto request); + Task GetSeriesDetail(PlusSeriesRequestDto request); + Task GetSeriesDetailById(ExternalMetadataIdsDto request); } -public class KavitaPlusApiService(ILogger logger): IKavitaPlusApiService +public class KavitaPlusApiService(ILogger logger, IUnitOfWork unitOfWork): IKavitaPlusApiService { private const string ScrobblingPath = "/api/scrobbling/"; @@ -42,6 +53,46 @@ public class KavitaPlusApiService(ILogger logger): IKavita return await PostAndReceive(ScrobblingPath + "update", data, license); } + public async Task> GetMalStacks(string malUsername, string license) + { + return await $"{Configuration.KavitaPlusApiUrl}/api/metadata/v2/stacks?username={malUsername}" + .WithKavitaPlusHeaders(license) + .GetJsonAsync>(); + } + + public async Task> MatchSeries(MatchSeriesRequestDto request) + { + var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + + return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/match-series") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(request) + .ReceiveJson>(); + } + + public async Task GetSeriesDetail(PlusSeriesRequestDto request) + { + var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + + return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-detail") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(request) + .ReceiveJson(); + } + + public async Task GetSeriesDetailById(ExternalMetadataIdsDto request) + { + var license = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value; + var token = (await unitOfWork.UserRepository.GetDefaultAdminUser()).AniListAccessToken; + + return await (Configuration.KavitaPlusApiUrl + "/api/metadata/v2/series-by-ids") + .WithKavitaPlusHeaders(license, token) + .PostJsonAsync(request) + .ReceiveJson(); + } + /// /// Send a GET request to K+ /// diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index efdaec8ff..6ff8d19de 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -12,7 +12,7 @@ public interface IReadingItemService int GetNumberOfPages(string filePath, MangaFormat format); string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default); void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1); - ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type); + ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata); } public class ReadingItemService : IReadingItemService @@ -71,11 +71,12 @@ public class ReadingItemService : IReadingItemService /// Path of a file /// /// Library type to determine parsing to perform - public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type) + /// Enable Metadata parsing overriding filename parsing + public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { try { - var info = Parse(path, rootPath, libraryRoot, type); + var info = Parse(path, rootPath, libraryRoot, type, enableMetadata); if (info == null) { _logger.LogError("Unable to parse any meaningful information out of file {FilePath}", path); @@ -174,28 +175,29 @@ public class ReadingItemService : IReadingItemService /// /// /// + /// /// - private ParserInfo? Parse(string path, string rootPath, string libraryRoot, LibraryType type) + private ParserInfo? Parse(string path, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata) { if (_comicVineParser.IsApplicable(path, type)) { - return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _comicVineParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_imageParser.IsApplicable(path, type)) { - return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _imageParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_bookParser.IsApplicable(path, type)) { - return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _bookParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_pdfParser.IsApplicable(path, type)) { - return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _pdfParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } if (_basicParser.IsApplicable(path, type)) { - return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + return _basicParser.Parse(path, rootPath, libraryRoot, type, enableMetadata, GetComicInfo(path)); } return null; diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index c3f36ef2e..83558eaa0 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -804,7 +804,7 @@ public class ParseScannedFiles { // Process files sequentially result.ParserInfos = files - .Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type)) + .Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type, library.EnableMetadata)) .Where(info => info != null) .ToList()!; } @@ -812,7 +812,7 @@ public class ParseScannedFiles { // Process files in parallel var tasks = files.Select(file => Task.Run(() => - _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type))); + _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type, library.EnableMetadata))); var infos = await Task.WhenAll(tasks); result.ParserInfos = infos.Where(info => info != null).ToList()!; diff --git a/API/Services/Tasks/Scanner/Parser/BasicParser.cs b/API/Services/Tasks/Scanner/Parser/BasicParser.cs index 1462ab3d3..168ca7f01 100644 --- a/API/Services/Tasks/Scanner/Parser/BasicParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BasicParser.cs @@ -12,7 +12,7 @@ namespace API.Services.Tasks.Scanner.Parser; ///
    public class BasicParser(IDirectoryService directoryService, IDefaultParser imageParser) : DefaultParser(directoryService) { - public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null) { var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); // TODO: Potential Bug: This will return null, but on Image libraries, if all images, we would want to include this. @@ -20,7 +20,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag if (Parser.IsImage(filePath)) { - return imageParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Image, comicInfo); + return imageParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Image, enableMetadata, comicInfo); } var ret = new ParserInfo() @@ -101,7 +101,12 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag } // Patch in other information from ComicInfo - UpdateFromComicInfo(ret); + if (enableMetadata) + { + UpdateFromComicInfo(ret); + } + + if (ret.Volumes == Parser.LooseLeafVolume && ret.Chapters == Parser.DefaultChapter) { diff --git a/API/Services/Tasks/Scanner/Parser/BookParser.cs b/API/Services/Tasks/Scanner/Parser/BookParser.cs index 499e554ef..14f42c989 100644 --- a/API/Services/Tasks/Scanner/Parser/BookParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BookParser.cs @@ -5,7 +5,7 @@ namespace API.Services.Tasks.Scanner.Parser; public class BookParser(IDirectoryService directoryService, IBookService bookService, BasicParser basicParser) : DefaultParser(directoryService) { - public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo comicInfo = null) + public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null) { var info = bookService.ParseInfo(filePath); if (info == null) return null; @@ -35,7 +35,7 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer } else { - var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, comicInfo); + var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, enableMetadata, comicInfo); info.Merge(info2); if (hasVolumeInSeries && info2 != null && Parser.ParseVolume(info2.Series, type) .Equals(Parser.LooseLeafVolume)) diff --git a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs index b68596245..b60f28aee 100644 --- a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs +++ b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs @@ -19,7 +19,7 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser /// /// /// - public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null) { if (type != LibraryType.ComicVine) return null; @@ -81,7 +81,10 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser info.IsSpecial = Parser.IsSpecial(info.Filename, type) || Parser.IsSpecial(info.ComicInfo?.Format, type); // Patch in other information from ComicInfo - UpdateFromComicInfo(info); + if (enableMetadata) + { + UpdateFromComicInfo(info); + } if (string.IsNullOrEmpty(info.Series)) { diff --git a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs index 679d6a031..687617fd7 100644 --- a/API/Services/Tasks/Scanner/Parser/DefaultParser.cs +++ b/API/Services/Tasks/Scanner/Parser/DefaultParser.cs @@ -8,7 +8,7 @@ namespace API.Services.Tasks.Scanner.Parser; public interface IDefaultParser { - ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null); + ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null); void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret); bool IsApplicable(string filePath, LibraryType type); } @@ -26,8 +26,9 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau /// /// Root folder /// Allows different Regex to be used for parsing. + /// Allows overriding data from metadata (ComicInfo/pdf/epub) /// or null if Series was empty - public abstract ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null); + public abstract ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null); /// /// Fills out by trying to parse volume, chapters, and series from folders diff --git a/API/Services/Tasks/Scanner/Parser/ImageParser.cs b/API/Services/Tasks/Scanner/Parser/ImageParser.cs index 415533631..12f9f4d50 100644 --- a/API/Services/Tasks/Scanner/Parser/ImageParser.cs +++ b/API/Services/Tasks/Scanner/Parser/ImageParser.cs @@ -7,7 +7,7 @@ namespace API.Services.Tasks.Scanner.Parser; public class ImageParser(IDirectoryService directoryService) : DefaultParser(directoryService) { - public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null) + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo? comicInfo = null) { if (!IsApplicable(filePath, type)) return null; diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index c8eb010b3..c0b130f91 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -165,9 +165,9 @@ public static partial class Parser new Regex( @"(卷|册)(?\d+)", MatchOptions, RegexTimeout), - // Korean Volume: 제n화|권|회|장 -> Volume n, n화|권|회|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) + // Korean Volume: 제n화|회|장 -> Volume n, n화|권|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) new Regex( - @"제?(?\d+(\.\d+)?)(권|회|화|장)", + @"제?(?\d+(\.\d+)?)(권|화|장)", MatchOptions, RegexTimeout), // Korean Season: 시즌n -> Season n, new Regex( diff --git a/API/Services/Tasks/Scanner/Parser/PdfParser.cs b/API/Services/Tasks/Scanner/Parser/PdfParser.cs index bc12e2c77..80bfa9a48 100644 --- a/API/Services/Tasks/Scanner/Parser/PdfParser.cs +++ b/API/Services/Tasks/Scanner/Parser/PdfParser.cs @@ -6,7 +6,7 @@ namespace API.Services.Tasks.Scanner.Parser; public class PdfParser(IDirectoryService directoryService) : DefaultParser(directoryService) { - public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo comicInfo = null) + public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, bool enableMetadata = true, ComicInfo comicInfo = null) { var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); var ret = new ParserInfo @@ -68,14 +68,18 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret); } - // Patch in other information from ComicInfo - UpdateFromComicInfo(ret); - - if (comicInfo != null && !string.IsNullOrEmpty(comicInfo.Title)) + if (enableMetadata) { - ret.Title = comicInfo.Title.Trim(); + // Patch in other information from ComicInfo + UpdateFromComicInfo(ret); + + if (comicInfo != null && !string.IsNullOrEmpty(comicInfo.Title)) + { + ret.Title = comicInfo.Title.Trim(); + } } + if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && type == LibraryType.Book) { ret.IsSpecial = true; diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index e22ee4bb6..cb5f4302f 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -521,6 +521,11 @@ public class ScannerService : IScannerService // Validations are done, now we can start actual scan _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); + if (!library.EnableMetadata) + { + _logger.LogInformation("[ScannerService] Warning! {LibraryName} has metadata turned off", library.Name); + } + // This doesn't work for something like M:/Manga/ and a series has library folder as root var shouldUseLibraryScan = !(await _unitOfWork.LibraryRepository.DoAnySeriesFoldersMatch(libraryFolderPaths)); if (!shouldUseLibraryScan) diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index ba967d8a6..87a464e6a 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -152,6 +152,10 @@ public static class MessageFactory /// A Person merged has been merged into another /// public const string PersonMerged = "PersonMerged"; + /// + /// A Rate limit error was hit when matching a series with Kavita+ + /// + public const string ExternalMatchRateLimitError = "ExternalMatchRateLimitError"; public static SignalRMessage DashboardUpdateEvent(int userId) { @@ -679,4 +683,16 @@ public static class MessageFactory }, }; } + public static SignalRMessage ExternalMatchRateLimitErrorEvent(int seriesId, string seriesName) + { + return new SignalRMessage() + { + Name = ExternalMatchRateLimitError, + Body = new + { + seriesId = seriesId, + seriesName = seriesName, + }, + }; + } } diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index f2d64cde6..ba4fd09b7 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -17,7 +17,7 @@ public static class Configuration private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development - ? "http://localhost:5020" : "https://plus.kavitareader.com"; + ? "https://plus.kavitareader.com" : "https://plus.kavitareader.com"; // http://localhost:5020 public static readonly string StatsApiUrl = "https://stats.kavitareader.com"; public static int Port diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 081ab80ca..b920416bb 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -9,12 +9,12 @@ - + - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/UI/Web/src/_tag-card-common.scss b/UI/Web/src/_tag-card-common.scss index 07f37c2a0..39a1e87fd 100644 --- a/UI/Web/src/_tag-card-common.scss +++ b/UI/Web/src/_tag-card-common.scss @@ -5,6 +5,11 @@ box-shadow: 0 2px 5px rgba(0,0,0,0.2); transition: transform 0.2s ease, background 0.3s ease; cursor: pointer; + + &.not-selectable:hover { + cursor: not-allowed; + background-color: var(--bs-card-color, #2c2c2c) !important; + } } .tag-card:hover { diff --git a/UI/Web/src/app/_helpers/form-debug.ts b/UI/Web/src/app/_helpers/form-debug.ts new file mode 100644 index 000000000..4ad70ac87 --- /dev/null +++ b/UI/Web/src/app/_helpers/form-debug.ts @@ -0,0 +1,120 @@ +import {AbstractControl, FormArray, FormControl, FormGroup} from '@angular/forms'; + +interface ValidationIssue { + path: string; + controlType: string; + value: any; + errors: { [key: string]: any } | null; + status: string; + disabled: boolean; +} + +export function analyzeFormGroupValidation(formGroup: FormGroup, basePath: string = ''): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + function analyzeControl(control: AbstractControl, path: string): void { + // Determine control type for better debugging + let controlType = 'AbstractControl'; + if (control instanceof FormGroup) { + controlType = 'FormGroup'; + } else if (control instanceof FormArray) { + controlType = 'FormArray'; + } else if (control instanceof FormControl) { + controlType = 'FormControl'; + } + + // Add issue if control has validation errors or is invalid + if (control.invalid || control.errors || control.disabled) { + issues.push({ + path: path || 'root', + controlType, + value: control.value, + errors: control.errors, + status: control.status, + disabled: control.disabled + }); + } + + // Recursively check nested controls + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach(key => { + const childPath = path ? `${path}.${key}` : key; + analyzeControl(control.controls[key], childPath); + }); + } else if (control instanceof FormArray) { + control.controls.forEach((childControl, index) => { + const childPath = path ? `${path}[${index}]` : `[${index}]`; + analyzeControl(childControl, childPath); + }); + } + } + + analyzeControl(formGroup, basePath); + return issues; +} + +export function printFormGroupValidation(formGroup: FormGroup, basePath: string = ''): void { + const issues = analyzeFormGroupValidation(formGroup, basePath); + + console.group(`🔍 FormGroup Validation Analysis (${basePath || 'root'})`); + console.log(`Overall Status: ${formGroup.status}`); + console.log(`Overall Valid: ${formGroup.valid}`); + console.log(`Total Issues Found: ${issues.length}`); + + if (issues.length === 0) { + console.log('✅ No validation issues found!'); + } else { + console.log('\n📋 Detailed Issues:'); + issues.forEach((issue, index) => { + console.group(`${index + 1}. ${issue.path} (${issue.controlType})`); + console.log(`Status: ${issue.status}`); + console.log(`Value:`, issue.value); + console.log(`Disabled: ${issue.disabled}`); + + if (issue.errors) { + console.log('Validation Errors:'); + Object.entries(issue.errors).forEach(([errorKey, errorValue]) => { + console.log(` • ${errorKey}:`, errorValue); + }); + } else { + console.log('No specific validation errors (but control is invalid)'); + } + console.groupEnd(); + }); + } + + console.groupEnd(); +} + +// Alternative function that returns a formatted string instead of console logging +export function getFormGroupValidationReport(formGroup: FormGroup, basePath: string = ''): string { + const issues = analyzeFormGroupValidation(formGroup, basePath); + + let report = `FormGroup Validation Report (${basePath || 'root'})\n`; + report += `Overall Status: ${formGroup.status}\n`; + report += `Overall Valid: ${formGroup.valid}\n`; + report += `Total Issues Found: ${issues.length}\n\n`; + + if (issues.length === 0) { + report += '✅ No validation issues found!'; + } else { + report += 'Detailed Issues:\n'; + issues.forEach((issue, index) => { + report += `\n${index + 1}. ${issue.path} (${issue.controlType})\n`; + report += ` Status: ${issue.status}\n`; + report += ` Value: ${JSON.stringify(issue.value)}\n`; + report += ` Disabled: ${issue.disabled}\n`; + + if (issue.errors) { + report += ' Validation Errors:\n'; + Object.entries(issue.errors).forEach(([errorKey, errorValue]) => { + report += ` • ${errorKey}: ${JSON.stringify(errorValue)}\n`; + }); + } else { + report += ' No specific validation errors (but control is invalid)\n'; + } + }); + } + + return report; +} diff --git a/UI/Web/src/app/_models/events/external-match-rate-limit-error-event.ts b/UI/Web/src/app/_models/events/external-match-rate-limit-error-event.ts new file mode 100644 index 000000000..3695651d6 --- /dev/null +++ b/UI/Web/src/app/_models/events/external-match-rate-limit-error-event.ts @@ -0,0 +1,4 @@ +export interface ExternalMatchRateLimitErrorEvent { + seriesId: number; + seriesName: string; +} diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index bad83f54b..0e7d90ee2 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -31,6 +31,7 @@ export interface Library { manageReadingLists: boolean; allowScrobbling: boolean; allowMetadataMatching: boolean; + enableMetadata: boolean; collapseSeriesRelationships: boolean; libraryFileTypes: Array; excludePatterns: Array; diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 67f07f32e..f870d1449 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -1,15 +1,16 @@ -import { Injectable } from '@angular/core'; -import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; -import { BehaviorSubject, ReplaySubject } from 'rxjs'; -import { environment } from 'src/environments/environment'; -import { LibraryModifiedEvent } from '../_models/events/library-modified-event'; -import { NotificationProgressEvent } from '../_models/events/notification-progress-event'; -import { ThemeProgressEvent } from '../_models/events/theme-progress-event'; -import { UserUpdateEvent } from '../_models/events/user-update-event'; -import { User } from '../_models/user'; +import {Injectable} from '@angular/core'; +import {HubConnection, HubConnectionBuilder} from '@microsoft/signalr'; +import {BehaviorSubject, ReplaySubject} from 'rxjs'; +import {environment} from 'src/environments/environment'; +import {LibraryModifiedEvent} from '../_models/events/library-modified-event'; +import {NotificationProgressEvent} from '../_models/events/notification-progress-event'; +import {ThemeProgressEvent} from '../_models/events/theme-progress-event'; +import {UserUpdateEvent} from '../_models/events/user-update-event'; +import {User} from '../_models/user'; import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event"; import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event"; import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event"; +import {ExternalMatchRateLimitErrorEvent} from "../_models/events/external-match-rate-limit-error-event"; export enum EVENTS { UpdateAvailable = 'UpdateAvailable', @@ -114,6 +115,10 @@ export enum EVENTS { * A Person merged has been merged into another */ PersonMerged = 'PersonMerged', + /** + * A Rate limit error was hit when matching a series with Kavita+ + */ + ExternalMatchRateLimitError = 'ExternalMatchRateLimitError' } export interface Message { @@ -236,6 +241,13 @@ export class MessageHubService { }); }); + this.hubConnection.on(EVENTS.ExternalMatchRateLimitError, resp => { + this.messagesSource.next({ + event: EVENTS.ExternalMatchRateLimitError, + payload: resp.body as ExternalMatchRateLimitErrorEvent + }); + }); + this.hubConnection.on(EVENTS.NotificationProgress, (resp: NotificationProgressEvent) => { this.messagesSource.next({ event: EVENTS.NotificationProgress, diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 05958ee61..52aef2a4a 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -266,13 +266,13 @@ export class ReaderService { getQueryParamsObject(incognitoMode: boolean = false, readingListMode: boolean = false, readingListId: number = -1) { - let params: {[key: string]: any} = {}; - if (incognitoMode) { - params['incognitoMode'] = true; - } + const params: {[key: string]: any} = {}; + params['incognitoMode'] = incognitoMode; + if (readingListMode) { params['readingListId'] = readingListId; } + return params; } diff --git a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts index 223b309da..2a5582145 100644 --- a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts +++ b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts @@ -1,7 +1,7 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit} from '@angular/core'; import {LicenseService} from "../../_services/license.service"; import {Router} from "@angular/router"; -import {TranslocoDirective} from "@jsverse/transloco"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; import {ImageComponent} from "../../shared/image/image.component"; import {ImageService} from "../../_services/image.service"; import {Series} from "../../_models/series"; @@ -23,6 +23,8 @@ import {EVENTS, MessageHubService} from "../../_services/message-hub.service"; import {ScanSeriesEvent} from "../../_models/events/scan-series-event"; import {LibraryTypePipe} from "../../_pipes/library-type.pipe"; import {allKavitaPlusMetadataApplicableTypes} from "../../_models/library/library"; +import {ExternalMatchRateLimitErrorEvent} from "../../_models/events/external-match-rate-limit-error-event"; +import {ToastrService} from "ngx-toastr"; @Component({ selector: 'app-manage-matched-metadata', @@ -55,6 +57,7 @@ export class ManageMatchedMetadataComponent implements OnInit { private readonly manageService = inject(ManageService); private readonly messageHub = inject(MessageHubService); private readonly cdRef = inject(ChangeDetectorRef); + private readonly toastr = inject(ToastrService); protected readonly imageService = inject(ImageService); @@ -74,12 +77,19 @@ export class ManageMatchedMetadataComponent implements OnInit { } this.messageHub.messages$.subscribe(message => { - if (message.event !== EVENTS.ScanSeries) return; - - const evt = message.payload as ScanSeriesEvent; - if (this.data.filter(d => d.series.id === evt.seriesId).length > 0) { - this.loadData(); + if (message.event == EVENTS.ScanSeries) { + const evt = message.payload as ScanSeriesEvent; + if (this.data.filter(d => d.series.id === evt.seriesId).length > 0) { + this.loadData(); + } } + + if (message.event == EVENTS.ExternalMatchRateLimitError) { + const evt = message.payload as ExternalMatchRateLimitErrorEvent; + this.toastr.error(translate('toasts.external-match-rate-error', {seriesName: evt.seriesName})) + } + + }); this.filterGroup.valueChanges.pipe( diff --git a/UI/Web/src/app/browse/browse-genres/browse-genres.component.html b/UI/Web/src/app/browse/browse-genres/browse-genres.component.html index 5eef2c91f..8166ef12c 100644 --- a/UI/Web/src/app/browse/browse-genres/browse-genres.component.html +++ b/UI/Web/src/app/browse/browse-genres/browse-genres.component.html @@ -19,7 +19,7 @@ > -
    +
    {{ item.title }}
    {{t('series-count', {num: item.seriesCount | compactNumber})}} diff --git a/UI/Web/src/app/browse/browse-genres/browse-genres.component.ts b/UI/Web/src/app/browse/browse-genres/browse-genres.component.ts index 02c2a8ead..c46795e25 100644 --- a/UI/Web/src/app/browse/browse-genres/browse-genres.component.ts +++ b/UI/Web/src/app/browse/browse-genres/browse-genres.component.ts @@ -1,6 +1,6 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core'; import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component"; -import {DecimalPipe} from "@angular/common"; +import {DecimalPipe, NgClass} from "@angular/common"; import { SideNavCompanionBarComponent } from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; @@ -24,7 +24,8 @@ import {Title} from "@angular/platform-browser"; DecimalPipe, SideNavCompanionBarComponent, TranslocoDirective, - CompactNumberPipe + CompactNumberPipe, + NgClass ], templateUrl: './browse-genres.component.html', styleUrl: './browse-genres.component.scss', @@ -62,7 +63,8 @@ export class BrowseGenresComponent implements OnInit { }); } - openFilter(field: FilterField, value: string | number) { - this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe(); + openFilter(field: FilterField, genre: BrowseGenre) { + if (genre.seriesCount === 0) return; // We don't yet have an issue page + this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${genre.id}`).subscribe(); } } diff --git a/UI/Web/src/app/browse/browse-tags/browse-tags.component.html b/UI/Web/src/app/browse/browse-tags/browse-tags.component.html index dcd59bb1f..627e05584 100644 --- a/UI/Web/src/app/browse/browse-tags/browse-tags.component.html +++ b/UI/Web/src/app/browse/browse-tags/browse-tags.component.html @@ -19,7 +19,7 @@ > -
    +
    {{ item.title }}
    {{t('series-count', {num: item.seriesCount | compactNumber})}} diff --git a/UI/Web/src/app/browse/browse-tags/browse-tags.component.ts b/UI/Web/src/app/browse/browse-tags/browse-tags.component.ts index 92910b0b9..05abb6300 100644 --- a/UI/Web/src/app/browse/browse-tags/browse-tags.component.ts +++ b/UI/Web/src/app/browse/browse-tags/browse-tags.component.ts @@ -1,6 +1,6 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, OnInit} from '@angular/core'; import {CardDetailLayoutComponent} from "../../cards/card-detail-layout/card-detail-layout.component"; -import {DecimalPipe} from "@angular/common"; +import {DecimalPipe, NgClass} from "@angular/common"; import { SideNavCompanionBarComponent } from "../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; @@ -25,7 +25,8 @@ import {Title} from "@angular/platform-browser"; DecimalPipe, SideNavCompanionBarComponent, TranslocoDirective, - CompactNumberPipe + CompactNumberPipe, + NgClass ], templateUrl: './browse-tags.component.html', styleUrl: './browse-tags.component.scss', @@ -61,7 +62,8 @@ export class BrowseTagsComponent implements OnInit { }); } - openFilter(field: FilterField, value: string | number) { - this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${value}`).subscribe(); + openFilter(field: FilterField, tag: BrowseTag) { + if (tag.seriesCount === 0) return; // We don't yet have an issue page + this.filterUtilityService.applyFilter(['all-series'], field, FilterComparison.Equal, `${tag.id}`).subscribe(); } } diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html index 1d1ce4c7e..7433c26c3 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.html @@ -229,14 +229,17 @@
    } - + diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts index 6e8e3b22a..3056d7eb5 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts @@ -25,7 +25,8 @@ import {ImageService} from 'src/app/_services/image.service'; import {ReadingListService} from 'src/app/_services/reading-list.service'; import { DraggableOrderedListComponent, - IndexUpdateEvent + IndexUpdateEvent, + ItemRemoveEvent } from '../draggable-ordered-list/draggable-ordered-list.component'; import {forkJoin, startWith, tap} from 'rxjs'; import {ReaderService} from 'src/app/_services/reader.service'; @@ -321,6 +322,7 @@ export class ReadingListDetailComponent implements OnInit { } editReadingList(readingList: ReadingList) { + if (!readingList) return; this.actionService.editReadingList(readingList, (readingList: ReadingList) => { // Reload information around list this.readingListService.getReadingList(this.listId).subscribe(rl => { @@ -347,10 +349,10 @@ export class ReadingListDetailComponent implements OnInit { }); } - itemRemoved(item: ReadingListItem, position: number) { + removeItem(removeEvent: ItemRemoveEvent) { if (!this.readingList) return; - this.readingListService.deleteItem(this.readingList.id, item.id).subscribe(() => { - this.items.splice(position, 1); + this.readingListService.deleteItem(this.readingList.id, removeEvent.item.id).subscribe(() => { + this.items.splice(removeEvent.position, 1); this.items = [...this.items]; this.cdRef.markForCheck(); this.toastr.success(translate('toasts.item-removed')); diff --git a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html index 901ee270a..6421205ab 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html @@ -18,10 +18,10 @@ {{item.title}}
    @if (showRemove) { - } diff --git a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.ts b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.ts index acde50022..7ce6f6790 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.ts @@ -9,6 +9,7 @@ import {ImageComponent} from '../../../shared/image/image.component'; import {TranslocoDirective} from "@jsverse/transloco"; import {SeriesFormatComponent} from "../../../shared/series-format/series-format.component"; import {ReadMoreComponent} from "../../../shared/read-more/read-more.component"; +import {ItemRemoveEvent} from "../draggable-ordered-list/draggable-ordered-list.component"; @Component({ selector: 'app-reading-list-item', @@ -33,9 +34,16 @@ export class ReadingListItemComponent { @Input() promoted: boolean = false; @Output() read: EventEmitter = new EventEmitter(); - @Output() remove: EventEmitter = new EventEmitter(); + @Output() remove: EventEmitter = new EventEmitter(); readChapter(item: ReadingListItem) { this.read.emit(item); } + + removeItem(item: ReadingListItem) { + this.remove.emit({ + item: item, + position: item.order + }); + } } diff --git a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts index 8685adc48..d18939c4e 100644 --- a/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts +++ b/UI/Web/src/app/series-detail/_components/external-rating/external-rating.component.ts @@ -61,7 +61,8 @@ export class ExternalRatingComponent implements OnInit { ngOnInit() { this.reviewService.overallRating(this.seriesId, this.chapterId).subscribe(r => { this.overallRating = r.averageScore; - }); + this.cdRef.markForCheck(); + }); } updateRating(rating: number) { @@ -92,6 +93,4 @@ export class ExternalRatingComponent implements OnInit { return ''; } - - protected readonly RatingAuthority = RatingAuthority; } diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html index 8cbac271a..ff97fcbb0 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html @@ -127,6 +127,16 @@
    +
    + + +
    + +
    +
    +
    +
    +
    diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts index 797124c4f..d0fed5c81 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts @@ -105,15 +105,16 @@ export class LibrarySettingsModalComponent implements OnInit { libraryForm: FormGroup = new FormGroup({ name: new FormControl('', { nonNullable: true, validators: [Validators.required] }), type: new FormControl(LibraryType.Manga, { nonNullable: true, validators: [Validators.required] }), - folderWatching: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), - includeInDashboard: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), - includeInRecommended: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), - includeInSearch: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), - manageCollections: new FormControl(false, { nonNullable: true, validators: [Validators.required] }), - manageReadingLists: new FormControl(false, { nonNullable: true, validators: [Validators.required] }), - allowScrobbling: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), - allowMetadataMatching: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), - collapseSeriesRelationships: new FormControl(false, { nonNullable: true, validators: [Validators.required] }), + folderWatching: new FormControl(true, { nonNullable: true, validators: [] }), + includeInDashboard: new FormControl(true, { nonNullable: true, validators: [] }), + includeInRecommended: new FormControl(true, { nonNullable: true, validators: [] }), + includeInSearch: new FormControl(true, { nonNullable: true, validators: [] }), + manageCollections: new FormControl(false, { nonNullable: true, validators: [] }), + manageReadingLists: new FormControl(false, { nonNullable: true, validators: [] }), + allowScrobbling: new FormControl(true, { nonNullable: true, validators: [] }), + allowMetadataMatching: new FormControl(true, { nonNullable: true, validators: [] }), + collapseSeriesRelationships: new FormControl(false, { nonNullable: true, validators: [] }), + enableMetadata: new FormControl(true, { nonNullable: true, validators: [] }), // required validator doesn't check value, just if true }); selectedFolders: string[] = []; @@ -155,7 +156,7 @@ export class LibrarySettingsModalComponent implements OnInit { this.libraryForm.get('allowScrobbling')?.disable(); if (this.IsMetadataDownloadEligible) { - this.libraryForm.get('allowMetadataMatching')?.setValue(this.library.allowMetadataMatching); + this.libraryForm.get('allowMetadataMatching')?.setValue(this.library.allowMetadataMatching ?? true); this.libraryForm.get('allowMetadataMatching')?.enable(); } else { this.libraryForm.get('allowMetadataMatching')?.setValue(false); @@ -184,6 +185,20 @@ export class LibrarySettingsModalComponent implements OnInit { this.setValues(); + // Turn on/off manage collections/rl + this.libraryForm.get('enableMetadata')?.valueChanges.pipe( + tap(enabled => { + const manageCollectionsFc = this.libraryForm.get('manageCollections'); + const manageReadingListsFc = this.libraryForm.get('manageReadingLists'); + + manageCollectionsFc?.setValue(enabled); + manageReadingListsFc?.setValue(enabled); + + this.cdRef.markForCheck(); + }), + takeUntilDestroyed(this.destroyRef) + ).subscribe(); + // This needs to only apply after first render this.libraryForm.get('type')?.valueChanges.pipe( tap((type: LibraryType) => { @@ -257,6 +272,8 @@ export class LibrarySettingsModalComponent implements OnInit { this.libraryForm.get('collapseSeriesRelationships')?.setValue(this.library.collapseSeriesRelationships); this.libraryForm.get('allowScrobbling')?.setValue(this.IsKavitaPlusEligible ? this.library.allowScrobbling : false); this.libraryForm.get('allowMetadataMatching')?.setValue(this.IsMetadataDownloadEligible ? this.library.allowMetadataMatching : false); + this.libraryForm.get('excludePatterns')?.setValue(this.excludePatterns ? this.library.excludePatterns : false); + this.libraryForm.get('enableMetadata')?.setValue(this.library.enableMetadata, true); this.selectedFolders = this.library.folders; this.madeChanges = false; diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 91a3dac9e..c6b8c823f 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1129,6 +1129,8 @@ "include-in-dashboard-tooltip": "Should series from the library be included on the Dashboard. This affects all streams, like On Deck, Recently Updated, Recently Added, or any custom additions.", "include-in-search-label": "Include in Search", "include-in-search-tooltip": "Should series and any derived information (genres, people, files) from the library be included in search results.", + "enable-metadata-label": "Enable Metadata (ComicInfo/Epub/PDF)", + "enable-metadata-tooltip": "Allow Kavita to read metadata files which override filename parsing.", "force-scan": "Force Scan", "force-scan-tooltip": "This will force a scan on the library, treating like a fresh scan", "reset": "{{common.reset}}", @@ -2743,7 +2745,8 @@ "webtoon-override": "Switching to Webtoon mode due to images representing a webtoon.", "scrobble-gen-init": "Enqueued a job to generate scrobble events from past reading history and ratings, syncing them with connected services.", "series-bound-to-reading-profile": "Series bound to Reading Profile {{name}}", - "library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}" + "library-bound-to-reading-profile": "Library bound to Reading Profile {{name}}", + "external-match-rate-error": "Kavita ran out of rate looking up {{seriesName}}. Try again in 5 minutes." }, "read-time-pipe": { From d536cc7f6a3f6b9e7fb00bdc6d460c2bfad86104 Mon Sep 17 00:00:00 2001 From: majora2007 Date: Mon, 23 Jun 2025 23:57:53 +0000 Subject: [PATCH 30/47] Bump versions by dotnet-bump-version. --- Kavita.Common/Kavita.Common.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index b920416bb..fb13c5605 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -3,7 +3,7 @@ net9.0 kavitareader.com Kavita - 0.8.6.17 + 0.8.6.18 en true From 62231d3c4e0b8b3f33ef3f91c7912cd422512cf9 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 23 Jun 2025 23:58:56 +0000 Subject: [PATCH 31/47] Update OpenAPI documentation --- openapi.json | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/openapi.json b/openapi.json index 5f50b88f7..0ee2657d0 100644 --- a/openapi.json +++ b/openapi.json @@ -2,12 +2,12 @@ "openapi": "3.0.4", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.16", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.6.17", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE" }, - "version": "0.8.6.16" + "version": "0.8.6.17" }, "servers": [ { @@ -21341,6 +21341,10 @@ "type": "boolean", "description": "Allow any series within this Library to download metadata." }, + "enableMetadata": { + "type": "boolean", + "description": "Should Kavita read metadata files from the library" + }, "created": { "type": "string", "format": "date-time" @@ -21499,6 +21503,10 @@ "allowMetadataMatching": { "type": "boolean", "description": "Allow any series within this Library to download metadata." + }, + "enableMetadata": { + "type": "boolean", + "description": "Allow Kavita to read metadata (ComicInfo.xml, Epub, PDF)" } }, "additionalProperties": false @@ -26367,6 +26375,7 @@ "required": [ "allowMetadataMatching", "allowScrobbling", + "enableMetadata", "excludePatterns", "fileGroupTypes", "folders", @@ -26428,6 +26437,9 @@ "allowMetadataMatching": { "type": "boolean" }, + "enableMetadata": { + "type": "boolean" + }, "fileGroupTypes": { "type": "array", "items": { From 6fa1cf994efe23096f05289dcb7cad91e9430ef3 Mon Sep 17 00:00:00 2001 From: Fesaa <77553571+Fesaa@users.noreply.github.com> Date: Wed, 25 Jun 2025 17:04:26 +0200 Subject: [PATCH 32/47] A bunch of bug fixes and some enhancements (#3871) Co-authored-by: Joseph Milazzo --- API.Tests/Repository/GenreRepositoryTests.cs | 280 ++++++++++++++ API.Tests/Repository/PersonRepositoryTests.cs | 342 ++++++++++++++++++ API.Tests/Repository/TagRepositoryTests.cs | 278 ++++++++++++++ .../Services/ExternalMetadataServiceTests.cs | 212 +++++++++++ API/Controllers/PersonController.cs | 3 +- .../Metadata/ExternalSeriesDetailDto.cs | 2 + API/Data/Repositories/GenreRepository.cs | 16 +- API/Data/Repositories/PersonRepository.cs | 68 ++-- API/Data/Repositories/TagRepository.cs | 14 +- API/Extensions/EnumerableExtensions.cs | 13 + .../RestrictByAgeExtensions.cs | 26 ++ .../RestrictByLibraryExtensions.cs | 31 ++ API/Helpers/Builders/ChapterBuilder.cs | 20 + API/Helpers/Builders/SeriesMetadataBuilder.cs | 19 + API/Services/Plus/ExternalMetadataService.cs | 101 +++++- API/Services/TaskScheduler.cs | 4 +- UI/Web/src/app/_services/nav.service.ts | 52 +++ .../user-scrobble-history.component.html | 2 +- .../manage-metadata-settings.component.ts | 11 +- .../nav-header/nav-header.component.html | 15 +- .../nav-header/nav-header.component.ts | 8 - .../nav-link-modal.component.html | 25 +- .../nav-link-modal.component.ts | 8 +- .../person-detail/person-detail.component.ts | 5 +- 24 files changed, 1464 insertions(+), 91 deletions(-) create mode 100644 API.Tests/Repository/GenreRepositoryTests.cs create mode 100644 API.Tests/Repository/PersonRepositoryTests.cs create mode 100644 API.Tests/Repository/TagRepositoryTests.cs 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'}]" > - + } 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 @@
    +
    + + +
    + +
    +
    +
    +
    +
    diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts index d0fed5c81..9331376ef 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts @@ -115,6 +115,7 @@ export class LibrarySettingsModalComponent implements OnInit { allowMetadataMatching: new FormControl(true, { nonNullable: true, validators: [] }), collapseSeriesRelationships: new FormControl(false, { nonNullable: true, validators: [] }), enableMetadata: new FormControl(true, { nonNullable: true, validators: [] }), // required validator doesn't check value, just if true + removePrefixForSortName: new FormControl(false, { nonNullable: true, validators: [] }), }); selectedFolders: string[] = []; @@ -273,7 +274,8 @@ export class LibrarySettingsModalComponent implements OnInit { this.libraryForm.get('allowScrobbling')?.setValue(this.IsKavitaPlusEligible ? this.library.allowScrobbling : false); this.libraryForm.get('allowMetadataMatching')?.setValue(this.IsMetadataDownloadEligible ? this.library.allowMetadataMatching : false); this.libraryForm.get('excludePatterns')?.setValue(this.excludePatterns ? this.library.excludePatterns : false); - this.libraryForm.get('enableMetadata')?.setValue(this.library.enableMetadata, true); + this.libraryForm.get('enableMetadata')?.setValue(this.library.enableMetadata); + this.libraryForm.get('removePrefixForSortName')?.setValue(this.library.removePrefixForSortName); this.selectedFolders = this.library.folders; this.madeChanges = false; diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index c6b8c823f..33bde5e0e 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1131,6 +1131,8 @@ "include-in-search-tooltip": "Should series and any derived information (genres, people, files) from the library be included in search results.", "enable-metadata-label": "Enable Metadata (ComicInfo/Epub/PDF)", "enable-metadata-tooltip": "Allow Kavita to read metadata files which override filename parsing.", + "remove-prefix-for-sortname-label": "Remove common prefixes for Sort Name", + "remove-prefix-for-sortname-tooltip": "Kavita will remove common prefixes like 'The', 'A', 'An' from titles for sort name. Does not override set metadata.", "force-scan": "Force Scan", "force-scan-tooltip": "This will force a scan on the library, treating like a fresh scan", "reset": "{{common.reset}}", From 76fd7ab4ce2b474fd1ed94281e9217f932975734 Mon Sep 17 00:00:00 2001 From: majora2007 Date: Sat, 5 Jul 2025 22:18:52 +0000 Subject: [PATCH 46/47] Bump versions by dotnet-bump-version. --- Kavita.Common/Kavita.Common.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index c2ba1669d..c7dd0ab94 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -3,7 +3,7 @@ net9.0 kavitareader.com Kavita - 0.8.7.0 + 0.8.7.1 en true @@ -20,4 +20,4 @@ - + \ No newline at end of file From ef2640b5fc2e7e2836cb30b8f8bbbf9025774d57 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 5 Jul 2025 22:20:01 +0000 Subject: [PATCH 47/47] Update OpenAPI documentation --- openapi.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/openapi.json b/openapi.json index e9a3620e9..3e4b797cb 100644 --- a/openapi.json +++ b/openapi.json @@ -21371,6 +21371,10 @@ "type": "boolean", "description": "Should Kavita read metadata files from the library" }, + "removePrefixForSortName": { + "type": "boolean", + "description": "Should Kavita remove sort articles \"The\" for the sort name" + }, "created": { "type": "string", "format": "date-time" @@ -21533,6 +21537,10 @@ "enableMetadata": { "type": "boolean", "description": "Allow Kavita to read metadata (ComicInfo.xml, Epub, PDF)" + }, + "removePrefixForSortName": { + "type": "boolean", + "description": "Should Kavita remove sort articles \"The\" for the sort name" } }, "additionalProperties": false @@ -26438,6 +26446,7 @@ "manageCollections", "manageReadingLists", "name", + "removePrefixForSortName", "type" ], "type": "object", @@ -26492,6 +26501,9 @@ "enableMetadata": { "type": "boolean" }, + "removePrefixForSortName": { + "type": "boolean" + }, "fileGroupTypes": { "type": "array", "items": {