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