diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index feaed4fe05..6aefef8304 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -29,7 +29,6 @@ - diff --git a/API.Tests/Extensions/QueryableExtensionsTests.cs b/API.Tests/Extensions/QueryableExtensionsTests.cs index 771ba940ce..d902ae3532 100644 --- a/API.Tests/Extensions/QueryableExtensionsTests.cs +++ b/API.Tests/Extensions/QueryableExtensionsTests.cs @@ -126,28 +126,45 @@ public void RestrictAgainstAgeRestriction_Tag_ShouldRestrictEverythingAboveTeen( [InlineData(false, 1)] public void RestrictAgainstAgeRestriction_Person_ShouldRestrictEverythingAboveTeen(bool includeUnknowns, int expectedCount) { - var items = new List() + // Arrange + var items = new List { - new PersonBuilder("Test", PersonRole.Character) - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()) - .Build(), - new PersonBuilder("Test", PersonRole.Character) - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Unknown).Build()) - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.Teen).Build()) - .Build(), - new PersonBuilder("Test", PersonRole.Character) - .WithSeriesMetadata(new SeriesMetadataBuilder().WithAgeRating(AgeRating.X18Plus).Build()) - .Build(), + CreatePersonWithSeriesMetadata("Test1", AgeRating.Teen), + CreatePersonWithSeriesMetadata("Test2", AgeRating.Unknown, AgeRating.Teen), + CreatePersonWithSeriesMetadata("Test3", AgeRating.X18Plus) }; - var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(new AgeRestriction() + var ageRestriction = new AgeRestriction { AgeRating = AgeRating.Teen, IncludeUnknowns = includeUnknowns - }); + }; + + // Act + var filtered = items.AsQueryable().RestrictAgainstAgeRestriction(ageRestriction); + + // Assert Assert.Equal(expectedCount, filtered.Count()); } + private static Person CreatePersonWithSeriesMetadata(string name, params AgeRating[] ageRatings) + { + var person = new PersonBuilder(name).Build(); + + foreach (var ageRating in ageRatings) + { + var seriesMetadata = new SeriesMetadataBuilder().WithAgeRating(ageRating).Build(); + person.SeriesMetadataPeople.Add(new SeriesMetadataPeople + { + SeriesMetadata = seriesMetadata, + Person = person, + Role = PersonRole.Character // Role is now part of the relationship + }); + } + + return person; + } + [Theory] [InlineData(true, 2)] [InlineData(false, 1)] diff --git a/API.Tests/Extensions/SeriesExtensionsTests.cs b/API.Tests/Extensions/SeriesExtensionsTests.cs index 38e5f00013..adaecfba52 100644 --- a/API.Tests/Extensions/SeriesExtensionsTests.cs +++ b/API.Tests/Extensions/SeriesExtensionsTests.cs @@ -185,6 +185,35 @@ public void GetCoverImage_JustVolumes() Assert.Equal("Volume 1 Chapter 1", series.GetCoverImage()); } + [Fact] + public void GetCoverImage_JustVolumes_ButVolume0() + { + var series = new SeriesBuilder("Test 1") + .WithFormat(MangaFormat.Archive) + + .WithVolume(new VolumeBuilder("0") + .WithName("Volume 0") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) + .WithCoverImage("Volume 0") + .Build()) + .Build()) + + .WithVolume(new VolumeBuilder("1") + .WithName("Volume 1") + .WithChapter(new ChapterBuilder(Parser.DefaultChapter) + .WithCoverImage("Volume 1") + .Build()) + .Build()) + .Build(); + + foreach (var vol in series.Volumes) + { + vol.CoverImage = vol.Chapters.MinBy(x => x.SortOrder, ChapterSortComparerDefaultFirst.Default)?.CoverImage; + } + + Assert.Equal("Volume 1", series.GetCoverImage()); + } + [Fact] public void GetCoverImage_JustSpecials_WithDecimal() { diff --git a/API.Tests/Helpers/PersonHelperTests.cs b/API.Tests/Helpers/PersonHelperTests.cs index ed59a958ff..cf11f0f1f5 100644 --- a/API.Tests/Helpers/PersonHelperTests.cs +++ b/API.Tests/Helpers/PersonHelperTests.cs @@ -13,403 +13,403 @@ namespace API.Tests.Helpers; public class PersonHelperTests { - #region UpdatePeople - [Fact] - public void UpdatePeople_ShouldAddNewPeople() - { - var allPeople = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - }; - var peopleAdded = new List(); - - PersonHelper.UpdatePeople(allPeople, new[] {"Joseph Shmo", "Sally Ann"}, PersonRole.Writer, person => - { - peopleAdded.Add(person); - }); - - Assert.Equal(2, peopleAdded.Count); - Assert.Equal(4, allPeople.Count); - } - - [Fact] - public void UpdatePeople_ShouldNotAddDuplicatePeople() - { - var allPeople = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - new PersonBuilder("Sally Ann", PersonRole.CoverArtist).Build(), - - }; - var peopleAdded = new List(); - - PersonHelper.UpdatePeople(allPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.CoverArtist, person => - { - peopleAdded.Add(person); - }); - - Assert.Equal(3, allPeople.Count); - } - #endregion - - #region UpdatePeopleList - - [Fact] - public void UpdatePeopleList_NullTags_NoChanges() - { - // Arrange - ICollection tags = null; - var series = new SeriesBuilder("Test Series").Build(); - var allTags = new List(); - var handleAddCalled = false; - var onModifiedCalled = false; - - // Act - PersonHelper.UpdatePeopleList(PersonRole.Writer, tags, series, allTags, p => handleAddCalled = true, () => onModifiedCalled = true); - - // Assert - Assert.False(handleAddCalled); - Assert.False(onModifiedCalled); - } - - [Fact] - public void UpdatePeopleList_AddNewTag_TagAddedAndOnModifiedCalled() - { - // Arrange - const PersonRole role = PersonRole.Writer; - var tags = new List - { - new PersonDto { Id = 1, Name = "John Doe", Role = role } - }; - var series = new SeriesBuilder("Test Series").Build(); - var allTags = new List(); - var handleAddCalled = false; - var onModifiedCalled = false; - - // Act - PersonHelper.UpdatePeopleList(role, tags, series, allTags, p => - { - handleAddCalled = true; - series.Metadata.People.Add(p); - }, () => onModifiedCalled = true); - - // Assert - Assert.True(handleAddCalled); - Assert.True(onModifiedCalled); - Assert.Single(series.Metadata.People); - Assert.Equal("John Doe", series.Metadata.People.First().Name); - } - - [Fact] - public void UpdatePeopleList_RemoveExistingTag_TagRemovedAndOnModifiedCalled() - { - // Arrange - const PersonRole role = PersonRole.Writer; - var tags = new List(); - var series = new SeriesBuilder("Test Series").Build(); - var person = new PersonBuilder("John Doe", role).Build(); - person.Id = 1; - series.Metadata.People.Add(person); - var allTags = new List - { - person - }; - var handleAddCalled = false; - var onModifiedCalled = false; - - // Act - PersonHelper.UpdatePeopleList(role, tags, series, allTags, p => - { - handleAddCalled = true; - series.Metadata.People.Add(p); - }, () => onModifiedCalled = true); - - // Assert - Assert.False(handleAddCalled); - Assert.True(onModifiedCalled); - Assert.Empty(series.Metadata.People); - } - - [Fact] - public void UpdatePeopleList_UpdateExistingTag_OnModifiedCalled() - { - // Arrange - const PersonRole role = PersonRole.Writer; - var tags = new List - { - new PersonDto { Id = 1, Name = "John Doe", Role = role } - }; - var series = new SeriesBuilder("Test Series").Build(); - var person = new PersonBuilder("John Doe", role).Build(); - person.Id = 1; - series.Metadata.People.Add(person); - var allTags = new List - { - person - }; - var handleAddCalled = false; - var onModifiedCalled = false; - - // Act - PersonHelper.UpdatePeopleList(role, tags, series, allTags, p => - { - handleAddCalled = true; - series.Metadata.People.Add(p); - }, () => onModifiedCalled = true); - - // Assert - Assert.False(handleAddCalled); - Assert.False(onModifiedCalled); - Assert.Single(series.Metadata.People); - Assert.Equal("John Doe", series.Metadata.People.First().Name); - } - - [Fact] - public void UpdatePeopleList_NoChanges_HandleAddAndOnModifiedNotCalled() - { - // Arrange - const PersonRole role = PersonRole.Writer; - var tags = new List - { - new PersonDto { Id = 1, Name = "John Doe", Role = role } - }; - var series = new SeriesBuilder("Test Series").Build(); - var person = new PersonBuilder("John Doe", role).Build(); - person.Id = 1; - series.Metadata.People.Add(person); - var allTags = new List - { - new PersonBuilder("John Doe", role).Build() - }; - var handleAddCalled = false; - var onModifiedCalled = false; - - // Act - PersonHelper.UpdatePeopleList(role, tags, series, allTags, p => - { - handleAddCalled = true; - series.Metadata.People.Add(p); - }, () => onModifiedCalled = true); - - // Assert - Assert.False(handleAddCalled); - Assert.False(onModifiedCalled); - Assert.Single(series.Metadata.People); - Assert.Equal("John Doe", series.Metadata.People.First().Name); - } - - - - #endregion - - #region RemovePeople - [Fact] - public void RemovePeople_ShouldRemovePeopleOfSameRole() - { - var existingPeople = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - }; - var peopleRemoved = new List(); - PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.Writer, person => - { - peopleRemoved.Add(person); - }); - - Assert.NotEqual(existingPeople, peopleRemoved); - Assert.Single(peopleRemoved); - } - - [Fact] - public void RemovePeople_ShouldRemovePeopleFromBothRoles() - { - var existingPeople = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - }; - var peopleRemoved = new List(); - PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.Writer, person => - { - peopleRemoved.Add(person); - }); - - Assert.NotEqual(existingPeople, peopleRemoved); - Assert.Single(peopleRemoved); - - PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo"}, PersonRole.CoverArtist, person => - { - peopleRemoved.Add(person); - }); - - Assert.Empty(existingPeople); - Assert.Equal(2, peopleRemoved.Count); - } - - [Fact] - public void RemovePeople_ShouldRemovePeopleOfSameRole_WhenNothingPassed() - { - var existingPeople = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - }; - var peopleRemoved = new List(); - PersonHelper.RemovePeople(existingPeople, new List(), PersonRole.Writer, person => - { - peopleRemoved.Add(person); - }); - - Assert.NotEqual(existingPeople, peopleRemoved); - Assert.Equal(2, peopleRemoved.Count); - } - - - #endregion - - #region KeepOnlySamePeopleBetweenLists - [Fact] - public void KeepOnlySamePeopleBetweenLists() - { - var existingPeople = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - new PersonBuilder("Sally", PersonRole.Writer).Build(), - }; - - var peopleFromChapters = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - }; - - var peopleRemoved = new List(); - PersonHelper.KeepOnlySamePeopleBetweenLists(existingPeople, - peopleFromChapters, person => - { - peopleRemoved.Add(person); - }); - - Assert.Equal(2, peopleRemoved.Count); - } - #endregion - - #region AddPeople - - [Fact] - public void AddPersonIfNotExists_ShouldAddPerson_WhenPersonDoesNotExist() - { - // Arrange - var metadataPeople = new List(); - var person = new PersonBuilder("John Smith", PersonRole.Character).Build(); - - // Act - PersonHelper.AddPersonIfNotExists(metadataPeople, person); - - // Assert - Assert.Single(metadataPeople); - Assert.Contains(person, metadataPeople); - } - - [Fact] - public void AddPersonIfNotExists_ShouldNotAddPerson_WhenPersonAlreadyExists() - { - // Arrange - var metadataPeople = new List - { - new PersonBuilder("John Smith", PersonRole.Character) - .WithId(1) - .Build() - }; - var person = new PersonBuilder("John Smith", PersonRole.Character).Build(); - // Act - PersonHelper.AddPersonIfNotExists(metadataPeople, person); - - // Assert - Assert.Single(metadataPeople); - Assert.NotNull(metadataPeople.SingleOrDefault(p => - p.Name.Equals(person.Name) && p.Role == person.Role && p.NormalizedName == person.NormalizedName)); - Assert.Equal(1, metadataPeople.First().Id); - } - - [Fact] - public void AddPersonIfNotExists_ShouldNotAddPerson_WhenPersonNameIsNullOrEmpty() - { - // Arrange - var metadataPeople = new List(); - var person2 = new PersonBuilder(string.Empty, PersonRole.Character).Build(); - - // Act - PersonHelper.AddPersonIfNotExists(metadataPeople, person2); - - // Assert - Assert.Empty(metadataPeople); - } - - [Fact] - public void AddPersonIfNotExists_ShouldAddPerson_WhenPersonNameIsDifferentButRoleIsSame() - { - // Arrange - var metadataPeople = new List - { - new PersonBuilder("John Smith", PersonRole.Character).Build() - }; - var person = new PersonBuilder("John Doe", PersonRole.Character).Build(); - - // Act - PersonHelper.AddPersonIfNotExists(metadataPeople, person); - - // Assert - Assert.Equal(2, metadataPeople.Count); - Assert.Contains(person, metadataPeople); - } - - [Fact] - public void AddPersonIfNotExists_ShouldAddPerson_WhenPersonNameIsSameButRoleIsDifferent() - { - // Arrange - var metadataPeople = new List - { - new PersonBuilder("John Doe", PersonRole.Writer).Build() - }; - var person = new PersonBuilder("John Smith", PersonRole.Character).Build(); - - // Act - PersonHelper.AddPersonIfNotExists(metadataPeople, person); - - // Assert - Assert.Equal(2, metadataPeople.Count); - Assert.Contains(person, metadataPeople); - } - - - - - [Fact] - public void AddPeople_ShouldAddOnlyNonExistingPeople() - { - var existingPeople = new List - { - new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), - new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), - new PersonBuilder("Sally", PersonRole.Writer).Build(), - }; - - - PersonHelper.AddPersonIfNotExists(existingPeople, new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build()); - Assert.Equal(3, existingPeople.Count); - - PersonHelper.AddPersonIfNotExists(existingPeople, new PersonBuilder("Joe Shmo", PersonRole.Writer).Build()); - Assert.Equal(3, existingPeople.Count); - - PersonHelper.AddPersonIfNotExists(existingPeople, new PersonBuilder("Joe Shmo Two", PersonRole.CoverArtist).Build()); - Assert.Equal(4, existingPeople.Count); - } - - #endregion + // #region UpdatePeople + // [Fact] + // public void UpdatePeople_ShouldAddNewPeople() + // { + // var allPeople = new List + // { + // new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), + // new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), + // }; + // var peopleAdded = new List(); + // + // PersonHelper.UpdatePeople(allPeople, new[] {"Joseph Shmo", "Sally Ann"}, PersonRole.Writer, person => + // { + // peopleAdded.Add(person); + // }); + // + // Assert.Equal(2, peopleAdded.Count); + // Assert.Equal(4, allPeople.Count); + // } + // + // [Fact] + // public void UpdatePeople_ShouldNotAddDuplicatePeople() + // { + // var allPeople = new List + // { + // new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), + // new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), + // new PersonBuilder("Sally Ann", PersonRole.CoverArtist).Build(), + // + // }; + // var peopleAdded = new List(); + // + // PersonHelper.UpdatePeople(allPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.CoverArtist, person => + // { + // peopleAdded.Add(person); + // }); + // + // Assert.Equal(3, allPeople.Count); + // } + // #endregion + // + // #region UpdatePeopleList + // + // [Fact] + // public void UpdatePeopleList_NullTags_NoChanges() + // { + // // Arrange + // ICollection tags = null; + // var series = new SeriesBuilder("Test Series").Build(); + // var allTags = new List(); + // var handleAddCalled = false; + // var onModifiedCalled = false; + // + // // Act + // PersonHelper.UpdatePeopleList(PersonRole.Writer, tags, series, allTags, p => handleAddCalled = true, () => onModifiedCalled = true); + // + // // Assert + // Assert.False(handleAddCalled); + // Assert.False(onModifiedCalled); + // } + // + // [Fact] + // public void UpdatePeopleList_AddNewTag_TagAddedAndOnModifiedCalled() + // { + // // Arrange + // const PersonRole role = PersonRole.Writer; + // var tags = new List + // { + // new PersonDto { Id = 1, Name = "John Doe", Role = role } + // }; + // var series = new SeriesBuilder("Test Series").Build(); + // var allTags = new List(); + // var handleAddCalled = false; + // var onModifiedCalled = false; + // + // // Act + // PersonHelper.UpdatePeopleList(role, tags, series, allTags, p => + // { + // handleAddCalled = true; + // series.Metadata.People.Add(p); + // }, () => onModifiedCalled = true); + // + // // Assert + // Assert.True(handleAddCalled); + // Assert.True(onModifiedCalled); + // Assert.Single(series.Metadata.People); + // Assert.Equal("John Doe", series.Metadata.People.First().Name); + // } + // + // [Fact] + // public void UpdatePeopleList_RemoveExistingTag_TagRemovedAndOnModifiedCalled() + // { + // // Arrange + // const PersonRole role = PersonRole.Writer; + // var tags = new List(); + // var series = new SeriesBuilder("Test Series").Build(); + // var person = new PersonBuilder("John Doe", role).Build(); + // person.Id = 1; + // series.Metadata.People.Add(person); + // var allTags = new List + // { + // person + // }; + // var handleAddCalled = false; + // var onModifiedCalled = false; + // + // // Act + // PersonHelper.UpdatePeopleList(role, tags, series, allTags, p => + // { + // handleAddCalled = true; + // series.Metadata.People.Add(p); + // }, () => onModifiedCalled = true); + // + // // Assert + // Assert.False(handleAddCalled); + // Assert.True(onModifiedCalled); + // Assert.Empty(series.Metadata.People); + // } + // + // [Fact] + // public void UpdatePeopleList_UpdateExistingTag_OnModifiedCalled() + // { + // // Arrange + // const PersonRole role = PersonRole.Writer; + // var tags = new List + // { + // new PersonDto { Id = 1, Name = "John Doe", Role = role } + // }; + // var series = new SeriesBuilder("Test Series").Build(); + // var person = new PersonBuilder("John Doe", role).Build(); + // person.Id = 1; + // series.Metadata.People.Add(person); + // var allTags = new List + // { + // person + // }; + // var handleAddCalled = false; + // var onModifiedCalled = false; + // + // // Act + // PersonHelper.UpdatePeopleList(role, tags, series, allTags, p => + // { + // handleAddCalled = true; + // series.Metadata.People.Add(p); + // }, () => onModifiedCalled = true); + // + // // Assert + // Assert.False(handleAddCalled); + // Assert.False(onModifiedCalled); + // Assert.Single(series.Metadata.People); + // Assert.Equal("John Doe", series.Metadata.People.First().Name); + // } + // + // [Fact] + // public void UpdatePeopleList_NoChanges_HandleAddAndOnModifiedNotCalled() + // { + // // Arrange + // const PersonRole role = PersonRole.Writer; + // var tags = new List + // { + // new PersonDto { Id = 1, Name = "John Doe", Role = role } + // }; + // var series = new SeriesBuilder("Test Series").Build(); + // var person = new PersonBuilder("John Doe", role).Build(); + // person.Id = 1; + // series.Metadata.People.Add(person); + // var allTags = new List + // { + // new PersonBuilder("John Doe", role).Build() + // }; + // var handleAddCalled = false; + // var onModifiedCalled = false; + // + // // Act + // PersonHelper.UpdatePeopleList(role, tags, series, allTags, p => + // { + // handleAddCalled = true; + // series.Metadata.People.Add(p); + // }, () => onModifiedCalled = true); + // + // // Assert + // Assert.False(handleAddCalled); + // Assert.False(onModifiedCalled); + // Assert.Single(series.Metadata.People); + // Assert.Equal("John Doe", series.Metadata.People.First().Name); + // } + // + // + // + // #endregion + // + // #region RemovePeople + // [Fact] + // public void RemovePeople_ShouldRemovePeopleOfSameRole() + // { + // var existingPeople = new List + // { + // new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), + // new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), + // }; + // var peopleRemoved = new List(); + // PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.Writer, person => + // { + // peopleRemoved.Add(person); + // }); + // + // Assert.NotEqual(existingPeople, peopleRemoved); + // Assert.Single(peopleRemoved); + // } + // + // [Fact] + // public void RemovePeople_ShouldRemovePeopleFromBothRoles() + // { + // var existingPeople = new List + // { + // new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), + // new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), + // }; + // var peopleRemoved = new List(); + // PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.Writer, person => + // { + // peopleRemoved.Add(person); + // }); + // + // Assert.NotEqual(existingPeople, peopleRemoved); + // Assert.Single(peopleRemoved); + // + // PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo"}, PersonRole.CoverArtist, person => + // { + // peopleRemoved.Add(person); + // }); + // + // Assert.Empty(existingPeople); + // Assert.Equal(2, peopleRemoved.Count); + // } + // + // [Fact] + // public void RemovePeople_ShouldRemovePeopleOfSameRole_WhenNothingPassed() + // { + // var existingPeople = new List + // { + // new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), + // new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), + // new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), + // }; + // var peopleRemoved = new List(); + // PersonHelper.RemovePeople(existingPeople, new List(), PersonRole.Writer, person => + // { + // peopleRemoved.Add(person); + // }); + // + // Assert.NotEqual(existingPeople, peopleRemoved); + // Assert.Equal(2, peopleRemoved.Count); + // } + // + // + // #endregion + // + // #region KeepOnlySamePeopleBetweenLists + // [Fact] + // public void KeepOnlySamePeopleBetweenLists() + // { + // var existingPeople = new List + // { + // new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), + // new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), + // new PersonBuilder("Sally", PersonRole.Writer).Build(), + // }; + // + // var peopleFromChapters = new List + // { + // new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), + // }; + // + // var peopleRemoved = new List(); + // PersonHelper.KeepOnlySamePeopleBetweenLists(existingPeople, + // peopleFromChapters, person => + // { + // peopleRemoved.Add(person); + // }); + // + // Assert.Equal(2, peopleRemoved.Count); + // } + // #endregion + // + // #region AddPeople + // + // [Fact] + // public void AddPersonIfNotExists_ShouldAddPerson_WhenPersonDoesNotExist() + // { + // // Arrange + // var metadataPeople = new List(); + // var person = new PersonBuilder("John Smith", PersonRole.Character).Build(); + // + // // Act + // PersonHelper.AddPersonIfNotExists(metadataPeople, person); + // + // // Assert + // Assert.Single(metadataPeople); + // Assert.Contains(person, metadataPeople); + // } + // + // [Fact] + // public void AddPersonIfNotExists_ShouldNotAddPerson_WhenPersonAlreadyExists() + // { + // // Arrange + // var metadataPeople = new List + // { + // new PersonBuilder("John Smith", PersonRole.Character) + // .WithId(1) + // .Build() + // }; + // var person = new PersonBuilder("John Smith", PersonRole.Character).Build(); + // // Act + // PersonHelper.AddPersonIfNotExists(metadataPeople, person); + // + // // Assert + // Assert.Single(metadataPeople); + // Assert.NotNull(metadataPeople.SingleOrDefault(p => + // p.Name.Equals(person.Name) && p.Role == person.Role && p.NormalizedName == person.NormalizedName)); + // Assert.Equal(1, metadataPeople.First().Id); + // } + // + // [Fact] + // public void AddPersonIfNotExists_ShouldNotAddPerson_WhenPersonNameIsNullOrEmpty() + // { + // // Arrange + // var metadataPeople = new List(); + // var person2 = new PersonBuilder(string.Empty, PersonRole.Character).Build(); + // + // // Act + // PersonHelper.AddPersonIfNotExists(metadataPeople, person2); + // + // // Assert + // Assert.Empty(metadataPeople); + // } + // + // [Fact] + // public void AddPersonIfNotExists_ShouldAddPerson_WhenPersonNameIsDifferentButRoleIsSame() + // { + // // Arrange + // var metadataPeople = new List + // { + // new PersonBuilder("John Smith", PersonRole.Character).Build() + // }; + // var person = new PersonBuilder("John Doe", PersonRole.Character).Build(); + // + // // Act + // PersonHelper.AddPersonIfNotExists(metadataPeople, person); + // + // // Assert + // Assert.Equal(2, metadataPeople.Count); + // Assert.Contains(person, metadataPeople); + // } + // + // [Fact] + // public void AddPersonIfNotExists_ShouldAddPerson_WhenPersonNameIsSameButRoleIsDifferent() + // { + // // Arrange + // var metadataPeople = new List + // { + // new PersonBuilder("John Doe", PersonRole.Writer).Build() + // }; + // var person = new PersonBuilder("John Smith", PersonRole.Character).Build(); + // + // // Act + // PersonHelper.AddPersonIfNotExists(metadataPeople, person); + // + // // Assert + // Assert.Equal(2, metadataPeople.Count); + // Assert.Contains(person, metadataPeople); + // } + // + // + // + // + // [Fact] + // public void AddPeople_ShouldAddOnlyNonExistingPeople() + // { + // var existingPeople = new List + // { + // new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build(), + // new PersonBuilder("Joe Shmo", PersonRole.Writer).Build(), + // new PersonBuilder("Sally", PersonRole.Writer).Build(), + // }; + // + // + // PersonHelper.AddPersonIfNotExists(existingPeople, new PersonBuilder("Joe Shmo", PersonRole.CoverArtist).Build()); + // Assert.Equal(3, existingPeople.Count); + // + // PersonHelper.AddPersonIfNotExists(existingPeople, new PersonBuilder("Joe Shmo", PersonRole.Writer).Build()); + // Assert.Equal(3, existingPeople.Count); + // + // PersonHelper.AddPersonIfNotExists(existingPeople, new PersonBuilder("Joe Shmo Two", PersonRole.CoverArtist).Build()); + // Assert.Equal(4, existingPeople.Count); + // } + // + // #endregion } diff --git a/API.Tests/Parsers/BasicParserTests.cs b/API.Tests/Parsers/BasicParserTests.cs index d47ebb8d29..ad040d59ee 100644 --- a/API.Tests/Parsers/BasicParserTests.cs +++ b/API.Tests/Parsers/BasicParserTests.cs @@ -138,13 +138,31 @@ public void Parse_MangaLibrary_SpecialMarkerInFilename() [Fact] public void Parse_MangaLibrary_SpecialInFilename() { - var actual = _parser.Parse("C:/Books/Summer Time Rendering/Specials/Volume Omake.cbr", + var actual = _parser.Parse("C:/Books/Summer Time Rendering/Volume SP01.cbr", "C:/Books/Summer Time Rendering/", RootDirectory, LibraryType.Manga, null); Assert.NotNull(actual); Assert.Equal("Summer Time Rendering", actual.Series); - Assert.Equal("Volume Omake", actual.Title); + Assert.Equal("Volume SP01", actual.Title); + Assert.Equal(Parser.SpecialVolume, actual.Volumes); + Assert.Equal(Parser.DefaultChapter, actual.Chapters); + Assert.True(actual.IsSpecial); + } + + /// + /// Tests that when the filename parses as a speical, it appropriately parses + /// + [Fact] + public void Parse_MangaLibrary_SpecialInFilename2() + { + var actual = _parser.Parse("M:/Kimi wa Midara na Boku no Joou/Specials/[Renzokusei] Special 1 SP02.zip", + "M:/Kimi wa Midara na Boku no Joou/", + RootDirectory, LibraryType.Manga, null); + Assert.NotNull(actual); + + Assert.Equal("Kimi wa Midara na Boku no Joou", actual.Series); + Assert.Equal("[Renzokusei] Special 1 SP02", actual.Title); Assert.Equal(Parser.SpecialVolume, actual.Volumes); Assert.Equal(Parser.DefaultChapter, actual.Chapters); Assert.True(actual.IsSpecial); diff --git a/API.Tests/Parsers/DefaultParserTests.cs b/API.Tests/Parsers/DefaultParserTests.cs index 9dc926ef5c..733b55d624 100644 --- a/API.Tests/Parsers/DefaultParserTests.cs +++ b/API.Tests/Parsers/DefaultParserTests.cs @@ -408,7 +408,7 @@ public void Parse_ParseInfo_Manga_WithSpecialsFolder() expected = new ParserInfo { Series = "Foo 50", Volumes = API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume, IsSpecial = true, - Chapters = "50", Filename = "Foo 50 SP01.cbz", Format = MangaFormat.Archive, + Chapters = Parser.DefaultChapter, Filename = "Foo 50 SP01.cbz", Format = MangaFormat.Archive, FullFilePath = filepath }; diff --git a/API.Tests/Parsing/BookParsingTests.cs b/API.Tests/Parsing/BookParsingTests.cs index 443d55b6db..9b02eff630 100644 --- a/API.Tests/Parsing/BookParsingTests.cs +++ b/API.Tests/Parsing/BookParsingTests.cs @@ -21,24 +21,4 @@ public void ParseVolumeTest(string filename, string expected) { Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseVolume(filename, LibraryType.Book)); } - - // [Theory] - // [InlineData("@font-face{font-family:'syyskuu_repaleinen';src:url(data:font/opentype;base64,AAEAAAA", "@font-face{font-family:'syyskuu_repaleinen';src:url(data:font/opentype;base64,AAEAAAA")] - // [InlineData("@font-face{font-family:'syyskuu_repaleinen';src:url('fonts/font.css')", "@font-face{font-family:'syyskuu_repaleinen';src:url('TEST/fonts/font.css')")] - // public void ReplaceFontSrcUrl(string input, string expected) - // { - // var apiBase = "TEST/"; - // var actual = API.Parser.Parser.FontSrcUrlRegex.Replace(input, "$1" + apiBase + "$2" + "$3"); - // Assert.Equal(expected, actual); - // } - // - // [Theory] - // [InlineData("@import url('font.css');", "@import url('TEST/font.css');")] - // public void ReplaceImportSrcUrl(string input, string expected) - // { - // var apiBase = "TEST/"; - // var actual = API.Parser.Parser.CssImportUrlRegex.Replace(input, "$1" + apiBase + "$2" + "$3"); - // Assert.Equal(expected, actual); - // } - } diff --git a/API.Tests/Parsing/ComicParsingTests.cs b/API.Tests/Parsing/ComicParsingTests.cs index 2d2e3d12db..ad28e80a9c 100644 --- a/API.Tests/Parsing/ComicParsingTests.cs +++ b/API.Tests/Parsing/ComicParsingTests.cs @@ -1,11 +1,6 @@ -using System.IO.Abstractions.TestingHelpers; using API.Entities.Enums; -using API.Services; using API.Services.Tasks.Scanner.Parser; -using Microsoft.Extensions.Logging; -using NSubstitute; using Xunit; -using Xunit.Abstractions; namespace API.Tests.Parsing; @@ -73,41 +68,41 @@ public class ComicParsingTests [InlineData("SKY WORLD สกายเวิลด์ เล่มที่ 1", "SKY WORLD สกายเวิลด์")] public void ParseComicSeriesTest(string filename, string expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicSeries(filename)); + Assert.Equal(expected, Parser.ParseComicSeries(filename)); } [Theory] - [InlineData("01 Spider-Man & Wolverine 01.cbr", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Batman & Catwoman - Trail of the Gun 01", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Batman & Daredevil - King of New York", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Batman & Grendel (1996) 01 - Devil's Bones", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Batman & Robin the Teen Wonder #0", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Batman & Wildcat (1 of 3)", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Batman And Superman World's Finest #01", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Babe 01", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life (2004)", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("01 Spider-Man & Wolverine 01.cbr", Parser.LooseLeafVolume)] + [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", Parser.LooseLeafVolume)] + [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", Parser.LooseLeafVolume)] + [InlineData("Batman & Catwoman - Trail of the Gun 01", Parser.LooseLeafVolume)] + [InlineData("Batman & Daredevil - King of New York", Parser.LooseLeafVolume)] + [InlineData("Batman & Grendel (1996) 01 - Devil's Bones", Parser.LooseLeafVolume)] + [InlineData("Batman & Robin the Teen Wonder #0", Parser.LooseLeafVolume)] + [InlineData("Batman & Wildcat (1 of 3)", Parser.LooseLeafVolume)] + [InlineData("Batman And Superman World's Finest #01", Parser.LooseLeafVolume)] + [InlineData("Babe 01", Parser.LooseLeafVolume)] + [InlineData("Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life (2004)", Parser.LooseLeafVolume)] [InlineData("Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "1")] - [InlineData("Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)", Parser.LooseLeafVolume)] [InlineData("Superman v1 024 (09-10 1943)", "1")] [InlineData("Superman v1.5 024 (09-10 1943)", "1.5")] - [InlineData("Amazing Man Comics chapter 25", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("spawn-123", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("spawn-chapter-123", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Spawn 062 (1997) (digital) (TLK-EMPIRE-HD).cbr", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Batman Beyond 04 (of 6) (1999)", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Batman Beyond 001 (2012)", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Batman Beyond 2.0 001 (2013)", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("Amazing Man Comics chapter 25", Parser.LooseLeafVolume)] + [InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", Parser.LooseLeafVolume)] + [InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", Parser.LooseLeafVolume)] + [InlineData("spawn-123", Parser.LooseLeafVolume)] + [InlineData("spawn-chapter-123", Parser.LooseLeafVolume)] + [InlineData("Spawn 062 (1997) (digital) (TLK-EMPIRE-HD).cbr", Parser.LooseLeafVolume)] + [InlineData("Batman Beyond 04 (of 6) (1999)", Parser.LooseLeafVolume)] + [InlineData("Batman Beyond 001 (2012)", Parser.LooseLeafVolume)] + [InlineData("Batman Beyond 2.0 001 (2013)", Parser.LooseLeafVolume)] + [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", Parser.LooseLeafVolume)] [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", "1")] - [InlineData("Chew Script Book (2011) (digital-Empire) SP04", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("Chew Script Book (2011) (digital-Empire) SP04", Parser.LooseLeafVolume)] [InlineData("Batgirl Vol.2000 #57 (December, 2004)", "2000")] [InlineData("Batgirl V2000 #57", "2000")] - [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire).cbr", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] - [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire).cbr", Parser.LooseLeafVolume)] + [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", Parser.LooseLeafVolume)] [InlineData("Daredevil - v6 - 10 - (2019)", "6")] [InlineData("Daredevil - v6.5", "6.5")] // Tome Tests @@ -117,25 +112,25 @@ public void ParseComicSeriesTest(string filename, string expected) [InlineData("Conquistador_Tome_2", "2")] [InlineData("Max_l_explorateur-_Tome_0", "0")] [InlineData("Chevaliers d'Héliopolis T3 - Rubedo, l'oeuvre au rouge (Jodorowsky & Jérémy)", "3")] - [InlineData("Adventure Time (2012)/Adventure Time #1 (2012)", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("Adventure Time (2012)/Adventure Time #1 (2012)", Parser.LooseLeafVolume)] [InlineData("Adventure Time TPB (2012)/Adventure Time v01 (2012).cbz", "1")] // Russian Tests [InlineData("Kebab Том 1 Глава 3", "1")] - [InlineData("Манга Глава 2", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("Манга Глава 2", Parser.LooseLeafVolume)] [InlineData("ย้อนเวลากลับมาร้าย เล่ม 1", "1")] [InlineData("เด็กคนนี้ขอลาออกจากการเป็นเจ้าของปราสาท เล่ม 1 ตอนที่ 3", "1")] - [InlineData("วิวาห์รัก เดิมพันชีวิต ตอนที่ 2", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("วิวาห์รัก เดิมพันชีวิต ตอนที่ 2", Parser.LooseLeafVolume)] public void ParseComicVolumeTest(string filename, string expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseComicVolume(filename)); + Assert.Equal(expected, Parser.ParseComicVolume(filename)); } [Theory] [InlineData("01 Spider-Man & Wolverine 01.cbr", "1")] - [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] - [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", Parser.DefaultChapter)] + [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", Parser.DefaultChapter)] [InlineData("Batman & Catwoman - Trail of the Gun 01", "1")] - [InlineData("Batman & Daredevil - King of New York", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("Batman & Daredevil - King of New York", Parser.DefaultChapter)] [InlineData("Batman & Grendel (1996) 01 - Devil's Bones", "1")] [InlineData("Batman & Robin the Teen Wonder #0", "0")] [InlineData("Batman & Wildcat (1 of 3)", "1")] @@ -159,8 +154,8 @@ public void ParseComicVolumeTest(string filename, string expected) [InlineData("Batman Beyond 001 (2012)", "1")] [InlineData("Batman Beyond 2.0 001 (2013)", "1")] [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", "1")] - [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] - [InlineData("Chew Script Book (2011) (digital-Empire) SP04", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", Parser.DefaultChapter)] + [InlineData("Chew Script Book (2011) (digital-Empire) SP04", Parser.DefaultChapter)] [InlineData("Batgirl Vol.2000 #57 (December, 2004)", "57")] [InlineData("Batgirl V2000 #57", "57")] [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire).cbr", "21")] @@ -169,7 +164,7 @@ public void ParseComicVolumeTest(string filename, string expected) [InlineData("Daredevil - v6 - 10 - (2019)", "10")] [InlineData("Batman Beyond 2016 - Chapter 001.cbz", "1")] [InlineData("Adventure Time (2012)/Adventure Time #1 (2012)", "1")] - [InlineData("Adventure Time TPB (2012)/Adventure Time v01 (2012).cbz", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("Adventure Time TPB (2012)/Adventure Time v01 (2012).cbz", Parser.DefaultChapter)] [InlineData("Kebab Том 1 Глава 3", "3")] [InlineData("Манга Глава 2", "2")] [InlineData("Манга 2 Глава", "2")] @@ -179,35 +174,35 @@ public void ParseComicVolumeTest(string filename, string expected) [InlineData("หนึ่งความคิด นิจนิรันดร์ บทที่ 112", "112")] public void ParseComicChapterTest(string filename, string expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename, LibraryType.Comic)); + Assert.Equal(expected, Parser.ParseChapter(filename, LibraryType.Comic)); } [Theory] - [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 02 (2018) (digital) (Son of Ultron-Empire)", true)] - [InlineData("Zombie Tramp vs. Vampblade TPB (2016) (Digital) (TheArchivist-Empire)", true)] + [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 02 (2018) (digital) (Son of Ultron-Empire)", false)] + [InlineData("Zombie Tramp vs. Vampblade TPB (2016) (Digital) (TheArchivist-Empire)", false)] [InlineData("Baldwin the Brave & Other Tales Special SP1.cbr", true)] - [InlineData("Mouse Guard Specials - Spring 1153 - Fraggle Rock FCBD 2010", true)] - [InlineData("Boule et Bill - THS -Bill à disparu", true)] - [InlineData("Asterix - HS - Les 12 travaux d'Astérix", true)] - [InlineData("Sillage Hors Série - Le Collectionneur - Concordance-DKFR", true)] + [InlineData("Mouse Guard Specials - Spring 1153 - Fraggle Rock FCBD 2010", false)] + [InlineData("Boule et Bill - THS -Bill à disparu", false)] + [InlineData("Asterix - HS - Les 12 travaux d'Astérix", false)] + [InlineData("Sillage Hors Série - Le Collectionneur - Concordance-DKFR", false)] [InlineData("laughs", false)] - [InlineData("Annual Days of Summer", true)] - [InlineData("Adventure Time 2013 Annual #001 (2013)", true)] - [InlineData("Adventure Time 2013_Annual_#001 (2013)", true)] - [InlineData("Adventure Time 2013_-_Annual #001 (2013)", true)] + [InlineData("Annual Days of Summer", false)] + [InlineData("Adventure Time 2013 Annual #001 (2013)", false)] + [InlineData("Adventure Time 2013_Annual_#001 (2013)", false)] + [InlineData("Adventure Time 2013_-_Annual #001 (2013)", false)] [InlineData("G.I. Joe - A Real American Hero Yearbook 004 Reprint (2021)", false)] [InlineData("Mazebook 001", false)] - [InlineData("X-23 One Shot (2010)", true)] - [InlineData("Casus Belli v1 Hors-Série 21 - Mousquetaires et Sorcellerie", true)] - [InlineData("Batman Beyond Annual", true)] - [InlineData("Batman Beyond Bonus", true)] - [InlineData("Batman Beyond OneShot", true)] - [InlineData("Batman Beyond Specials", true)] - [InlineData("Batman Beyond Omnibus (1999)", true)] - [InlineData("Batman Beyond Omnibus", true)] - [InlineData("01 Annual Batman Beyond", true)] - [InlineData("Blood Syndicate Annual #001", true)] + [InlineData("X-23 One Shot (2010)", false)] + [InlineData("Casus Belli v1 Hors-Série 21 - Mousquetaires et Sorcellerie", false)] + [InlineData("Batman Beyond Annual", false)] + [InlineData("Batman Beyond Bonus", false)] + [InlineData("Batman Beyond OneShot", false)] + [InlineData("Batman Beyond Specials", false)] + [InlineData("Batman Beyond Omnibus (1999)", false)] + [InlineData("Batman Beyond Omnibus", false)] + [InlineData("01 Annual Batman Beyond", false)] + [InlineData("Blood Syndicate Annual #001", false)] public void IsComicSpecialTest(string input, bool expected) { Assert.Equal(expected, Parser.IsSpecial(input, LibraryType.Comic)); diff --git a/API.Tests/Parsing/MangaParsingTests.cs b/API.Tests/Parsing/MangaParsingTests.cs index 64d303f4d6..852eedb9eb 100644 --- a/API.Tests/Parsing/MangaParsingTests.cs +++ b/API.Tests/Parsing/MangaParsingTests.cs @@ -326,18 +326,18 @@ public void ParseEditionTest(string input, string expected) Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseEdition(input)); } [Theory] - [InlineData("Beelzebub Special OneShot - Minna no Kochikame x Beelzebub (2016) [Mangastream].cbz", true)] - [InlineData("Beelzebub_Omake_June_2012_RHS", true)] + [InlineData("Beelzebub Special OneShot - Minna no Kochikame x Beelzebub (2016) [Mangastream].cbz", false)] + [InlineData("Beelzebub_Omake_June_2012_RHS", false)] [InlineData("Beelzebub_Side_Story_02_RHS.zip", false)] - [InlineData("Darker than Black Shikkoku no Hana Special [Simple Scans].zip", true)] - [InlineData("Darker than Black Shikkoku no Hana Fanbook Extra [Simple Scans].zip", true)] - [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter", true)] - [InlineData("Ani-Hina Art Collection.cbz", true)] - [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", true)] - [InlineData("A Town Where You Live - Bonus Chapter.zip", true)] + [InlineData("Darker than Black Shikkoku no Hana Special [Simple Scans].zip", false)] + [InlineData("Darker than Black Shikkoku no Hana Fanbook Extra [Simple Scans].zip", false)] + [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter", false)] + [InlineData("Ani-Hina Art Collection.cbz", false)] + [InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", false)] + [InlineData("A Town Where You Live - Bonus Chapter.zip", false)] [InlineData("Yuki Merry - 4-Komga Anthology", false)] - [InlineData("Beastars - SP01", false)] - [InlineData("Beastars SP01", false)] + [InlineData("Beastars - SP01", true)] + [InlineData("Beastars SP01", true)] [InlineData("The League of Extraordinary Gentlemen", false)] [InlineData("The League of Extra-ordinary Gentlemen", false)] [InlineData("Dr. Ramune - Mysterious Disease Specialist v01 (2020) (Digital) (danke-Empire)", false)] diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index 9844e7766f..737779f0f5 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -6,6 +6,7 @@ using System.Text; using System.Threading.Tasks; using API.Services; +using Kavita.Common.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -745,6 +746,12 @@ public void FindHighestDirectoriesFromFilesTest(string[] rootDirectories, string [InlineData(new [] {"/manga"}, new [] {"/manga/Love Hina/Vol. 01.cbz", "/manga/Love Hina/Specials/Sp01.cbz"}, "/manga/Love Hina")] + [InlineData(new [] {"/manga"}, + new [] {"/manga/Love Hina/Hina/Vol. 01.cbz", "/manga/Love Hina/Specials/Sp01.cbz"}, + "/manga/Love Hina")] + [InlineData(new [] {"/manga"}, + new [] {"/manga/Dress Up Darling/Dress Up Darling Ch 01.cbz", "/manga/Dress Up Darling/Dress Up Darling/Dress Up Darling Vol 01.cbz"}, + "/manga/Dress Up Darling")] public void FindLowestDirectoriesFromFilesTest(string[] rootDirectories, string[] files, string expectedDirectory) { var fileSystem = new MockFileSystem(); @@ -920,8 +927,9 @@ public Task ScanFiles_ShouldFindNoFiles_AllAreIgnored() var ds = new DirectoryService(Substitute.For>(), fileSystem); - - var allFiles = ds.ScanFiles("C:/Data/", API.Services.Tasks.Scanner.Parser.Parser.SupportedExtensions); + var globMatcher = new GlobMatcher(); + globMatcher.AddExclude("*.*"); + var allFiles = ds.ScanFiles("C:/Data/", API.Services.Tasks.Scanner.Parser.Parser.SupportedExtensions, globMatcher); Assert.Empty(allFiles); @@ -945,7 +953,9 @@ public Task ScanFiles_ShouldFindNoNestedFiles_IgnoreNestedFiles() var ds = new DirectoryService(Substitute.For>(), fileSystem); - var allFiles = ds.ScanFiles("C:/Data/", API.Services.Tasks.Scanner.Parser.Parser.SupportedExtensions); + var globMatcher = new GlobMatcher(); + globMatcher.AddExclude("**/Accel World/*"); + var allFiles = ds.ScanFiles("C:/Data/", API.Services.Tasks.Scanner.Parser.Parser.SupportedExtensions, globMatcher); Assert.Single(allFiles); // Ignore files are not counted in files, only valid extensions @@ -974,7 +984,10 @@ public Task ScanFiles_NestedIgnore_IgnoreNestedFilesInOneDirectoryOnly() var ds = new DirectoryService(Substitute.For>(), fileSystem); - var allFiles = ds.ScanFiles("C:/Data/", API.Services.Tasks.Scanner.Parser.Parser.SupportedExtensions); + var globMatcher = new GlobMatcher(); + globMatcher.AddExclude("**/Accel World/*"); + globMatcher.AddExclude("**/ArtBooks/*"); + var allFiles = ds.ScanFiles("C:/Data/", API.Services.Tasks.Scanner.Parser.Parser.SupportedExtensions, globMatcher); Assert.Equal(2, allFiles.Count); // Ignore files are not counted in files, only valid extensions diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index 9fbb76ec36..ff4868a8c1 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -206,24 +206,6 @@ public async Task ScanLibrariesForSeries_ShouldFindFiles() var psf = new ParseScannedFiles(Substitute.For>(), ds, new MockReadingItemService(ds, Substitute.For()), Substitute.For()); - // var parsedSeries = new Dictionary>(); - // - // Task TrackFiles(Tuple> parsedInfo) - // { - // var skippedScan = parsedInfo.Item1; - // var parsedFiles = parsedInfo.Item2; - // if (parsedFiles.Count == 0) return Task.CompletedTask; - // - // var foundParsedSeries = new ParsedSeries() - // { - // Name = parsedFiles.First().Series, - // NormalizedName = parsedFiles.First().Series.ToNormalized(), - // Format = parsedFiles.First().Format - // }; - // - // parsedSeries.Add(foundParsedSeries, parsedFiles); - // return Task.CompletedTask; - // } var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, @@ -273,7 +255,7 @@ public async Task ProcessFiles_ForLibraryMode_OnlyCallsFolderActionForEachTopLev var directoriesSeen = new HashSet(); var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); - var scanResults = await psf.ProcessFiles("C:/Data/", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); + var scanResults = await psf.ScanFiles("C:/Data/", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); foreach (var scanResult in scanResults) { directoriesSeen.Add(scanResult.Folder); @@ -295,7 +277,7 @@ public async Task ProcessFiles_ForNonLibraryMode_CallsFolderActionOnce() Assert.NotNull(library); var directoriesSeen = new HashSet(); - var scanResults = await psf.ProcessFiles("C:/Data/", false, + var scanResults = await psf.ScanFiles("C:/Data/", false, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); foreach (var scanResult in scanResults) @@ -328,7 +310,7 @@ public async Task ProcessFiles_ShouldCallFolderActionTwice() var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); Assert.NotNull(library); - var scanResults = await psf.ProcessFiles("C:/Data", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); + var scanResults = await psf.ScanFiles("C:/Data", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); Assert.Equal(2, scanResults.Count); } @@ -357,7 +339,7 @@ public async Task ProcessFiles_ShouldCallFolderActionOnce() var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Folders | LibraryIncludes.FileTypes); Assert.NotNull(library); - var scanResults = await psf.ProcessFiles("C:/Data", false, + var scanResults = await psf.ScanFiles("C:/Data", false, await _unitOfWork.SeriesRepository.GetFolderPathMap(1), library); Assert.Single(scanResults); diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 67b93273bc..fcbfe82602 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -51,85 +51,83 @@ protected override async Task ResetDb() } [Fact] - public void FindSeriesNotOnDisk_Should_Remove1() + public async Task ScanLibrary_ComicVine_PublisherFolder() { - var infos = new Dictionary>(); - - ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Volumes = "1", Format = MangaFormat.Archive}); - //AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Volumes = "1", Format = MangaFormat.Epub}); + var testcase = "Publisher - ComicVine.json"; + var library = await GenerateScannerData(testcase); + var scanner = CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); - var existingSeries = new List - { - new SeriesBuilder("Darker Than Black") - .WithFormat(MangaFormat.Epub) + Assert.NotNull(postLib); + Assert.Equal(4, postLib.Series.Count); + } - .WithVolume(new VolumeBuilder("1") - .WithName("1") - .Build()) - .WithLocalizedName("Darker Than Black") - .Build() - }; + [Fact] + public async Task ScanLibrary_ShouldCombineNestedFolder() + { + var testcase = "Series and Series-Series Combined - Manga.json"; + var library = await GenerateScannerData(testcase); + var scanner = CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); - Assert.Single(ScannerService.FindSeriesNotOnDisk(existingSeries, infos)); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Equal(2, postLib.Series.First().Volumes.Count); } + [Fact] - public void FindSeriesNotOnDisk_Should_RemoveNothing_Test() + public async Task ScanLibrary_FlatSeries() { - var infos = new Dictionary>(); + var testcase = "Flat Series - Manga.json"; + var library = await GenerateScannerData(testcase); + var scanner = CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); - ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Darker than Black", Format = MangaFormat.Archive}); - ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Cage of Eden", Volumes = "1", Format = MangaFormat.Archive}); - ParserInfoFactory.AddToParsedInfo(infos, new ParserInfo() {Series = "Cage of Eden", Volumes = "10", Format = MangaFormat.Archive}); + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Equal(3, postLib.Series.First().Volumes.Count); - var existingSeries = new List - { - new SeriesBuilder("Cage of Eden") - .WithFormat(MangaFormat.Archive) - - .WithVolume(new VolumeBuilder("1") - .WithName("1") - .Build()) - .WithLocalizedName("Darker Than Black") - .Build(), - new SeriesBuilder("Darker Than Black") - .WithFormat(MangaFormat.Archive) - .WithVolume(new VolumeBuilder("1") - .WithName("1") - .Build()) - .WithLocalizedName("Darker Than Black") - .Build(), - }; - - Assert.Empty(ScannerService.FindSeriesNotOnDisk(existingSeries, infos)); + // TODO: Trigger a deletion of ch 10 } [Fact] - public async Task ScanLibrary_ComicVine_PublisherFolder() + public async Task ScanLibrary_FlatSeriesWithSpecialFolder() { - var testcase = "Publisher - ComicVine.json"; - var postLib = await GenerateScannerData(testcase); + var testcase = "Flat Series with Specials Folder - Manga.json"; + var library = await GenerateScannerData(testcase); + var scanner = CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); - Assert.Equal(4, postLib.Series.Count); + Assert.Single(postLib.Series); + Assert.Equal(4, postLib.Series.First().Volumes.Count); + Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null)); } [Fact] - public async Task ScanLibrary_ShouldCombineNestedFolder() + public async Task ScanLibrary_FlatSeriesWithSpecial() { - var testcase = "Series and Series-Series Combined - Manga.json"; - var postLib = await GenerateScannerData(testcase); + const string testcase = "Flat Special - Manga.json"; + + var library = await GenerateScannerData(testcase); + var scanner = CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); Assert.NotNull(postLib); Assert.Single(postLib.Series); - Assert.Single(postLib.Series); - Assert.Equal(2, postLib.Series.First().Volumes.Count); + Assert.Equal(3, postLib.Series.First().Volumes.Count); + Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null)); } private async Task GenerateScannerData(string testcase) { var testDirectoryPath = await GenerateTestDirectory(Path.Join(_testcasesDirectory, testcase)); - _testOutputHelper.WriteLine($"Test Directory Path: {testDirectoryPath}"); var (publisher, type) = SplitPublisherAndLibraryType(Path.GetFileNameWithoutExtension(testcase)); @@ -145,25 +143,26 @@ private async Task GenerateScannerData(string testcase) _unitOfWork.LibraryRepository.Add(library); await _unitOfWork.CommitAsync(); + return library; + } + + private ScannerService CreateServices() + { var ds = new DirectoryService(Substitute.For>(), new FileSystem()); var mockReadingService = new MockReadingItemService(ds, Substitute.For()); var processSeries = new ProcessSeries(_unitOfWork, Substitute.For>(), Substitute.For(), ds, Substitute.For(), mockReadingService, Substitute.For(), Substitute.For(), - Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For(), - Substitute.For(), new TagManagerService(_unitOfWork, Substitute.For>())); + Substitute.For()); var scanner = new ScannerService(_unitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), Substitute.For(), ds, mockReadingService, processSeries, Substitute.For()); - - await scanner.ScanLibrary(library.Id); - - var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); - return postLib; + return scanner; } private static (string Publisher, LibraryType Type) SplitPublisherAndLibraryType(string input) @@ -209,6 +208,8 @@ private async Task GenerateTestDirectory(string mapPath) // Generate the files and folders await Scaffold(testDirectory, filePaths); + _testOutputHelper.WriteLine($"Test Directory Path: {testDirectory}"); + return testDirectory; } diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 67a541b050..7196c16fa8 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -817,12 +817,17 @@ public async Task UpdateSeriesMetadata_ShouldRemoveExistingTags() public async Task UpdateSeriesMetadata_ShouldAddNewPerson_NoExistingPeople() { await ResetDb(); + var g = new PersonBuilder("Existing Person").Build(); + await _context.SaveChangesAsync(); + var s = new SeriesBuilder("Test") - .WithMetadata(new SeriesMetadataBuilder().Build()) + .WithMetadata(new SeriesMetadataBuilder() + .WithPerson(g, PersonRole.Publisher) + .Build()) .Build(); s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); - var g = new PersonBuilder("Existing Person", PersonRole.Publisher).Build(); + _context.Series.Add(s); _context.Person.Add(g); @@ -833,7 +838,7 @@ public async Task UpdateSeriesMetadata_ShouldAddNewPerson_NoExistingPeople() SeriesMetadata = new SeriesMetadataDto { SeriesId = 1, - Publishers = new List {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}}, + Publishers = new List {new () {Id = 0, Name = "Existing Person"}}, }, }); @@ -842,7 +847,7 @@ public async Task UpdateSeriesMetadata_ShouldAddNewPerson_NoExistingPeople() var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); Assert.NotNull(series.Metadata); - Assert.True(series.Metadata.People.Select(g => g.Name).All(g => g == "Existing Person")); + Assert.True(series.Metadata.People.Select(g => g.Person.Name).All(personName => personName == "Existing Person")); Assert.False(series.Metadata.PublisherLocked); // PublisherLocked is false unless the UI Explicitly says it should be locked } @@ -854,10 +859,14 @@ public async Task UpdateSeriesMetadata_ShouldAddNewPerson_ExistingPeople() .WithMetadata(new SeriesMetadataBuilder().Build()) .Build(); s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); - var g = new PersonBuilder("Existing Person", PersonRole.Publisher).Build(); - s.Metadata.People = new List - {new PersonBuilder("Existing Writer", PersonRole.Writer).Build(), - new PersonBuilder("Existing Translator", PersonRole.Translator).Build(), new PersonBuilder("Existing Publisher 2", PersonRole.Publisher).Build()}; + var g = new PersonBuilder("Existing Person").Build(); + s.Metadata.People = new List + { + new SeriesMetadataPeople() {Person = new PersonBuilder("Existing Writer").Build(), Role = PersonRole.Writer}, + new SeriesMetadataPeople() {Person = new PersonBuilder("Existing Translator").Build(), Role = PersonRole.Translator}, + new SeriesMetadataPeople() {Person = new PersonBuilder("Existing Publisher 2").Build(), Role = PersonRole.Publisher} + }; + _context.Series.Add(s); _context.Person.Add(g); @@ -868,7 +877,7 @@ public async Task UpdateSeriesMetadata_ShouldAddNewPerson_ExistingPeople() SeriesMetadata = new SeriesMetadataDto { SeriesId = 1, - Publishers = new List {new () {Id = 0, Name = "Existing Person", Role = PersonRole.Publisher}}, + Publishers = new List {new () {Id = 0, Name = "Existing Person"}}, PublisherLocked = true }, @@ -878,7 +887,7 @@ public async Task UpdateSeriesMetadata_ShouldAddNewPerson_ExistingPeople() var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); Assert.NotNull(series.Metadata); - Assert.True(series.Metadata.People.Select(g => g.Name).All(g => g == "Existing Person")); + Assert.True(series.Metadata.People.Select(g => g.Person.Name).All(personName => personName == "Existing Person")); Assert.True(series.Metadata.PublisherLocked); } @@ -891,7 +900,7 @@ public async Task UpdateSeriesMetadata_ShouldRemoveExistingPerson() .WithMetadata(new SeriesMetadataBuilder().Build()) .Build(); s.Library = new LibraryBuilder("Test LIb", LibraryType.Book).Build(); - var g = new PersonBuilder("Existing Person", PersonRole.Publisher).Build(); + var g = new PersonBuilder("Existing Person").Build(); _context.Series.Add(s); _context.Person.Add(g); diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series - Manga.json new file mode 100644 index 0000000000..6b4b701604 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series - Manga.json @@ -0,0 +1,5 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling v01.cbz", + "My Dress-Up Darling/My Dress-Up Darling v02.cbz", + "My Dress-Up Darling/My Dress-Up Darling ch 10.cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder - Manga.json new file mode 100644 index 0000000000..12e80ea958 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder - Manga.json @@ -0,0 +1,6 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling v01.cbz", + "My Dress-Up Darling/My Dress-Up Darling v02.cbz", + "My Dress-Up Darling/My Dress-Up Darling ch 10.cbz", + "My Dress-Up Darling/Specials/Official Anime Fanbook SP05 (2024) (Digital).cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Special - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Special - Manga.json new file mode 100644 index 0000000000..d864da284e --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Special - Manga.json @@ -0,0 +1,5 @@ +[ + "Uzaki-chan Wants to Hang Out!\\Uzaki-chan Wants to Hang Out! - 2022 New Years Special SP01.cbz", + "Uzaki-chan Wants to Hang Out!\\Uzaki-chan Wants to Hang Out! - Ch. 103 - Kouhai and Control.cbz", + "Uzaki-chan Wants to Hang Out!\\Uzaki-chan Wants to Hang Out! v01 (2019) (Digital) (danke-Empire).cbz" +] \ No newline at end of file diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Nested Chapters - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Nested Chapters - Manga.json new file mode 100644 index 0000000000..586ae90f5c --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Nested Chapters - Manga.json @@ -0,0 +1,4 @@ +[ + "My Dress-Up Darling/Chapter 1/01.cbz", + "My Dress-Up Darling/Chapter 2/02.cbz" +] \ No newline at end of file diff --git a/API/API.csproj.DotSettings b/API/API.csproj.DotSettings index c7410bba23..ced14c1540 100644 --- a/API/API.csproj.DotSettings +++ b/API/API.csproj.DotSettings @@ -1,3 +1,4 @@  True + True True \ No newline at end of file diff --git a/API/Controllers/ChapterController.cs b/API/Controllers/ChapterController.cs index a9f19a9514..b2c65282ca 100644 --- a/API/Controllers/ChapterController.cs +++ b/API/Controllers/ChapterController.cs @@ -79,7 +79,8 @@ public async Task> DeleteChapter(int chapterId) [HttpPost("update")] public async Task UpdateChapterMetadata(UpdateChapterDto dto) { - var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.Id, ChapterIncludes.People | ChapterIncludes.Genres | ChapterIncludes.Tags); + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.Id, + ChapterIncludes.People | ChapterIncludes.Genres | ChapterIncludes.Tags); if (chapter == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist")); @@ -135,105 +136,130 @@ public async Task UpdateChapterMetadata(UpdateChapterDto dto) #region Genres - if (dto.Genres != null && - dto.Genres.Count != 0) + if (dto.Genres is {Count: > 0}) { - var allGenres = (await _unitOfWork.GenreRepository.GetAllGenresByNamesAsync(dto.Genres.Select(t => Parser.Normalize(t.Title)))).ToList(); chapter.Genres ??= new List(); - GenreHelper.UpdateGenreList(dto.Genres, chapter, allGenres, genre => - { - chapter.Genres.Add(genre); - }, () => chapter.GenresLocked = true); + await GenreHelper.UpdateChapterGenres(chapter, dto.Genres.Select(t => t.Title), _unitOfWork); } #endregion #region Tags if (dto.Tags is {Count: > 0}) { - var allTags = (await _unitOfWork.TagRepository - .GetAllTagsByNameAsync(dto.Tags.Select(t => Parser.Normalize(t.Title)))) - .ToList(); chapter.Tags ??= new List(); - TagHelper.UpdateTagList(dto.Tags, chapter, allTags, tag => - { - chapter.Tags.Add(tag); - }, () => chapter.TagsLocked = true); + await TagHelper.UpdateChapterTags(chapter, dto.Tags.Select(t => t.Title), _unitOfWork); } #endregion #region People if (PersonHelper.HasAnyPeople(dto)) { - void HandleAddPerson(Person person) - { - PersonHelper.AddPersonIfNotExists(chapter.People, person); - } - - chapter.People ??= new List(); - var allWriters = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Writer, - dto.Writers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Writer, dto.Writers, chapter, allWriters.AsReadOnly(), - HandleAddPerson, () => chapter.WriterLocked = true); - - var allCharacters = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Character, - dto.Characters.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Character, dto.Characters, chapter, allCharacters.AsReadOnly(), - HandleAddPerson, () => chapter.CharacterLocked = true); - - var allColorists = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Colorist, - dto.Colorists.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Colorist, dto.Colorists, chapter, allColorists.AsReadOnly(), - HandleAddPerson, () => chapter.ColoristLocked = true); - - var allEditors = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Editor, - dto.Editors.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Editor, dto.Editors, chapter, allEditors.AsReadOnly(), - HandleAddPerson, () => chapter.EditorLocked = true); - - var allInkers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Inker, - dto.Inkers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Inker, dto.Inkers, chapter, allInkers.AsReadOnly(), - HandleAddPerson, () => chapter.InkerLocked = true); - - var allLetterers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Letterer, - dto.Letterers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Letterer, dto.Letterers, chapter, allLetterers.AsReadOnly(), - HandleAddPerson, () => chapter.LettererLocked = true); - - var allPencillers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Penciller, - dto.Pencillers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Penciller, dto.Pencillers, chapter, allPencillers.AsReadOnly(), - HandleAddPerson, () => chapter.PencillerLocked = true); - - var allPublishers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Publisher, - dto.Publishers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Publisher, dto.Publishers, chapter, allPublishers.AsReadOnly(), - HandleAddPerson, () => chapter.PublisherLocked = true); - - var allImprints = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Imprint, - dto.Imprints.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Imprint, dto.Imprints, chapter, allImprints.AsReadOnly(), - HandleAddPerson, () => chapter.ImprintLocked = true); - - var allTeams = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Team, - dto.Imprints.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Team, dto.Teams, chapter, allTeams.AsReadOnly(), - HandleAddPerson, () => chapter.TeamLocked = true); - - var allLocations = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Location, - dto.Imprints.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Location, dto.Locations, chapter, allLocations.AsReadOnly(), - HandleAddPerson, () => chapter.LocationLocked = true); - - var allTranslators = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Translator, - dto.Translators.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Translator, dto.Translators, chapter, allTranslators.AsReadOnly(), - HandleAddPerson, () => chapter.TranslatorLocked = true); - - var allCoverArtists = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.CoverArtist, - dto.CoverArtists.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.CoverArtist, dto.CoverArtists, chapter, allCoverArtists.AsReadOnly(), - HandleAddPerson, () => chapter.CoverArtistLocked = true); + chapter.People ??= new List(); + + + // Update writers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Writers.Select(p => Parser.Normalize(p.Name)).ToList(), + PersonRole.Writer, + _unitOfWork + ); + + // Update characters + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Characters.Select(p => Parser.Normalize(p.Name)).ToList(), + PersonRole.Character, + _unitOfWork + ); + + // Update pencillers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Pencillers.Select(p => Parser.Normalize(p.Name)).ToList(), + PersonRole.Penciller, + _unitOfWork + ); + + // Update inkers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Inkers.Select(p => Parser.Normalize(p.Name)).ToList(), + PersonRole.Inker, + _unitOfWork + ); + + // Update colorists + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Colorists.Select(p => Parser.Normalize(p.Name)).ToList(), + PersonRole.Colorist, + _unitOfWork + ); + + // Update letterers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Letterers.Select(p => Parser.Normalize(p.Name)).ToList(), + PersonRole.Letterer, + _unitOfWork + ); + + // Update cover artists + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.CoverArtists.Select(p => Parser.Normalize(p.Name)).ToList(), + PersonRole.CoverArtist, + _unitOfWork + ); + + // Update editors + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Editors.Select(p => Parser.Normalize(p.Name)).ToList(), + PersonRole.Editor, + _unitOfWork + ); + + // Update publishers + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Publishers.Select(p => Parser.Normalize(p.Name)).ToList(), + PersonRole.Publisher, + _unitOfWork + ); + + // Update translators + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Translators.Select(p => Parser.Normalize(p.Name)).ToList(), + PersonRole.Translator, + _unitOfWork + ); + + // Update imprints + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Imprints.Select(p => Parser.Normalize(p.Name)).ToList(), + PersonRole.Imprint, + _unitOfWork + ); + + // Update teams + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Teams.Select(p => Parser.Normalize(p.Name)).ToList(), + PersonRole.Team, + _unitOfWork + ); + + // Update locations + await PersonHelper.UpdateChapterPeopleAsync( + chapter, + dto.Locations.Select(p => Parser.Normalize(p.Name)).ToList(), + PersonRole.Location, + _unitOfWork + ); } #endregion diff --git a/API/Controllers/ColorScapeController.cs b/API/Controllers/ColorScapeController.cs index 415f4aad8f..04827658dd 100644 --- a/API/Controllers/ColorScapeController.cs +++ b/API/Controllers/ColorScapeController.cs @@ -55,7 +55,6 @@ public async Task> GetColorScapeForChapter(int id) } - private ActionResult GetColorSpaceDto(IHasCoverImage entity) { if (entity == null) return Ok(ColorScapeDto.Empty); diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index 6275c6d4cd..011ae471f4 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -45,7 +45,7 @@ public ImageController(IUnitOfWork unitOfWork, IDirectoryService directoryServic /// /// [HttpGet("chapter-cover")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["chapterId", "apiKey"])] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"chapterId", "apiKey"})] public async Task GetChapterCoverImage(int chapterId, string apiKey) { var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); @@ -130,6 +130,7 @@ public async Task GetCollectionCoverImage(int collectionTagId, str { var destFile = await GenerateCollectionCoverImage(collectionTagId); if (string.IsNullOrEmpty(destFile)) return BadRequest(await _localizationService.Translate(userId, "no-cover-image")); + return PhysicalFile(destFile, MimeTypeMap.GetMimeType(_directoryService.FileSystem.Path.GetExtension(destFile)), _directoryService.FileSystem.Path.GetFileName(destFile)); } @@ -170,6 +171,7 @@ private async Task GenerateCollectionCoverImage(int collectionId) ImageService.GetCollectionTagFormat(collectionId)); var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); destFile += settings.EncodeMediaAs.GetExtension(); + if (_directoryService.FileSystem.File.Exists(destFile)) return destFile; ImageService.CreateMergedImage( covers.Select(c => _directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, c)).ToList(), @@ -282,6 +284,43 @@ public async Task GetPublisherImage(string publisherName, string a return PhysicalFile(file.FullName, MimeTypeMap.GetMimeType(format), Path.GetFileName(file.FullName)); } + /// + /// Returns cover image for Person + /// + /// + /// + [HttpGet("person-cover")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["personId", "apiKey"])] + public async Task GetPersonCoverImage(int personId, string apiKey) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + if (userId == 0) return BadRequest(); + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.PersonRepository.GetCoverImageAsync(personId)); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest(await _localizationService.Translate(userId, "no-cover-image")); + var format = _directoryService.FileSystem.Path.GetExtension(path); + + return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path)); + } + + /// + /// Returns cover image for Person + /// + /// + /// + [HttpGet("person-cover-by-name")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = ["personId", "apiKey"])] + public async Task GetPersonCoverImageByName(string name, string apiKey) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + if (userId == 0) return BadRequest(); + + var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.PersonRepository.GetCoverImageByNameAsync(name)); + if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest(await _localizationService.Translate(userId, "no-cover-image")); + var format = _directoryService.FileSystem.Path.GetExtension(path); + + return PhysicalFile(path, MimeTypeMap.GetMimeType(format), _directoryService.FileSystem.Path.GetFileName(path)); + } + /// /// Returns a temp coverupload image /// diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs new file mode 100644 index 0000000000..fb18156bab --- /dev/null +++ b/API/Controllers/PersonController.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Data; +using API.DTOs; +using API.Entities.Enums; +using API.Extensions; +using API.Helpers; +using API.Services; +using AutoMapper; +using Microsoft.AspNetCore.Mvc; +using Nager.ArticleNumber; + +namespace API.Controllers; + +public class PersonController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly IMapper _mapper; + + public PersonController(IUnitOfWork unitOfWork, ILocalizationService localizationService, IMapper mapper) + { + _unitOfWork = unitOfWork; + _localizationService = localizationService; + _mapper = mapper; + } + + + [HttpGet] + public async Task> GetPersonByName(string name) + { + return Ok(await _unitOfWork.PersonRepository.GetPersonDtoByName(name, User.GetUserId())); + } + + [HttpGet("roles")] + public async Task>> GetRolesForPersonByName(string name) + { + return Ok(await _unitOfWork.PersonRepository.GetRolesForPersonByName(name, User.GetUserId())); + } + + /// + /// Returns a list of authors for browsing + /// + /// + /// + [HttpPost("authors")] + public async Task>> GetAuthorsForBrowse([FromQuery] UserParams? userParams) + { + userParams ??= UserParams.Default; + var list = await _unitOfWork.PersonRepository.GetAllWritersAndSeriesCount(User.GetUserId(), userParams); + Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages); + return Ok(list); + } + + /// + /// Updates the Person + /// + /// + /// + [HttpPost("update")] + public async Task> UpdatePerson(UpdatePersonDto dto) + { + // This needs to get all people and update them equally + var person = await _unitOfWork.PersonRepository.GetPersonById(dto.Id); + if (person == null) return BadRequest(_localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); + + dto.Description ??= string.Empty; + person.Description = dto.Description; + person.CoverImageLocked = dto.CoverImageLocked; + + if (dto.MalId is > 0) + { + person.MalId = (long) dto.MalId; + } + if (dto.AniListId is > 0) + { + person.AniListId = (int) dto.AniListId; + } + + if (!string.IsNullOrEmpty(dto.HardcoverId?.Trim())) + { + person.HardcoverId = dto.HardcoverId.Trim(); + } + + var asin = dto.Asin?.Trim(); + if (!string.IsNullOrEmpty(asin) && + (ArticleNumberHelper.IsValidIsbn10(asin) || ArticleNumberHelper.IsValidIsbn13(asin))) + { + person.Asin = asin; + } + + _unitOfWork.PersonRepository.Update(person); + await _unitOfWork.CommitAsync(); + + return Ok(_mapper.Map(person)); + } + + /// + /// Returns the top 20 series that the "person" is known for. This will use Average Rating when applicable (Kavita+ field), else it's a random sort + /// + /// + /// + [HttpGet("series-known-for")] + public async Task>> GetKnownSeries(int personId) + { + return Ok(await _unitOfWork.PersonRepository.GetSeriesKnownFor(personId)); + } + + [HttpGet("chapters-by-role")] + public async Task>> GetChaptersByRole(int personId, PersonRole role) + { + return Ok(await _unitOfWork.PersonRepository.GetChaptersForPersonByRole(personId, User.GetUserId(), role)); + } + + +} diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index e89904deb4..333d4b45fd 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -480,5 +480,54 @@ public async Task ResetChapterLock(UploadFileDto uploadFileDto) return BadRequest(await _localizationService.Translate(User.GetUserId(), "reset-chapter-lock")); } + /// + /// Replaces person tag cover image and locks it with a base64 encoded image + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] + [HttpPost("person")] + public async Task UploadPersonCoverImageFromUrl(UploadFileDto uploadFileDto) + { + // Check if Url is non-empty, request the image and place in temp, then ask image service to handle it. + // See if we can do this all in memory without touching underlying system + if (string.IsNullOrEmpty(uploadFileDto.Url)) + { + return BadRequest(await _localizationService.Translate(User.GetUserId(), "url-required")); + } + + try + { + var person = await _unitOfWork.PersonRepository.GetPersonById(uploadFileDto.Id); + if (person == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "person-doesnt-exist")); + var filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetPersonFormat(uploadFileDto.Id)}"); + + if (!string.IsNullOrEmpty(filePath)) + { + person.CoverImage = filePath; + person.CoverImageLocked = true; + _imageService.UpdateColorScape(person); + _unitOfWork.PersonRepository.Update(person); + } + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, + MessageFactory.CoverUpdateEvent(person.Id, MessageFactoryEntityTypes.Person), false); + return Ok(); + } + + } + catch (Exception e) + { + _logger.LogError(e, "There was an issue uploading cover image for Person {Id}", uploadFileDto.Id); + await _unitOfWork.RollbackAsync(); + } + + return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-cover-person-save")); + } + } diff --git a/API/DTOs/Person/BrowsePersonDto.cs b/API/DTOs/Person/BrowsePersonDto.cs new file mode 100644 index 0000000000..8d69999737 --- /dev/null +++ b/API/DTOs/Person/BrowsePersonDto.cs @@ -0,0 +1,16 @@ +namespace API.DTOs; + +/// +/// Used to browse writers and click in to see their series +/// +public class BrowsePersonDto : PersonDto +{ + /// + /// Number of Series this Person is the Writer for + /// + public int SeriesCount { get; set; } + /// + /// Number or Issues this Person is the Writer for + /// + public int IssueCount { get; set; } +} diff --git a/API/DTOs/Person/PersonDto.cs b/API/DTOs/Person/PersonDto.cs new file mode 100644 index 0000000000..aa0f0680cb --- /dev/null +++ b/API/DTOs/Person/PersonDto.cs @@ -0,0 +1,37 @@ +namespace API.DTOs; + +public class PersonDto +{ + public int Id { get; set; } + public required string Name { get; set; } + + public bool CoverImageLocked { get; set; } + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } + + public string? CoverImage { get; set; } + + public string Description { get; set; } + /// + /// ASIN for person + /// + /// Can be used for Amazon author lookup + public string? Asin { get; set; } + + /// + /// https://anilist.co/staff/{AniListId}/ + /// + /// Kavita+ Only + public int AniListId { get; set; } = 0; + /// + /// https://myanimelist.net/people/{MalId}/ + /// https://myanimelist.net/character/{MalId}/CharacterName + /// + /// Kavita+ Only + public long MalId { get; set; } = 0; + /// + /// https://hardcover.app/authors/{HardcoverId} + /// + /// Kavita+ Only + public string? HardcoverId { get; set; } +} diff --git a/API/DTOs/Person/UpdatePersonDto.cs b/API/DTOs/Person/UpdatePersonDto.cs new file mode 100644 index 0000000000..fe57632577 --- /dev/null +++ b/API/DTOs/Person/UpdatePersonDto.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs; + +public class UpdatePersonDto +{ + [Required] + public int Id { get; init; } + [Required] + public bool CoverImageLocked { get; set; } + public string? Description { get; set; } + + public int? AniListId { get; set; } + public long? MalId { get; set; } + public string? HardcoverId { get; set; } + public string? Asin { get; set; } +} diff --git a/API/DTOs/PersonDto.cs b/API/DTOs/PersonDto.cs deleted file mode 100644 index 85cc72bb02..0000000000 --- a/API/DTOs/PersonDto.cs +++ /dev/null @@ -1,10 +0,0 @@ -using API.Entities.Enums; - -namespace API.DTOs; - -public class PersonDto -{ - public int Id { get; set; } - public required string Name { get; set; } - public PersonRole Role { get; set; } -} diff --git a/API/DTOs/StandaloneChapterDto.cs b/API/DTOs/StandaloneChapterDto.cs new file mode 100644 index 0000000000..6d8b5423d4 --- /dev/null +++ b/API/DTOs/StandaloneChapterDto.cs @@ -0,0 +1,14 @@ +using API.Entities.Enums; + +namespace API.DTOs; + +/// +/// Used on Person Profile page +/// +public class StandaloneChapterDto : ChapterDto +{ + public int SeriesId { get; set; } + public int LibraryId { get; set; } + public LibraryType LibraryType { get; set; } + public string VolumeTitle { get; set; } +} diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 4af1652495..21b7c26c80 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -66,6 +66,8 @@ public DataContext(DbContextOptions options) : base(options) public DbSet ManualMigrationHistory { get; set; } = null!; public DbSet SeriesBlacklist { get; set; } = null!; public DbSet AppUserCollection { get; set; } = null!; + public DbSet ChapterPeople { get; set; } = null!; + public DbSet SeriesMetadataPeople { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) @@ -155,6 +157,36 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .Property(b => b.AgeRating) .HasDefaultValue(AgeRating.Unknown); + + // Configure the many-to-many relationship for Movie and Person + builder.Entity() + .HasKey(cp => new { cp.ChapterId, cp.PersonId, cp.Role }); + + builder.Entity() + .HasOne(cp => cp.Chapter) + .WithMany(c => c.People) + .HasForeignKey(cp => cp.ChapterId); + + builder.Entity() + .HasOne(cp => cp.Person) + .WithMany(p => p.ChapterPeople) + .HasForeignKey(cp => cp.PersonId) + .OnDelete(DeleteBehavior.Cascade); + + + builder.Entity() + .HasKey(smp => new { smp.SeriesMetadataId, smp.PersonId, smp.Role }); + + builder.Entity() + .HasOne(smp => smp.SeriesMetadata) + .WithMany(sm => sm.People) + .HasForeignKey(smp => smp.SeriesMetadataId); + + builder.Entity() + .HasOne(smp => smp.Person) + .WithMany(p => p.SeriesMetadataPeople) + .HasForeignKey(smp => smp.PersonId) + .OnDelete(DeleteBehavior.Cascade); } #nullable enable diff --git a/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath2.cs b/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath2.cs new file mode 100644 index 0000000000..bb79c33597 --- /dev/null +++ b/API/Data/ManualMigrations/MigrateLowestSeriesFolderPath2.cs @@ -0,0 +1,51 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Services.Tasks.Scanner.Parser; +using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace API.Data.ManualMigrations; + +/// +/// v0.8.3 still had a bug around LowestSeriesPath. This resets it for all users. +/// +public static class MigrateLowestSeriesFolderPath2 +{ + public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger logger) + { + if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateLowestSeriesFolderPath2")) + { + return; + } + + logger.LogCritical( + "Running MigrateLowestSeriesFolderPath2 migration - Please be patient, this may take some time. This is not an error"); + + var series = await dataContext.Series.Where(s => !string.IsNullOrEmpty(s.LowestFolderPath)).ToListAsync(); + foreach (var s in series) + { + s.LowestFolderPath = string.Empty; + unitOfWork.SeriesRepository.Update(s); + } + + // Save changes after processing all series + if (dataContext.ChangeTracker.HasChanges()) + { + await dataContext.SaveChangesAsync(); + } + + dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory() + { + Name = "MigrateLowestSeriesFolderPath2", + ProductVersion = BuildInfo.Version.ToString(), + RanAt = DateTime.UtcNow + }); + + await dataContext.SaveChangesAsync(); + logger.LogCritical( + "Running MigrateLowestSeriesFolderPath2 migration - Completed. This is not an error"); + } +} diff --git a/API/Data/Migrations/20240704144224_PersonFields.Designer.cs b/API/Data/Migrations/20240704144224_PersonFields.Designer.cs new file mode 100644 index 0000000000..ddc41d8113 --- /dev/null +++ b/API/Data/Migrations/20240704144224_PersonFields.Designer.cs @@ -0,0 +1,3064 @@ +// +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("20240704144224_PersonFields")] + partial class PersonFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.6"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("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("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.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("Promoted") + .HasColumnType("INTEGER"); + + 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("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.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("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AvgHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + 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("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .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("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .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.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.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + 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("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.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.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("AverageScore") + .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.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("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + 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.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("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.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("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + 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("Promoted") + .HasColumnType("INTEGER"); + + 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("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .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("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("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + 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("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("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + 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("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + 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.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.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.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.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("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .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("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .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("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("Files"); + + 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.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/20240704144224_PersonFields.cs b/API/Data/Migrations/20240704144224_PersonFields.cs new file mode 100644 index 0000000000..2d30696ced --- /dev/null +++ b/API/Data/Migrations/20240704144224_PersonFields.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PersonFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AniListId", + table: "Person", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "Asin", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "CoverImage", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "CoverImageLocked", + table: "Person", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Description", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "HardcoverId", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "MalId", + table: "Person", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AniListId", + table: "Person"); + + migrationBuilder.DropColumn( + name: "Asin", + table: "Person"); + + migrationBuilder.DropColumn( + name: "CoverImage", + table: "Person"); + + migrationBuilder.DropColumn( + name: "CoverImageLocked", + table: "Person"); + + migrationBuilder.DropColumn( + name: "Description", + table: "Person"); + + migrationBuilder.DropColumn( + name: "HardcoverId", + table: "Person"); + + migrationBuilder.DropColumn( + name: "MalId", + table: "Person"); + } + } +} diff --git a/API/Data/Migrations/20241011143144_PeopleOverhaulPart1.Designer.cs b/API/Data/Migrations/20241011143144_PeopleOverhaulPart1.Designer.cs new file mode 100644 index 0000000000..3865e60073 --- /dev/null +++ b/API/Data/Migrations/20241011143144_PeopleOverhaulPart1.Designer.cs @@ -0,0 +1,3170 @@ +// +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("20241011143144_PeopleOverhaulPart1")] + partial class PeopleOverhaulPart1 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + 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("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("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.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("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.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("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.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + 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.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.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + 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.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.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("AverageScore") + .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.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("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + 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.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("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.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + 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("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .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.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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.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.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + 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.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.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.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.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.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.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("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("Files"); + + b.Navigation("People"); + + 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.Person", b => + { + 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/20241011143144_PeopleOverhaulPart1.cs b/API/Data/Migrations/20241011143144_PeopleOverhaulPart1.cs new file mode 100644 index 0000000000..1bf0cf6c40 --- /dev/null +++ b/API/Data/Migrations/20241011143144_PeopleOverhaulPart1.cs @@ -0,0 +1,159 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PeopleOverhaulPart1 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChapterPerson"); + + migrationBuilder.DropTable( + name: "PersonSeriesMetadata"); + + migrationBuilder.DropColumn( + name: "Role", + table: "Person"); + + migrationBuilder.CreateTable( + name: "ChapterPeople", + columns: table => new + { + ChapterId = table.Column(type: "INTEGER", nullable: false), + PersonId = table.Column(type: "INTEGER", nullable: false), + Role = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterPeople", x => new { x.ChapterId, x.PersonId, x.Role }); + table.ForeignKey( + name: "FK_ChapterPeople_Chapter_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChapterPeople_Person_PersonId", + column: x => x.PersonId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "SeriesMetadataPeople", + columns: table => new + { + SeriesMetadataId = table.Column(type: "INTEGER", nullable: false), + PersonId = table.Column(type: "INTEGER", nullable: false), + Role = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SeriesMetadataPeople", x => new { x.SeriesMetadataId, x.PersonId, x.Role }); + table.ForeignKey( + name: "FK_SeriesMetadataPeople_Person_PersonId", + column: x => x.PersonId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SeriesMetadataPeople_SeriesMetadata_SeriesMetadataId", + column: x => x.SeriesMetadataId, + principalTable: "SeriesMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChapterPeople_PersonId", + table: "ChapterPeople", + column: "PersonId"); + + migrationBuilder.CreateIndex( + name: "IX_SeriesMetadataPeople_PersonId", + table: "SeriesMetadataPeople", + column: "PersonId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChapterPeople"); + + migrationBuilder.DropTable( + name: "SeriesMetadataPeople"); + + migrationBuilder.AddColumn( + name: "Role", + table: "Person", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "ChapterPerson", + columns: table => new + { + ChapterMetadatasId = table.Column(type: "INTEGER", nullable: false), + PeopleId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterPerson", x => new { x.ChapterMetadatasId, x.PeopleId }); + table.ForeignKey( + name: "FK_ChapterPerson_Chapter_ChapterMetadatasId", + column: x => x.ChapterMetadatasId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChapterPerson_Person_PeopleId", + column: x => x.PeopleId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PersonSeriesMetadata", + columns: table => new + { + PeopleId = table.Column(type: "INTEGER", nullable: false), + SeriesMetadatasId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PersonSeriesMetadata", x => new { x.PeopleId, x.SeriesMetadatasId }); + table.ForeignKey( + name: "FK_PersonSeriesMetadata_Person_PeopleId", + column: x => x.PeopleId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PersonSeriesMetadata_SeriesMetadata_SeriesMetadatasId", + column: x => x.SeriesMetadatasId, + principalTable: "SeriesMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChapterPerson_PeopleId", + table: "ChapterPerson", + column: "PeopleId"); + + migrationBuilder.CreateIndex( + name: "IX_PersonSeriesMetadata_SeriesMetadatasId", + table: "PersonSeriesMetadata", + column: "SeriesMetadatasId"); + } + } +} diff --git a/API/Data/Migrations/20241011152321_PeopleOverhaulPart2.Designer.cs b/API/Data/Migrations/20241011152321_PeopleOverhaulPart2.Designer.cs new file mode 100644 index 0000000000..bbbf0f989d --- /dev/null +++ b/API/Data/Migrations/20241011152321_PeopleOverhaulPart2.Designer.cs @@ -0,0 +1,3182 @@ +// +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("20241011152321_PeopleOverhaulPart2")] + partial class PeopleOverhaulPart2 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + 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("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("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.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("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.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("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.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + 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.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.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + 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.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.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("AverageScore") + .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.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("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + 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.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("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.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .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.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("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .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.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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.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.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + 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.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.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.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.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.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.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("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("Files"); + + b.Navigation("People"); + + 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.Person", b => + { + 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/20241011152321_PeopleOverhaulPart2.cs b/API/Data/Migrations/20241011152321_PeopleOverhaulPart2.cs new file mode 100644 index 0000000000..4fd8e4b8d1 --- /dev/null +++ b/API/Data/Migrations/20241011152321_PeopleOverhaulPart2.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PeopleOverhaulPart2 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CoverImage", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "CoverImageLocked", + table: "Person", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "Person", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CoverImage", + table: "Person"); + + migrationBuilder.DropColumn( + name: "CoverImageLocked", + table: "Person"); + + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "Person"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "Person"); + } + } +} diff --git a/API/Data/Migrations/20241011172428_PeopleOverhaulPart3.Designer.cs b/API/Data/Migrations/20241011172428_PeopleOverhaulPart3.Designer.cs new file mode 100644 index 0000000000..6f76df92c4 --- /dev/null +++ b/API/Data/Migrations/20241011172428_PeopleOverhaulPart3.Designer.cs @@ -0,0 +1,3197 @@ +// +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("20241011172428_PeopleOverhaulPart3")] + partial class PeopleOverhaulPart3 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + 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("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("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.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("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.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("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.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + 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.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.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + 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.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.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("AverageScore") + .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.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("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + 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.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("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.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.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("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .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.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + 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.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.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + 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.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.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.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.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.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.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("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("Files"); + + b.Navigation("People"); + + 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.Person", b => + { + 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/20241011172428_PeopleOverhaulPart3.cs b/API/Data/Migrations/20241011172428_PeopleOverhaulPart3.cs new file mode 100644 index 0000000000..13aa9e050d --- /dev/null +++ b/API/Data/Migrations/20241011172428_PeopleOverhaulPart3.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class PeopleOverhaulPart3 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AniListId", + table: "Person", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "Asin", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Description", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "HardcoverId", + table: "Person", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "MalId", + table: "Person", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AniListId", + table: "Person"); + + migrationBuilder.DropColumn( + name: "Asin", + table: "Person"); + + migrationBuilder.DropColumn( + name: "Description", + table: "Person"); + + migrationBuilder.DropColumn( + name: "HardcoverId", + table: "Person"); + + migrationBuilder.DropColumn( + name: "MalId", + table: "Person"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 9d899ca665..ddcfeb10e9 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -901,6 +901,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Chapter"); }); + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + modelBuilder.Entity("API.Entities.CollectionTag", b => { b.Property("Id") @@ -1531,14 +1549,38 @@ protected override void BuildModel(ModelBuilder modelBuilder) .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("Role") - .HasColumnType("INTEGER"); + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); b.HasKey("Id"); @@ -1903,6 +1945,24 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Series"); }); + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + modelBuilder.Entity("API.Entities.ServerSetting", b => { b.Property("Key") @@ -2149,21 +2209,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ChapterGenre"); }); - modelBuilder.Entity("ChapterPerson", b => - { - b.Property("ChapterMetadatasId") - .HasColumnType("INTEGER"); - - b.Property("PeopleId") - .HasColumnType("INTEGER"); - - b.HasKey("ChapterMetadatasId", "PeopleId"); - - b.HasIndex("PeopleId"); - - b.ToTable("ChapterPerson"); - }); - modelBuilder.Entity("ChapterTag", b => { b.Property("ChaptersId") @@ -2338,21 +2383,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUserTokens", (string)null); }); - modelBuilder.Entity("PersonSeriesMetadata", b => - { - b.Property("PeopleId") - .HasColumnType("INTEGER"); - - b.Property("SeriesMetadatasId") - .HasColumnType("INTEGER"); - - b.HasKey("PeopleId", "SeriesMetadatasId"); - - b.HasIndex("SeriesMetadatasId"); - - b.ToTable("PersonSeriesMetadata"); - }); - modelBuilder.Entity("SeriesMetadataTag", b => { b.Property("SeriesMetadatasId") @@ -2600,6 +2630,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Volume"); }); + modelBuilder.Entity("API.Entities.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + modelBuilder.Entity("API.Entities.Device", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -2827,6 +2876,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Library"); }); + modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.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.Volume", b => { b.HasOne("API.Entities.Series", "Series") @@ -2883,21 +2951,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); - modelBuilder.Entity("ChapterPerson", b => - { - b.HasOne("API.Entities.Chapter", null) - .WithMany() - .HasForeignKey("ChapterMetadatasId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("API.Entities.Person", null) - .WithMany() - .HasForeignKey("PeopleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - modelBuilder.Entity("ChapterTag", b => { b.HasOne("API.Entities.Chapter", null) @@ -3024,21 +3077,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); - modelBuilder.Entity("PersonSeriesMetadata", b => - { - b.HasOne("API.Entities.Person", null) - .WithMany() - .HasForeignKey("PeopleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("API.Entities.Metadata.SeriesMetadata", null) - .WithMany() - .HasForeignKey("SeriesMetadatasId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - modelBuilder.Entity("SeriesMetadataTag", b => { b.HasOne("API.Entities.Metadata.SeriesMetadata", null) @@ -3096,6 +3134,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("Files"); + b.Navigation("People"); + b.Navigation("UserProgress"); }); @@ -3110,6 +3150,18 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Series"); }); + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + modelBuilder.Entity("API.Entities.ReadingList", b => { b.Navigation("Items"); diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index cbe62313a1..ca178fa7d4 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -60,6 +60,7 @@ public interface ICollectionTagRepository Task> GetCollectionsByIds(IEnumerable tags, CollectionIncludes includes = CollectionIncludes.None); Task> GetAllCollectionsForSyncing(DateTime expirationTime); } + public class CollectionTagRepository : ICollectionTagRepository { private readonly DataContext _context; @@ -219,7 +220,6 @@ public async Task> GetAllCollectionsForSyncing(DateTime .ToListAsync(); } - public async Task GetCollectionAsync(int tagId, CollectionIncludes includes = CollectionIncludes.None) { return await _context.AppUserCollection diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs index a68c3c5484..f6916e21e9 100644 --- a/API/Data/Repositories/GenreRepository.cs +++ b/API/Data/Repositories/GenreRepository.cs @@ -6,6 +6,7 @@ using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -24,6 +25,7 @@ public interface IGenreRepository Task GetCountAsync(); Task GetRandomGenre(); Task GetGenreById(int id); + Task> GetAllGenresNotInListAsync(ICollection genreNames); } public class GenreRepository : IGenreRepository @@ -133,4 +135,33 @@ public async Task> GetAllGenreDtosForLibrariesAsync(int userI .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } + + /// + /// Gets all genres that are not already present in the system. + /// Normalizes genres for lookup, but returns non-normalized names for creation. + /// + /// The list of genre names (non-normalized). + /// A list of genre names that do not exist in the system. + public async Task> GetAllGenresNotInListAsync(ICollection genreNames) + { + // Group the genres by their normalized names, keeping track of the original names + var normalizedToOriginalMap = genreNames + .Distinct() + .GroupBy(Parser.Normalize) + .ToDictionary(group => group.Key, group => group.First()); // Take the first original name for each normalized name + + var normalizedGenreNames = normalizedToOriginalMap.Keys.ToList(); + + // Query the database for existing genres using the normalized names + var existingGenres = await _context.Genre + .Where(g => normalizedGenreNames.Contains(g.NormalizedTitle)) // Assuming you have a normalized field + .Select(g => g.NormalizedTitle) + .ToListAsync(); + + // Find the normalized genres that do not exist in the database + var missingGenres = normalizedGenreNames.Except(existingGenres).ToList(); + + // Return the original non-normalized genres for the missing ones + return missingGenres.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList(); + } } diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs index 5633d74031..a6bb7b2717 100644 --- a/API/Data/Repositories/PersonRepository.cs +++ b/API/Data/Repositories/PersonRepository.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System.Collections; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.DTOs; @@ -6,6 +8,7 @@ using API.Entities.Enums; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Helpers; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -15,7 +18,12 @@ namespace API.Data.Repositories; public interface IPersonRepository { void Attach(Person person); + void Attach(IEnumerable person); void Remove(Person person); + void Remove(ChapterPeople person); + void Remove(SeriesMetadataPeople person); + void Update(Person person); + Task> GetAllPeople(); Task> GetAllPersonDtosAsync(int userId); Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role); @@ -23,7 +31,17 @@ public interface IPersonRepository Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null); Task GetCountAsync(); - Task> GetAllPeopleByRoleAndNames(PersonRole role, IEnumerable normalizeNames); + Task GetCoverImageAsync(int personId); + Task GetCoverImageByNameAsync(string name); + Task> GetRolesForPersonByName(string name, int userId); + Task> GetAllWritersAndSeriesCount(int userId, UserParams userParams); + Task GetPersonById(int personId); + Task GetPersonDtoByName(string name, int userId); + Task GetPersonByName(string name); + + Task> GetSeriesKnownFor(int personId); + Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role); + Task> GetPeopleByNames(List normalizedNames); } public class PersonRepository : IPersonRepository @@ -42,17 +60,37 @@ public void Attach(Person person) _context.Person.Attach(person); } + public void Attach(IEnumerable person) + { + _context.Person.AttachRange(person); + } + public void Remove(Person person) { _context.Person.Remove(person); } + public void Remove(ChapterPeople person) + { + _context.ChapterPeople.Remove(person); + } + + public void Remove(SeriesMetadataPeople person) + { + _context.SeriesMetadataPeople.Remove(person); + } + + public void Update(Person person) + { + _context.Person.Update(person); + } + public async Task RemoveAllPeopleNoLongerAssociated() { var peopleWithNoConnections = await _context.Person - .Include(p => p.SeriesMetadatas) - .Include(p => p.ChapterMetadatas) - .Where(p => p.SeriesMetadatas.Count == 0 && p.ChapterMetadatas.Count == 0) + .Include(p => p.SeriesMetadataPeople) + .Include(p => p.ChapterPeople) + .Where(p => p.SeriesMetadataPeople.Count == 0 && p.ChapterPeople.Count == 0) .AsSplitQuery() .ToListAsync(); @@ -61,6 +99,7 @@ public async Task RemoveAllPeopleNoLongerAssociated() await _context.SaveChangesAsync(); } + public async Task> GetAllPeopleDtosForLibrariesAsync(int userId, List? libraryIds = null) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); @@ -74,7 +113,7 @@ public async Task> GetAllPeopleDtosForLibrariesAsync(int userId return await _context.Series .Where(s => userLibs.Contains(s.LibraryId)) .RestrictAgainstAgeRestriction(ageRating) - .SelectMany(s => s.Metadata.People) + .SelectMany(s => s.Metadata.People.Select(p => p.Person)) .Distinct() .OrderBy(p => p.Name) .AsNoTracking() @@ -88,13 +127,124 @@ public async Task GetCountAsync() return await _context.Person.CountAsync(); } - public async Task> GetAllPeopleByRoleAndNames(PersonRole role, IEnumerable normalizeNames) + public async Task GetCoverImageAsync(int personId) + { + return await _context.Person + .Where(c => c.Id == personId) + .Select(c => c.CoverImage) + .SingleOrDefaultAsync(); + } + + public async Task GetCoverImageByNameAsync(string name) + { + var normalized = name.ToNormalized(); + return await _context.Person + .Where(c => c.NormalizedName == normalized) + .Select(c => c.CoverImage) + .SingleOrDefaultAsync(); + } + + public async Task> GetRolesForPersonByName(string name, int userId) + { + // TODO: This will need to check both series and chapters (in cases where komf only updates series) + var normalized = name.ToNormalized(); + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + return await _context.Person + .Where(p => p.NormalizedName == normalized) + .RestrictAgainstAgeRestriction(ageRating) + .SelectMany(p => p.ChapterPeople.Select(cp => cp.Role)) + .Distinct() + .ToListAsync(); + } + + public async Task> GetAllWritersAndSeriesCount(int userId, UserParams userParams) + { + List roles = [PersonRole.Writer, PersonRole.CoverArtist]; + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + var query = _context.Person + .Where(p => p.SeriesMetadataPeople.Any(smp => roles.Contains(smp.Role)) || p.ChapterPeople.Any(cmp => roles.Contains(cmp.Role))) + .RestrictAgainstAgeRestriction(ageRating) + .Select(p => new BrowsePersonDto + { + Id = p.Id, + Name = p.Name, + Description = p.Description, + SeriesCount = p.SeriesMetadataPeople + .Where(smp => roles.Contains(smp.Role)) + .Select(smp => smp.SeriesMetadata.SeriesId) + .Distinct() + .Count(), + IssueCount = p.ChapterPeople + .Where(cp => roles.Contains(cp.Role)) + .Select(cp => cp.Chapter.Id) + .Distinct() + .Count() + }) + .OrderBy(p => p.Name); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + public async Task GetPersonById(int personId) + { + return await _context.Person.Where(p => p.Id == personId) + .FirstOrDefaultAsync(); + } + + public async Task GetPersonDtoByName(string name, int userId) + { + var normalized = name.ToNormalized(); + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + return await _context.Person + .Where(p => p.NormalizedName == normalized) + .RestrictAgainstAgeRestriction(ageRating) + .ProjectTo(_mapper.ConfigurationProvider) + .FirstOrDefaultAsync(); + } + + public async Task GetPersonByName(string name) + { + return await _context.Person.FirstOrDefaultAsync(p => p.NormalizedName == name.ToNormalized()); + } + + public async Task> GetSeriesKnownFor(int personId) { return await _context.Person - .Where(p => p.Role == role && normalizeNames.Contains(p.NormalizedName)) + .Where(p => p.Id == personId) + .SelectMany(p => p.SeriesMetadataPeople) + .Select(smp => smp.SeriesMetadata) + .Select(sm => sm.Series) + .Distinct() + .OrderByDescending(s => s.ExternalSeriesMetadata.AverageExternalRating) + .Take(20) + .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); } + public async Task> GetChaptersForPersonByRole(int personId, int userId, PersonRole role) + { + var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + + return await _context.ChapterPeople + .Where(cp => cp.PersonId == personId && cp.Role == role) + .Select(cp => cp.Chapter) + .RestrictAgainstAgeRestriction(ageRating) + .OrderBy(ch => ch.SortOrder) + .Take(20) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + + public async Task> GetPeopleByNames(List normalizedNames) + { + return await _context.Person + .Where(p => normalizedNames.Contains(p.NormalizedName)) + .OrderBy(p => p.Name) + .ToListAsync(); + } public async Task> GetAllPeople() { @@ -106,7 +256,7 @@ public async Task> GetAllPeople() public async Task> GetAllPersonDtosAsync(int userId) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); - var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync(); + return await _context.Person .OrderBy(p => p.Name) .RestrictAgainstAgeRestriction(ageRating) @@ -117,8 +267,9 @@ public async Task> GetAllPersonDtosAsync(int userId) public async Task> GetAllPersonDtosByRoleAsync(int userId, PersonRole role) { var ageRating = await _context.AppUser.GetUserAgeRestriction(userId); + return await _context.Person - .Where(p => p.Role == role) + .Where(p => p.SeriesMetadataPeople.Any(smp => smp.Role == role) || p.ChapterPeople.Any(cp => cp.Role == role)) // Filter by role in both series and chapters .OrderBy(p => p.Name) .RestrictAgainstAgeRestriction(ageRating) .ProjectTo(_mapper.ConfigurationProvider) diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index af064a75e8..ef6cf01fc7 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -122,8 +122,10 @@ public IEnumerable GetReadingListCharactersAsync(int readingListId) { return _context.ReadingListItem .Where(item => item.ReadingListId == readingListId) - .SelectMany(item => item.Chapter.People.Where(p => p.Role == PersonRole.Character)) - .OrderBy(p => p.NormalizedName) + .SelectMany(item => item.Chapter.People) + .Where(p => p.Role == PersonRole.Character) + .OrderBy(p => p.Person.NormalizedName) + .Select(p => p.Person) .Distinct() .ProjectTo(_mapper.ConfigurationProvider) .AsEnumerable(); diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 88b9b0a75c..741968d3af 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -44,6 +44,9 @@ public enum SeriesIncludes { None = 1, Volumes = 2, + /// + /// This will include all necessary includes + /// Metadata = 4, Related = 8, Library = 16, @@ -51,8 +54,7 @@ public enum SeriesIncludes ExternalReviews = 64, ExternalRatings = 128, ExternalRecommendations = 256, - ExternalMetadata = 512 - + ExternalMetadata = 512, } /// @@ -138,7 +140,7 @@ public interface ISeriesRepository Task> GetWantToReadForUserAsync(int userId); Task IsSeriesInWantToRead(int userId, int seriesId); Task GetSeriesByFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None); - Task GetSeriesThatContainsLowestFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None); + Task GetSeriesThatContainsLowestFolderPath(string path, SeriesIncludes includes = SeriesIncludes.None); Task> GetAllSeriesByNameAsync(IList normalizedNames, int userId, SeriesIncludes includes = SeriesIncludes.None); Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true); @@ -363,11 +365,11 @@ public async Task SearchSeries(int userId, bool isAdmin, I var searchQueryNormalized = searchQuery.ToNormalized(); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); - var seriesIds = _context.Series + var seriesIds = await _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) .RestrictAgainstAgeRestriction(userRating) .Select(s => s.Id) - .ToList(); + .ToListAsync(); result.Libraries = await _context.Library .Search(searchQuery, userId, libraryIds) @@ -440,6 +442,7 @@ public async Task SearchSeries(int userId, bool isAdmin, I .SearchPeople(searchQuery, seriesIds) .Take(maxRecords) .OrderBy(t => t.NormalizedName) + .Distinct() .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); @@ -532,14 +535,6 @@ public async Task SearchSeries(int userId, bool isAdmin, I .SingleOrDefaultAsync(); } - public async Task GetSeriesByIdForUserAsync(int seriesId, int userId, SeriesIncludes includes = SeriesIncludes.Volumes | SeriesIncludes.Metadata) - { - return await _context.Series - .Where(s => s.Id == seriesId) - .Includes(includes) - .SingleOrDefaultAsync(); - } - /// /// Returns Full Series including all external links /// @@ -661,6 +656,7 @@ public async Task> GetSeriesMetadataForIds(IEnumerable< .Include(m => m.Genres.OrderBy(g => g.NormalizedTitle)) .Include(m => m.Tags.OrderBy(g => g.NormalizedTitle)) .Include(m => m.People) + .ThenInclude(p => p.Person) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .AsSplitQuery() @@ -1273,7 +1269,7 @@ private async Task> CreateFilteredSearchQueryable(int userId, var query = sQuery .WhereIf(hasGenresFilter, s => s.Metadata.Genres.Any(g => filter.Genres.Contains(g.Id))) - .WhereIf(hasPeopleFilter, s => s.Metadata.People.Any(p => allPeopleIds.Contains(p.Id))) + .WhereIf(hasPeopleFilter, s => s.Metadata.People.Any(p => allPeopleIds.Contains(p.PersonId))) .WhereIf(hasCollectionTagFilter, s => s.Metadata.CollectionTags.Any(t => filter.CollectionTags.Contains(t.Id))) .WhereIf(hasRatingFilter, s => s.Ratings.Any(r => r.Rating >= filter.Rating && r.AppUserId == userId)) @@ -1302,6 +1298,7 @@ private async Task> CreateFilteredSearchQueryable(int userId, .Include(m => m.Genres.OrderBy(g => g.NormalizedTitle)) .Include(m => m.Tags.OrderBy(g => g.NormalizedTitle)) .Include(m => m.People) + .ThenInclude(p => p.Person) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .AsSplitQuery() @@ -1606,9 +1603,24 @@ public async Task> GetRediscover(int userId, int libraryId, .SingleOrDefaultAsync(); } - public async Task GetSeriesThatContainsLowestFolderPath(string folder, SeriesIncludes includes = SeriesIncludes.None) + public async Task GetSeriesThatContainsLowestFolderPath(string path, SeriesIncludes includes = SeriesIncludes.None) { - var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(folder); + // Check if the path ends with a file (has a file extension) + string directoryPath; + if (Path.HasExtension(path)) + { + // Remove the file part and get the directory path + directoryPath = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(directoryPath)) return null; + } + else + { + // Use the path as is if it doesn't end with a file + directoryPath = path; + } + + // Normalize the directory path + var normalized = Services.Tasks.Scanner.Parser.Parser.NormalizePath(directoryPath); if (string.IsNullOrEmpty(normalized)) return null; normalized = normalized.TrimEnd('/'); @@ -1672,6 +1684,7 @@ public async Task> GetAllSeriesByNameAsync(IList nor .Include(s => s.Metadata) .ThenInclude(m => m.People) + .ThenInclude(p => p.Person) .Include(s => s.Metadata) .ThenInclude(m => m.Genres) @@ -1682,6 +1695,7 @@ public async Task> GetAllSeriesByNameAsync(IList nor .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) .ThenInclude(cm => cm.People) + .ThenInclude(p => p.Person) .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) @@ -1697,6 +1711,7 @@ public async Task> GetAllSeriesByNameAsync(IList nor .AsSplitQuery(); return query.SingleOrDefaultAsync(); + #nullable enable } @@ -1705,6 +1720,7 @@ public async Task> GetAllSeriesByNameAsync(IList nor var libraryIds = GetLibraryIdsForUser(userId); var normalizedSeries = seriesName.ToNormalized(); var normalizedLocalized = localizedName.ToNormalized(); + return await _context.Series .Where(s => libraryIds.Contains(s.LibraryId)) .Where(s => formats.Contains(s.Format)) @@ -1749,45 +1765,36 @@ public async Task> GetAllSeriesByAnyName(string seriesName, string /// public async Task> RemoveSeriesNotInList(IList seenSeries, int libraryId) { - if (seenSeries.Count == 0) return Array.Empty(); + if (!seenSeries.Any()) return Array.Empty(); + + // Get all series from DB in one go, based on libraryId + var dbSeries = await _context.Series + .Where(s => s.LibraryId == libraryId) + .ToListAsync(); + + // Get a set of matching series ids for the given parsedSeries + var ids = new HashSet(); - var ids = new List(); foreach (var parsedSeries in seenSeries) { - try - { - var seriesId = await _context.Series - .Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName && - s.LibraryId == libraryId) - .Select(s => s.Id) - .SingleOrDefaultAsync(); - if (seriesId > 0) - { - ids.Add(seriesId); - } - } - catch (Exception) + var matchingSeries = dbSeries + .Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName) + .OrderBy(s => s.Id) // Sort to handle potential duplicates + .ToList(); + + // Prefer the first match or handle duplicates by choosing the last one + if (matchingSeries.Any()) { - // This is due to v0.5.6 introducing bugs where we could have multiple series get duplicated and no way to delete them - // This here will delete the 2nd one as the first is the one to likely be used. - var sId = await _context.Series - .Where(s => s.Format == parsedSeries.Format && s.NormalizedName == parsedSeries.NormalizedName && - s.LibraryId == libraryId) - .Select(s => s.Id) - .OrderBy(s => s) - .LastAsync(); - if (sId > 0) - { - ids.Add(sId); - } + ids.Add(matchingSeries.Last().Id); } } - var seriesToRemove = await _context.Series - .Where(s => s.LibraryId == libraryId) + // Filter out series that are not in the seenSeries + var seriesToRemove = dbSeries .Where(s => !ids.Contains(s.Id)) - .ToListAsync(); + .ToList(); + // Remove series in bulk _context.Series.RemoveRange(seriesToRemove); return seriesToRemove; diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index 2fdb8377e2..4a7fbf4abf 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -5,6 +5,7 @@ using API.Entities; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -20,6 +21,7 @@ public interface ITagRepository Task> GetAllTagDtosAsync(int userId); Task RemoveAllTagNoLongerAssociated(); Task> GetAllTagDtosForLibrariesAsync(int userId, IList? libraryIds = null); + Task> GetAllTagsNotInListAsync(ICollection tags); } public class TagRepository : ITagRepository @@ -79,6 +81,28 @@ public async Task> GetAllTagDtosForLibrariesAsync(int userId, ILis .ToListAsync(); } + public async Task> GetAllTagsNotInListAsync(ICollection tags) + { + // Create a dictionary mapping normalized names to non-normalized names + var normalizedToOriginalMap = tags.Distinct() + .GroupBy(Parser.Normalize) + .ToDictionary(group => group.Key, group => group.First()); + + var normalizedTagNames = normalizedToOriginalMap.Keys.ToList(); + + // Query the database for existing genres using the normalized names + var existingTags = await _context.Tag + .Where(g => normalizedTagNames.Contains(g.NormalizedTitle)) // Assuming you have a normalized field + .Select(g => g.NormalizedTitle) + .ToListAsync(); + + // Find the normalized genres that do not exist in the database + var missingTags = normalizedTagNames.Except(existingTags).ToList(); + + // Return the original non-normalized genres for the missing ones + return missingTags.Select(normalizedName => normalizedToOriginalMap[normalizedName]).ToList(); + } + public async Task> GetAllTagsAsync() { return await _context.Tag.ToListAsync(); diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index ddc682c326..85971558c7 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -114,6 +114,14 @@ ..new List Order = 5, IsProvided = true, Visible = true + }, + new AppUserSideNavStream() + { + Name = "browse-authors", + StreamType = SideNavStreamType.BrowseAuthors, + Order = 6, + IsProvided = true, + Visible = true }); @@ -183,10 +191,10 @@ public static async Task SeedDefaultSideNavStreams(IUnitOfWork unitOfWork) var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync(AppUserIncludes.SideNavStreams); foreach (var user in allUsers) { - if (user.SideNavStreams.Count != 0) continue; user.SideNavStreams ??= new List(); foreach (var defaultStream in DefaultSideNavStreams) { + if (user.SideNavStreams.Any(s => s.Name == defaultStream.Name && s.StreamType == defaultStream.StreamType)) continue; var newStream = new AppUserSideNavStream() { Name = defaultStream.Name, diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index 97ef3e07b6..e3c1ffcb1f 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -9,6 +9,7 @@ namespace API.Data; public interface IUnitOfWork { + DataContext DataContext { get; } ISeriesRepository SeriesRepository { get; } IUserRepository UserRepository { get; } ILibraryRepository LibraryRepository { get; } @@ -36,6 +37,7 @@ public interface IUnitOfWork bool HasChanges(); Task RollbackAsync(); } + public class UnitOfWork : IUnitOfWork { private readonly DataContext _context; @@ -47,33 +49,57 @@ public UnitOfWork(DataContext context, IMapper mapper, UserManager user _context = context; _mapper = mapper; _userManager = userManager; - } - - public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper, _userManager); - public IUserRepository UserRepository => new UserRepository(_context, _userManager, _mapper); - public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper); - - public IVolumeRepository VolumeRepository => new VolumeRepository(_context, _mapper); - public ISettingsRepository SettingsRepository => new SettingsRepository(_context, _mapper); + SeriesRepository = new SeriesRepository(_context, _mapper, _userManager); + UserRepository = new UserRepository(_context, _userManager, _mapper); + LibraryRepository = new LibraryRepository(_context, _mapper); + VolumeRepository = new VolumeRepository(_context, _mapper); + SettingsRepository = new SettingsRepository(_context, _mapper); + AppUserProgressRepository = new AppUserProgressRepository(_context, _mapper); + CollectionTagRepository = new CollectionTagRepository(_context, _mapper); + ChapterRepository = new ChapterRepository(_context, _mapper); + ReadingListRepository = new ReadingListRepository(_context, _mapper); + SeriesMetadataRepository = new SeriesMetadataRepository(_context); + PersonRepository = new PersonRepository(_context, _mapper); + GenreRepository = new GenreRepository(_context, _mapper); + TagRepository = new TagRepository(_context, _mapper); + SiteThemeRepository = new SiteThemeRepository(_context, _mapper); + MangaFileRepository = new MangaFileRepository(_context); + DeviceRepository = new DeviceRepository(_context, _mapper); + MediaErrorRepository = new MediaErrorRepository(_context, _mapper); + ScrobbleRepository = new ScrobbleRepository(_context, _mapper); + UserTableOfContentRepository = new UserTableOfContentRepository(_context, _mapper); + AppUserSmartFilterRepository = new AppUserSmartFilterRepository(_context, _mapper); + AppUserExternalSourceRepository = new AppUserExternalSourceRepository(_context, _mapper); + ExternalSeriesMetadataRepository = new ExternalSeriesMetadataRepository(_context, _mapper); + } - public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context, _mapper); - public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper); - public IChapterRepository ChapterRepository => new ChapterRepository(_context, _mapper); - public IReadingListRepository ReadingListRepository => new ReadingListRepository(_context, _mapper); - public ISeriesMetadataRepository SeriesMetadataRepository => new SeriesMetadataRepository(_context); - public IPersonRepository PersonRepository => new PersonRepository(_context, _mapper); - public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper); - public ITagRepository TagRepository => new TagRepository(_context, _mapper); - public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper); - public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context); - public IDeviceRepository DeviceRepository => new DeviceRepository(_context, _mapper); - public IMediaErrorRepository MediaErrorRepository => new MediaErrorRepository(_context, _mapper); - public IScrobbleRepository ScrobbleRepository => new ScrobbleRepository(_context, _mapper); - public IUserTableOfContentRepository UserTableOfContentRepository => new UserTableOfContentRepository(_context, _mapper); - public IAppUserSmartFilterRepository AppUserSmartFilterRepository => new AppUserSmartFilterRepository(_context, _mapper); - public IAppUserExternalSourceRepository AppUserExternalSourceRepository => new AppUserExternalSourceRepository(_context, _mapper); - public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository => new ExternalSeriesMetadataRepository(_context, _mapper); + /// + /// This is here for Scanner only. Don't use otherwise. + /// + public DataContext DataContext => _context; + public ISeriesRepository SeriesRepository { get; } + public IUserRepository UserRepository { get; } + public ILibraryRepository LibraryRepository { get; } + public IVolumeRepository VolumeRepository { get; } + public ISettingsRepository SettingsRepository { get; } + public IAppUserProgressRepository AppUserProgressRepository { get; } + public ICollectionTagRepository CollectionTagRepository { get; } + public IChapterRepository ChapterRepository { get; } + public IReadingListRepository ReadingListRepository { get; } + public ISeriesMetadataRepository SeriesMetadataRepository { get; } + public IPersonRepository PersonRepository { get; } + public IGenreRepository GenreRepository { get; } + public ITagRepository TagRepository { get; } + public ISiteThemeRepository SiteThemeRepository { get; } + public IMangaFileRepository MangaFileRepository { get; } + public IDeviceRepository DeviceRepository { get; } + public IMediaErrorRepository MediaErrorRepository { get; } + public IScrobbleRepository ScrobbleRepository { get; } + public IUserTableOfContentRepository UserTableOfContentRepository { get; } + public IAppUserSmartFilterRepository AppUserSmartFilterRepository { get; } + public IAppUserExternalSourceRepository AppUserExternalSourceRepository { get; } + public IExternalSeriesMetadataRepository ExternalSeriesMetadataRepository { get; } /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index 3d6b28e5b6..60228ab9f0 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -153,7 +153,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage /// /// All people attached at a Chapter level. Usually Comics will have different people per issue. /// - public ICollection People { get; set; } = new List(); + public ICollection People { get; set; } = new List(); /// /// Genres for the Chapter /// diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index a2a7f7722d..6e594aa73a 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -14,16 +14,6 @@ public class SeriesMetadata : IHasConcurrencyToken public string Summary { get; set; } = string.Empty; - [Obsolete("Use AppUserCollection instead")] - public ICollection CollectionTags { get; set; } = new List(); - - public ICollection Genres { get; set; } = new List(); - public ICollection Tags { get; set; } = new List(); - /// - /// All people attached at a Series level. - /// - public ICollection People { get; set; } = new List(); - /// /// Highest Age Rating from all Chapters /// @@ -51,7 +41,8 @@ public class SeriesMetadata : IHasConcurrencyToken /// This is not populated from Chapters of the Series public string WebLinks { get; set; } = string.Empty; - // Locks + #region Locks + public bool LanguageLocked { get; set; } public bool SummaryLocked { get; set; } /// @@ -79,9 +70,26 @@ public class SeriesMetadata : IHasConcurrencyToken public bool CoverArtistLocked { get; set; } public bool ReleaseYearLocked { get; set; } - // Relationship - public Series Series { get; set; } = null!; + #endregion + + #region Relationships + + [Obsolete("Use AppUserCollection instead")] + public ICollection CollectionTags { get; set; } = new List(); + + public ICollection Genres { get; set; } = new List(); + public ICollection Tags { get; set; } = new List(); + + /// + /// All people attached at a Series level. + /// + public ICollection People { get; set; } = new List(); + public int SeriesId { get; set; } + public Series Series { get; set; } = null!; + + #endregion + /// [ConcurrencyCheck] diff --git a/API/Entities/Person.cs b/API/Entities/Person.cs deleted file mode 100644 index eeb21d6b18..0000000000 --- a/API/Entities/Person.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using API.Entities.Enums; -using API.Entities.Metadata; - -namespace API.Entities; - -public class Person -{ - public int Id { get; set; } - public required string Name { get; set; } - public required string NormalizedName { get; set; } - public required PersonRole Role { get; set; } - - // Relationships - public ICollection SeriesMetadatas { get; set; } = null!; - public ICollection ChapterMetadatas { get; set; } = null!; -} diff --git a/API/Entities/Person/ChapterPeople.cs b/API/Entities/Person/ChapterPeople.cs new file mode 100644 index 0000000000..cc08027821 --- /dev/null +++ b/API/Entities/Person/ChapterPeople.cs @@ -0,0 +1,14 @@ +using API.Entities.Enums; + +namespace API.Entities; + +public class ChapterPeople +{ + public int ChapterId { get; set; } + public virtual Chapter Chapter { get; set; } + + public int PersonId { get; set; } + public virtual Person Person { get; set; } + + public required PersonRole Role { get; set; } +} diff --git a/API/Entities/Person/Person.cs b/API/Entities/Person/Person.cs new file mode 100644 index 0000000000..ba40b5f828 --- /dev/null +++ b/API/Entities/Person/Person.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using API.Entities.Enums; +using API.Entities.Interfaces; +using API.Entities.Metadata; + +namespace API.Entities; + +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 string? CoverImage { get; set; } + public bool CoverImageLocked { get; set; } + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } + + public string Description { get; set; } + /// + /// ASIN for person + /// + /// Can be used for Amazon author lookup + public string? Asin { get; set; } + + /// + /// https://anilist.co/staff/{AniListId}/ + /// + /// Kavita+ Only + public int AniListId { get; set; } = 0; + /// + /// https://myanimelist.net/people/{MalId}/ + /// https://myanimelist.net/character/{MalId}/CharacterName + /// + /// Kavita+ Only + public long MalId { get; set; } = 0; + /// + /// https://hardcover.app/authors/{HardcoverId} + /// + /// Kavita+ Only + public string? HardcoverId { get; set; } + /// + /// https://metron.cloud/creator/{slug}/ + /// + /// Kavita+ Only + //public long MetronId { get; set; } = 0; + + // Relationships + public ICollection ChapterPeople { get; set; } = new List(); + public ICollection SeriesMetadataPeople { get; set; } = new List(); + + + public void ResetColorScape() + { + PrimaryColor = string.Empty; + SecondaryColor = string.Empty; + } +} diff --git a/API/Entities/Person/SeriesMetadataPeople.cs b/API/Entities/Person/SeriesMetadataPeople.cs new file mode 100644 index 0000000000..dd188ddf0d --- /dev/null +++ b/API/Entities/Person/SeriesMetadataPeople.cs @@ -0,0 +1,15 @@ +using API.Entities.Enums; +using API.Entities.Metadata; + +namespace API.Entities; + +public class SeriesMetadataPeople +{ + public int SeriesMetadataId { get; set; } + public virtual SeriesMetadata SeriesMetadata { get; set; } + + public int PersonId { get; set; } + public virtual Person Person { get; set; } + + public required PersonRole Role { get; set; } +} diff --git a/API/Entities/SideNavStreamType.cs b/API/Entities/SideNavStreamType.cs index 3150bf08e0..545c630d83 100644 --- a/API/Entities/SideNavStreamType.cs +++ b/API/Entities/SideNavStreamType.cs @@ -10,4 +10,5 @@ public enum SideNavStreamType ExternalSource = 6, AllSeries = 7, WantToRead = 8, + BrowseAuthors = 9 } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index f68b4461d1..4b5c73af7e 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -24,6 +24,8 @@ public static void AddApplicationServices(this IServiceCollection services, ICon { services.AddAutoMapper(typeof(AutoMapperProfiles).Assembly); + //services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -45,7 +47,6 @@ public static void AddApplicationServices(this IServiceCollection services, ICon services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -55,12 +56,12 @@ public static void AddApplicationServices(this IServiceCollection services, ICon services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); 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 d30bafbfee..f6606026bd 100644 --- a/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs +++ b/API/Extensions/QueryExtensions/Filtering/SearchQueryableExtensions.cs @@ -4,6 +4,7 @@ using API.Data.Repositories; using API.Entities; using API.Entities.Metadata; +using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; namespace API.Extensions.QueryExtensions.Filtering; @@ -45,10 +46,25 @@ public static IQueryable Search(this IQueryable queryable, public static IQueryable SearchPeople(this IQueryable queryable, string searchQuery, IEnumerable seriesIds) { - return queryable + // Get people from SeriesMetadata + var peopleFromSeriesMetadata = queryable .Where(sm => seriesIds.Contains(sm.SeriesId)) - .SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%"))) - .AsSplitQuery() + .SelectMany(sm => sm.People) + .Where(p => p.Person.Name != null && EF.Functions.Like(p.Person.Name, $"%{searchQuery}%")) + .Select(p => p.Person); + + // 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); + + // Combine both queries and ensure distinct results + return peopleFromSeriesMetadata + .Union(peopleFromChapterPeople) .Distinct() .OrderBy(p => p.NormalizedName); } diff --git a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs index c5b044665d..b6f1082af8 100644 --- a/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/SeriesFilter.cs @@ -471,22 +471,22 @@ public static IQueryable HasPeople(this IQueryable queryable, bo { case FilterComparison.Equal: case FilterComparison.Contains: - return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.Id))); + return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.PersonId))); case FilterComparison.NotEqual: case FilterComparison.NotContains: - return queryable.Where(s => s.Metadata.People.All(t => !people.Contains(t.Id))); + return queryable.Where(s => s.Metadata.People.All(t => !people.Contains(t.PersonId))); case FilterComparison.MustContains: // Deconstruct and do a Union of a bunch of where statements since this doesn't translate var queries = new List>() { queryable }; - queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.Id == gId)))); + queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.PersonId == gId)))); return queries.Aggregate((q1, q2) => q1.Intersect(q2)); case FilterComparison.IsEmpty: // Check if there are no people with specific roles (e.g., Writer, Penciller, etc.) - return queryable.Where(s => !s.Metadata.People.Any(p => p.Role == role)); + return queryable.Where(s => s.Metadata.People.All(p => p.Role != role)); case FilterComparison.GreaterThan: case FilterComparison.GreaterThanEqual: case FilterComparison.LessThan: @@ -513,17 +513,17 @@ public static IQueryable HasPeopleLegacy(this IQueryable queryab { case FilterComparison.Equal: case FilterComparison.Contains: - return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.Id))); + return queryable.Where(s => s.Metadata.People.Any(p => people.Contains(p.PersonId))); case FilterComparison.NotEqual: case FilterComparison.NotContains: - return queryable.Where(s => s.Metadata.People.All(t => !people.Contains(t.Id))); + return queryable.Where(s => s.Metadata.People.All(t => !people.Contains(t.PersonId))); case FilterComparison.MustContains: // Deconstruct and do a Union of a bunch of where statements since this doesn't translate var queries = new List>() { queryable }; - queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.Id == gId)))); + queries.AddRange(people.Select(gId => queryable.Where(s => s.Metadata.People.Any(p => p.PersonId == gId)))); return queries.Aggregate((q1, q2) => q1.Intersect(q2)); case FilterComparison.IsEmpty: diff --git a/API/Extensions/QueryExtensions/IncludesExtensions.cs b/API/Extensions/QueryExtensions/IncludesExtensions.cs index 49f183590c..983f6798e8 100644 --- a/API/Extensions/QueryExtensions/IncludesExtensions.cs +++ b/API/Extensions/QueryExtensions/IncludesExtensions.cs @@ -56,7 +56,8 @@ public static IQueryable Includes(this IQueryable queryable, if (includes.HasFlag(ChapterIncludes.People)) { queryable = queryable - .Include(c => c.People); + .Include(c => c.People) + .ThenInclude(cp => cp.Person); } if (includes.HasFlag(ChapterIncludes.Genres)) @@ -161,17 +162,16 @@ public static IQueryable Includes(this IQueryable query, if (includeFlags.HasFlag(SeriesIncludes.Metadata)) { - query = query.Include(s => s.Metadata) - .ThenInclude(m => m.CollectionTags.OrderBy(g => g.NormalizedTitle)) + query = query .Include(s => s.Metadata) .ThenInclude(m => m.Genres.OrderBy(g => g.NormalizedTitle)) .Include(s => s.Metadata) .ThenInclude(m => m.People) + .ThenInclude(smp => smp.Person) .Include(s => s.Metadata) .ThenInclude(m => m.Tags.OrderBy(g => g.NormalizedTitle)); } - return query.AsSplitQuery(); } diff --git a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs index 1d42723cc0..b8def73778 100644 --- a/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs +++ b/API/Extensions/QueryExtensions/RestrictByAgeExtensions.cs @@ -25,6 +25,19 @@ public static IQueryable RestrictAgainstAgeRestriction(this IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) + { + if (restriction.AgeRating == AgeRating.NotApplicable) return queryable; + var q = queryable.Where(chapter => chapter.Volume.Series.Metadata.AgeRating <= restriction.AgeRating); + + if (!restriction.IncludeUnknowns) + { + return q.Where(s => s.Volume.Series.Metadata.AgeRating != AgeRating.Unknown); + } + + return q; + } + [Obsolete] public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) { @@ -88,12 +101,12 @@ public static IQueryable RestrictAgainstAgeRestriction(this IQueryable

c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating)); + return queryable.Where(c => c.SeriesMetadataPeople.All(sm => + sm.SeriesMetadata.AgeRating <= restriction.AgeRating)); } - return queryable.Where(c => c.SeriesMetadatas.All(sm => - sm.AgeRating <= restriction.AgeRating && sm.AgeRating > AgeRating.Unknown)); + return queryable.Where(c => c.SeriesMetadataPeople.All(sm => + sm.SeriesMetadata.AgeRating <= restriction.AgeRating && sm.SeriesMetadata.AgeRating > AgeRating.Unknown)); } public static IQueryable RestrictAgainstAgeRestriction(this IQueryable queryable, AgeRestriction restriction) diff --git a/API/Extensions/SeriesExtensions.cs b/API/Extensions/SeriesExtensions.cs index 83f6ecfdff..01ae718c73 100644 --- a/API/Extensions/SeriesExtensions.cs +++ b/API/Extensions/SeriesExtensions.cs @@ -28,6 +28,12 @@ public static class SeriesExtensions firstVolume = volumes[1]; } + // If the first volume is 0, then use Volume 1 + if (firstVolume.MinNumber.Is(0f) && volumes.Count > 1) + { + firstVolume = volumes[1]; + } + var chapters = firstVolume.Chapters .OrderBy(c => c.SortOrder) .ToList(); diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index fb77179b32..89cd93d129 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -95,59 +95,73 @@ public AutoMapperProfiles() opt => opt.MapFrom( src => src.PagesRead)); + CreateMap() - .ForMember(dest => dest.Writers, - opt => - opt.MapFrom( - src => src.People.Where(p => p.Role == PersonRole.Writer).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.CoverArtists, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.CoverArtist).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Characters, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Character).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Publishers, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Publisher).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Colorists, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Colorist).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Inkers, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Inker).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Imprints, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Imprint).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Letterers, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Letterer).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Pencillers, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Penciller).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Translators, - opt => - opt.MapFrom(src => - src.People.Where(p => p.Role == PersonRole.Translator).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Editors, - opt => - opt.MapFrom( - src => src.People.Where(p => p.Role == PersonRole.Editor).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Teams, - opt => - opt.MapFrom( - src => src.People.Where(p => p.Role == PersonRole.Team).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Locations, - opt => - opt.MapFrom( - src => src.People.Where(p => p.Role == PersonRole.Location).OrderBy(p => p.NormalizedName))) + // Map Writers + .ForMember(dest => dest.Writers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Writer) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map CoverArtists + .ForMember(dest => dest.CoverArtists, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.CoverArtist) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Publishers + .ForMember(dest => dest.Publishers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Publisher) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Characters + .ForMember(dest => dest.Characters, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Character) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Pencillers + .ForMember(dest => dest.Pencillers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Penciller) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Inkers + .ForMember(dest => dest.Inkers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Inker) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Imprints + .ForMember(dest => dest.Imprints, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Imprint) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Colorists + .ForMember(dest => dest.Colorists, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Colorist) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Letterers + .ForMember(dest => dest.Letterers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Letterer) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Editors + .ForMember(dest => dest.Editors, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Editor) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Translators + .ForMember(dest => dest.Translators, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Translator) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Teams + .ForMember(dest => dest.Teams, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Team) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Locations + .ForMember(dest => dest.Locations, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Location) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) .ForMember(dest => dest.Genres, opt => opt.MapFrom( @@ -157,89 +171,73 @@ public AutoMapperProfiles() opt.MapFrom( src => src.Tags.OrderBy(p => p.NormalizedTitle))); - CreateMap() - .ForMember(dest => dest.Writers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.CoverArtists, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Colorists, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Inkers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Imprints, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Imprint).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Letterers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Pencillers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Publishers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Translators, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Characters, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Editors, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Teams, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Team).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Locations, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Location).OrderBy(p => p.NormalizedName))) - ; - CreateMap() - .ForMember(dest => dest.Writers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.CoverArtists, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Colorists, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Inkers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Imprints, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Imprint).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Letterers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Pencillers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Publishers, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Translators, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Translator).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Characters, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Editors, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Teams, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Team).OrderBy(p => p.NormalizedName))) - .ForMember(dest => dest.Locations, - opt => - opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Location).OrderBy(p => p.NormalizedName))) - ; + // Map Writers + .ForMember(dest => dest.Writers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Writer) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map CoverArtists + .ForMember(dest => dest.CoverArtists, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.CoverArtist) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Publishers + .ForMember(dest => dest.Publishers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Publisher) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Characters + .ForMember(dest => dest.Characters, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Character) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Pencillers + .ForMember(dest => dest.Pencillers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Penciller) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Inkers + .ForMember(dest => dest.Inkers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Inker) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Imprints + .ForMember(dest => dest.Imprints, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Imprint) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Colorists + .ForMember(dest => dest.Colorists, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Colorist) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Letterers + .ForMember(dest => dest.Letterers, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Letterer) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Editors + .ForMember(dest => dest.Editors, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Editor) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Translators + .ForMember(dest => dest.Translators, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Translator) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Teams + .ForMember(dest => dest.Teams, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Team) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))) + // Map Locations + .ForMember(dest => dest.Locations, opt => opt.MapFrom(src => src.People + .Where(cp => cp.Role == PersonRole.Location) + .Select(cp => cp.Person) + .OrderBy(p => p.NormalizedName))); + CreateMap() .ForMember(dest => dest.AgeRestriction, @@ -337,5 +335,11 @@ public AutoMapperProfiles() CreateMap(); + + CreateMap() + .ForMember(dest => dest.SeriesId, opt => opt.MapFrom(src => src.Volume.SeriesId)) + .ForMember(dest => dest.VolumeTitle, opt => opt.MapFrom(src => src.Volume.Name)) + .ForMember(dest => dest.LibraryId, opt => opt.MapFrom(src => src.Volume.Series.LibraryId)) + .ForMember(dest => dest.LibraryType, opt => opt.MapFrom(src => src.Volume.Series.Library.Type)); } } diff --git a/API/Helpers/Builders/PersonBuilder.cs b/API/Helpers/Builders/PersonBuilder.cs index e7e1b573ee..2bbdfa7448 100644 --- a/API/Helpers/Builders/PersonBuilder.cs +++ b/API/Helpers/Builders/PersonBuilder.cs @@ -11,15 +11,14 @@ public class PersonBuilder : IEntityBuilder private readonly Person _person; public Person Build() => _person; - public PersonBuilder(string name, PersonRole role) + public PersonBuilder(string name) { _person = new Person() { Name = name.Trim(), NormalizedName = name.ToNormalized(), - Role = role, - ChapterMetadatas = new List(), - SeriesMetadatas = new List() + SeriesMetadataPeople = new List(), + ChapterPeople = new List() }; } @@ -34,10 +33,10 @@ public PersonBuilder WithId(int id) return this; } - public PersonBuilder WithSeriesMetadata(SeriesMetadata metadata) + public PersonBuilder WithSeriesMetadata(SeriesMetadataPeople seriesMetadataPeople) { - _person.SeriesMetadatas ??= new List(); - _person.SeriesMetadatas.Add(metadata); + _person.SeriesMetadataPeople.Add(seriesMetadataPeople); return this; } + } diff --git a/API/Helpers/Builders/SeriesMetadataBuilder.cs b/API/Helpers/Builders/SeriesMetadataBuilder.cs index d90e896efc..7a51dea424 100644 --- a/API/Helpers/Builders/SeriesMetadataBuilder.cs +++ b/API/Helpers/Builders/SeriesMetadataBuilder.cs @@ -17,7 +17,7 @@ public SeriesMetadataBuilder() CollectionTags = new List(), Genres = new List(), Tags = new List(), - People = new List() + People = new List() }; } @@ -45,4 +45,19 @@ public SeriesMetadataBuilder WithAgeRating(AgeRating rating) _seriesMetadata.AgeRating = rating; return this; } + + public SeriesMetadataBuilder WithPerson(Person person, PersonRole role) + { + _seriesMetadata.People ??= new List(); + _seriesMetadata.People.Add(new SeriesMetadataPeople() + { + Role = role, + Person = person, + SeriesMetadata = _seriesMetadata, + PersonId = person.Id, + SeriesMetadataId = _seriesMetadata.Id + }); + + return this; + } } diff --git a/API/Helpers/CacheHelper.cs b/API/Helpers/CacheHelper.cs index 510ff54099..ede5caaef8 100644 --- a/API/Helpers/CacheHelper.cs +++ b/API/Helpers/CacheHelper.cs @@ -14,8 +14,8 @@ bool ShouldUpdateCoverImage(string coverPath, MangaFile? firstFile, DateTime cha bool CoverImageExists(string path); - bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile); - bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile); + bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile? firstFile); + bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile? firstFile); } @@ -56,7 +56,7 @@ public bool ShouldUpdateCoverImage(string coverPath, MangaFile? firstFile, DateT /// /// /// - public bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile) + public bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile? firstFile) { return firstFile != null && (!forceUpdate && @@ -71,7 +71,7 @@ public bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool fo /// Should we ignore any logic and force this to return true /// The file in question /// - public bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile) + public bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile? firstFile) { if (firstFile == null) return false; if (forceUpdate) return true; diff --git a/API/Helpers/GenreHelper.cs b/API/Helpers/GenreHelper.cs index e13a1783c3..148f47e5e9 100644 --- a/API/Helpers/GenreHelper.cs +++ b/API/Helpers/GenreHelper.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using API.Data; using API.DTOs.Metadata; using API.Entities; using API.Extensions; using API.Helpers.Builders; +using Microsoft.EntityFrameworkCore; namespace API.Helpers; #nullable enable @@ -13,6 +15,57 @@ namespace API.Helpers; public static class GenreHelper { + public static async Task UpdateChapterGenres(Chapter chapter, IEnumerable genreNames, IUnitOfWork unitOfWork) + { + // Normalize and build genres from the list of genre names + var genresToAdd = genreNames + .Select(g => new GenreBuilder(g).Build()) + .ToList(); + + // Remove any genres that are not part of the new list + var genresToRemove = chapter.Genres + .Where(g => genresToAdd.TrueForAll(ga => ga.NormalizedTitle != g.NormalizedTitle)) + .ToList(); + + foreach (var genreToRemove in genresToRemove) + { + chapter.Genres.Remove(genreToRemove); + } + + // Get all normalized titles for bulk lookup + var normalizedTitles = genresToAdd.Select(g => g.NormalizedTitle).ToList(); + + // Bulk lookup for existing genres in the database + var existingGenres = await unitOfWork.DataContext.Genre + .Where(g => normalizedTitles.Contains(g.NormalizedTitle)) + .ToListAsync(); + + // Find genres that do not exist in the database + var missingGenres = genresToAdd + .Where(g => existingGenres.TrueForAll(eg => eg.NormalizedTitle != g.NormalizedTitle)) + .ToList(); + + // Add missing genres to the database + if (missingGenres.Count != 0) + { + unitOfWork.DataContext.Genre.AddRange(missingGenres); + await unitOfWork.CommitAsync(); // Commit the changes to the database + } + + // Add the new or existing genres to the chapter + foreach (var genre in genresToAdd) + { + var existingGenre = existingGenres.FirstOrDefault(g => g.NormalizedTitle == genre.NormalizedTitle) + ?? missingGenres.FirstOrDefault(g => g.NormalizedTitle == genre.NormalizedTitle); + + if (existingGenre != null && !chapter.Genres.Contains(existingGenre)) + { + chapter.Genres.Add(existingGenre); + } + } + } + + public static void UpdateGenre(Dictionary allGenres, IEnumerable names, Action action) { diff --git a/API/Helpers/PersonHelper.cs b/API/Helpers/PersonHelper.cs index 71da50b596..f03a95a7b8 100644 --- a/API/Helpers/PersonHelper.cs +++ b/API/Helpers/PersonHelper.cs @@ -1,18 +1,212 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; +using API.Data; using API.DTOs; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Extensions; using API.Helpers.Builders; namespace API.Helpers; #nullable enable +// This isn't needed in the new person architecture public static class PersonHelper { + public static async Task UpdateSeriesMetadataPeopleAsync(SeriesMetadata metadata, ICollection metadataPeople, + IEnumerable chapterPeople, PersonRole role, IUnitOfWork unitOfWork) + { + // Normalize and group by person + var peopleToAdd = chapterPeople + .Where(cp => cp.Role == role) + .Select(cp => cp.Person) + .ToList(); + + var modification = false; + + // Remove any people who are not part of the new list + var peopleToRemove = metadataPeople + .Where(mp => mp.Role == role && peopleToAdd.TrueForAll(p => p.NormalizedName != mp.Person.NormalizedName)) + .ToList(); + + foreach (var personToRemove in peopleToRemove) + { + metadataPeople.Remove(personToRemove); + modification = true; + } + + // Add new people if they do not already exist + foreach (var person in peopleToAdd) + { + var existingPerson = metadataPeople + .FirstOrDefault(mp => mp.Person.NormalizedName == person.NormalizedName && mp.Role == role); + + if (existingPerson == null) + { + // Check if the person already exists in the database + var dbPerson = await unitOfWork.PersonRepository.GetPersonByName(person.Name); + + if (dbPerson == null) + { + // Create a new Person entity if not found in the database + dbPerson = new PersonBuilder(person.Name).Build(); + + // Attach and save the new Person entity + unitOfWork.DataContext.Person.Attach(dbPerson); + await unitOfWork.CommitAsync(); + } + + // Add the person to the SeriesMetadataPeople collection + metadataPeople.Add(new SeriesMetadataPeople + { + PersonId = dbPerson.Id, + Person = dbPerson, + SeriesMetadataId = metadata.Id, + SeriesMetadata = metadata, + Role = role + }); + modification = true; + } + } + + // Commit the changes if any modifications were made + if (modification) + { + await unitOfWork.CommitAsync(); + } + } + + + public static async Task UpdateSeriesMetadataPeopleAsync(SeriesMetadata metadata, ICollection metadataPeople, + IEnumerable seriesPeople, PersonRole role, IUnitOfWork unitOfWork) + { + // Normalize and group by person + var peopleToAdd = seriesPeople + .Where(cp => cp.Role == role) + .Select(cp => cp.Person) + .ToList(); + + var modification = false; + + // Remove any people who are not part of the new list + var peopleToRemove = metadataPeople + .Where(mp => mp.Role == role && peopleToAdd.TrueForAll(p => p.NormalizedName != mp.Person.NormalizedName)) + .ToList(); + + foreach (var personToRemove in peopleToRemove) + { + metadataPeople.Remove(personToRemove); + modification = true; + } + + // Add new people if they do not already exist + foreach (var person in peopleToAdd) + { + var existingPerson = metadataPeople + .FirstOrDefault(mp => mp.Person.NormalizedName == person.NormalizedName && mp.Role == role); + + if (existingPerson == null) + { + // Check if the person already exists in the database + var dbPerson = await unitOfWork.PersonRepository.GetPersonByName(person.Name); + + if (dbPerson == null) + { + // Create a new Person entity if not found in the database + dbPerson = new PersonBuilder(person.Name).Build(); + + // Attach and save the new Person entity + unitOfWork.DataContext.Person.Attach(dbPerson); + await unitOfWork.CommitAsync(); + } + + // Add the person to the SeriesMetadataPeople collection + metadataPeople.Add(new SeriesMetadataPeople + { + PersonId = dbPerson.Id, + Person = dbPerson, + SeriesMetadataId = metadata.Id, + SeriesMetadata = metadata, + Role = role + }); + modification = true; + } + } + + // Commit the changes if any modifications were made + if (modification) + { + await unitOfWork.CommitAsync(); + } + } + + + + public static async Task UpdateChapterPeopleAsync(Chapter chapter, IList people, PersonRole role, IUnitOfWork unitOfWork) + { + var modification = false; + // Normalize the input names for comparison + var normalizedPeople = people.Select(p => p.ToNormalized()).ToList(); + + // Get all existing ChapterPeople for the role + var existingChapterPeople = chapter.People.Where(cp => cp.Role == role).ToList(); + + // Remove people not in the new list + foreach (var existingChapterPerson in existingChapterPeople) + { + if (!normalizedPeople.Contains(existingChapterPerson.Person.NormalizedName)) + { + chapter.People.Remove(existingChapterPerson); + unitOfWork.PersonRepository.Remove(existingChapterPerson); + modification = true; + } + } + + // Add new people or existing ones if not already in the Chapter + foreach (var personName in people) + { + var person = await unitOfWork.PersonRepository.GetPersonByName(personName); + + // If the person doesn't exist, create a new Person entity + if (person == null) + { + person = new PersonBuilder(personName).Build(); + + modification = true; + unitOfWork.DataContext.Person.Attach(person); + await unitOfWork.CommitAsync(); + } + + // Check if the person with the specific role is already added to the chapter's People collection + var existingChapterPerson = chapter.People + .FirstOrDefault(cp => cp.PersonId == person.Id && cp.Role == role); + + // Check if this person with the specific role already exists for the chapter + if (existingChapterPerson == null) + { + var chapterPerson = new ChapterPeople + { + PersonId = person.Id, + ChapterId = chapter.Id, + Role = role + }; + + chapter.People.Add(chapterPerson); + modification = true; + } + } + + // Commit the changes to remove and add people + if (modification) + { + await unitOfWork.CommitAsync(); + } + } + ///

/// Given a list of all existing people, this will check the new names and roles and if it doesn't exist in allPeople, will create and /// add an entry. For each person in name, the callback will be executed. @@ -25,22 +219,23 @@ public static class PersonHelper /// public static void UpdatePeople(ICollection allPeople, IEnumerable names, PersonRole role, Action action) { - var allPeopleTypeRole = allPeople.Where(p => p.Role == role).ToList(); - - foreach (var name in names) - { - var normalizedName = name.ToNormalized(); - // BUG: Doesn't this create a duplicate entry because allPeopleTypeRoles is a different instance? - var person = allPeopleTypeRole.Find(p => - p.NormalizedName != null && p.NormalizedName.Equals(normalizedName)); - if (person == null) - { - person = new PersonBuilder(name, role).Build(); - allPeople.Add(person); - } - - action(person); - } + // TODO: Implement People Support + // var allPeopleTypeRole = allPeople.Where(p => p.Role == role).ToList(); + // + // foreach (var name in names) + // { + // var normalizedName = name.ToNormalized(); + // // BUG: Doesn't this create a duplicate entry because allPeopleTypeRoles is a different instance? + // var person = allPeopleTypeRole.Find(p => + // p.NormalizedName != null && p.NormalizedName.Equals(normalizedName)); + // if (person == null) + // { + // person = new PersonBuilder(name, role).Build(); + // allPeople.Add(person); + // } + // + // action(person); + // } } /// @@ -53,29 +248,31 @@ public static void UpdatePeople(ICollection allPeople, IEnumerableCallback which will be executed for each person removed public static void RemovePeople(ICollection existingPeople, IEnumerable people, PersonRole role, Action? action = null) { - var normalizedPeople = people.Select(Services.Tasks.Scanner.Parser.Parser.Normalize).ToList(); - if (normalizedPeople.Count == 0) - { - var peopleToRemove = existingPeople.Where(p => p.Role == role).ToList(); - foreach (var existingRoleToRemove in peopleToRemove) - { - existingPeople.Remove(existingRoleToRemove); - action?.Invoke(existingRoleToRemove); - } - return; - } - - foreach (var person in normalizedPeople) - { - var existingPerson = existingPeople.FirstOrDefault(p => p.Role == role && person.Equals(p.NormalizedName)); - if (existingPerson == null) continue; - - existingPeople.Remove(existingPerson); - action?.Invoke(existingPerson); - } + // TODO: Implement People support + // var normalizedPeople = people.Select(Services.Tasks.Scanner.Parser.Parser.Normalize).ToList(); + // if (normalizedPeople.Count == 0) + // { + // var peopleToRemove = existingPeople.Where(p => p.Role == role).ToList(); + // foreach (var existingRoleToRemove in peopleToRemove) + // { + // existingPeople.Remove(existingRoleToRemove); + // action?.Invoke(existingRoleToRemove); + // } + // return; + // } + // + // foreach (var person in normalizedPeople) + // { + // var existingPerson = existingPeople.FirstOrDefault(p => p.Role == role && person.Equals(p.NormalizedName)); + // if (existingPerson == null) continue; + // + // existingPeople.Remove(existingPerson); + // action?.Invoke(existingPerson); + // } } + /// /// Removes all people that are not present in the removeAllExcept list. /// @@ -84,15 +281,16 @@ public static void RemovePeople(ICollection existingPeople, IEnumerable< /// Callback for all entities that should be removed public static void KeepOnlySamePeopleBetweenLists(IEnumerable existingPeople, ICollection removeAllExcept, Action? action = null) { - foreach (var person in existingPeople) - { - var existingPerson = removeAllExcept - .FirstOrDefault(p => p.Role == person.Role && person.NormalizedName.Equals(p.NormalizedName)); - if (existingPerson == null) - { - action?.Invoke(person); - } - } + // TODO: Implement People support + // foreach (var person in existingPeople) + // { + // var existingPerson = removeAllExcept + // .FirstOrDefault(p => p.Role == person.Role && person.NormalizedName.Equals(p.NormalizedName)); + // if (existingPerson == null) + // { + // action?.Invoke(person); + // } + // } } /// @@ -100,19 +298,42 @@ public static void KeepOnlySamePeopleBetweenLists(IEnumerable existingPe /// /// /// - public static void AddPersonIfNotExists(ICollection metadataPeople, Person person) + // public static void AddPersonIfNotExists(ICollection metadataPeople, Person person) + // { + // + // if (string.IsNullOrEmpty(person.Name)) return; + // var existingPerson = metadataPeople.FirstOrDefault(p => + // p.NormalizedName == person.Name.ToNormalized() && p.Role == person.Role); + // + // if (existingPerson == null) + // { + // metadataPeople.Add(person); + // } + // } + + + public static void AddPersonIfNotExists(ICollection metadataPeople, Person person, PersonRole role) { if (string.IsNullOrEmpty(person.Name)) return; + + // Check if the person with the specific role already exists in the collection var existingPerson = metadataPeople.FirstOrDefault(p => - p.NormalizedName == person.Name.ToNormalized() && p.Role == person.Role); + p.Person.NormalizedName == person.Name.ToNormalized() && p.Role == role); + // Add the person with the role if not already present if (existingPerson == null) { - metadataPeople.Add(person); + metadataPeople.Add(new SeriesMetadataPeople + { + Person = person, + Role = role + }); } } + + /// /// For a given role and people dtos, update a series /// @@ -125,87 +346,89 @@ public static void AddPersonIfNotExists(ICollection metadataPeople, Pers public static void UpdatePeopleList(PersonRole role, ICollection? people, Series series, IReadOnlyCollection allPeople, Action handleAdd, Action onModified) { - if (people == null) return; - var isModified = false; - // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different - var existingTags = series.Metadata.People.Where(p => p.Role == role).ToList(); - foreach (var existing in existingTags) - { - if (people.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role - { - // Remove tag - series.Metadata.People.Remove(existing); - isModified = true; - } - } - - // At this point, all tags that aren't in dto have been removed. - foreach (var tag in people) - { - var existingTag = allPeople.FirstOrDefault(t => t.Name == tag.Name && t.Role == tag.Role); - if (existingTag != null) - { - if (series.Metadata.People.Where(t => t.Role == tag.Role).All(t => t.Name != null && !t.Name.Equals(tag.Name))) - { - handleAdd(existingTag); - isModified = true; - } - } - else - { - // Add new tag - handleAdd(new PersonBuilder(tag.Name, role).Build()); - isModified = true; - } - } - - if (isModified) - { - onModified(); - } + // TODO: Implement People support + // if (people == null) return; + // var isModified = false; + // // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different + // var existingTags = series.Metadata.People.Where(p => p.Role == role).ToList(); + // foreach (var existing in existingTags) + // { + // if (people.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role + // { + // // Remove tag + // series.Metadata.People.Remove(existing); + // isModified = true; + // } + // } + // + // // At this point, all tags that aren't in dto have been removed. + // foreach (var tag in people) + // { + // var existingTag = allPeople.FirstOrDefault(t => t.Name == tag.Name && t.Role == tag.Role); + // if (existingTag != null) + // { + // if (series.Metadata.People.Where(t => t.Role == tag.Role).All(t => t.Name != null && !t.Name.Equals(tag.Name))) + // { + // handleAdd(existingTag); + // isModified = true; + // } + // } + // else + // { + // // Add new tag + // handleAdd(new PersonBuilder(tag.Name, role).Build()); + // isModified = true; + // } + // } + // + // if (isModified) + // { + // onModified(); + // } } public static void UpdatePeopleList(PersonRole role, ICollection? people, Chapter chapter, IReadOnlyCollection allPeople, Action handleAdd, Action onModified) { - if (people == null) return; - var isModified = false; - // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different - var existingTags = chapter.People.Where(p => p.Role == role).ToList(); - foreach (var existing in existingTags) - { - if (people.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role - { - // Remove tag - chapter.People.Remove(existing); - isModified = true; - } - } - - // At this point, all tags that aren't in dto have been removed. - foreach (var tag in people) - { - var existingTag = allPeople.FirstOrDefault(t => t.Name == tag.Name && t.Role == tag.Role); - if (existingTag != null) - { - if (chapter.People.Where(t => t.Role == tag.Role).All(t => t.Name != null && !t.Name.Equals(tag.Name))) - { - handleAdd(existingTag); - isModified = true; - } - } - else - { - // Add new tag - handleAdd(new PersonBuilder(tag.Name, role).Build()); - isModified = true; - } - } - - if (isModified) - { - onModified(); - } + // TODO: Implement People support + // if (people == null) return; + // var isModified = false; + // // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different + // var existingTags = chapter.People.Where(p => p.Role == role).ToList(); + // foreach (var existing in existingTags) + // { + // if (people.SingleOrDefault(t => t.Id == existing.Id) == null) // This needs to check against role + // { + // // Remove tag + // chapter.People.Remove(existing); + // isModified = true; + // } + // } + // + // // At this point, all tags that aren't in dto have been removed. + // foreach (var tag in people) + // { + // var existingTag = allPeople.FirstOrDefault(t => t.Name == tag.Name && t.Role == tag.Role); + // if (existingTag != null) + // { + // if (chapter.People.Where(t => t.Role == tag.Role).All(t => t.Name != null && !t.Name.Equals(tag.Name))) + // { + // handleAdd(existingTag); + // isModified = true; + // } + // } + // else + // { + // // Add new tag + // handleAdd(new PersonBuilder(tag.Name, role).Build()); + // isModified = true; + // } + // } + // + // if (isModified) + // { + // onModified(); + // } } public static bool HasAnyPeople(SeriesMetadataDto? dto) diff --git a/API/Helpers/TagHelper.cs b/API/Helpers/TagHelper.cs index 91e2506da3..ebd9741d63 100644 --- a/API/Helpers/TagHelper.cs +++ b/API/Helpers/TagHelper.cs @@ -3,18 +3,71 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Threading.Tasks; using API.Data; using API.DTOs.Metadata; using API.Entities; using API.Extensions; using API.Helpers.Builders; using API.Services.Tasks.Scanner.Parser; +using Microsoft.EntityFrameworkCore; namespace API.Helpers; #nullable enable public static class TagHelper { + + public static async Task UpdateChapterTags(Chapter chapter, IEnumerable tagNames, IUnitOfWork unitOfWork) + { + // Normalize and build genres from the list of genre names + var tagsToAdd = tagNames + .Select(g => new TagBuilder(g).Build()) + .ToList(); + + // Remove any genres that are not part of the new list + var tagsToRemove = chapter.Tags + .Where(g => tagsToAdd.TrueForAll(ga => ga.NormalizedTitle != g.NormalizedTitle)) + .ToList(); + + foreach (var tagToRemove in tagsToRemove) + { + chapter.Tags.Remove(tagToRemove); + } + + // Get all normalized titles for bulk lookup + var normalizedTitles = tagsToAdd.Select(g => g.NormalizedTitle).ToList(); + + // Bulk lookup for existing genres in the database + var existingTags = await unitOfWork.DataContext.Tag + .Where(g => normalizedTitles.Contains(g.NormalizedTitle)) + .ToListAsync(); + + // Find genres that do not exist in the database + var missingGenres = tagsToAdd + .Where(g => existingTags.TrueForAll(eg => eg.NormalizedTitle != g.NormalizedTitle)) + .ToList(); + + // Add missing genres to the database + if (missingGenres.Count != 0) + { + unitOfWork.DataContext.Tag.AddRange(missingGenres); + await unitOfWork.CommitAsync(); // Commit the changes to the database + } + + // Add the new or existing genres to the chapter + foreach (var tag in tagsToAdd) + { + var existingGenre = existingTags.FirstOrDefault(g => g.NormalizedTitle == tag.NormalizedTitle) + ?? missingGenres.FirstOrDefault(g => g.NormalizedTitle == tag.NormalizedTitle); + + if (existingGenre != null && !chapter.Tags.Contains(existingGenre)) + { + chapter.Tags.Add(existingGenre); + } + } + } + public static void UpdateTag(Dictionary allTags, IEnumerable names, Action action) { foreach (var name in names) @@ -81,8 +134,7 @@ public static IList GetTagValues(string comicInfoTagSeparatedByComma) return ImmutableList.Empty; } - return comicInfoTagSeparatedByComma.Split(",") - .Select(s => s.Trim()) + return comicInfoTagSeparatedByComma.Split(",", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) .DistinctBy(Parser.Normalize) .ToList(); } diff --git a/API/I18N/en.json b/API/I18N/en.json index e038a9ea89..1e8b5aa1fb 100644 --- a/API/I18N/en.json +++ b/API/I18N/en.json @@ -52,6 +52,8 @@ "collection-already-exists":"Collection already exists", "error-import-stack": "There was an issue importing MAL stack", + "person-doesnt-exist": "Person does not exist", + "device-doesnt-exist": "Device does not exist", "generic-device-create": "There was an error when creating the device", "generic-device-update": "There was an error when updating the device", diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index c19914668a..1c56c65fcb 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -55,7 +55,7 @@ public interface IDirectoryService bool CopyDirectoryToDirectory(string? sourceDirName, string destDirName, string searchPattern = ""); Dictionary FindHighestDirectoriesFromFiles(IEnumerable libraryFolders, IList filePaths); - string? FindLowestDirectoriesFromFiles(IEnumerable libraryFolders, + string? FindLowestDirectoriesFromFiles(IList libraryFolders, IList filePaths); IEnumerable GetFoldersTillRoot(string rootPath, string fullPath); IEnumerable GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly); @@ -69,14 +69,13 @@ IEnumerable GetFilesWithCertainExtensions(string path, SearchOption searchOption = SearchOption.TopDirectoryOnly); IEnumerable GetDirectories(string folderPath); IEnumerable GetDirectories(string folderPath, GlobMatcher? matcher); + IEnumerable GetAllDirectories(string folderPath, GlobMatcher? matcher = null); string GetParentDirectoryName(string fileOrFolder); - IList ScanFiles(string folderPath, string fileTypes, GlobMatcher? matcher = null); + IList ScanFiles(string folderPath, string fileTypes, GlobMatcher? matcher = null, SearchOption searchOption = SearchOption.AllDirectories); DateTime GetLastWriteTime(string folderPath); - GlobMatcher? CreateMatcherFromFile(string filePath); } public class DirectoryService : IDirectoryService { - public const string KavitaIgnoreFile = ".kavitaignore"; public IFileSystem FileSystem { get; } public string CacheDirectory { get; } public string CoverImageDirectory { get; } @@ -95,11 +94,9 @@ public class DirectoryService : IDirectoryService private static readonly Regex ExcludeDirectories = new Regex( @"@eaDir|\.DS_Store|\.qpkg|__MACOSX|@Recently-Snapshot|@recycle|\.@__thumb|\.caltrash|#recycle|\.yacreaderlibrary", - MatchOptions, - Tasks.Scanner.Parser.Parser.RegexTimeout); + MatchOptions, Parser.RegexTimeout); private static readonly Regex FileCopyAppend = new Regex(@"\(\d+\)", - MatchOptions, - Tasks.Scanner.Parser.Parser.RegexTimeout); + MatchOptions, Parser.RegexTimeout); public static readonly string BackupDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "backups"); public DirectoryService(ILogger logger, IFileSystem fileSystem) @@ -136,22 +133,38 @@ public DirectoryService(ILogger logger, IFileSystem fileSystem /// /// This will always exclude patterns /// Directory to search - /// Regex version of search pattern (ie \.mp3|\.mp4). Defaults to * meaning all files. + /// Regex version of search pattern (e.g., \.mp3|\.mp4). Defaults to * meaning all files. /// SearchOption to use, defaults to TopDirectoryOnly /// List of file paths public IEnumerable GetFilesWithCertainExtensions(string path, string searchPatternExpression = "", SearchOption searchOption = SearchOption.TopDirectoryOnly) { - if (!FileSystem.Directory.Exists(path)) return ImmutableList.Empty; - var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase, Tasks.Scanner.Parser.Parser.RegexTimeout); + // If directory doesn't exist, exit the iterator with no results + if (!FileSystem.Directory.Exists(path)) + yield break; - return FileSystem.Directory.EnumerateFiles(path, "*", searchOption) - .Where(file => - reSearchPattern.IsMatch(FileSystem.Path.GetExtension(file)) && !FileSystem.Path.GetFileName(file).StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith)); + // Compile the regex pattern for faster repeated matching + var reSearchPattern = new Regex(searchPatternExpression, + RegexOptions.IgnoreCase | RegexOptions.Compiled, + Parser.RegexTimeout); + + // Enumerate files in the directory and apply filters + foreach (var file in FileSystem.Directory.EnumerateFiles(path, "*", searchOption)) + { + var fileName = FileSystem.Path.GetFileName(file); + var fileExtension = FileSystem.Path.GetExtension(file); + + // Check if the file matches the pattern and exclude macOS metadata files + if (reSearchPattern.IsMatch(fileExtension) && !fileName.StartsWith(Parser.MacOsMetadataFileStartsWith)) + { + yield return file; + } + } } + /// /// Returns a list of folders from end of fullPath to rootPath. If a file is passed at the end of the fullPath, it will be ignored. /// @@ -173,8 +186,6 @@ public IEnumerable GetFoldersTillRoot(string rootPath, string fullPath) rootPath = rootPath.Replace(FileSystem.Path.DirectorySeparatorChar, FileSystem.Path.AltDirectorySeparatorChar); } - - var path = fullPath.EndsWith(separator) ? fullPath.Substring(0, fullPath.Length - 1) : fullPath; var root = rootPath.EndsWith(separator) ? rootPath.Substring(0, rootPath.Length - 1) : rootPath; var paths = new List(); @@ -215,25 +226,34 @@ public bool Exists(string directory) /// public IEnumerable GetFiles(string path, string fileNameRegex = "", SearchOption searchOption = SearchOption.TopDirectoryOnly) { - if (!FileSystem.Directory.Exists(path)) return ImmutableList.Empty; + if (!FileSystem.Directory.Exists(path)) + yield break; // Use yield break to exit the iterator early - if (fileNameRegex != string.Empty) + Regex? reSearchPattern = null; + if (!string.IsNullOrEmpty(fileNameRegex)) { - var reSearchPattern = new Regex(fileNameRegex, RegexOptions.IgnoreCase, - Tasks.Scanner.Parser.Parser.RegexTimeout); - return FileSystem.Directory.EnumerateFiles(path, "*", searchOption) - .Where(file => - { - var fileName = FileSystem.Path.GetFileName(file); - return reSearchPattern.IsMatch(fileName) && - !fileName.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith); - }); + // Compile the regex for better performance when used frequently + reSearchPattern = new Regex(fileNameRegex, RegexOptions.IgnoreCase | RegexOptions.Compiled, Tasks.Scanner.Parser.Parser.RegexTimeout); } - return FileSystem.Directory.EnumerateFiles(path, "*", searchOption).Where(file => - !FileSystem.Path.GetFileName(file).StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith)); + // Enumerate files lazily + foreach (var file in FileSystem.Directory.EnumerateFiles(path, "*", searchOption)) + { + var fileName = FileSystem.Path.GetFileName(file); + + // Exclude macOS metadata files + if (fileName.StartsWith(Tasks.Scanner.Parser.Parser.MacOsMetadataFileStartsWith)) + continue; + + // If a regex is provided, match the file name against it + if (reSearchPattern != null && !reSearchPattern.IsMatch(fileName)) + continue; + + yield return file; // Yield each matching file as it's found + } } + /// /// Copies a file into a directory. Does not maintain parent folder of file. /// Will create target directory if doesn't exist. Automatically overwrites what is there. @@ -329,7 +349,7 @@ public string[] GetFilesWithExtension(string path, string searchPatternExpressio return GetFilesWithCertainExtensions(path, searchPatternExpression).ToArray(); } - return !FileSystem.Directory.Exists(path) ? Array.Empty() : FileSystem.Directory.GetFiles(path); + return !FileSystem.Directory.Exists(path) ? [] : FileSystem.Directory.GetFiles(path); } /// @@ -391,10 +411,12 @@ public void ClearDirectory(string directoryPath) { foreach (var file in di.EnumerateFiles()) { + if (!file.Exists) continue; file.Delete(); } foreach (var dir in di.EnumerateDirectories()) { + if (!dir.Exists) continue; dir.Delete(true); } } @@ -594,46 +616,60 @@ public Dictionary FindHighestDirectoriesFromFiles(IEnumerable /// Finds the lowest directory from a set of file paths. Does not return the root path, will always select the lowest non-root path. /// - /// If the file paths do not contain anything from libraryFolders, this returns an empty dictionary back + /// If the file paths do not contain anything from libraryFolders, this returns null. /// List of top level folders which files belong to /// List of file paths that belong to libraryFolders - /// - public string? FindLowestDirectoriesFromFiles(IEnumerable libraryFolders, IList filePaths) + /// Lowest non-root path, or null if not found + public string? FindLowestDirectoriesFromFiles(IList libraryFolders, IList filePaths) { - var dirs = new Dictionary(); + // Normalize the file paths only once var normalizedFilePaths = filePaths.Select(Parser.NormalizePath).ToList(); - foreach (var folder in libraryFolders.Select(Parser.NormalizePath)) + // Use a list to store all directories for comparison + var dirs = new List(); + + // Iterate through each library folder and collect matching directories + foreach (var normalizedFolder in libraryFolders.Select(Parser.NormalizePath)) { foreach (var file in normalizedFilePaths) { - if (!file.Contains(folder)) continue; + // If the file path contains the folder path, get its directory + if (!file.Contains(normalizedFolder)) continue; - var lowestPath = Path.GetDirectoryName(file); + var lowestPath = Path.GetDirectoryName(file); if (!string.IsNullOrEmpty(lowestPath)) { - dirs.TryAdd(Parser.NormalizePath(lowestPath), string.Empty); + dirs.Add(Parser.NormalizePath(lowestPath)); // Add to list } - } } - if (dirs.Keys.Count == 1) return dirs.Keys.First(); - if (dirs.Keys.Count > 1) + if (dirs.Count == 0) { - // For each key, validate that each file exists in the key path - foreach (var folder in dirs.Keys) - { - if (normalizedFilePaths.TrueForAll(filePath => filePath.Contains(Parser.NormalizePath(folder)))) - { - return folder; - } - } + return null; // No directories found } - return null; + // Now find the deepest common directory among all paths + var commonPath = dirs.Aggregate(GetDeepestCommonPath); // Use new method to get deepest path + + // Return the common path if it exists and is not one of the root directories + return libraryFolders.Any(folder => commonPath == Parser.NormalizePath(folder)) ? null : commonPath; } + public static string GetDeepestCommonPath(string path1, string path2) + { + var parts1 = path1.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var parts2 = path2.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + // Get the longest matching parts, ensuring that deeper parts in hierarchy are considered + var commonParts = parts1.Zip(parts2, (p1, p2) => p1 == p2 ? p1 : null) + .TakeWhile(part => part != null) + .ToArray(); + + return Parser.NormalizePath(string.Join(Path.DirectorySeparatorChar.ToString(), commonParts)); + } + + /// /// Gets a set of directories from the folder path. Automatically excludes directories that shouldn't be in scope. /// @@ -665,8 +701,9 @@ public IEnumerable GetDirectories(string folderPath, GlobMatcher? matche /// Returns all directories, including subdirectories. Automatically excludes directories that shouldn't be in scope. /// /// + /// /// - public IEnumerable GetAllDirectories(string folderPath) + public IEnumerable GetAllDirectories(string folderPath, GlobMatcher? matcher = null) { if (!FileSystem.Directory.Exists(folderPath)) return ImmutableArray.Empty; var directories = new List(); @@ -675,7 +712,7 @@ public IEnumerable GetAllDirectories(string folderPath) foreach (var foundDir in foundDirs) { directories.Add(foundDir); - directories.AddRange(GetAllDirectories(foundDir)); + directories.AddRange(GetAllDirectories(foundDir, matcher)); } return directories; @@ -699,93 +736,82 @@ public string GetParentDirectoryName(string fileOrFolder) } /// - /// Scans a directory by utilizing a recursive folder search. If a .kavitaignore file is found, will ignore matching patterns + /// Scans a directory by utilizing a recursive folder search. /// /// /// /// + /// Pass TopDirectories /// - public IList ScanFiles(string folderPath, string fileTypes, GlobMatcher? matcher = null) + public IList ScanFiles(string folderPath, string fileTypes, GlobMatcher? matcher = null, + SearchOption searchOption = SearchOption.AllDirectories) { _logger.LogTrace("[ScanFiles] called on {Path}", folderPath); var files = new List(); + if (!Exists(folderPath)) return files; - var potentialIgnoreFile = FileSystem.Path.Join(folderPath, KavitaIgnoreFile); - if (matcher == null) + if (searchOption == SearchOption.AllDirectories) { - matcher = CreateMatcherFromFile(potentialIgnoreFile); - } - else - { - matcher.Merge(CreateMatcherFromFile(potentialIgnoreFile)); - } - - var directories = GetDirectories(folderPath, matcher); + // Stack to hold directories to process + var directoriesToProcess = new Stack(); + directoriesToProcess.Push(folderPath); - foreach (var directory in directories) - { - files.AddRange(ScanFiles(directory, fileTypes, matcher)); - } + while (directoriesToProcess.Count > 0) + { + var currentDirectory = directoriesToProcess.Pop(); + // Get files from the current directory + var filesInCurrentDirectory = GetFilesWithCertainExtensions(currentDirectory, fileTypes); + files.AddRange(filesInCurrentDirectory); - // Get the matcher from either ignore or global (default setup) - if (matcher == null) - { - files.AddRange(GetFilesWithCertainExtensions(folderPath, fileTypes)); + // Get subdirectories and add them to the stack + var subdirectories = GetDirectories(currentDirectory, matcher); + foreach (var subdirectory in subdirectories) + { + directoriesToProcess.Push(subdirectory); + } + } } else { - var foundFiles = GetFilesWithCertainExtensions(folderPath, - fileTypes) - .Where(file => !matcher.ExcludeMatches(FileSystem.FileInfo.New(file).Name)); - files.AddRange(foundFiles); + // If TopDirectoryOnly is specified, only get files in the specified folder + var filesInCurrentDirectory = GetFilesWithCertainExtensions(folderPath, fileTypes); + files.AddRange(filesInCurrentDirectory); + } + + // Filter out unwanted files based on matcher if provided + if (matcher != null) + { + files = files.Where(file => !matcher.ExcludeMatches(FileSystem.FileInfo.New(file).Name)).ToList(); } return files; } + /// /// Recursively scans a folder and returns the max last write time on any folders and files /// - /// If the folder is empty or non-existant, this will return MaxValue for a DateTime + /// If the folder is empty or non-existent, this will return MaxValue for a DateTime /// /// Max Last Write Time public DateTime GetLastWriteTime(string folderPath) { if (!FileSystem.Directory.Exists(folderPath)) return DateTime.MaxValue; + var fileEntries = FileSystem.Directory.GetFileSystemEntries(folderPath, "*.*", SearchOption.AllDirectories); if (fileEntries.Length == 0) return DateTime.MaxValue; - return fileEntries.Max(path => FileSystem.File.GetLastWriteTime(path)); - } - - /// - /// Generates a GlobMatcher from a .kavitaignore file found at path. Returns null otherwise. - /// - /// - /// - public GlobMatcher? CreateMatcherFromFile(string filePath) - { - if (!FileSystem.File.Exists(filePath)) - { - return null; - } - // Read file in and add each line to Matcher - var lines = FileSystem.File.ReadAllLines(filePath); - if (lines.Length == 0) - { - return null; - } + // Find the max last write time of the files + var maxFiles = fileEntries.Max(path => FileSystem.File.GetLastWriteTime(path)); - GlobMatcher matcher = new(); - foreach (var line in lines.Where(s => !string.IsNullOrEmpty(s))) - { - matcher.AddExclude(line); - } + // Get the last write time of the directory itself + var directoryLastWriteTime = FileSystem.Directory.GetLastWriteTime(folderPath); - return matcher; + // Use comparison to get the max DateTime value + return directoryLastWriteTime > maxFiles ? directoryLastWriteTime : maxFiles; } diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 75a47f4799..3236acdf90 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -888,6 +888,16 @@ public static string GetThumbnailFormat(int chapterId) return $"thumbnail{chapterId}"; } + /// + /// Returns the name format for a person cover + /// + /// + /// + public static string GetPersonFormat(int personId) + { + return $"person{personId}"; + } + public static string GetWebLinkFormat(string url, EncodeFormat encodeFormat) { return $"{new Uri(url).Host.Replace("www.", string.Empty)}{encodeFormat.GetExtension()}"; diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 72eee36720..9140d26e6d 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -111,7 +111,7 @@ public async Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSerie try { var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId; - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Metadata); if (series == null) return false; series.Metadata ??= new SeriesMetadataBuilder() @@ -201,76 +201,80 @@ public async Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSerie { if (PersonHelper.HasAnyPeople(updateSeriesMetadataDto.SeriesMetadata)) { - void HandleAddPerson(Person person) + series.Metadata.People ??= new List(); + + // Writers + if (!series.Metadata.WriterLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Writers, PersonRole.Writer); + } + + // Cover Artists + if (!series.Metadata.CoverArtistLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, PersonRole.CoverArtist); + } + + // Colorists + if (!series.Metadata.ColoristLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Colorists, PersonRole.Colorist); + } + + // Editors + if (!series.Metadata.EditorLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Editors, PersonRole.Editor); + } + + // Inkers + if (!series.Metadata.InkerLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Inkers, PersonRole.Inker); + } + + // Letterers + if (!series.Metadata.LettererLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Letterers, PersonRole.Letterer); + } + + // Pencillers + if (!series.Metadata.PencillerLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Pencillers, PersonRole.Penciller); + } + + // Publishers + if (!series.Metadata.PublisherLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Publishers, PersonRole.Publisher); + } + + // Imprints + if (!series.Metadata.ImprintLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Imprints, PersonRole.Imprint); + } + + // Teams + if (!series.Metadata.TeamLocked) { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Teams, PersonRole.Team); + } + + // Locations + if (!series.Metadata.LocationLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Locations, PersonRole.Location); + } + + // Translators + if (!series.Metadata.TranslatorLocked) + { + await HandlePeopleUpdateAsync(series.Metadata, updateSeriesMetadataDto.SeriesMetadata.Translators, PersonRole.Translator); } - series.Metadata.People ??= new List(); - var allWriters = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Writer, - updateSeriesMetadataDto.SeriesMetadata!.Writers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Writer, updateSeriesMetadataDto.SeriesMetadata.Writers, series, allWriters.AsReadOnly(), - HandleAddPerson, () => series.Metadata.WriterLocked = true); - - var allCharacters = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Character, - updateSeriesMetadataDto.SeriesMetadata!.Characters.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Character, updateSeriesMetadataDto.SeriesMetadata.Characters, series, allCharacters.AsReadOnly(), - HandleAddPerson, () => series.Metadata.CharacterLocked = true); - - var allColorists = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Colorist, - updateSeriesMetadataDto.SeriesMetadata!.Colorists.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Colorist, updateSeriesMetadataDto.SeriesMetadata.Colorists, series, allColorists.AsReadOnly(), - HandleAddPerson, () => series.Metadata.ColoristLocked = true); - - var allEditors = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Editor, - updateSeriesMetadataDto.SeriesMetadata!.Editors.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Editor, updateSeriesMetadataDto.SeriesMetadata.Editors, series, allEditors.AsReadOnly(), - HandleAddPerson, () => series.Metadata.EditorLocked = true); - - var allInkers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Inker, - updateSeriesMetadataDto.SeriesMetadata!.Inkers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Inker, updateSeriesMetadataDto.SeriesMetadata.Inkers, series, allInkers.AsReadOnly(), - HandleAddPerson, () => series.Metadata.InkerLocked = true); - - var allLetterers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Letterer, - updateSeriesMetadataDto.SeriesMetadata!.Letterers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Letterer, updateSeriesMetadataDto.SeriesMetadata.Letterers, series, allLetterers.AsReadOnly(), - HandleAddPerson, () => series.Metadata.LettererLocked = true); - - var allPencillers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Penciller, - updateSeriesMetadataDto.SeriesMetadata!.Pencillers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Penciller, updateSeriesMetadataDto.SeriesMetadata.Pencillers, series, allPencillers.AsReadOnly(), - HandleAddPerson, () => series.Metadata.PencillerLocked = true); - - var allPublishers = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Publisher, - updateSeriesMetadataDto.SeriesMetadata!.Publishers.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Publisher, updateSeriesMetadataDto.SeriesMetadata.Publishers, series, allPublishers.AsReadOnly(), - HandleAddPerson, () => series.Metadata.PublisherLocked = true); - - var allImprints = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Imprint, - updateSeriesMetadataDto.SeriesMetadata!.Imprints.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Imprint, updateSeriesMetadataDto.SeriesMetadata.Imprints, series, allImprints.AsReadOnly(), - HandleAddPerson, () => series.Metadata.ImprintLocked = true); - - var allTeams = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Team, - updateSeriesMetadataDto.SeriesMetadata!.Imprints.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Team, updateSeriesMetadataDto.SeriesMetadata.Teams, series, allTeams.AsReadOnly(), - HandleAddPerson, () => series.Metadata.TeamLocked = true); - - var allLocations = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Location, - updateSeriesMetadataDto.SeriesMetadata!.Imprints.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Location, updateSeriesMetadataDto.SeriesMetadata.Locations, series, allLocations.AsReadOnly(), - HandleAddPerson, () => series.Metadata.LocationLocked = true); - - var allTranslators = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Translator, - updateSeriesMetadataDto.SeriesMetadata!.Translators.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.Translator, updateSeriesMetadataDto.SeriesMetadata.Translators, series, allTranslators.AsReadOnly(), - HandleAddPerson, () => series.Metadata.TranslatorLocked = true); - - var allCoverArtists = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.CoverArtist, - updateSeriesMetadataDto.SeriesMetadata!.CoverArtists.Select(p => Parser.Normalize(p.Name))); - PersonHelper.UpdatePeopleList(PersonRole.CoverArtist, updateSeriesMetadataDto.SeriesMetadata.CoverArtists, series, allCoverArtists.AsReadOnly(), - HandleAddPerson, () => series.Metadata.CoverArtistLocked = true); } series.Metadata.AgeRatingLocked = updateSeriesMetadataDto.SeriesMetadata.AgeRatingLocked; @@ -321,6 +325,90 @@ void HandleAddPerson(Person person) return false; } + /// + /// Exclusively for Series Update API + /// + /// + /// + /// + private async Task HandlePeopleUpdateAsync(SeriesMetadata metadata, ICollection peopleDtos, PersonRole role) + { + // Normalize all names from the DTOs + var normalizedNames = peopleDtos.Select(p => Parser.Normalize(p.Name)).ToList(); + + // Bulk select people who already exist in the database + var existingPeople = await _unitOfWork.PersonRepository.GetPeopleByNames(normalizedNames); + + // Use a dictionary for quick lookups + var existingPeopleDictionary = existingPeople.ToDictionary(p => p.NormalizedName, p => p); + + // List to track people that will be added to the metadata + var peopleToAdd = new List(); + + foreach (var personDto in peopleDtos) + { + var normalizedPersonName = Parser.Normalize(personDto.Name); + + // Check if the person exists in the dictionary + if (existingPeopleDictionary.TryGetValue(normalizedPersonName, out _)) continue; + + // Person doesn't exist, so create a new one + var newPerson = new Person + { + Name = personDto.Name, + NormalizedName = normalizedPersonName + }; + + peopleToAdd.Add(newPerson); + existingPeopleDictionary[normalizedPersonName] = newPerson; + } + + // Add any new people to the database in bulk + if (peopleToAdd.Count != 0) + { + _unitOfWork.PersonRepository.Attach(peopleToAdd); + } + + // Now that we have all the people (new and existing), update the SeriesMetadataPeople + UpdateSeriesMetadataPeople(metadata, metadata.People, existingPeopleDictionary.Values, role); + } + + private static void UpdateSeriesMetadataPeople(SeriesMetadata metadata, ICollection metadataPeople, IEnumerable people, PersonRole role) + { + var peopleToAdd = people.ToList(); + + // Remove any people in the existing metadataPeople for this role that are no longer present in the input list + var peopleToRemove = metadataPeople + .Where(mp => mp.Role == role && peopleToAdd.TrueForAll(p => p.NormalizedName != mp.Person.NormalizedName)) + .ToList(); + + foreach (var personToRemove in peopleToRemove) + { + metadataPeople.Remove(personToRemove); + } + + // Add new people for this role if they don't already exist + foreach (var person in peopleToAdd) + { + var existingPersonEntry = metadataPeople + .FirstOrDefault(mp => mp.Person.NormalizedName == person.NormalizedName && mp.Role == role); + + if (existingPersonEntry == null) + { + metadataPeople.Add(new SeriesMetadataPeople + { + PersonId = person.Id, + Person = person, + SeriesMetadataId = metadata.Id, + SeriesMetadata = metadata, + Role = role + }); + } + } + } + + + /// /// /// @@ -384,6 +472,7 @@ public async Task DeleteMultipleSeries(IList seriesIds) allChapterIds.AddRange(mapping.Value); } + // NOTE: This isn't getting all the people and whatnot currently var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(seriesIds); _unitOfWork.SeriesRepository.Remove(series); diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index fda3d923f3..24083d19db 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -282,10 +282,11 @@ public void ScanFolder(string folderPath, string originalPath, TimeSpan delay) { var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath); var normalizedOriginal = Tasks.Scanner.Parser.Parser.NormalizePath(originalPath); + if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, normalizedOriginal]) || HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty])) { - _logger.LogInformation("Skipped scheduling ScanFolder for {Folder} as a job already queued", + _logger.LogDebug("Skipped scheduling ScanFolder for {Folder} as a job already queued", normalizedFolder); return; } @@ -293,9 +294,6 @@ public void ScanFolder(string folderPath, string originalPath, TimeSpan delay) // Not sure where we should put this code, but we can get a bunch of ScanFolders when original has slight variations, like // create a folder, add a new file, etc. All of these can be merged into just 1 request. - - - _logger.LogInformation("Scheduling ScanFolder for {Folder}", normalizedFolder); BackgroundJob.Schedule(() => _scannerService.ScanFolder(normalizedFolder, normalizedOriginal), delay); } @@ -305,7 +303,7 @@ public void ScanFolder(string folderPath) var normalizedFolder = Tasks.Scanner.Parser.Parser.NormalizePath(folderPath); if (HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", [normalizedFolder, string.Empty])) { - _logger.LogInformation("Skipped scheduling ScanFolder for {Folder} as a job already queued", + _logger.LogDebug("Skipped scheduling ScanFolder for {Folder} as a job already queued", normalizedFolder); return; } diff --git a/API/Services/Tasks/Scanner/LibraryWatcher.cs b/API/Services/Tasks/Scanner/LibraryWatcher.cs index e429a5aeda..d2e6437a39 100644 --- a/API/Services/Tasks/Scanner/LibraryWatcher.cs +++ b/API/Services/Tasks/Scanner/LibraryWatcher.cs @@ -278,7 +278,7 @@ public async Task ProcessChange(string filePath, bool isDirectoryChange = false) _logger.LogTrace("Folder path: {FolderPath}", fullPath); if (string.IsNullOrEmpty(fullPath)) { - _logger.LogTrace("[LibraryWatcher] Change from {FilePath} could not find root level folder, ignoring change", filePath); + _logger.LogInformation("[LibraryWatcher] Change from {FilePath} could not find root level folder, ignoring change", filePath); return; } diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index 16586bc9df..dccbd8e90b 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; @@ -121,7 +122,7 @@ public ParseScannedFiles(ILogger logger, IDirectoryService directoryService, /// A dictionary mapping a normalized path to a list of to help scanner skip I/O /// A library folder or series folder /// If we should bypass any folder last write time checks on the scan and force I/O - public async Task> ProcessFiles(string folderPath, bool scanDirectoryByDirectory, + public async Task> ScanFiles(string folderPath, bool scanDirectoryByDirectory, IDictionary> seriesPaths, Library library, bool forceCheck = false) { var fileExtensions = string.Join("|", library.LibraryFileTypes.Select(l => l.FileTypeGroup.GetRegex())); @@ -138,69 +139,128 @@ public async Task> ProcessFiles(string folderPath, bool scanDi return await ScanSingleDirectory(folderPath, seriesPaths, library, forceCheck, result, fileExtensions, matcher); } - private async Task> ScanDirectories(string folderPath, IDictionary> seriesPaths, Library library, bool forceCheck, - GlobMatcher matcher, List result, string fileExtensions) + private async Task> ScanDirectories(string folderPath, IDictionary> seriesPaths, + Library library, bool forceCheck, GlobMatcher matcher, List result, string fileExtensions) { - var directories = _directoryService.GetDirectories(folderPath, matcher).Select(Parser.Parser.NormalizePath); - foreach (var directory in directories) + var allDirectories = _directoryService.GetAllDirectories(folderPath, matcher) + .Select(Parser.Parser.NormalizePath) + .OrderByDescending(d => d.Length) + .ToList(); + + var processedDirs = new HashSet(); + + _logger.LogDebug("[ScannerService] Step 1.C Found {DirectoryCount} directories to process for {FolderPath}", allDirectories.Count, folderPath); + foreach (var directory in allDirectories) { + // Don't process any folders where we've already scanned everything below + if (processedDirs.Any(d => d.StartsWith(directory + Path.AltDirectorySeparatorChar) || d.Equals(directory))) + { + // Skip this directory as we've already processed a parent unless there are loose files at that directory + CheckSurfaceFiles(result, directory, folderPath, fileExtensions, matcher); + continue; + } + + // Skip directories ending with "Specials", let the parent handle it + if (directory.EndsWith("Specials", StringComparison.OrdinalIgnoreCase)) + { + // Log or handle that we are skipping this directory + _logger.LogDebug("Skipping {Directory} as it ends with 'Specials'", directory); + continue; + } + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent(directory, library.Name, ProgressEventType.Updated)); - if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, directory, forceCheck)) { - if (result.Exists(r => r.Folder == directory)) - { - _logger.LogDebug("[ProcessFiles] Skipping adding {Directory} as it's already added", directory); - continue; - } - _logger.LogDebug("[ProcessFiles] Skipping {Directory} as it hasn't changed since last scan", directory); - result.Add(CreateScanResult(directory, folderPath, false, ArraySegment.Empty)); + HandleUnchangedFolder(result, folderPath, directory); } - else if (!forceCheck && seriesPaths.TryGetValue(directory, out var series) - && series.Count > 1 && series.All(s => !string.IsNullOrEmpty(s.LowestFolderPath))) + else { - // If there are multiple series inside this path, let's check each of them to see which was modified and only scan those - // This is very helpful for ComicVine libraries by Publisher + PerformFullScan(result, directory, folderPath, fileExtensions, matcher); + } - // TODO: BUG: We might miss new folders this way. Likely need to get all folder names and see if there are any that aren't in known series list + processedDirs.Add(directory); + } - _logger.LogDebug("[ProcessFiles] {Directory} is dirty and has multiple series folders, checking if we can avoid a full scan", directory); - foreach (var seriesModified in series) - { - var hasFolderChangedSinceLastScan = seriesModified.LastScanned.Truncate(TimeSpan.TicksPerSecond) < - _directoryService - .GetLastWriteTime(seriesModified.LowestFolderPath!) - .Truncate(TimeSpan.TicksPerSecond); + return result; + } - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.FileScanProgressEvent(seriesModified.LowestFolderPath!, library.Name, ProgressEventType.Updated)); + /// + /// Checks against all folder paths on file if the last scanned is >= the directory's last write time, down to the second + /// + /// + /// This should be normalized + /// + /// + private bool HasSeriesFolderNotChangedSinceLastScan(IDictionary> seriesPaths, string directory, bool forceCheck) + { + // With the bottom-up approach, this can report a false positive where a nested folder will get scanned even though a parent is the series + // This can't really be avoided. This is more likely to happen on Image chapter folder library layouts. + if (forceCheck || !seriesPaths.TryGetValue(directory, out var seriesList)) + { + return false; + } - if (!hasFolderChangedSinceLastScan) - { - _logger.LogDebug("[ProcessFiles] {Directory} subfolder {Folder} did not change since last scan, adding entry to skip", directory, seriesModified.LowestFolderPath); - result.Add(CreateScanResult(seriesModified.LowestFolderPath!, folderPath, false, ArraySegment.Empty)); - } - else - { - _logger.LogDebug("[ProcessFiles] {Directory} subfolder {Folder} changed for Series {SeriesName}", directory, seriesModified.LowestFolderPath, seriesModified.SeriesName); - result.Add(CreateScanResult(directory, folderPath, true, - _directoryService.ScanFiles(seriesModified.LowestFolderPath!, fileExtensions, matcher))); - } - } - } - else + foreach (var series in seriesList) + { + var lastWriteTime = _directoryService.GetLastWriteTime(series.LowestFolderPath!).Truncate(TimeSpan.TicksPerSecond); + var seriesLastScanned = series.LastScanned.Truncate(TimeSpan.TicksPerSecond); + if (seriesLastScanned < lastWriteTime) { - _logger.LogDebug("[ProcessFiles] Performing file scan on {Directory}", directory); - var files = _directoryService.ScanFiles(directory, fileExtensions, matcher); - result.Add(CreateScanResult(directory, folderPath, true, files)); + return false; } } - return result; + return true; + } + + /// + /// Handles directories that haven't changed since the last scan. + /// + private void HandleUnchangedFolder(List result, string folderPath, string directory) + { + if (result.Exists(r => r.Folder == directory)) + { + _logger.LogDebug("[ProcessFiles] Skipping adding {Directory} as it's already added, this indicates a bad layout issue", directory); + } + else + { + _logger.LogDebug("[ProcessFiles] Skipping {Directory} as it hasn't changed since last scan", directory); + result.Add(CreateScanResult(directory, folderPath, false, ArraySegment.Empty)); + } + } + + /// + /// Performs a full scan of the directory and adds it to the result. + /// + private void PerformFullScan(List result, string directory, string folderPath, string fileExtensions, GlobMatcher matcher) + { + _logger.LogDebug("[ProcessFiles] Performing full scan on {Directory}", directory); + var files = _directoryService.ScanFiles(directory, fileExtensions, matcher); + if (files.Count == 0) + { + _logger.LogDebug("[ProcessFiles] Empty directory: {Directory}. Keeping empty will cause Kavita to scan this each time", directory); + } + result.Add(CreateScanResult(directory, folderPath, true, files)); + } + + /// + /// Performs a full scan of the directory and adds it to the result. + /// + private void CheckSurfaceFiles(List result, string directory, string folderPath, string fileExtensions, GlobMatcher matcher) + { + var files = _directoryService.ScanFiles(directory, fileExtensions, matcher, SearchOption.TopDirectoryOnly); + if (files.Count == 0) + { + return; + } + result.Add(CreateScanResult(directory, folderPath, true, files)); } + /// + /// Scans a single directory and processes the scan result. + /// private async Task> ScanSingleDirectory(string folderPath, IDictionary> seriesPaths, Library library, bool forceCheck, List result, string fileExtensions, GlobMatcher matcher) { @@ -249,6 +309,33 @@ private static ScanResult CreateScanResult(string folderPath, string libraryRoot }; } + /// + /// Processes scanResults to track all series across the combined results. + /// Ensures series are correctly grouped even if they span multiple folders. + /// + /// A collection of scan results + /// A concurrent dictionary to store the tracked series + private void TrackSeriesAcrossScanResults(IList scanResults, ConcurrentDictionary> scannedSeries) + { + // Flatten all ParserInfos from scanResults + var allInfos = scanResults.SelectMany(sr => sr.ParserInfos).ToList(); + + // Iterate through each ParserInfo and track the series + foreach (var info in allInfos) + { + if (info == null) continue; + + try + { + TrackSeries(scannedSeries, info); + } + catch (Exception ex) + { + _logger.LogError(ex, "[ScannerService] Exception occurred during tracking {FilePath}. Skipping this file", info?.FullFilePath); + } + } + } + /// /// Attempts to either add a new instance of a series mapping to the _scannedSeries bag or adds to an existing. @@ -263,6 +350,8 @@ private void TrackSeries(ConcurrentDictionary> sc // Check if normalized info.Series already exists and if so, update info to use that name instead info.Series = MergeName(scannedSeries, info); + // BUG: This will fail for Solo Leveling & Solo Leveling (Manga) + var normalizedSeries = info.Series.ToNormalized(); var normalizedSortSeries = info.SeriesSort.ToNormalized(); var normalizedLocalizedSeries = info.LocalizedSeries.ToNormalized(); @@ -293,13 +382,13 @@ private void TrackSeries(ConcurrentDictionary> sc } catch (Exception ex) { - _logger.LogCritical(ex, "[ScannerService] {SeriesName} matches against multiple series in the parsed series. This indicates a critical kavita issue. Key will be skipped", info.Series); + _logger.LogCritical("[ScannerService] {SeriesName} matches against multiple series in the parsed series. This indicates a critical kavita issue. Key will be skipped", info.Series); foreach (var seriesKey in scannedSeries.Keys.Where(ps => ps.Format == info.Format && (ps.NormalizedName.Equals(normalizedSeries) || ps.NormalizedName.Equals(normalizedLocalizedSeries) || ps.NormalizedName.Equals(normalizedSortSeries)))) { - _logger.LogCritical("[ScannerService] Matches: {SeriesName} matches on {SeriesKey}", info.Series, seriesKey.Name); + _logger.LogCritical("[ScannerService] Matches: '{SeriesName}' matches on '{SeriesKey}'", info.Series, seriesKey.Name); } } } @@ -338,11 +427,12 @@ private string MergeName(ConcurrentDictionary> sc } catch (Exception ex) { - _logger.LogCritical(ex, "[ScannerService] Multiple series detected for {SeriesName} ({File})! This is critical to fix! There should only be 1", info.Series, info.FullFilePath); + _logger.LogCritical("[ScannerService] Multiple series detected for {SeriesName} ({File})! This is critical to fix! There should only be 1", info.Series, info.FullFilePath); var values = scannedSeries.Where(p => (p.Key.NormalizedName.ToNormalized() == normalizedSeries || p.Key.NormalizedName.ToNormalized() == normalizedLocalSeries) && p.Key.Format == info.Format); + foreach (var pair in values) { _logger.LogCritical("[ScannerService] Duplicate Series in DB matches with {SeriesName}: {DuplicateName}", info.Series, pair.Key.Name); @@ -353,7 +443,6 @@ private string MergeName(ConcurrentDictionary> sc return info.Series; } - /// /// This will process series by folder groups. This is used solely by ScanSeries /// @@ -364,151 +453,306 @@ private string MergeName(ConcurrentDictionary> sc /// Defaults to false /// public async Task> ScanLibrariesForSeries(Library library, - IEnumerable folders, bool isLibraryScan, + IList folders, bool isLibraryScan, IDictionary> seriesPaths, bool forceCheck = false) { - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Starting", library.Name, ProgressEventType.Started)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent("File Scan Starting", library.Name, ProgressEventType.Started)); _logger.LogDebug("[ScannerService] Library {LibraryName} Step 1.A: Process {FolderCount} folders", library.Name, folders.Count()); - var processedScannedSeries = new List(); - //var processedScannedSeries = new ConcurrentBag(); - foreach (var folderPath in folders) + var processedScannedSeries = new ConcurrentBag(); + + foreach (var folder in folders) { try { - _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.B: Scan files in {Folder}", library.Name, folderPath); - var scanResults = await ProcessFiles(folderPath, isLibraryScan, seriesPaths, library, forceCheck); - - _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.C: Process files in {Folder}", library.Name, folderPath); - foreach (var scanResult in scanResults) - { - await ParseAndTrackSeries(library, seriesPaths, scanResult, processedScannedSeries); - } - - // This reduced a 1.1k series networked scan by a little more than 1 hour, but the order series were added to Kavita was not alphabetical - // await Task.WhenAll(scanResults.Select(async scanResult => - // { - // await ParseAndTrackSeries(library, seriesPaths, scanResult, processedScannedSeries); - // })); - + await ScanAndParseFolder(folder, library, isLibraryScan, seriesPaths, processedScannedSeries, forceCheck); } catch (ArgumentException ex) { - _logger.LogError(ex, "[ScannerService] The directory '{FolderPath}' does not exist", folderPath); + _logger.LogError(ex, "[ScannerService] The directory '{FolderPath}' does not exist", folder); } } - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Done", library.Name, ProgressEventType.Ended)); + await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, + MessageFactory.FileScanProgressEvent("File Scan Done", library.Name, ProgressEventType.Ended)); return processedScannedSeries.ToList(); - } - private async Task ParseAndTrackSeries(Library library, IDictionary> seriesPaths, ScanResult scanResult, - List processedScannedSeries) + /// + /// Helper method to scan and parse a folder + /// + /// + /// + /// + /// + /// + /// + private async Task ScanAndParseFolder(string folderPath, Library library, + bool isLibraryScan, IDictionary> seriesPaths, + ConcurrentBag processedScannedSeries, bool forceCheck) { - // scanResult is updated with the parsed infos - await ProcessScanResult(scanResult, seriesPaths, library); // NOTE: This may be able to be parallelized + _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.B: Scan files in {Folder}", library.Name, folderPath); + var scanResults = await ScanFiles(folderPath, isLibraryScan, seriesPaths, library, forceCheck); - // We now have all the parsed infos from the scan result, perform any merging that is necessary and post processing steps + // Aggregate the scanned series across all scanResults var scannedSeries = new ConcurrentDictionary>(); - // Merge any series together (like Nagatoro/nagator.cbz, japanesename.cbz) -> Nagator series - MergeLocalizedSeriesWithSeries(scanResult.ParserInfos); - - // Combine everything into scannedSeries - foreach (var info in scanResult.ParserInfos) + _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.C: Process files in {Folder}", library.Name, folderPath); + foreach (var scanResult in scanResults) { - try - { - TrackSeries(scannedSeries, info); - } - catch (Exception ex) - { - _logger.LogError(ex, - "[ScannerService] There was an exception that occurred during tracking {FilePath}. Skipping this file", - info?.FullFilePath); - } + await ParseFiles(scanResult, seriesPaths, library); } + _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.D: Merge any localized series with series {Folder}", library.Name, folderPath); + scanResults = MergeLocalizedSeriesAcrossScanResults(scanResults); + + _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.E: Group all parsed data into logical Series", library.Name); + TrackSeriesAcrossScanResults(scanResults, scannedSeries); + + + // Now transform and add to processedScannedSeries AFTER everything is processed + _logger.LogDebug("\t[ScannerService] Library {LibraryName} Step 1.F: Generate Sort Order for Series and Finalize", library.Name); + GenerateProcessedScannedSeries(scannedSeries, scanResults, processedScannedSeries); + } + + /// + /// Processes and generates the final results for processedScannedSeries after updating sort order. + /// + /// A concurrent dictionary of tracked series and their parsed infos + /// List of all scan results, used to determine if any series has changed + /// A thread-safe concurrent bag of processed series results + private void GenerateProcessedScannedSeries(ConcurrentDictionary> scannedSeries, IList scanResults, ConcurrentBag processedScannedSeries) + { + // First, update the sort order for all series + UpdateSeriesSortOrder(scannedSeries); + + // Now, generate the final processed scanned series results + CreateFinalSeriesResults(scannedSeries, scanResults, processedScannedSeries); + } + + /// + /// Updates the sort order for all series in the scannedSeries dictionary. + /// + /// A concurrent dictionary of tracked series and their parsed infos + private void UpdateSeriesSortOrder(ConcurrentDictionary> scannedSeries) + { foreach (var series in scannedSeries.Keys) { if (scannedSeries[series].Count <= 0) continue; try { - UpdateSortOrder(scannedSeries, series); + UpdateSortOrder(scannedSeries, series); // Call to method that updates sort order } catch (Exception ex) { - _logger.LogError(ex, "There was an issue setting IssueOrder"); + _logger.LogError(ex, "[ScannerService] Issue occurred while setting IssueOrder for series {SeriesName}", series.Name); } + } + } + /// + /// Generates the final processed scanned series results after processing the sort order. + /// + /// A concurrent dictionary of tracked series and their parsed infos + /// List of all scan results, used to determine if any series has changed + /// The list where processed results will be added + private static void CreateFinalSeriesResults(ConcurrentDictionary> scannedSeries, + IList scanResults, ConcurrentBag processedScannedSeries) + { + foreach (var series in scannedSeries.Keys) + { + if (scannedSeries[series].Count <= 0) continue; - processedScannedSeries.Add(new ScannedSeriesResult() + processedScannedSeries.Add(new ScannedSeriesResult { - HasChanged = scanResult.HasChanged, + HasChanged = scanResults.Any(sr => sr.HasChanged), // Combine HasChanged flag across all scanResults ParsedSeries = series, ParsedInfos = scannedSeries[series] }); } } + /// + /// Merges localized series with the series field across all scan results. + /// Combines ParserInfos from all scanResults and processes them collectively + /// to ensure consistent series names. + /// + /// + /// Accel World v01.cbz has Series "Accel World" and Localized Series "World of Acceleration" + /// World of Acceleration v02.cbz has Series "World of Acceleration" + /// After running this code, we'd have: + /// World of Acceleration v02.cbz having Series "Accel World" and Localized Series of "World of Acceleration" + /// + /// A collection of scan results + /// A new list of scan results with merged series + private IList MergeLocalizedSeriesAcrossScanResults(IList scanResults) + { + // Flatten all ParserInfos across scanResults + var allInfos = scanResults.SelectMany(sr => sr.ParserInfos).ToList(); + + // Filter relevant infos (non-special and with localized series) + var relevantInfos = GetRelevantInfos(allInfos); + + if (relevantInfos.Count == 0) return scanResults; + + // Get distinct localized series and process each one + var distinctLocalizedSeries = relevantInfos + .Select(i => i.LocalizedSeries) + .Distinct() + .ToList(); + + foreach (var localizedSeries in distinctLocalizedSeries) + { + if (string.IsNullOrEmpty(localizedSeries)) continue; + + // Process the localized series for merging + ProcessLocalizedSeries(scanResults, allInfos, relevantInfos, localizedSeries); + } + + // Remove or clear any scan results that now have no ParserInfos after merging + return scanResults.Where(sr => sr.ParserInfos.Any()).ToList(); + } + + private static List GetRelevantInfos(List allInfos) + { + return allInfos + .Where(i => !i.IsSpecial && !string.IsNullOrEmpty(i.LocalizedSeries)) + .GroupBy(i => i.Format) + .SelectMany(g => g.ToList()) + .ToList(); + } + + private void ProcessLocalizedSeries(IList scanResults, List allInfos, List relevantInfos, string localizedSeries) + { + var seriesForLocalized = GetSeriesForLocalized(relevantInfos, localizedSeries); + if (seriesForLocalized.Count == 0) return; + + var nonLocalizedSeries = GetNonLocalizedSeries(seriesForLocalized, localizedSeries); + if (nonLocalizedSeries == null) return; + + // Remap and update relevant ParserInfos + RemapSeries(scanResults, allInfos, localizedSeries, nonLocalizedSeries); + + } + + private static List GetSeriesForLocalized(List relevantInfos, string localizedSeries) + { + return relevantInfos + .Where(i => i.LocalizedSeries == localizedSeries) + .DistinctBy(r => r.Series) + .Select(r => r.Series) + .ToList(); + } + + private string? GetNonLocalizedSeries(List seriesForLocalized, string localizedSeries) + { + switch (seriesForLocalized.Count) + { + case 1: + return seriesForLocalized[0]; + case <= 2: + return seriesForLocalized.FirstOrDefault(s => !s.Equals(Parser.Parser.Normalize(localizedSeries))); + default: + _logger.LogError( + "[ScannerService] Multiple series detected across scan results that contain localized series. " + + "This will cause them to group incorrectly. Please separate series into their own dedicated folder: {LocalizedSeries}", + string.Join(", ", seriesForLocalized) + ); + return null; + } + } + + private void RemapSeries(IList scanResults, List allInfos, string localizedSeries, string nonLocalizedSeries) + { + // Find all infos that need to be remapped from the localized series to the non-localized series + var seriesToBeRemapped = allInfos.Where(i => i.Series.Equals(localizedSeries)).ToList(); + + foreach (var infoNeedingMapping in seriesToBeRemapped) + { + infoNeedingMapping.Series = nonLocalizedSeries; + + // Find the scan result containing the localized info + var localizedScanResult = scanResults.FirstOrDefault(sr => sr.ParserInfos.Contains(infoNeedingMapping)); + if (localizedScanResult == null) continue; + + // Remove the localized series from this scan result + localizedScanResult.ParserInfos.Remove(infoNeedingMapping); + + // Find the scan result that should be merged with + var nonLocalizedScanResult = scanResults.FirstOrDefault(sr => sr.ParserInfos.Any(pi => pi.Series == nonLocalizedSeries)); + + if (nonLocalizedScanResult == null) continue; + + // Add the remapped info to the non-localized scan result + nonLocalizedScanResult.ParserInfos.Add(infoNeedingMapping); + + // Assign the higher folder path (i.e., the one closer to the root) + //nonLocalizedScanResult.Folder = DirectoryService.GetDeepestCommonPath(localizedScanResult.Folder, nonLocalizedScanResult.Folder); + } + } + /// /// For a given ScanResult, sets the ParserInfos on the result /// /// /// /// - private async Task ProcessScanResult(ScanResult result, IDictionary> seriesPaths, Library library) + private async Task ParseFiles(ScanResult result, IDictionary> seriesPaths, Library library) { - // TODO: This should return the result as we are modifying it as a side effect - - // If the folder hasn't changed, generate fake ParserInfos for the Series that were in that folder. var normalizedFolder = Parser.Parser.NormalizePath(result.Folder); + + // If folder hasn't changed, generate fake ParserInfos if (!result.HasChanged) { result.ParserInfos = seriesPaths[normalizedFolder] - .Select(fp => new ParserInfo() - { - Series = fp.SeriesName, - Format = fp.Format, - }) + .Select(fp => new ParserInfo { Series = fp.SeriesName, Format = fp.Format }) .ToList(); - _logger.LogDebug("[ScannerService] Skipped File Scan for {Folder} as it hasn't changed since last scan", normalizedFolder); + _logger.LogDebug("[ScannerService] Skipped File Scan for {Folder} as it hasn't changed", normalizedFolder); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.FileScanProgressEvent("Skipped " + normalizedFolder, library.Name, ProgressEventType.Updated)); + MessageFactory.FileScanProgressEvent($"Skipped {normalizedFolder}", library.Name, ProgressEventType.Updated)); return; } var files = result.Files; + var fileCount = files.Count; - // When processing files for a folder and we do enter, we need to parse the information and combine parser infos - // NOTE: We might want to move the merge step later in the process, like return and combine. - - if (files.Count == 0) + if (fileCount == 0) { - _logger.LogInformation("[ScannerService] {Folder} is empty, no longer in this location, or has no file types that match Library File Types", normalizedFolder); + _logger.LogInformation("[ScannerService] {Folder} is empty or has no matching file types", normalizedFolder); result.ParserInfos = ArraySegment.Empty; return; } _logger.LogDebug("[ScannerService] Found {Count} files for {Folder}", files.Count, normalizedFolder); await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, - MessageFactory.FileScanProgressEvent($"{files.Count} files in {normalizedFolder}", library.Name, ProgressEventType.Updated)); + MessageFactory.FileScanProgressEvent($"{fileCount} files in {normalizedFolder}", library.Name, ProgressEventType.Updated)); - // Multiple Series can exist within a folder. We should instead put these infos on the result and perform merging above - IList infos = files - .Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type)) - .Where(info => info != null) - .ToList()!; + // Parse files into ParserInfos + if (fileCount < 100) + { + // Process files sequentially + result.ParserInfos = files + .Select(file => _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type)) + .Where(info => info != null) + .ToList()!; + } + else + { + // Process files in parallel + var tasks = files.Select(file => Task.Run(() => + _readingItemService.ParseFile(file, normalizedFolder, result.LibraryRoot, library.Type))); - result.ParserInfos = infos; + var infos = await Task.WhenAll(tasks); + result.ParserInfos = infos.Where(info => info != null).ToList()!; + } } - public static void UpdateSortOrder(ConcurrentDictionary> scannedSeries, ParsedSeries series) + private static void UpdateSortOrder(ConcurrentDictionary> scannedSeries, ParsedSeries series) { // Set the Sort order per Volume var volumes = scannedSeries[series].GroupBy(info => info.Volumes); @@ -586,96 +830,4 @@ public static void UpdateSortOrder(ConcurrentDictionary seriesFolders, - string normalizedFolder) - { - return seriesFolders.All(f => HasSeriesFolderNotChangedSinceLastScan(f, normalizedFolder)); - } - - /// - /// Checks against all folder paths on file if the last scanned is >= the directory's last write down to the second - /// - /// - /// - /// - /// - private bool HasSeriesFolderNotChangedSinceLastScan(IDictionary> seriesPaths, string normalizedFolder, bool forceCheck = false) - { - if (forceCheck) return false; - - if (seriesPaths.TryGetValue(normalizedFolder, out var v)) - { - return HasAllSeriesFolderNotChangedSinceLastScan(v, normalizedFolder); - } - - return false; - } - - private bool HasSeriesFolderNotChangedSinceLastScan(SeriesModified seriesModified, string normalizedFolder) - { - return seriesModified.LastScanned.Truncate(TimeSpan.TicksPerSecond) >= - _directoryService.GetLastWriteTime(normalizedFolder) - .Truncate(TimeSpan.TicksPerSecond); - } - - - - /// - /// Checks if there are any ParserInfos that have a Series that matches the LocalizedSeries field in any other info. If so, - /// rewrites the infos with series name instead of the localized name, so they stack. - /// - /// - /// Accel World v01.cbz has Series "Accel World" and Localized Series "World of Acceleration" - /// World of Acceleration v02.cbz has Series "World of Acceleration" - /// After running this code, we'd have: - /// World of Acceleration v02.cbz having Series "Accel World" and Localized Series of "World of Acceleration" - /// - /// A collection of ParserInfos - private void MergeLocalizedSeriesWithSeries(IList infos) - { - var hasLocalizedSeries = infos.Any(i => !string.IsNullOrEmpty(i.LocalizedSeries)); - if (!hasLocalizedSeries) return; - - var localizedSeries = infos - .Where(i => !i.IsSpecial) - .Select(i => i.LocalizedSeries) - .Distinct() - .FirstOrDefault(i => !string.IsNullOrEmpty(i)); - if (string.IsNullOrEmpty(localizedSeries)) return; - - // NOTE: If we have multiple series in a folder with a localized title, then this will fail. It will group into one series. User needs to fix this themselves. - string? nonLocalizedSeries; - // Normalize this as many of the cases is a capitalization difference - var nonLocalizedSeriesFound = infos - .Where(i => !i.IsSpecial) - .Select(i => i.Series) - .DistinctBy(Parser.Parser.Normalize) - .ToList(); - - if (nonLocalizedSeriesFound.Count == 1) - { - nonLocalizedSeries = nonLocalizedSeriesFound[0]; - } - else - { - // There can be a case where there are multiple series in a folder that causes merging. - if (nonLocalizedSeriesFound.Count > 2) - { - _logger.LogError("[ScannerService] There are multiple series within one folder that contain localized series. This will cause them to group incorrectly. Please separate series into their own dedicated folder or ensure there is only 2 potential series (localized and series): {LocalizedSeries}", string.Join(", ", nonLocalizedSeriesFound)); - } - nonLocalizedSeries = nonLocalizedSeriesFound.Find(s => !s.Equals(localizedSeries)); - } - - if (nonLocalizedSeries == null) return; - - var normalizedNonLocalizedSeries = nonLocalizedSeries.ToNormalized(); - foreach (var infoNeedingMapping in infos.Where(i => - !i.Series.ToNormalized().Equals(normalizedNonLocalizedSeries))) - { - infoNeedingMapping.Series = nonLocalizedSeries; - infoNeedingMapping.LocalizedSeries = localizedSeries; - } - } } diff --git a/API/Services/Tasks/Scanner/Parser/BasicParser.cs b/API/Services/Tasks/Scanner/Parser/BasicParser.cs index 98264faf81..7687b3040d 100644 --- a/API/Services/Tasks/Scanner/Parser/BasicParser.cs +++ b/API/Services/Tasks/Scanner/Parser/BasicParser.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; using API.Data.Metadata; using API.Entities.Enums; @@ -79,7 +80,25 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag // NOTE: This uses rootPath. LibraryRoot works better for manga, but it's not always that way. // It might be worth writing some logic if the file is a special, to take the folder above the Specials/ // if present - ParseFromFallbackFolders(filePath, rootPath, type, ref ret); + var tempRootPath = rootPath; + if (rootPath.EndsWith("Specials") || rootPath.EndsWith("Specials/")) + { + tempRootPath = rootPath.Replace("Specials", string.Empty).TrimEnd('/'); + } + + // Check if the folder the file exists in is Specials/ and if so, take the parent directory as series (cleaned) + var fileDirectory = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(fileDirectory) && + (fileDirectory.EndsWith("Specials", StringComparison.OrdinalIgnoreCase) || + fileDirectory.EndsWith("Specials/", StringComparison.OrdinalIgnoreCase))) + { + ret.Series = Parser.CleanTitle(Directory.GetParent(fileDirectory)?.Name ?? string.Empty); + } + else + { + ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret); + } + } if (string.IsNullOrEmpty(ret.Series)) diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 56ed6fe163..ebeaaa6f1b 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -714,8 +714,9 @@ public static string ParseEdition(string filePath) /// /// /// - public static bool HasSpecialMarker(string filePath) + public static bool HasSpecialMarker(string? filePath) { + if (string.IsNullOrEmpty(filePath)) return false; return SpecialMarkerRegex.IsMatch(filePath); } @@ -728,30 +729,19 @@ public static int ParseSpecialIndex(string filePath) public static bool IsSpecial(string? filePath, LibraryType type) { - return type switch - { - LibraryType.Manga => IsMangaSpecial(filePath), - LibraryType.Comic => IsComicSpecial(filePath), - LibraryType.Book => IsMangaSpecial(filePath), - LibraryType.Image => IsMangaSpecial(filePath), - LibraryType.LightNovel => IsMangaSpecial(filePath), - LibraryType.ComicVine => IsComicSpecial(filePath), - _ => false - }; + return HasSpecialMarker(filePath); } private static bool IsMangaSpecial(string? filePath) { if (string.IsNullOrEmpty(filePath)) return false; - filePath = ReplaceUnderscores(filePath); - return MangaSpecialRegex.IsMatch(filePath); + return HasSpecialMarker(filePath); } private static bool IsComicSpecial(string? filePath) { if (string.IsNullOrEmpty(filePath)) return false; - filePath = ReplaceUnderscores(filePath); - return ComicSpecialRegex.IsMatch(filePath); + return HasSpecialMarker(filePath); } diff --git a/API/Services/Tasks/Scanner/Parser/PdfParser.cs b/API/Services/Tasks/Scanner/Parser/PdfParser.cs index d589a9914a..696a618670 100644 --- a/API/Services/Tasks/Scanner/Parser/PdfParser.cs +++ b/API/Services/Tasks/Scanner/Parser/PdfParser.cs @@ -59,7 +59,13 @@ public override ParserInfo Parse(string filePath, string rootPath, string librar ret.Chapters = Parser.DefaultChapter; ret.Volumes = Parser.SpecialVolume; - ParseFromFallbackFolders(filePath, rootPath, type, ref ret); + var tempRootPath = rootPath; + if (rootPath.EndsWith("Specials") || rootPath.EndsWith("Specials/")) + { + tempRootPath = rootPath.Replace("Specials", string.Empty).TrimEnd('/'); + } + + ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret); } if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && type == LibraryType.Book) diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 0f1340d4e4..ccf5ecdd93 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; +using System.IO; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -9,6 +10,7 @@ using API.Data.Repositories; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Extensions; using API.Helpers; using API.Helpers.Builders; @@ -16,7 +18,6 @@ using API.Services.Tasks.Metadata; using API.Services.Tasks.Scanner.Parser; using API.SignalR; -using Hangfire; using Kavita.Common; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -26,13 +27,6 @@ namespace API.Services.Tasks.Scanner; public interface IProcessSeries { - /// - /// Do not allow this Prime to be invoked by multiple threads. It will break the DB. - /// - /// - Task Prime(); - - void Reset(); Task ProcessSeriesAsync(IList parsedInfos, Library library, int totalToProcess, bool forceUpdate = false); } @@ -50,17 +44,15 @@ public class ProcessSeries : IProcessSeries private readonly IFileService _fileService; private readonly IMetadataService _metadataService; private readonly IWordCountAnalyzerService _wordCountAnalyzerService; - private readonly ICollectionTagService _collectionTagService; private readonly IReadingListService _readingListService; private readonly IExternalMetadataService _externalMetadataService; - private readonly ITagManagerService _tagManagerService; public ProcessSeries(IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub, IDirectoryService directoryService, ICacheHelper cacheHelper, IReadingItemService readingItemService, IFileService fileService, IMetadataService metadataService, IWordCountAnalyzerService wordCountAnalyzerService, - ICollectionTagService collectionTagService, IReadingListService readingListService, - IExternalMetadataService externalMetadataService, ITagManagerService tagManagerService) + IReadingListService readingListService, + IExternalMetadataService externalMetadataService) { _unitOfWork = unitOfWork; _logger = logger; @@ -71,34 +63,10 @@ public ProcessSeries(IUnitOfWork unitOfWork, ILogger logger, IEve _fileService = fileService; _metadataService = metadataService; _wordCountAnalyzerService = wordCountAnalyzerService; - _collectionTagService = collectionTagService; _readingListService = readingListService; _externalMetadataService = externalMetadataService; - _tagManagerService = tagManagerService; } - /// - /// Invoke this before processing any series, just once to prime all the needed data during a scan - /// - public async Task Prime() - { - try - { - await _tagManagerService.Prime(); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "Unable to prime tag manager. Scan cannot proceed. Report to Kavita dev"); - } - } - - /// - /// Frees up memory - /// - public void Reset() - { - _tagManagerService.Reset(); - } public async Task ProcessSeriesAsync(IList parsedInfos, Library library, int totalToProcess, bool forceUpdate = false) { @@ -192,29 +160,6 @@ await _unitOfWork.SeriesRepository.GetFullSeriesByAnyName(firstInfo.Series, firs } catch (DbUpdateConcurrencyException ex) { - foreach (var entry in ex.Entries) - { - if (entry.Entity is Series) - { - var proposedValues = entry.CurrentValues; - var databaseValues = await entry.GetDatabaseValuesAsync(); - - foreach (var property in proposedValues.Properties) - { - var proposedValue = proposedValues[property]; - var databaseValue = databaseValues[property]; - - // TODO: decide which value should be written to database - _logger.LogDebug("Property conflict, proposed: {Proposed} vs db: {Database}", proposedValue, databaseValue); - // proposedValues[property] = ; - } - - // Refresh original values to bypass next concurrency check - entry.OriginalValues.SetValues(databaseValues); - } - } - - _logger.LogCritical(ex, "[ScannerService] There was an issue writing to the database for series {SeriesName}", series.Name); @@ -245,8 +190,6 @@ await _eventHub.SendMessageAsync(MessageFactory.Error, // See if any recommendations can link up to the series and pre-fetch external metadata for the series _logger.LogInformation("Linking up External Recommendations new series (if applicable)"); - // BackgroundJob.Enqueue(() => - // _externalMetadataService.GetNewSeriesData(series.Id, series.Library.Type)); await _externalMetadataService.GetNewSeriesData(series.Id, series.Library.Type); await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded, @@ -268,11 +211,9 @@ await _eventHub.SendMessageAsync(MessageFactory.SeriesAdded, var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); await _metadataService.GenerateCoversForSeries(series, settings.EncodeMediaAs, settings.CoverImageSize); - // BackgroundJob.Enqueue(() => _wordCountAnalyzerService.ScanSeries(series.LibraryId, series.Id, forceUpdate)); await _wordCountAnalyzerService.ScanSeries(series.LibraryId, series.Id, forceUpdate); } - private async Task ReportDuplicateSeriesLookup(Library library, ParserInfo firstInfo, Exception ex) { var seriesCollisions = await _unitOfWork.SeriesRepository.GetAllSeriesByAnyName(firstInfo.LocalizedSeries, string.Empty, library.Id, firstInfo.Format); @@ -291,7 +232,7 @@ private async Task ReportDuplicateSeriesLookup(Library library, ParserInfo first var htmlTable = $"{string.Join(string.Empty, tableRows)}
Series 1Series 2
"; - _logger.LogError(ex, "Scanner found a Series {SeriesName} which matched another Series {LocalizedName} in a different folder parallel to Library {LibraryName} root folder. This is not allowed. Please correct", + _logger.LogError(ex, "[ScannerService] Scanner found a Series {SeriesName} which matched another Series {LocalizedName} in a different folder parallel to Library {LibraryName} root folder. This is not allowed. Please correct, scan will abort", firstInfo.Series, firstInfo.LocalizedSeries, library.Name); await _eventHub.SendMessageAsync(MessageFactory.Error, @@ -341,10 +282,11 @@ private async Task UpdateSeriesMetadata(Series series, Library library) var firstChapter = SeriesService.GetFirstChapterForMetadata(series); var firstFile = firstChapter?.Files.FirstOrDefault(); - if (firstFile == null) return; - if (Parser.Parser.IsPdf(firstFile.FilePath)) return; + if (firstFile == null || Parser.Parser.IsPdf(firstFile.FilePath)) return; - var chapters = series.Volumes.SelectMany(volume => volume.Chapters).ToList(); + var chapters = series.Volumes + .SelectMany(volume => volume.Chapters) + .ToList(); // Update Metadata based on Chapter metadata if (!series.Metadata.ReleaseYearLocked) @@ -367,216 +309,214 @@ private async Task UpdateSeriesMetadata(Series series, Library library) series.Metadata.Language = firstChapter.Language; } + if (!string.IsNullOrEmpty(firstChapter?.SeriesGroup) && library.ManageCollections) { - // Get the default admin to associate these tags to - var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections); - if (defaultAdmin == null) return; + await UpdateCollectionTags(series, firstChapter); + } - _logger.LogDebug("Collection tag(s) found for {SeriesName}, updating collections", series.Name); - foreach (var collection in firstChapter.SeriesGroup.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { - var t = await _tagManagerService.GetCollectionTag(collection, defaultAdmin); - if (t.Item1 == null) continue; - var tag = t.Item1; + #region PeopleAndTagsAndGenres + if (!series.Metadata.WriterLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList(); + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer); + } - // Check if the Series is already on the tag - if (tag.Items.Any(s => s.MatchesSeriesByName(series.NormalizedName, series.NormalizedLocalizedName))) - { - continue; - } + if (!series.Metadata.ColoristLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Colorist)).ToList(); + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Colorist); + } - tag.Items.Add(series); - await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(tag); - } + if (!series.Metadata.PublisherLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Publisher)).ToList(); + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Publisher); + } + + if (!series.Metadata.CoverArtistLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.CoverArtist)).ToList(); + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.CoverArtist); + } + + if (!series.Metadata.CharacterLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Character)).ToList(); + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Character); + } + + if (!series.Metadata.EditorLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Editor)).ToList(); + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Editor); + } + + if (!series.Metadata.InkerLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Inker)).ToList(); + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Inker); + } + + if (!series.Metadata.ImprintLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Imprint)).ToList(); + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Imprint); + } + + if (!series.Metadata.TeamLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Team)).ToList(); + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Team); + } + + if (!series.Metadata.LocationLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Location)).ToList(); + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Location); + } + + if (!series.Metadata.LettererLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Letterer)).ToList(); + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Letterer); + } + + if (!series.Metadata.PencillerLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Penciller)).ToList(); + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Penciller); + } + + if (!series.Metadata.TranslatorLocked) + { + var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Translator)).ToList(); + await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Translator); + } + + + if (!series.Metadata.TagsLocked) + { + var tags = chapters.SelectMany(c => c.Tags).ToList(); + UpdateSeriesMetadataTags(series.Metadata.Tags, tags); } if (!series.Metadata.GenresLocked) { var genres = chapters.SelectMany(c => c.Genres).ToList(); - GenreHelper.KeepOnlySameGenreBetweenLists(series.Metadata.Genres.ToList(), genres, genre => - { - series.Metadata.Genres.Remove(genre); - }); + UpdateSeriesMetadataGenres(series.Metadata.Genres, genres); } + #endregion + } + + private async Task UpdateCollectionTags(Series series, Chapter firstChapter) + { + // Get the default admin to associate these tags to + var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser(AppUserIncludes.Collections); + if (defaultAdmin == null) return; - #region People + _logger.LogDebug("Collection tag(s) found for {SeriesName}, updating collections", series.Name); - // Handle People - foreach (var chapter in chapters) + foreach (var collection in firstChapter.SeriesGroup.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) { - if (!series.Metadata.WriterLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Writer)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } + // Try to find an existing collection tag by its normalized name + var normalizedCollectionName = collection.ToNormalized(); + var collectionTag = defaultAdmin.Collections.FirstOrDefault(c => c.NormalizedTitle == normalizedCollectionName); - if (!series.Metadata.CoverArtistLocked) + // If the collection tag does not exist, create a new one + if (collectionTag == null) { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.CoverArtist)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } + _logger.LogDebug("Creating new collection tag for {Tag}", collection); - if (!series.Metadata.PublisherLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Publisher)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } + collectionTag = new AppUserCollectionBuilder(collection).Build(); + defaultAdmin.Collections.Add(collectionTag); - if (!series.Metadata.CharacterLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Character)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } + _unitOfWork.UserRepository.Update(defaultAdmin); - if (!series.Metadata.ColoristLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Colorist)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } + await _unitOfWork.CommitAsync(); } - if (!series.Metadata.EditorLocked) + // Check if the Series is already associated with this collection + if (collectionTag.Items.Any(s => s.MatchesSeriesByName(series.NormalizedName, series.NormalizedLocalizedName))) { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Editor)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } + continue; } - if (!series.Metadata.InkerLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Inker)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } + // Add the series to the collection tag + collectionTag.Items.Add(series); - if (!series.Metadata.ImprintLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Imprint)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } + // Update the collection age rating + await _unitOfWork.CollectionTagRepository.UpdateCollectionAgeRating(collectionTag); + } + } - if (!series.Metadata.TeamLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Team)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } - if (!series.Metadata.LocationLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Location)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } + private static void UpdateSeriesMetadataTags(ICollection metadataTags, IEnumerable chapterTags) + { + // Normalize and group by tag + var tagsToAdd = chapterTags + .Select(tag => tag) + .ToList(); - if (!series.Metadata.LettererLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Letterer)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } + // Remove any tags that are not part of the new list + var tagsToRemove = metadataTags + .Where(mt => tagsToAdd.TrueForAll(ct => ct.NormalizedTitle != mt.NormalizedTitle)) + .ToList(); - if (!series.Metadata.PencillerLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Penciller)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } + foreach (var tagToRemove in tagsToRemove) + { + metadataTags.Remove(tagToRemove); + } - if (!series.Metadata.TranslatorLocked) - { - foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Translator)) - { - PersonHelper.AddPersonIfNotExists(series.Metadata.People, person); - } - } + // Add new tags if they do not already exist + foreach (var tag in tagsToAdd) + { + var existingTag = metadataTags + .FirstOrDefault(mt => mt.NormalizedTitle == tag.NormalizedTitle); - if (!series.Metadata.TagsLocked) + if (existingTag == null) { - foreach (var tag in chapter.Tags) - { - TagHelper.AddTagIfNotExists(series.Metadata.Tags, tag); - } + metadataTags.Add(tag); } + } + } - if (!series.Metadata.GenresLocked) + private static void UpdateSeriesMetadataGenres(ICollection metadataGenres, IEnumerable chapterGenres) + { + // Normalize and group by genre + var genresToAdd = chapterGenres.ToList(); + + // Remove any genres that are not part of the new list + var genresToRemove = metadataGenres + .Where(mg => genresToAdd.TrueForAll(cg => cg.NormalizedTitle != mg.NormalizedTitle)) + .ToList(); + + foreach (var genreToRemove in genresToRemove) + { + metadataGenres.Remove(genreToRemove); + } + + // Add new genres if they do not already exist + foreach (var genre in genresToAdd) + { + var existingGenre = metadataGenres + .FirstOrDefault(mg => mg.NormalizedTitle == genre.NormalizedTitle); + + if (existingGenre == null) { - foreach (var genre in chapter.Genres) - { - GenreHelper.AddGenreIfNotExists(series.Metadata.Genres, genre); - } + metadataGenres.Add(genre); } } - // NOTE: The issue here is that people is just from chapter, but series metadata might already have some people on it - // I might be able to filter out people that are in locked fields? - var people = chapters.SelectMany(c => c.People).ToList(); - PersonHelper.KeepOnlySamePeopleBetweenLists(series.Metadata.People.ToList(), - people, person => - { - switch (person.Role) - { - case PersonRole.Writer: - if (!series.Metadata.WriterLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Penciller: - if (!series.Metadata.PencillerLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Inker: - if (!series.Metadata.InkerLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Imprint: - if (!series.Metadata.ImprintLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Colorist: - if (!series.Metadata.ColoristLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Letterer: - if (!series.Metadata.LettererLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.CoverArtist: - if (!series.Metadata.CoverArtistLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Editor: - if (!series.Metadata.EditorLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Publisher: - if (!series.Metadata.PublisherLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Character: - if (!series.Metadata.CharacterLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Translator: - if (!series.Metadata.TranslatorLocked) series.Metadata.People.Remove(person); - break; - case PersonRole.Other: - default: - series.Metadata.People.Remove(person); - break; - } - }); + } + - #endregion + private async Task UpdateSeriesMetadataPeople(SeriesMetadata metadata, ICollection metadataPeople, + IEnumerable chapterPeople, PersonRole role) + { + await PersonHelper.UpdateSeriesMetadataPeopleAsync(metadata, metadataPeople, chapterPeople, role, _unitOfWork); } private void DeterminePublicationStatus(Series series, List chapters) @@ -671,54 +611,44 @@ private async Task UpdateVolumes(Series series, IList parsedInfos, b _logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); - UpdateChapters(series, volume, infos, forceUpdate); + await UpdateChapters(series, volume, infos, forceUpdate); volume.Pages = volume.Chapters.Sum(c => c.Pages); - - // Update all the metadata on the Chapters - foreach (var chapter in volume.Chapters) - { - var firstFile = chapter.Files.MinBy(x => x.Chapter); - if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) continue; - try - { - var firstChapterInfo = infos.SingleOrDefault(i => i.FullFilePath.Equals(firstFile.FilePath)); - await UpdateChapterFromComicInfo(chapter, firstChapterInfo?.ComicInfo, forceUpdate); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was some issue when updating chapter's metadata"); - } - } } // Remove existing volumes that aren't in parsedInfos + RemoveVolumes(series, parsedInfos); + } + + private void RemoveVolumes(Series series, IList parsedInfos) + { + var nonDeletedVolumes = series.Volumes .Where(v => parsedInfos.Select(p => p.Volumes).Contains(v.LookupName)) .ToList(); - if (series.Volumes.Count != nonDeletedVolumes.Count) + if (series.Volumes.Count == nonDeletedVolumes.Count) return; + + + _logger.LogDebug("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name", + (series.Volumes.Count - nonDeletedVolumes.Count), series.Name); + var deletedVolumes = series.Volumes.Except(nonDeletedVolumes); + foreach (var volume in deletedVolumes) { - _logger.LogDebug("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name", - (series.Volumes.Count - nonDeletedVolumes.Count), series.Name); - var deletedVolumes = series.Volumes.Except(nonDeletedVolumes); - foreach (var volume in deletedVolumes) + var file = volume.Chapters.FirstOrDefault()?.Files?.FirstOrDefault()?.FilePath ?? string.Empty; + if (!string.IsNullOrEmpty(file) && _directoryService.FileSystem.File.Exists(file)) { - var file = volume.Chapters.FirstOrDefault()?.Files?.FirstOrDefault()?.FilePath ?? string.Empty; - if (!string.IsNullOrEmpty(file) && _directoryService.FileSystem.File.Exists(file)) - { - // This can happen when file is renamed and volume is removed - _logger.LogInformation( - "[ScannerService] Volume cleanup code was trying to remove a volume with a file still existing on disk (usually volume marker removed) File: {File}", - file); - } - - _logger.LogDebug("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file); + // This can happen when file is renamed and volume is removed + _logger.LogInformation( + "[ScannerService] Volume cleanup code was trying to remove a volume with a file still existing on disk (usually volume marker removed) File: {File}", + file); } - series.Volumes = nonDeletedVolumes; + _logger.LogDebug("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file); } + + series.Volumes = nonDeletedVolumes; } - private void UpdateChapters(Series series, Volume volume, IList parsedInfos, bool forceUpdate = false) + private async Task UpdateChapters(Series series, Volume volume, IList parsedInfos, bool forceUpdate = false) { // Add new chapters foreach (var info in parsedInfos) @@ -749,10 +679,7 @@ private void UpdateChapters(Series series, Volume volume, IList pars chapter.UpdateFrom(info); } - if (chapter == null) - { - continue; - } + // Add files AddOrUpdateFileForChapter(chapter, info, forceUpdate); @@ -773,27 +700,86 @@ private void UpdateChapters(Series series, Volume volume, IList pars chapter.Title = chapter.GetNumberTitle(); } + try + { + await UpdateChapterFromComicInfo(chapter, info.ComicInfo, forceUpdate); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was some issue when updating chapter's metadata"); + } + } + RemoveChapters(volume, parsedInfos); + + // // Update all the metadata on the Chapters + // foreach (var chapter in volume.Chapters) + // { + // var firstFile = chapter.Files.MinBy(x => x.Chapter); + // if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) continue; + // try + // { + // var firstChapterInfo = infos.SingleOrDefault(i => i.FullFilePath.Equals(firstFile.FilePath)); + // await UpdateChapterFromComicInfo(chapter, firstChapterInfo?.ComicInfo, forceUpdate); + // } + // catch (Exception ex) + // { + // _logger.LogError(ex, "There was some issue when updating chapter's metadata"); + // } + // } + } + private void RemoveChapters(Volume volume, IList parsedInfos) + { // Remove chapters that aren't in parsedInfos or have no files linked - var existingChapters = volume.Chapters.ToList(); + var existingChapters = volume.Chapters; + + // Extract the directories (without filenames) from parserInfos + var parsedDirectories = parsedInfos + .Select(p => Path.GetDirectoryName(p.FullFilePath)) // Get directory path + .Distinct() + .ToList(); + foreach (var existingChapter in existingChapters) { - if (existingChapter.Files.Count == 0 || !parsedInfos.HasInfo(existingChapter)) + // Get the directories for the files in the current chapter + var chapterFileDirectories = existingChapter.Files + .Select(f => Path.GetDirectoryName(f.FilePath)) // Get directory path minus the filename + .Distinct() + .ToList(); + + // Check if any of the chapter's file directories match the parsedDirectories + var hasMatchingDirectory = chapterFileDirectories.Exists(dir => parsedDirectories.Contains(dir)); + + if (hasMatchingDirectory) { + // Ensure we remove any files that no longer exist AND order the remaining files + existingChapter.Files = existingChapter.Files + .Where(f => parsedInfos.Any(p => Parser.Parser.NormalizePath(p.FullFilePath) == Parser.Parser.NormalizePath(f.FilePath))) + .OrderByNatural(f => f.FilePath) + .ToList(); + + // Update the chapter's page count after filtering the files + existingChapter.Pages = existingChapter.Files.Sum(f => f.Pages); + + // If no files remain after filtering, remove the chapter + if (existingChapter.Files.Count != 0) continue; + _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", existingChapter.Range, volume.Name, parsedInfos[0].Series); volume.Chapters.Remove(existingChapter); } else { - // Ensure we remove any files that no longer exist AND order - existingChapter.Files = existingChapter.Files - .Where(f => parsedInfos.Any(p => Parser.Parser.NormalizePath(p.FullFilePath) == Parser.Parser.NormalizePath(f.FilePath))) - .OrderByNatural(f => f.FilePath) - .ToList(); - existingChapter.Pages = existingChapter.Files.Sum(f => f.Pages); + // If there are no matching directories in the current scan, check if the files still exist on disk + var filesExist = existingChapter.Files.Any(f => File.Exists(f.FilePath)); + + // If no files exist, remove the chapter + if (filesExist) continue; + _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName} as no files exist", + existingChapter.Range, volume.Name, parsedInfos[0].Series); + volume.Chapters.Remove(existingChapter); } } } @@ -832,8 +818,6 @@ private async Task UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicI if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return; - _logger.LogTrace("[ScannerService] Read ComicInfo for {File}", firstFile.FilePath); - if (!chapter.AgeRatingLocked) { chapter.AgeRating = ComicInfo.ConvertAgeRatingToEnum(comicInfo.AgeRating); @@ -890,7 +874,7 @@ private async Task UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicI .Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ); - // For each weblink, try to parse out some MetadataIds and store in the Chapter directly for matching (CBL) + // TODO: For each weblink, try to parse out some MetadataIds and store in the Chapter directly for matching (CBL) } if (!chapter.ISBNLocked && !string.IsNullOrEmpty(comicInfo.Isbn)) @@ -914,132 +898,135 @@ private async Task UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicI chapter.ReleaseDate = new DateTime(comicInfo.Year, month, day); } + + if (!chapter.ColoristLocked) { var people = TagHelper.GetTagValues(comicInfo.Colorist); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Colorist); - await UpdatePeople(chapter, people, PersonRole.Colorist); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Colorist); } if (!chapter.CharacterLocked) { var people = TagHelper.GetTagValues(comicInfo.Characters); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Character); - await UpdatePeople(chapter, people, PersonRole.Character); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Character); } if (!chapter.TranslatorLocked) { var people = TagHelper.GetTagValues(comicInfo.Translator); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Translator); - await UpdatePeople(chapter, people, PersonRole.Translator); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Translator); } if (!chapter.WriterLocked) { var people = TagHelper.GetTagValues(comicInfo.Writer); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Writer); - await UpdatePeople(chapter, people, PersonRole.Writer); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Writer); } if (!chapter.EditorLocked) { var people = TagHelper.GetTagValues(comicInfo.Editor); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Editor); - await UpdatePeople(chapter, people, PersonRole.Editor); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Editor); } if (!chapter.InkerLocked) { var people = TagHelper.GetTagValues(comicInfo.Inker); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Inker); - await UpdatePeople(chapter, people, PersonRole.Inker); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Inker); } if (!chapter.LettererLocked) { var people = TagHelper.GetTagValues(comicInfo.Letterer); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Letterer); - await UpdatePeople(chapter, people, PersonRole.Letterer); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Letterer); } if (!chapter.PencillerLocked) { var people = TagHelper.GetTagValues(comicInfo.Penciller); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Penciller); - await UpdatePeople(chapter, people, PersonRole.Penciller); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Penciller); } if (!chapter.CoverArtistLocked) { var people = TagHelper.GetTagValues(comicInfo.CoverArtist); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.CoverArtist); - await UpdatePeople(chapter, people, PersonRole.CoverArtist); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.CoverArtist); } if (!chapter.PublisherLocked) { var people = TagHelper.GetTagValues(comicInfo.Publisher); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Publisher); - await UpdatePeople(chapter, people, PersonRole.Publisher); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Publisher); } if (!chapter.ImprintLocked) { var people = TagHelper.GetTagValues(comicInfo.Imprint); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Imprint); - await UpdatePeople(chapter, people, PersonRole.Imprint); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Imprint); } if (!chapter.TeamLocked) { var people = TagHelper.GetTagValues(comicInfo.Teams); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Team); - await UpdatePeople(chapter, people, PersonRole.Team); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Team); } if (!chapter.LocationLocked) { var people = TagHelper.GetTagValues(comicInfo.Locations); - PersonHelper.RemovePeople(chapter.People, people, PersonRole.Location); - await UpdatePeople(chapter, people, PersonRole.Location); + await UpdateChapterPeopleAsync(chapter, people, PersonRole.Location); } if (!chapter.GenresLocked) { var genres = TagHelper.GetTagValues(comicInfo.Genre); - GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres, - genres.Select(g => new GenreBuilder(g).Build()).ToList()); - foreach (var genre in genres) - { - var g = await _tagManagerService.GetGenre(genre); - if (g == null) continue; - chapter.Genres.Add(g); - } + await UpdateChapterGenres(chapter, genres); } if (!chapter.TagsLocked) { var tags = TagHelper.GetTagValues(comicInfo.Tags); - TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => new TagBuilder(t).Build()).ToList()); - foreach (var tag in tags) - { - var t = await _tagManagerService.GetTag(tag); - if (t == null) continue; - chapter.Tags.Add(t); - } + await UpdateChapterTags(chapter, tags); } } - private async Task UpdatePeople(Chapter chapter, IList people, PersonRole role) + private async Task UpdateChapterGenres(Chapter chapter, IEnumerable genreNames) { - foreach (var person in people) + try + { + await GenreHelper.UpdateChapterGenres(chapter, genreNames, _unitOfWork); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an error updating the chapter genres"); + } + } + + + private async Task UpdateChapterTags(Chapter chapter, IEnumerable tagNames) + { + try + { + await TagHelper.UpdateChapterTags(chapter, tagNames, _unitOfWork); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an error updating the chapter tags"); + } + } + + private async Task UpdateChapterPeopleAsync(Chapter chapter, IList people, PersonRole role) + { + try + { + await PersonHelper.UpdateChapterPeopleAsync(chapter, people, role, _unitOfWork); + } + catch (Exception ex) { - var p = await _tagManagerService.GetPerson(person, role); - if (p == null) continue; - chapter.People.Add(p); + _logger.LogError(ex, "[ScannerService] There was an issue adding/updating a person"); } } } diff --git a/API/Services/Tasks/Scanner/TagManagerService.cs b/API/Services/Tasks/Scanner/TagManagerService.cs deleted file mode 100644 index e885263a5c..0000000000 --- a/API/Services/Tasks/Scanner/TagManagerService.cs +++ /dev/null @@ -1,268 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using API.Data; -using API.Data.Repositories; -using API.Entities; -using API.Entities.Enums; -using API.Extensions; -using API.Helpers.Builders; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace API.Services.Tasks.Scanner; -#nullable enable - -public interface ITagManagerService -{ - /// - /// Should be called once before any usage - /// - /// - Task Prime(); - /// - /// Should be called after all work is done, will free up memory - /// - /// - void Reset(); - - Task GetGenre(string genre); - Task GetTag(string tag); - Task GetPerson(string name, PersonRole role); - Task> GetCollectionTag(string? tag, AppUser userWithCollections); -} - -/// -/// This is responsible for handling existing and new tags during the scan. When a new tag doesn't exist, it will create it. -/// This is Thread Safe. -/// -public class TagManagerService : ITagManagerService -{ - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - private Dictionary _genres; - private Dictionary _tags; - private Dictionary _people; - private Dictionary _collectionTags; - - private readonly SemaphoreSlim _genreSemaphore = new SemaphoreSlim(1, 1); - private readonly SemaphoreSlim _tagSemaphore = new SemaphoreSlim(1, 1); - private readonly SemaphoreSlim _personSemaphore = new SemaphoreSlim(1, 1); - private readonly SemaphoreSlim _collectionTagSemaphore = new SemaphoreSlim(1, 1); - - public TagManagerService(IUnitOfWork unitOfWork, ILogger logger) - { - _unitOfWork = unitOfWork; - _logger = logger; - Reset(); - - } - - public void Reset() - { - _genres = []; - _tags = []; - _people = []; - _collectionTags = []; - } - - public async Task Prime() - { - _genres = (await _unitOfWork.GenreRepository.GetAllGenresAsync()).ToDictionary(t => t.NormalizedTitle); - _tags = (await _unitOfWork.TagRepository.GetAllTagsAsync()).ToDictionary(t => t.NormalizedTitle); - _people = (await _unitOfWork.PersonRepository.GetAllPeople()) - .GroupBy(GetPersonKey) - .Select(g => g.First()) - .ToDictionary(GetPersonKey); - var defaultAdmin = await _unitOfWork.UserRepository.GetDefaultAdminUser()!; - _collectionTags = (await _unitOfWork.CollectionTagRepository.GetCollectionsForUserAsync(defaultAdmin.Id, CollectionIncludes.Series)) - .ToDictionary(t => t.NormalizedTitle); - - } - - /// - /// Gets the Genre entity for the given string. If one doesn't exist, one will be created and committed. - /// - /// - /// - public async Task GetGenre(string genre) - { - if (string.IsNullOrEmpty(genre)) return null; - - await _genreSemaphore.WaitAsync(); - try - { - if (_genres.TryGetValue(genre.ToNormalized(), out var result)) - { - return result; - } - - // We need to create a new Genre - result = new GenreBuilder(genre).Build(); - _unitOfWork.GenreRepository.Attach(result); - await _unitOfWork.CommitAsync(); - _genres.Add(result.NormalizedTitle, result); - return result; - } - finally - { - _genreSemaphore.Release(); - } - } - - /// - /// Gets the Tag entity for the given string. If one doesn't exist, one will be created and committed. - /// - /// - /// - public async Task GetTag(string tag) - { - if (string.IsNullOrEmpty(tag)) return null; - - await _tagSemaphore.WaitAsync(); - try - { - if (_tags.TryGetValue(tag.ToNormalized(), out var result)) - { - return result; - } - - // We need to create a new Genre - result = new TagBuilder(tag).Build(); - _unitOfWork.TagRepository.Attach(result); - await _unitOfWork.CommitAsync(); - _tags.Add(result.NormalizedTitle, result); - return result; - } - catch (Exception ex) - { - _logger.LogCritical(ex, "There was an exception when creating a new Tag. Scan again to get this included: {Tag}", tag); - return null; - } - finally - { - _tagSemaphore.Release(); - } - } - - /// - /// Gets the Person entity for the given string and role. If one doesn't exist, one will be created and committed. - /// - /// Person Name - /// - /// - public async Task GetPerson(string name, PersonRole role) - { - if (string.IsNullOrEmpty(name)) return null; - - await _personSemaphore.WaitAsync(); - try - { - var key = GetPersonKey(name.ToNormalized(), role); - if (_people.TryGetValue(key, out var result)) - { - return result; - } - - // We need to create a new Genre - result = new PersonBuilder(name, role).Build(); - _unitOfWork.PersonRepository.Attach(result); - await _unitOfWork.CommitAsync(); - _people.Add(key, result); - return result; - } - catch (DbUpdateConcurrencyException ex) - { - foreach (var entry in ex.Entries) - { - if (entry.Entity is Person) - { - var proposedValues = entry.CurrentValues; - var databaseValues = await entry.GetDatabaseValuesAsync(); - - foreach (var property in proposedValues.Properties) - { - var proposedValue = proposedValues[property]; - var databaseValue = databaseValues[property]; - - // TODO: decide which value should be written to database - _logger.LogDebug(ex, "There was an exception when creating a new Person: {PersonName} ({Role})", name, role); - _logger.LogDebug("Property conflict, proposed: {Proposed} vs db: {Database}", proposedValue, databaseValue); - // proposedValues[property] = ; - } - - // Refresh original values to bypass next concurrency check - entry.OriginalValues.SetValues(databaseValues); - //return (Person) entry.Entity; - return null; - } - // else - // { - // throw new NotSupportedException( - // "Don't know how to handle concurrency conflicts for " - // + entry.Metadata.Name); - // } - } - - return null; - } - catch (Exception ex) - { - _logger.LogCritical(ex, "There was an exception when creating a new Person. Scan again to get this included: {PersonName} ({Role})", name, role); - return null; - } - finally - { - _personSemaphore.Release(); - } - } - - private static string GetPersonKey(string normalizedName, PersonRole role) - { - return normalizedName + "_" + role; - } - - private static string GetPersonKey(Person p) - { - return GetPersonKey(p.NormalizedName, p.Role); - } - - /// - /// Gets the CollectionTag entity for the given string. If one doesn't exist, one will be created and committed. - /// - /// - /// - public async Task> GetCollectionTag(string? tag, AppUser userWithCollections) - { - if (string.IsNullOrEmpty(tag)) return Tuple.Create(null, false); - - await _collectionTagSemaphore.WaitAsync(); - AppUserCollection? result; - try - { - if (_collectionTags.TryGetValue(tag.ToNormalized(), out result)) - { - return Tuple.Create(result, false); - } - - // We need to create a new Genre - result = new AppUserCollectionBuilder(tag).Build(); - userWithCollections.Collections.Add(result); - _unitOfWork.UserRepository.Update(userWithCollections); - await _unitOfWork.CommitAsync(); - _collectionTags.Add(result.NormalizedTitle, result); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "There was an exception when creating a new Collection. Scan again to get this included: {Tag}", tag); - return Tuple.Create(null, false); - } - finally - { - _collectionTagSemaphore.Release(); - } - return Tuple.Create(result, true); - } -} diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 5fbc2602d2..d8d0d50277 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -12,6 +12,7 @@ using API.Entities.Enums; using API.Extensions; using API.Helpers; +using API.Helpers.Builders; using API.Services.Tasks.Metadata; using API.Services.Tasks.Scanner; using API.Services.Tasks.Scanner.Parser; @@ -156,14 +157,14 @@ await _unitOfWork.SeriesRepository.GetSeriesByFolderPath(originalPath, SeriesInc } } - // TODO: Figure out why we have the library type restriction here - if (series != null)// && series.Library.Type is not (LibraryType.Book or LibraryType.LightNovel) + if (series != null) { if (TaskScheduler.HasScanTaskRunningForSeries(series.Id)) { _logger.LogDebug("[ScannerService] Scan folder invoked for {Folder} but a task is already queued for this series. Dropping request", folder); return; } + _logger.LogInformation("[ScannerService] Scan folder invoked for {Folder}, Series matched to folder and ScanSeries enqueued for 1 minute", folder); BackgroundJob.Schedule(() => ScanSeries(series.Id, true), TimeSpan.FromMinutes(1)); return; @@ -226,12 +227,14 @@ public async Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = return; } + // TODO: We need to refactor this to handle the path changes better var folderPath = series.LowestFolderPath ?? series.FolderPath; if (string.IsNullOrEmpty(folderPath) || !_directoryService.Exists(folderPath)) { // We don't care if it's multiple due to new scan loop enforcing all in one root directory var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); - var seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(libraryPaths, files.Select(f => f.FilePath).ToList()); + var seriesDirs = _directoryService.FindHighestDirectoriesFromFiles(libraryPaths, + files.Select(f => f.FilePath).ToList()); if (seriesDirs.Keys.Count == 0) { _logger.LogCritical("Scan Series has files spread outside a main series folder. Defaulting to library folder (this is expensive)"); @@ -257,23 +260,15 @@ public async Task ScanSeries(int seriesId, bool bypassFolderOptimizationChecks = return; } - // If the series path doesn't exist anymore, it was either moved or renamed. We need to essentially delete it - var parsedSeries = new Dictionary>(); - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Started, series.Name, 1)); _logger.LogInformation("Beginning file scan on {SeriesName}", series.Name); - var (scanElapsedTime, processedSeries) = await ScanFiles(library, new []{ folderPath }, + var (scanElapsedTime, parsedSeries) = await ScanFiles(library, [folderPath], false, true); - // Transform seen series into the parsedSeries (I think we can actually just have processedSeries be used instead - TrackFoundSeriesAndFiles(parsedSeries, processedSeries); - _logger.LogInformation("ScanFiles for {Series} took {Time} milliseconds", series.Name, scanElapsedTime); - // We now technically have all scannedSeries, we could invoke each Series to be scanned - // Remove any parsedSeries keys that don't belong to our series. This can occur when users store 2 series in the same folder RemoveParsedInfosNotForSeries(parsedSeries, series); @@ -310,8 +305,10 @@ await _eventHub.SendMessageAsync(MessageFactory.Error, } // At this point, parsedSeries will have at least one key and we can perform the update. If it still doesn't, just return and don't do anything - if (parsedSeries.Count == 0) return; - + if (parsedSeries.Count == 0) + { + return; + } // Don't allow any processing on files that aren't part of this series @@ -320,11 +317,6 @@ await _eventHub.SendMessageAsync(MessageFactory.Error, key.NormalizedName.Equals(series.OriginalName?.ToNormalized())) .ToList(); - if (toProcess.Count > 0) - { - await _processSeries.Prime(); - } - var seriesLeftToProcess = toProcess.Count; foreach (var pSeries in toProcess) { @@ -333,8 +325,6 @@ await _eventHub.SendMessageAsync(MessageFactory.Error, seriesLeftToProcess--; } - _processSeries.Reset(); - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.LibraryScanProgressEvent(library.Name, ProgressEventType.Ended, series.Name, 0)); // Tell UI that this series is done @@ -347,13 +337,16 @@ await _eventHub.SendMessageAsync(MessageFactory.ScanSeries, BackgroundJob.Enqueue(() => _directoryService.ClearDirectory(_directoryService.CacheDirectory)); } - private void TrackFoundSeriesAndFiles(Dictionary> parsedSeries, IList seenSeries) + private static Dictionary> TrackFoundSeriesAndFiles(IList seenSeries) { - foreach (var series in seenSeries.Where(s => s.ParsedInfos.Count > 0)) + var parsedSeries = new Dictionary>(); + foreach (var series in seenSeries.Where(s => s.ParsedInfos.Count > 0 && s.HasChanged)) { var parsedFiles = series.ParsedInfos; parsedSeries.Add(series.ParsedSeries, parsedFiles); } + + return parsedSeries; } private async Task ShouldScanSeries(int seriesId, Library library, IList libraryPaths, Series series, bool bypassFolderChecks = false) @@ -493,7 +486,7 @@ public async Task ScanLibraries(bool forceUpdate = false) await ScanLibrary(lib.Id, forceUpdate, true); } - _processSeries.Reset(); + _logger.LogInformation("[ScannerService] Scan of All Libraries Finished"); } @@ -530,30 +523,20 @@ public async Task ScanLibrary(int libraryId, bool forceUpdate = false, bool isSi } - _logger.LogDebug("[ScannerService] Library {LibraryName} Step 1: Scan Files", library.Name); - var (scanElapsedTime, processedSeries) = await ScanFiles(library, libraryFolderPaths, + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 1: Scan & Parse Files", library.Name); + var (scanElapsedTime, parsedSeries) = await ScanFiles(library, libraryFolderPaths, shouldUseLibraryScan, forceUpdate); - _logger.LogDebug("[ScannerService] Library {LibraryName} Step 2: Track Found Series", library.Name); - var parsedSeries = new Dictionary>(); - TrackFoundSeriesAndFiles(parsedSeries, processedSeries); - // We need to remove any keys where there is no actual parser info - _logger.LogDebug("[ScannerService] Library {LibraryName} Step 3: Process Parsed Series", library.Name); + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 2: Process and Update Database", library.Name); var totalFiles = await ProcessParsedSeries(forceUpdate, parsedSeries, library, scanElapsedTime); UpdateLastScanned(library); - - _unitOfWork.LibraryRepository.Update(library); - _logger.LogDebug("[ScannerService] Library {LibraryName} Step 4: Save Library", library.Name); + + _logger.LogDebug("[ScannerService] Library {LibraryName} Step 3: Save Library", library.Name); if (await _unitOfWork.CommitAsync()) { - if (isSingleScan) - { - _processSeries.Reset(); - } - if (totalFiles == 0) { _logger.LogInformation( @@ -587,54 +570,115 @@ private async Task RemoveSeriesNotFound(Dictionary s.Name)); - _logger.LogDebug("[ScannerService] Removing Series that were not found during the scan - complete"); + _logger.LogDebug("[ScannerService] Found {Count} series to remove: {SeriesList}", + removedSeries.Count, string.Join(", ", removedSeries.Select(s => s.Name))); + // Commit the changes await _unitOfWork.CommitAsync(); - foreach (var s in removedSeries) + // Notify for each removed series + foreach (var series in removedSeries) { - await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved, - MessageFactory.SeriesRemovedEvent(s.Id, s.Name, s.LibraryId), false); + await _eventHub.SendMessageAsync( + MessageFactory.SeriesRemoved, + MessageFactory.SeriesRemovedEvent(series.Id, series.Name, series.LibraryId), + false + ); } + + _logger.LogDebug("[ScannerService] Series removal process completed"); } catch (Exception ex) { - _logger.LogCritical(ex, "[ScannerService] There was an issue deleting series for cleanup. Please check logs and rescan"); + _logger.LogCritical(ex, "[ScannerService] Error during series cleanup. Please check logs and rescan"); } } private async Task ProcessParsedSeries(bool forceUpdate, Dictionary> parsedSeries, Library library, long scanElapsedTime) { - var toProcess = parsedSeries.Keys - .Where(k => parsedSeries[k].Any() && !string.IsNullOrEmpty(parsedSeries[k][0].Filename)) - .ToList(); + // Iterate over the dictionary and remove only the ParserInfos that don't need processing + var toProcess = new Dictionary>(); + + foreach (var series in parsedSeries) + { + // Filter out ParserInfos where FullFilePath is empty (i.e., folder not modified) + var validInfos = series.Value.Where(info => !string.IsNullOrEmpty(info.Filename)).ToList(); + + if (validInfos.Count != 0) + { + toProcess[series.Key] = validInfos; + } + } if (toProcess.Count > 0) { - // This grabs all the shared entities, like tags, genre, people. To be solved later in this refactor on how to not have blocking access. - await _processSeries.Prime(); + // For all Genres in the ParserInfos, do a bulk check against the DB on what is not in the DB and create them + // This will ensure all Genres are pre-created and allow our Genre lookup (and Priming) to be much simpler. It will be slower, but more consistent. + var allGenres = toProcess + .SelectMany(s => s.Value + .SelectMany(p => p.ComicInfo?.Genre? + .Split(",", StringSplitOptions.RemoveEmptyEntries) // Split on comma and remove empty entries + .Select(g => g.Trim()) // Trim each genre + .Where(g => !string.IsNullOrWhiteSpace(g)) // Ensure no null/empty genres + ?? [])); // Handle null Genre or ComicInfo safely + + await CreateAllGenresAsync(allGenres.Distinct().ToList()); + + var allTags = toProcess + .SelectMany(s => s.Value + .SelectMany(p => p.ComicInfo?.Tags? + .Split(",", StringSplitOptions.RemoveEmptyEntries) // Split on comma and remove empty entries + .Select(g => g.Trim()) // Trim each genre + .Where(g => !string.IsNullOrWhiteSpace(g)) // Ensure no null/empty genres + ?? [])); // Handle null Tag or ComicInfo safely + + await CreateAllTagsAsync(allTags.Distinct().ToList()); + + // Do the above for People as well (until we overhaul the People code) + var allPeople = toProcess + .SelectMany(s => s.Value + .SelectMany(p => new List<(string Name, PersonRole Role)> + { + // Split comma-delimited strings into individual names + (p.ComicInfo?.Writer ?? string.Empty, PersonRole.Writer), + (p.ComicInfo?.Penciller ?? string.Empty, PersonRole.Penciller), + (p.ComicInfo?.Inker ?? string.Empty, PersonRole.Inker), + (p.ComicInfo?.Colorist ?? string.Empty, PersonRole.Colorist), + (p.ComicInfo?.Letterer ?? string.Empty, PersonRole.Letterer), + (p.ComicInfo?.CoverArtist ?? string.Empty, PersonRole.CoverArtist), + (p.ComicInfo?.Editor ?? string.Empty, PersonRole.Editor), + (p.ComicInfo?.Publisher ?? string.Empty, PersonRole.Publisher), + (p.ComicInfo?.Imprint ?? string.Empty, PersonRole.Imprint), + (p.ComicInfo?.Characters ?? string.Empty, PersonRole.Character), + (p.ComicInfo?.Teams ?? string.Empty, PersonRole.Team), + (p.ComicInfo?.Locations ?? string.Empty, PersonRole.Location) + } + // Split by comma for each field that contains comma-delimited values + .SelectMany(pair => pair.Name + .Split(",", StringSplitOptions.RemoveEmptyEntries) // Split by comma and remove empty entries + .Select(name => pair with {Name = name.Trim()}) // Trim each name and keep the role + ) + .Where(pair => !string.IsNullOrWhiteSpace(pair.Name)) // Filter out empty/null names + ) + ) + .Distinct() // Ensure distinct people (name and role) + .ToList(); } var totalFiles = 0; - //var tasks = new List(); var seriesLeftToProcess = toProcess.Count; + _logger.LogInformation("[ScannerService] Found {SeriesCount} Series that need processing", toProcess.Count); + foreach (var pSeries in toProcess) { - totalFiles += parsedSeries[pSeries].Count; - //tasks.Add(_processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, forceUpdate)); - // We can't do Task.WhenAll because of concurrency issues. - await _processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, seriesLeftToProcess, forceUpdate); + totalFiles += pSeries.Value.Count; + await _processSeries.ProcessSeriesAsync(pSeries.Value, library, seriesLeftToProcess, forceUpdate); seriesLeftToProcess--; } - //await Task.WhenAll(tasks); - await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent(string.Empty, library.Name, ProgressEventType.Ended)); @@ -644,6 +688,7 @@ await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, return totalFiles; } + private static void UpdateLastScanned(Library library) { var time = DateTime.Now; @@ -655,7 +700,7 @@ private static void UpdateLastScanned(Library library) library.UpdateLastScanned(time); } - private async Task>> ScanFiles(Library library, IEnumerable dirs, + private async Task>>> ScanFiles(Library library, IList dirs, bool isLibraryScan, bool forceChecks = false) { var scanner = new ParseScannedFiles(_logger, _directoryService, _readingItemService, _eventHub); @@ -666,12 +711,74 @@ private async Task>> ScanFiles(Library li var scanElapsedTime = scanWatch.ElapsedMilliseconds; - return Tuple.Create(scanElapsedTime, processedSeries); + var parsedSeries = TrackFoundSeriesAndFiles(processedSeries); + + return Tuple.Create(scanElapsedTime, parsedSeries); } - public static IEnumerable FindSeriesNotOnDisk(IEnumerable existingSeries, Dictionary> parsedSeries) + /// + /// Given a list of all Genres, generates new Genre entries for any that do not exist. + /// Does not delete anything, that will be handled by nightly task + /// + /// + private async Task CreateAllGenresAsync(ICollection genres) { - return existingSeries.Where(es => !ParserInfoHelpers.SeriesHasMatchingParserInfoFormat(es, parsedSeries)); + _logger.LogInformation("[ScannerService] Attempting to pre-save all Genres"); + + try + { + // Pass the non-normalized genres directly to the repository + var nonExistingGenres = await _unitOfWork.GenreRepository.GetAllGenresNotInListAsync(genres); + + // Create and attach new genres using the non-normalized names + foreach (var genre in nonExistingGenres) + { + var newGenre = new GenreBuilder(genre).Build(); + _unitOfWork.GenreRepository.Attach(newGenre); + } + + // Commit changes + if (nonExistingGenres.Count > 0) + { + await _unitOfWork.CommitAsync(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[ScannerService] There was an unknown issue when pre-saving all Genres"); + } } + /// + /// Given a list of all Tags, generates new Tag entries for any that do not exist. + /// Does not delete anything, that will be handled by nightly task + /// + /// + private async Task CreateAllTagsAsync(ICollection tags) + { + _logger.LogInformation("[ScannerService] Attempting to pre-save all Tags"); + + try + { + // Pass the non-normalized tags directly to the repository + var nonExistingTags = await _unitOfWork.TagRepository.GetAllTagsNotInListAsync(tags); + + // Create and attach new genres using the non-normalized names + foreach (var tag in nonExistingTags) + { + var newTag = new TagBuilder(tag).Build(); + _unitOfWork.TagRepository.Attach(newTag); + } + + // Commit changes + if (nonExistingTags.Count > 0) + { + await _unitOfWork.CommitAsync(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[ScannerService] There was an unknown issue when pre-saving all Tags"); + } + } } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 49f7d2275c..de9818b797 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -13,6 +13,7 @@ public static class MessageFactoryEntityTypes public const string Chapter = "chapter"; public const string CollectionTag = "collection"; public const string ReadingList = "readingList"; + public const string Person = "person"; } public static class MessageFactory { diff --git a/API/Startup.cs b/API/Startup.cs index 37f2efbbf9..103ded8132 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -271,6 +271,9 @@ public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJo await MigrateInitialInstallData.Migrate(dataContext, logger, directoryService); await MigrateSeriesLowestFolderPath.Migrate(dataContext, logger, directoryService); + // v0.8.4 + await MigrateLowestSeriesFolderPath2.Migrate(dataContext, unitOfWork, logger); + // Update the version in the DB after all migrations are run var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion); installVersion.Value = BuildInfo.Version.ToString(); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9085e94446..43cd2e2081 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,4 +65,7 @@ If you just want to play with Swagger, you can just - dotnet run -c Debug - Go to http://localhost:5000/swagger/index.html +If you have a build issue around swagger run: +` swagger tofile --output ../openapi.json API/bin/Debug/net8.0/API.dll v1` to see the error and correct it + If you have any questions about any of this, please let us know. diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 958a6970c0..48b9beba0c 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -3,7 +3,7 @@ net8.0 kavitareader.com Kavita - 0.8.3.4 + 0.8.3.10 en true diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 09af58f198..5c4a5c95be 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -504,7 +504,6 @@ "version": "17.3.4", "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz", "integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==", - "dev": true, "dependencies": { "@babel/core": "7.23.9", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -532,7 +531,6 @@ "version": "7.23.9", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", - "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -561,14 +559,12 @@ "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "bin": { "semver": "bin/semver.js" } @@ -749,7 +745,6 @@ "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", - "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -778,14 +773,12 @@ "node_modules/@babel/core/node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, "bin": { "semver": "bin/semver.js" } @@ -5629,7 +5622,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -5642,7 +5634,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -5914,7 +5905,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "engines": { "node": ">=8" }, @@ -6226,7 +6216,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -6518,8 +6507,7 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/cookie": { "version": "0.6.0", @@ -7421,7 +7409,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -7431,7 +7418,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -8540,7 +8526,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -9222,7 +9207,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -11063,7 +11047,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -12453,7 +12436,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -12465,7 +12447,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -12476,8 +12457,7 @@ "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, "node_modules/regenerate": { "version": "1.4.2", @@ -12945,7 +12925,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "devOptional": true }, "node_modules/sass": { "version": "1.71.1", @@ -13064,7 +13044,6 @@ "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -13079,7 +13058,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -13090,8 +13068,7 @@ "node_modules/semver/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/send": { "version": "0.18.0", @@ -14222,7 +14199,6 @@ "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", - "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/UI/Web/src/_card-item-common.scss b/UI/Web/src/_card-item-common.scss index f187660fd9..f27a8768b3 100644 --- a/UI/Web/src/_card-item-common.scss +++ b/UI/Web/src/_card-item-common.scss @@ -125,7 +125,6 @@ $image-width: 160px; .overlay-information--centered { position: absolute; - border-radius: 15px; background-color: rgba(0, 0, 0, 0.7); border-radius: 50px; top: 50%; @@ -169,7 +168,7 @@ $image-width: 160px; margin: 0; text-align: center; max-width: 98px; - + a { overflow: hidden; text-overflow: ellipsis; diff --git a/UI/Web/src/app/_models/metadata/person.ts b/UI/Web/src/app/_models/metadata/person.ts index a53d4ed5ca..47c7590b3d 100644 --- a/UI/Web/src/app/_models/metadata/person.ts +++ b/UI/Web/src/app/_models/metadata/person.ts @@ -17,7 +17,15 @@ export enum PersonRole { } export interface Person { - id: number; - name: string; - role: PersonRole; + id: number; + name: string; + description: string; + coverImage?: string; + coverImageLocked: boolean; + malId?: number; + aniListId?: number; + hardcoverId?: string; + asin?: string; + primaryColor?: string; + secondaryColor?: string; } diff --git a/UI/Web/src/app/_models/metadata/v2/filter-field.ts b/UI/Web/src/app/_models/metadata/v2/filter-field.ts index fa62a004a3..2adebdf6d5 100644 --- a/UI/Web/src/app/_models/metadata/v2/filter-field.ts +++ b/UI/Web/src/app/_models/metadata/v2/filter-field.ts @@ -1,3 +1,5 @@ +import {PersonRole} from "../person"; + export enum FilterField { None = -1, @@ -47,3 +49,36 @@ enumArray.sort((a, b) => a.value.localeCompare(b.value)); export const allFields = enumArray .map(key => parseInt(key.key, 10))as FilterField[]; + +export const allPeople = [ + FilterField.Characters, + FilterField.Colorist, + FilterField.CoverArtist, + FilterField.Editor, + FilterField.Inker, + FilterField.Letterer, + FilterField.Penciller, + FilterField.Publisher, + FilterField.Translators, + FilterField.Writers, +]; + +export const personRoleForFilterField = (role: PersonRole) => { + switch (role) { + case PersonRole.Artist: return FilterField.CoverArtist; + case PersonRole.Character: return FilterField.Characters; + case PersonRole.Colorist: return FilterField.Colorist; + case PersonRole.CoverArtist: return FilterField.CoverArtist; + case PersonRole.Editor: return FilterField.Editor; + case PersonRole.Inker: return FilterField.Inker; + case PersonRole.Letterer: return FilterField.Letterer; + case PersonRole.Penciller: return FilterField.Penciller; + case PersonRole.Publisher: return FilterField.Publisher; + case PersonRole.Translator: return FilterField.Translators; + case PersonRole.Writer: return FilterField.Writers; + case PersonRole.Imprint: return FilterField.Imprint; + case PersonRole.Location: return FilterField.Location; + case PersonRole.Team: return FilterField.Team; + case PersonRole.Other: return FilterField.None; + } +}; diff --git a/UI/Web/src/app/_models/person/browse-person.ts b/UI/Web/src/app/_models/person/browse-person.ts new file mode 100644 index 0000000000..aeddac7cd5 --- /dev/null +++ b/UI/Web/src/app/_models/person/browse-person.ts @@ -0,0 +1,6 @@ +import {Person} from "../metadata/person"; + +export interface BrowsePerson extends Person { + seriesCount: number; + issueCount: number; +} diff --git a/UI/Web/src/app/_models/search/search-result-group.ts b/UI/Web/src/app/_models/search/search-result-group.ts index a96670aaf9..7391cdad9f 100644 --- a/UI/Web/src/app/_models/search/search-result-group.ts +++ b/UI/Web/src/app/_models/search/search-result-group.ts @@ -4,14 +4,18 @@ import { MangaFile } from "../manga-file"; import { SearchResult } from "./search-result"; import { Tag } from "../tag"; import {BookmarkSearchResult} from "./bookmark-search-result"; +import {Genre} from "../metadata/genre"; +import {ReadingList} from "../reading-list"; +import {UserCollection} from "../collection-tag"; +import {Person} from "../metadata/person"; export class SearchResultGroup { libraries: Array = []; series: Array = []; - collections: Array = []; - readingLists: Array = []; - persons: Array = []; - genres: Array = []; + collections: Array = []; + readingLists: Array = []; + persons: Array = []; + genres: Array = []; tags: Array = []; files: Array = []; chapters: Array = []; diff --git a/UI/Web/src/app/_models/sidenav/sidenav-stream-type.enum.ts b/UI/Web/src/app/_models/sidenav/sidenav-stream-type.enum.ts index a294f96968..2d6ef4e4c9 100644 --- a/UI/Web/src/app/_models/sidenav/sidenav-stream-type.enum.ts +++ b/UI/Web/src/app/_models/sidenav/sidenav-stream-type.enum.ts @@ -7,4 +7,5 @@ export enum SideNavStreamType { ExternalSource = 6, AllSeries = 7, WantToRead = 8, + BrowseAuthors = 9 } diff --git a/UI/Web/src/app/_models/sidenav/sidenav-stream.ts b/UI/Web/src/app/_models/sidenav/sidenav-stream.ts index 37a2c20e38..192bf73bdd 100644 --- a/UI/Web/src/app/_models/sidenav/sidenav-stream.ts +++ b/UI/Web/src/app/_models/sidenav/sidenav-stream.ts @@ -1,5 +1,5 @@ import {SideNavStreamType} from "./sidenav-stream-type.enum"; -import {Library, LibraryType} from "../library/library"; +import {Library} from "../library/library"; import {CommonStream} from "../common-stream"; import {ExternalSource} from "./external-source"; diff --git a/UI/Web/src/app/_models/standalone-chapter.ts b/UI/Web/src/app/_models/standalone-chapter.ts new file mode 100644 index 0000000000..9d640ad819 --- /dev/null +++ b/UI/Web/src/app/_models/standalone-chapter.ts @@ -0,0 +1,9 @@ +import {Chapter} from "./chapter"; +import {LibraryType} from "./library/library"; + +export interface StandaloneChapter extends Chapter { + seriesId: number; + libraryId: number; + libraryType: LibraryType; + volumeTitle?: string; +} diff --git a/UI/Web/src/app/_pipes/person-role.pipe.ts b/UI/Web/src/app/_pipes/person-role.pipe.ts index e6f4f4cb8f..c73f0b04dd 100644 --- a/UI/Web/src/app/_pipes/person-role.pipe.ts +++ b/UI/Web/src/app/_pipes/person-role.pipe.ts @@ -18,7 +18,7 @@ export class PersonRolePipe implements PipeTransform { case PersonRole.Colorist: return this.translocoService.translate('person-role-pipe.colorist'); case PersonRole.CoverArtist: - return this.translocoService.translate('person-role-pipe.cover-artist'); + return this.translocoService.translate('person-role-pipe.artist'); case PersonRole.Editor: return this.translocoService.translate('person-role-pipe.editor'); case PersonRole.Inker: diff --git a/UI/Web/src/app/_routes/browse-authors-routing.module.ts b/UI/Web/src/app/_routes/browse-authors-routing.module.ts new file mode 100644 index 0000000000..e7aab1b575 --- /dev/null +++ b/UI/Web/src/app/_routes/browse-authors-routing.module.ts @@ -0,0 +1,8 @@ +import { Routes } from "@angular/router"; +import { AllSeriesComponent } from "../all-series/_components/all-series/all-series.component"; +import {BrowseAuthorsComponent} from "../browse-people/browse-authors.component"; + + +export const routes: Routes = [ + {path: '', component: BrowseAuthorsComponent, pathMatch: 'full'}, +]; diff --git a/UI/Web/src/app/_routes/person-detail-routing.module.ts b/UI/Web/src/app/_routes/person-detail-routing.module.ts new file mode 100644 index 0000000000..95b610cea4 --- /dev/null +++ b/UI/Web/src/app/_routes/person-detail-routing.module.ts @@ -0,0 +1,19 @@ +import { Routes } from '@angular/router'; +import { AuthGuard } from '../_guards/auth.guard'; +import {PersonDetailComponent} from "../person-detail/person-detail.component"; + + +export const routes: Routes = [ + { + path: ':name', + runGuardsAndResolvers: 'always', + canActivate: [AuthGuard], + component: PersonDetailComponent + }, + { + path: '', + runGuardsAndResolvers: 'always', + canActivate: [AuthGuard], + component: PersonDetailComponent + } +]; diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 25c791b2fb..24ca2a76a3 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -12,6 +12,7 @@ import { DeviceService } from './device.service'; import {SideNavStream} from "../_models/sidenav/sidenav-stream"; import {SmartFilter} from "../_models/metadata/v2/smart-filter"; import {translate} from "@jsverse/transloco"; +import {Person} from "../_models/metadata/person"; export enum Action { Submenu = -1, @@ -160,6 +161,8 @@ export class ActionFactoryService { bookmarkActions: Array> = []; + private personActions: Array> = []; + sideNavStreamActions: Array> = []; smartFilterActions: Array> = []; @@ -180,11 +183,11 @@ export class ActionFactoryService { } getLibraryActions(callback: ActionCallback) { - return this.applyCallbackToList(this.libraryActions, callback); + return this.applyCallbackToList(this.libraryActions, callback); } getSeriesActions(callback: ActionCallback) { - return this.applyCallbackToList(this.seriesActions, callback); + return this.applyCallbackToList(this.seriesActions, callback); } getSideNavStreamActions(callback: ActionCallback) { @@ -196,7 +199,7 @@ export class ActionFactoryService { } getVolumeActions(callback: ActionCallback) { - return this.applyCallbackToList(this.volumeActions, callback); + return this.applyCallbackToList(this.volumeActions, callback); } getChapterActions(callback: ActionCallback) { @@ -204,7 +207,7 @@ export class ActionFactoryService { } getCollectionTagActions(callback: ActionCallback) { - return this.applyCallbackToList(this.collectionTagActions, callback); + return this.applyCallbackToList(this.collectionTagActions, callback); } getReadingListActions(callback: ActionCallback) { @@ -215,6 +218,10 @@ export class ActionFactoryService { return this.applyCallbackToList(this.bookmarkActions, callback); } + getPersonActions(callback: ActionCallback) { + return this.applyCallbackToList(this.personActions, callback); + } + dummyCallback(action: ActionItem, data: any) {} filterSendToAction(actions: Array>, chapter: Chapter) { @@ -424,7 +431,7 @@ export class ActionFactoryService { callback: this.dummyCallback, requiresAdmin: false, children: [ - { + { action: Action.AddToWantToReadList, title: 'add-to-want-to-read', description: 'add-to-want-to-read-tooltip', @@ -579,23 +586,23 @@ export class ActionFactoryService { requiresAdmin: false, children: [], }, - { - action: Action.Submenu, - title: 'add-to', + { + action: Action.Submenu, + title: 'add-to', description: '=', - callback: this.dummyCallback, - requiresAdmin: false, - children: [ - { - action: Action.AddToReadingList, - title: 'add-to-reading-list', + callback: this.dummyCallback, + requiresAdmin: false, + children: [ + { + action: Action.AddToReadingList, + title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', - callback: this.dummyCallback, - requiresAdmin: false, - children: [], - } - ] - }, + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + } + ] + }, { action: Action.Submenu, title: 'send-to', @@ -676,23 +683,23 @@ export class ActionFactoryService { requiresAdmin: false, children: [], }, - { - action: Action.Submenu, - title: 'add-to', + { + action: Action.Submenu, + title: 'add-to', description: '', - callback: this.dummyCallback, - requiresAdmin: false, - children: [ - { - action: Action.AddToReadingList, - title: 'add-to-reading-list', + callback: this.dummyCallback, + requiresAdmin: false, + children: [ + { + action: Action.AddToReadingList, + title: 'add-to-reading-list', description: 'add-to-reading-list-tooltip', - callback: this.dummyCallback, - requiresAdmin: false, - children: [], - } - ] - }, + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + } + ] + }, { action: Action.Submenu, title: 'send-to', @@ -785,6 +792,17 @@ export class ActionFactoryService { }, ]; + this.personActions = [ + { + action: Action.Edit, + title: 'edit', + description: 'edit-person-tooltip', + callback: this.dummyCallback, + requiresAdmin: true, + children: [], + } + ]; + this.bookmarkActions = [ { action: Action.ViewSeries, @@ -854,13 +872,13 @@ export class ActionFactoryService { }); } - public applyCallbackToList(list: Array>, callback: (action: ActionItem, data: any) => void): Array> { - const actions = list.map((a) => { - return { ...a }; - }); - actions.forEach((action) => this.applyCallback(action, callback)); - return actions; - } + public applyCallbackToList(list: Array>, callback: (action: ActionItem, data: any) => void): Array> { + const actions = list.map((a) => { + return { ...a }; + }); + actions.forEach((action) => this.applyCallback(action, callback)); + return actions; + } // Checks the whole tree for the action and returns true if it exists public hasAction(actions: Array>, action: Action) { diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index e9dba7420d..86aa8872ad 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -17,18 +17,21 @@ export class ImageService { public errorImage = 'assets/images/error-placeholder2.dark-min.png'; public resetCoverImage = 'assets/images/image-reset-cover-min.png'; public errorWebLinkImage = 'assets/images/broken-white-32x32.png'; - public nextChapterImage = 'assets/images/image-placeholder.dark-min.png' + public nextChapterImage = 'assets/images/image-placeholder.dark-min.png'; + public noPersonImage = 'assets/images/error-person-missing.dark.min.png'; constructor(private accountService: AccountService, private themeService: ThemeService) { this.themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(theme => { if (this.themeService.isDarkTheme()) { this.placeholderImage = 'assets/images/image-placeholder.dark-min.png'; this.errorImage = 'assets/images/error-placeholder2.dark-min.png'; - this.errorWebLinkImage = 'assets/images/broken-white-32x32.png'; + this.errorWebLinkImage = 'assets/images/broken-black-32x32.png'; + this.noPersonImage = 'assets/images/error-person-missing.dark.min.png'; } else { this.placeholderImage = 'assets/images/image-placeholder-min.png'; this.errorImage = 'assets/images/error-placeholder2-min.png'; - this.errorWebLinkImage = 'assets/images/broken-black-32x32.png'; + this.errorWebLinkImage = 'assets/images/broken-white-32x32.png'; + this.noPersonImage = 'assets/images/error-person-missing.min.png'; } }); @@ -59,6 +62,13 @@ export class ImageService { return part.substring(0, equalIndex).replace('Id', ''); } + getPersonImage(personId: number) { + return `${this.baseUrl}image/person-cover?personId=${personId}&apiKey=${this.encodedKey}`; + } + getPersonImageByName(name: string) { + return `${this.baseUrl}image/person-cover-by-name?name=${name}&apiKey=${this.encodedKey}`; + } + getLibraryCoverImage(libraryId: number) { return `${this.baseUrl}image/library-cover?libraryId=${libraryId}&apiKey=${this.encodedKey}`; } diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts new file mode 100644 index 0000000000..cc83e2a50e --- /dev/null +++ b/UI/Web/src/app/_services/person.service.ts @@ -0,0 +1,53 @@ +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"; + +@Injectable({ + providedIn: 'root' +}) +export class PersonService { + + baseUrl = environment.apiUrl; + + constructor(private httpClient: HttpClient, private utilityService: UtilityService) { } + + updatePerson(person: Person) { + return this.httpClient.post(this.baseUrl + "person/update", person); + } + + get(name: string) { + return this.httpClient.get(this.baseUrl + `person?name=${name}`); + } + + getRolesForPerson(name: string) { + return this.httpClient.get>(this.baseUrl + `person/roles?name=${name}`); + } + + getSeriesMostKnownFor(personId: number) { + return this.httpClient.get>(this.baseUrl + `person/series-known-for?personId=${personId}`); + } + + getChaptersByRole(personId: number, role: PersonRole) { + return this.httpClient.get>(this.baseUrl + `person/chapters-by-role?personId=${personId}&role=${role}`); + } + + getAuthorsToBrowse(pageNum?: number, itemsPerPage?: number) { + let params = new HttpParams(); + params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage); + + return this.httpClient.post>(this.baseUrl + 'person/authors', {}, {observe: 'response', params}).pipe( + map((response: any) => { + return this.utilityService.createPaginatedResult(response) as PaginatedResult; + }) + ); + } +} diff --git a/UI/Web/src/app/_services/theme.service.ts b/UI/Web/src/app/_services/theme.service.ts index ab889937f0..9390f25fb5 100644 --- a/UI/Web/src/app/_services/theme.service.ts +++ b/UI/Web/src/app/_services/theme.service.ts @@ -195,7 +195,7 @@ export class ThemeService { * @param entity * @param id */ - refreshColorScape(entity: 'series' | 'volume' | 'chapter', id: number) { + refreshColorScape(entity: 'series' | 'volume' | 'chapter' | 'person', id: number) { return this.httpClient.get(`${this.baseUrl}colorscape/${entity}?id=${id}`).pipe(tap((cs) => { this.setColorScape(cs.primary || '', cs.secondary); })); diff --git a/UI/Web/src/app/_services/upload.service.ts b/UI/Web/src/app/_services/upload.service.ts index c801c4b887..f2a8111612 100644 --- a/UI/Web/src/app/_services/upload.service.ts +++ b/UI/Web/src/app/_services/upload.service.ts @@ -64,6 +64,12 @@ export class UploadService { })); } + updatePersonCoverImage(personId: number, url: string, lockCover: boolean = true) { + return this.httpClient.post(this.baseUrl + 'upload/person', {id: personId, url: this._cleanBase64Url(url), lockCover}).pipe(tap(_ => { + this.toastr.info(translate('series-detail.cover-change')); + })); + } + resetChapterCoverLock(chapterId: number, ) { return this.httpClient.post(this.baseUrl + 'upload/reset-chapter-lock', {id: chapterId, url: ''}); } diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html index 5ce715b6f2..c3e6c2dff0 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.html +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.html @@ -41,7 +41,7 @@

{{t('tags-title')}}

- +
@@ -49,7 +49,7 @@

{{t('tags-title')}}

- +
@@ -57,7 +57,7 @@

{{t('tags-title')}}

- +
@@ -66,7 +66,7 @@

{{t('tags-title')}}

- +
@@ -74,7 +74,7 @@

{{t('tags-title')}}

- +
@@ -82,7 +82,7 @@

{{t('tags-title')}}

- +
@@ -90,7 +90,7 @@

{{t('tags-title')}}

- +
@@ -98,7 +98,7 @@

{{t('tags-title')}}

- +
@@ -106,7 +106,7 @@

{{t('tags-title')}}

- +
@@ -114,7 +114,7 @@

{{t('tags-title')}}

- +
@@ -122,7 +122,7 @@

{{t('tags-title')}}

- +
@@ -130,7 +130,7 @@

{{t('tags-title')}}

- +
diff --git a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts index 39b3b1fe58..dd9d644d69 100644 --- a/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts +++ b/UI/Web/src/app/_single-module/details-tab/details-tab.component.ts @@ -46,16 +46,8 @@ export class DetailsTabComponent { @Input() webLinks: Array = []; - openPerson(queryParamName: FilterField, filter: Person) { - if (queryParamName === FilterField.None) return; - this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter.id}`).subscribe(); - } - openGeneric(queryParamName: FilterField, filter: string | number) { if (queryParamName === FilterField.None) return; this.filterUtilityService.applyFilter(['all-series'], queryParamName, FilterComparison.Equal, `${filter}`).subscribe(); } - - - protected readonly TagBadgeCursor = TagBadgeCursor; } 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 00e3c0edf0..25980865d5 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 @@ -192,7 +192,7 @@ export class EditChapterModalComponent implements OnInit { })).subscribe(); this.editForm.addControl('titleName', new FormControl(this.chapter.titleName, [])); - this.editForm.addControl('sortOrder', new FormControl(this.chapter.sortOrder, [Validators.required, Validators.min(0)])); + this.editForm.addControl('sortOrder', new FormControl(Math.max(0, this.chapter.sortOrder), [Validators.required, Validators.min(0)])); this.editForm.addControl('summary', new FormControl(this.chapter.summary || '', [])); this.editForm.addControl('language', new FormControl(this.chapter.language, [])); this.editForm.addControl('isbn', new FormControl(this.chapter.isbn, [])); @@ -466,12 +466,12 @@ export class EditChapterModalComponent implements OnInit { fetchPeople(role: PersonRole, filter: string) { return this.metadataService.getAllPeople().pipe(map(people => { - return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter)); + return people.filter(p => this.utilityService.filter(p.name, filter)); })); } createBlankPersonSettings(id: string, role: PersonRole) { - var personSettings = new TypeaheadSettings(); + let personSettings = new TypeaheadSettings(); personSettings.minCharacters = 0; personSettings.multiple = true; personSettings.showLocked = true; @@ -486,14 +486,14 @@ export class EditChapterModalComponent implements OnInit { } personSettings.selectionCompareFn = (a: Person, b: Person) => { - return a.name == b.name && a.role == b.role; + return a.name == b.name; } personSettings.fetchFn = (filter: string) => { return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter))); }; personSettings.addTransformFn = ((title: string) => { - return {id: 0, name: title, role: role }; + return {id: 0, name: title, role: role, description: '', coverImage: '', coverImageLocked: false }; }); return personSettings; diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index 58980f834b..32eae3b874 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -46,6 +46,14 @@ const routes: Routes = [ path: 'home', loadChildren: () => import('./_routes/dashboard-routing.module').then(m => m.routes) }, + { + path: 'person', + loadChildren: () => import('./_routes/person-detail-routing.module').then(m => m.routes) + }, + { + path: 'browse/authors', + loadChildren: () => import('./_routes/browse-authors-routing.module').then(m => m.routes) + }, { path: 'library', runGuardsAndResolvers: 'always', diff --git a/UI/Web/src/app/browse-people/browse-authors.component.html b/UI/Web/src/app/browse-people/browse-authors.component.html new file mode 100644 index 0000000000..600f7c71ce --- /dev/null +++ b/UI/Web/src/app/browse-people/browse-authors.component.html @@ -0,0 +1,33 @@ +
+ + +

+ {{t('title')}} +

+
{{t('author-count', {num: pagination.totalItems | number})}}
+ +
+ + + + + +
+
{{item.seriesCount | compactNumber}} series
+
{{item.issueCount | compactNumber}} issues
+
+
+
+
+
+ +
+
diff --git a/UI/Web/src/app/browse-people/browse-authors.component.scss b/UI/Web/src/app/browse-people/browse-authors.component.scss new file mode 100644 index 0000000000..dc52bc49c1 --- /dev/null +++ b/UI/Web/src/app/browse-people/browse-authors.component.scss @@ -0,0 +1,4 @@ +.main-container { + margin-top: 10px; + padding: 0 0 0 10px; +} diff --git a/UI/Web/src/app/browse-people/browse-authors.component.ts b/UI/Web/src/app/browse-people/browse-authors.component.ts new file mode 100644 index 0000000000..e9d1991e12 --- /dev/null +++ b/UI/Web/src/app/browse-people/browse-authors.component.ts @@ -0,0 +1,90 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + EventEmitter, + inject, + OnInit +} from '@angular/core'; +import { + SideNavCompanionBarComponent +} from "../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component"; +import {CardDetailLayoutComponent} from "../cards/card-detail-layout/card-detail-layout.component"; +import {DecimalPipe} from "@angular/common"; +import {Series} from "../_models/series"; +import {Pagination} from "../_models/pagination"; +import {JumpKey} from "../_models/jumpbar/jump-key"; +import {ActivatedRoute, Router} from "@angular/router"; +import {Title} from "@angular/platform-browser"; +import {ActionFactoryService} from "../_services/action-factory.service"; +import {ActionService} from "../_services/action.service"; +import {MessageHubService} from "../_services/message-hub.service"; +import {UtilityService} from "../shared/_services/utility.service"; +import {PersonService} from "../_services/person.service"; +import {BrowsePerson} from "../_models/person/browse-person"; +import {CardItemComponent} from "../cards/card-item/card-item.component"; +import {JumpbarService} from "../_services/jumpbar.service"; +import {PersonCardComponent} from "../cards/person-card/person-card.component"; +import {ImageService} from "../_services/image.service"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {CompactNumberPipe} from "../_pipes/compact-number.pipe"; + + +@Component({ + selector: 'app-browse-authors', + standalone: true, + imports: [ + SideNavCompanionBarComponent, + TranslocoDirective, + CardDetailLayoutComponent, + DecimalPipe, + CardItemComponent, + PersonCardComponent, + CompactNumberPipe, + ], + templateUrl: './browse-authors.component.html', + styleUrl: './browse-authors.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class BrowseAuthorsComponent implements OnInit { + + private readonly destroyRef = inject(DestroyRef); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly titleService = inject(Title); + private readonly actionFactoryService = inject(ActionFactoryService); + private readonly actionService = inject(ActionService); + private readonly hubService = inject(MessageHubService); + private readonly utilityService = inject(UtilityService); + private readonly personService = inject(PersonService); + private readonly jumpbarService = inject(JumpbarService); + protected readonly imageService = inject(ImageService); + + + series: Series[] = []; + isLoading = false; + authors: Array = []; + pagination: Pagination = {currentPage: 0, totalPages: 0, totalItems: 0, itemsPerPage: 0}; + refresh: EventEmitter = new EventEmitter(); + jumpKeys: Array = []; + trackByIdentity = (index: number, item: BrowsePerson) => `${item.id}`; + + ngOnInit() { + this.isLoading = true; + this.cdRef.markForCheck(); + this.personService.getAuthorsToBrowse(undefined, undefined).subscribe(d => { + this.authors = d.result; + this.pagination = d.pagination; + this.jumpKeys = this.jumpbarService.getJumpKeys(this.authors, d => d.name); + this.isLoading = false; + this.cdRef.markForCheck(); + }); + } + + goToPerson(person: BrowsePerson) { + this.router.navigate(['person', person.name]); + } + +} 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 cea5ffd00d..219099f02d 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 @@ -488,12 +488,12 @@ export class EditSeriesModalComponent implements OnInit { fetchPeople(role: PersonRole, filter: string) { return this.metadataService.getAllPeople().pipe(map(people => { - return people.filter(p => p.role == role && this.utilityService.filter(p.name, filter)); + return people.filter(p => this.utilityService.filter(p.name, filter)); })); } createBlankPersonSettings(id: string, role: PersonRole) { - var personSettings = new TypeaheadSettings(); + const personSettings = new TypeaheadSettings(); personSettings.minCharacters = 0; personSettings.multiple = true; personSettings.showLocked = true; @@ -508,14 +508,14 @@ export class EditSeriesModalComponent implements OnInit { } personSettings.selectionCompareFn = (a: Person, b: Person) => { - return a.name == b.name && a.role == b.role; + return a.name == b.name; } personSettings.fetchFn = (filter: string) => { return this.fetchPeople(role, filter).pipe(map(items => personSettings.compareFn(items, filter))); }; personSettings.addTransformFn = ((title: string) => { - return {id: 0, name: title, role: role }; + return {id: 0, name: title, description: '', coverImageLocked: false }; }); return personSettings; diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index bd4f3d0a1a..187377ce8e 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -46,6 +46,9 @@ import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; import {PromotedIconComponent} from "../../shared/_components/promoted-icon/promoted-icon.component"; import {SeriesFormatComponent} from "../../shared/series-format/series-format.component"; +import {BrowsePerson} from "../../_models/person/browse-person"; + +export type CardEntity = Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter | BrowsePerson; @Component({ selector: 'app-card-item', @@ -116,7 +119,7 @@ export class CardItemComponent implements OnInit { /** * This is the entity we are representing. It will be returned if an action is executed. */ - @Input({required: true}) entity!: Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter; + @Input({required: true}) entity!: CardEntity; /** * If the entity is selected or not. */ @@ -161,7 +164,7 @@ export class CardItemComponent implements OnInit { * When the card is selected. */ @Output() selection = new EventEmitter(); - @Output() readClicked = new EventEmitter(); + @Output() readClicked = new EventEmitter(); @ContentChild('subtitle') subtitleTemplate!: TemplateRef; /** * Library name item belongs to diff --git a/UI/Web/src/app/cards/person-card/person-card.component.html b/UI/Web/src/app/cards/person-card/person-card.component.html new file mode 100644 index 0000000000..bfca352d18 --- /dev/null +++ b/UI/Web/src/app/cards/person-card/person-card.component.html @@ -0,0 +1,48 @@ + +
+
+ @if(entity.coverImage) { + + } @else { +
+ +
+ } + + + @if (allowSelection) { +
+ +
+ } + + @if (count > 1) { +
+ {{count}} +
+ } + +
+ @if (title.length > 0 || actions.length > 0) { +
+
+ + {{title}} + + @if (actions && actions.length > 0) { + + + + } +
+ + @if (subtitleTemplate) { +
+ +
+ } +
+ } +
+ +
diff --git a/UI/Web/src/app/cards/person-card/person-card.component.scss b/UI/Web/src/app/cards/person-card/person-card.component.scss new file mode 100644 index 0000000000..a768ca0d91 --- /dev/null +++ b/UI/Web/src/app/cards/person-card/person-card.component.scss @@ -0,0 +1,56 @@ +$image-height: 160px; +@use '../../../card-item-common'; + +// Override so we can have square cards + +.bulk-mode { + &.always-show { + height: $image-height; + } +} + +.card-item-container { + background-color: unset; + border: 1px solid transparent; + transition: all ease-in-out 300ms; + + .overlay { + height: $image-height; + position: relative; + display: flex; + background-color: hsl(0deg 0% 0% / 12%); + /* TODO: Robbie fix this hack */ + .missing-img { + align-self: center; + display: flex; + } + + .card-overlay { + height: 100%; + } + } + + .card-body { + bottom: 0; + background-color: transparent; + position: unset !important; + margin-bottom: 5px; + display: flex; + flex-direction: column; + + .card-title { + margin: 0 auto; + } + } + + &:hover { + cursor: pointer; + border-color: var(--primary-color); + + .overlay { + .missing-img { + } + } + } +} + diff --git a/UI/Web/src/app/cards/person-card/person-card.component.ts b/UI/Web/src/app/cards/person-card/person-card.component.ts new file mode 100644 index 0000000000..3c12f6b5e7 --- /dev/null +++ b/UI/Web/src/app/cards/person-card/person-card.component.ts @@ -0,0 +1,157 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, ContentChild, + DestroyRef, EventEmitter, + HostListener, + inject, + Input, Output, TemplateRef +} from '@angular/core'; +import {Action, ActionFactoryService, ActionItem} from "../../_services/action-factory.service"; +import {ImageService} from "../../_services/image.service"; +import {BulkSelectionService} from "../bulk-selection.service"; +import {LibraryService} from "../../_services/library.service"; +import {DownloadService} from "../../shared/_services/download.service"; +import {UtilityService} from "../../shared/_services/utility.service"; +import {MessageHubService} from "../../_services/message-hub.service"; +import {AccountService} from "../../_services/account.service"; +import {ScrollService} from "../../_services/scroll.service"; +import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap"; +import {CardActionablesComponent} from "../../_single-module/card-actionables/card-actionables.component"; +import {NgTemplateOutlet} from "@angular/common"; +import {BrowsePerson} from "../../_models/person/browse-person"; +import {Person} from "../../_models/metadata/person"; +import {FormsModule} from "@angular/forms"; +import {ImageComponent} from "../../shared/image/image.component"; +import {TranslocoDirective} from "@jsverse/transloco"; + + +@Component({ + selector: 'app-person-card', + standalone: true, + imports: [ + NgbTooltip, + CardActionablesComponent, + NgTemplateOutlet, + FormsModule, + ImageComponent, + TranslocoDirective + ], + templateUrl: './person-card.component.html', + styleUrl: './person-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class PersonCardComponent { + + private readonly destroyRef = inject(DestroyRef); + public readonly imageService = inject(ImageService); + public readonly bulkSelectionService = inject(BulkSelectionService); + private readonly messageHub = inject(MessageHubService); + private readonly scrollService = inject(ScrollService); + private readonly cdRef = inject(ChangeDetectorRef); + + /** + * Card item url. Will internally handle error and missing covers + */ + @Input() imageUrl = ''; + /** + * Name of the card + */ + @Input() title = ''; + /** + * If the entity is selected or not. + */ + @Input() selected: boolean = false; + /** + * Any actions to perform on the card + */ + @Input() actions: ActionItem[] = []; + /** + * This is the entity we are representing. It will be returned if an action is executed. + */ + @Input({required: true}) entity!: BrowsePerson | Person; + /** + * If the entity should show selection code + */ + @Input() allowSelection: boolean = false; + /** + * The number of updates/items within the card. If less than 2, will not be shown. + */ + @Input() count: number = 0; + /** + * Event emitted when item is clicked + */ + @Output() clicked = new EventEmitter(); + /** + * When the card is selected. + */ + @Output() selection = new EventEmitter(); + @ContentChild('subtitle') subtitleTemplate!: TemplateRef; + + tooltipTitle: string = this.title; + /** + * Handles touch events for selection on mobile devices + */ + prevTouchTime: number = 0; + /** + * Handles touch events for selection on mobile devices to ensure you aren't touch scrolling + */ + prevOffset: number = 0; + selectionInProgress: boolean = false; + + @HostListener('touchmove', ['$event']) + onTouchMove(event: TouchEvent) { + if (!this.allowSelection) return; + + this.selectionInProgress = false; + this.cdRef.markForCheck(); + } + + @HostListener('touchstart', ['$event']) + onTouchStart(event: TouchEvent) { + if (!this.allowSelection) return; + + this.prevTouchTime = event.timeStamp; + this.prevOffset = this.scrollService.scrollPosition; + this.selectionInProgress = true; + } + + @HostListener('touchend', ['$event']) + onTouchEnd(event: TouchEvent) { + if (!this.allowSelection) return; + const delta = event.timeStamp - this.prevTouchTime; + const verticalOffset = this.scrollService.scrollPosition; + + if (delta >= 300 && delta <= 1000 && (verticalOffset === this.prevOffset) && this.selectionInProgress) { + this.handleSelection(); + event.stopPropagation(); + event.preventDefault(); + } + this.prevTouchTime = 0; + this.selectionInProgress = false; + } + + + handleClick(event?: any) { + if (this.bulkSelectionService.hasSelections()) { + this.handleSelection(); + return; + } + this.clicked.emit(this.title); + } + + performAction(action: ActionItem) { + if (typeof action.callback === 'function') { + action.callback(action, this.entity); + } + } + + handleSelection(event?: any) { + if (event) { + event.stopPropagation(); + } + this.selection.emit(this.selected); + this.cdRef.detectChanges(); + } + +} diff --git a/UI/Web/src/app/library-detail/library-detail.component.html b/UI/Web/src/app/library-detail/library-detail.component.html index 884d557cce..ac2fa25d38 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.html +++ b/UI/Web/src/app/library-detail/library-detail.component.html @@ -14,20 +14,20 @@
{{t('common.series-count', {num: @if (filter) { + (selection)="bulkSelectionService.handleCardSelection('series', position, series.length, $event)" + [selected]="bulkSelectionService.isCardSelected('series', position)" [allowSelection]="true"> } diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index 0961182c04..ed04191730 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -47,11 +47,11 @@ import {LoadingComponent} from "../shared/loading/loading.component"; import {debounceTime, ReplaySubject, tap} from "rxjs"; @Component({ - selector: 'app-library-detail', - templateUrl: './library-detail.component.html', - styleUrls: ['./library-detail.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, + selector: 'app-library-detail', + templateUrl: './library-detail.component.html', + styleUrls: ['./library-detail.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, imports: [SideNavCompanionBarComponent, CardActionablesComponent, NgbNav, NgbNavItem, NgbNavItemRole, NgbNavLink, NgbNavContent, CardDetailLayoutComponent, SeriesCardComponent, BulkOperationsComponent, NgbNavOutlet, DecimalPipe, SentenceCasePipe, TranslocoDirective, LoadingComponent] }) @@ -321,11 +321,11 @@ export class LibraryDetailComponent implements OnInit { this.seriesService.getSeriesForLibraryV2(undefined, undefined, this.filter) .subscribe(series => { - this.series = series.result; - this.pagination = series.pagination; - this.loadingSeries = false; - this.cdRef.markForCheck(); - }); + this.series = series.result; + this.pagination = series.pagination; + this.loadingSeries = false; + this.cdRef.markForCheck(); + }); } trackByIdentity = (index: number, item: Series) => `${item.id}_${item.name}_${item.localizedName}_${item.pagesRead}`; diff --git a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.ts b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.ts index a01e066a88..d805d49418 100644 --- a/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.ts +++ b/UI/Web/src/app/nav/_components/grouped-typeahead/grouped-typeahead.component.ts @@ -119,9 +119,7 @@ export class GroupedTypeaheadComponent implements OnInit { @HostListener('window:click', ['$event']) handleDocumentClick(event: MouseEvent) { - console.log('click: ', event) this.close(); - } @HostListener('window:keydown', ['$event']) 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 8a1feae7a7..9582132251 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 @@ -108,15 +108,18 @@ - -
-
- -
-
{{item.role | personRole}}
-
-
-
+ +
+
+ +
+
+
{{item.name}}
+
+
+
diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts index ad2116e37a..54bfff8db0 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.ts @@ -17,7 +17,7 @@ import {Chapter} from 'src/app/_models/chapter'; import {UserCollection} from 'src/app/_models/collection-tag'; import {Library} from 'src/app/_models/library/library'; import {MangaFile} from 'src/app/_models/manga-file'; -import {PersonRole} from 'src/app/_models/metadata/person'; +import {Person, PersonRole} from 'src/app/_models/metadata/person'; import {ReadingList} from 'src/app/_models/reading-list'; import {SearchResult} from 'src/app/_models/search/search-result'; import {SearchResultGroup} from 'src/app/_models/search/search-result-group'; @@ -178,56 +178,9 @@ export class NavHeaderComponent implements OnInit { this.goTo({field, comparison: FilterComparison.Equal, value: value + ''}); } - goToPerson(role: PersonRole, filter: any) { + goToPerson(person: Person) { this.clearSearch(); - filter = filter + ''; - switch(role) { - case PersonRole.Other: - break; - case PersonRole.Writer: - this.goTo({field: FilterField.Writers, comparison: FilterComparison.Equal, value: filter}); - break; - case PersonRole.Artist: - this.goTo({field: FilterField.CoverArtist, comparison: FilterComparison.Equal, value: filter}); // TODO: What is this supposed to be? - break; - case PersonRole.Character: - this.goTo({field: FilterField.Characters, comparison: FilterComparison.Equal, value: filter}); - break; - case PersonRole.Colorist: - this.goTo({field: FilterField.Colorist, comparison: FilterComparison.Equal, value: filter}); - break; - case PersonRole.Editor: - this.goTo({field: FilterField.Editor, comparison: FilterComparison.Equal, value: filter}); - break; - case PersonRole.Inker: - this.goTo({field: FilterField.Inker, comparison: FilterComparison.Equal, value: filter}); - break; - case PersonRole.CoverArtist: - this.goTo({field: FilterField.CoverArtist, comparison: FilterComparison.Equal, value: filter}); - break; - case PersonRole.Letterer: - this.goTo({field: FilterField.Letterer, comparison: FilterComparison.Equal, value: filter}); - break; - case PersonRole.Penciller: - this.goTo({field: FilterField.Penciller, comparison: FilterComparison.Equal, value: filter}); - break; - case PersonRole.Publisher: - this.goTo({field: FilterField.Publisher, comparison: FilterComparison.Equal, value: filter}); - break; - case PersonRole.Imprint: - this.goTo({field: FilterField.Imprint, comparison: FilterComparison.Equal, value: filter}); - break; - case PersonRole.Team: - this.goTo({field: FilterField.Team, comparison: FilterComparison.Equal, value: filter}); - break; - case PersonRole.Location: - this.goTo({field: FilterField.Location, comparison: FilterComparison.Equal, value: filter}); - break; - case PersonRole.Translator: - this.goTo({field: FilterField.Translators, comparison: FilterComparison.Equal, value: filter}); - break; - - } + this.router.navigate(['person', person.name]); } clearSearch() { 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 new file mode 100644 index 0000000000..86e5603bd1 --- /dev/null +++ b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html @@ -0,0 +1,123 @@ + + @if (person !== undefined) { + + } + diff --git a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.scss b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 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 new file mode 100644 index 0000000000..5c2511adb0 --- /dev/null +++ b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts @@ -0,0 +1,151 @@ +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 {NgTemplateOutlet} from "@angular/common"; +import {PersonRolePipe} from "../../../_pipes/person-role.pipe"; +import {Person, PersonRole} from "../../../_models/metadata/person"; +import { + NgbActiveModal, + NgbNav, + NgbNavContent, + NgbNavItem, NgbNavLink, + NgbNavLinkBase, + NgbNavOutlet +} from "@ng-bootstrap/ng-bootstrap"; +import {PersonService} from "../../../_services/person.service"; +import { TranslocoDirective } from '@jsverse/transloco'; +import {CoverImageChooserComponent} from "../../../cards/cover-image-chooser/cover-image-chooser.component"; +import {forkJoin} from "rxjs"; +import {UploadService} from "../../../_services/upload.service"; +import {CompactNumberPipe} from "../../../_pipes/compact-number.pipe"; +import {SettingItemComponent} from "../../../settings/_components/setting-item/setting-item.component"; +import {AccountService} from "../../../_services/account.service"; +import {User} from "../../../_models/user"; + +enum TabID { + General = 'general-tab', + CoverImage = 'cover-image-tab', +} + +@Component({ + selector: 'app-edit-person-modal', + standalone: true, + imports: [ + ReactiveFormsModule, + NgTemplateOutlet, + PersonRolePipe, + NgbNav, + NgbNavItem, + TranslocoDirective, + NgbNavLinkBase, + NgbNavContent, + NgbNavOutlet, + CoverImageChooserComponent, + CompactNumberPipe, + SettingItemComponent, + NgbNavLink + ], + templateUrl: './edit-person-modal.component.html', + styleUrl: './edit-person-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class EditPersonModalComponent implements OnInit { + + protected readonly utilityService = inject(UtilityService); + private readonly modal = inject(NgbActiveModal); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly personService = inject(PersonService); + private readonly uploadService = inject(UploadService); + protected readonly accountService = inject(AccountService); + + protected readonly Breakpoint = Breakpoint; + protected readonly TabID = TabID; + + @Input({required: true}) person!: Person; + + active = TabID.General; + editForm: FormGroup = new FormGroup({ + name: new FormControl('', [Validators.required]), + description: new FormControl('', []), + asin: new FormControl('', []), + aniListId: new FormControl('', []), + malId: new FormControl('', []), + hardcoverId: new FormControl('', []), + }); + + imageUrls: Array = []; + selectedCover: string = ''; + coverImageReset = false; + touchedCoverImage = false; + + ngOnInit() { + if (this.person) { + this.editForm.get('name')!.setValue(this.person.name); + this.editForm.get('description')!.setValue(this.person.description); + this.editForm.get('asin')!.setValue((this.person.asin || '')); + this.editForm.get('aniListId')!.setValue((this.person.aniListId || '') + '') ; + this.editForm.get('malId')!.setValue((this.person.malId || '') + ''); + this.editForm.get('hardcoverId')!.setValue(this.person.hardcoverId || ''); + + this.editForm.addControl('coverImageIndex', new FormControl(0, [])); + this.editForm.addControl('coverImageLocked', new FormControl(this.person.coverImageLocked, [])); + + this.cdRef.markForCheck(); + } + } + + + close() { + this.modal.close({success: false, coverImageUpdate: false}); + } + + save() { + const apis = []; + + if (this.touchedCoverImage || this.coverImageReset) { + apis.push(this.uploadService.updatePersonCoverImage(this.person.id, this.selectedCover, !this.coverImageReset)); + } + + const person: Person = { + id: this.person.id, + coverImageLocked: this.person.coverImageLocked, + name: this.editForm.get('name')!.value || '', + description: this.editForm.get('description')!.value || '', + asin: this.editForm.get('asin')!.value || '', + // @ts-ignore + aniListId: this.editForm.get('aniListId')!.value === '' ? null : parseInt(this.editForm.get('aniListId').value, 10), + // @ts-ignore + malId: this.editForm.get('malId')!.value === '' ? null : parseInt(this.editForm.get('malId').value, 10), + hardcoverId: this.editForm.get('hardcoverId')!.value || '', + }; + apis.push(this.personService.updatePerson(person)); + + forkJoin(apis).subscribe(_ => { + this.modal.close({success: true, coverImageUpdate: false, person: person}); + }); + } + + updateSelectedIndex(index: number) { + this.editForm.patchValue({ + coverImageIndex: index + }); + this.touchedCoverImage = true; + this.cdRef.markForCheck(); + } + + updateSelectedImage(url: string) { + this.selectedCover = url; + this.touchedCoverImage = true; + this.cdRef.markForCheck(); + } + + handleReset() { + this.coverImageReset = true; + this.editForm.patchValue({ + coverImageLocked: false + }); + this.touchedCoverImage = true; + this.cdRef.markForCheck(); + } + +} diff --git a/UI/Web/src/app/person-detail/person-detail.component.html b/UI/Web/src/app/person-detail/person-detail.component.html new file mode 100644 index 0000000000..d1bb9aa329 --- /dev/null +++ b/UI/Web/src/app/person-detail/person-detail.component.html @@ -0,0 +1,94 @@ +
+ + @if (person$ | async; as person) { +
+ + +

+ {{person.name}} + +

+
+
+
+ +
+
+
+ +
+
+
+
+ @if (roles$ | async; as roles) { +
+ @for(role of roles; track role) { + {{role | personRole}} + } +
+ } +
+ +
+
+
+
+ + @if (works$ | async; as works) { +
+ + + + + Hello + + + + +
+ } + + + + @if (roles$ | async; as roles) { + @for(role of roles; track role) { +
+ + + + + +
+ } + } + + + + + + +
+ +
+ + +
+ +
+ + + @if (accountService.hasValidLicense$ | async) { +
+ +
+ } + + } +
+
diff --git a/UI/Web/src/app/person-detail/person-detail.component.scss b/UI/Web/src/app/person-detail/person-detail.component.scss new file mode 100644 index 0000000000..85679e5855 --- /dev/null +++ b/UI/Web/src/app/person-detail/person-detail.component.scss @@ -0,0 +1,59 @@ +@import '../../theme/variables'; + +.main-container { + margin-top: 10px; + + .main-container { + padding: unset; + + .info-container { + display: flex; + gap: 3rem; + + .image-container { + background-color: rgba(0, 0, 0, .45); + border-radius: 3%; + box-shadow: 0 0 4px rgba(0, 0, 0, .3); + } + + .person-details-container { + .person-details { + gap: 2rem; + + .person-name-role-container { + .person-name { + margin: unset; + } + } + } + } + } + } +} + +@media (max-width: $grid-breakpoints-sm) { + .main-container { + + .main-container { + + .info-container { + flex-direction: column; + + .image-container { + display: flex; + align-self: center; + } + .person-details-container { + .person-details { + margin: 0 8px; + .person-name-role-container { + display: flex; + flex-direction: column; + align-items: center; + } + } + } + } + } + } +} diff --git a/UI/Web/src/app/person-detail/person-detail.component.ts b/UI/Web/src/app/person-detail/person-detail.component.ts new file mode 100644 index 0000000000..278016fb64 --- /dev/null +++ b/UI/Web/src/app/person-detail/person-detail.component.ts @@ -0,0 +1,209 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, DestroyRef, + ElementRef, + Inject, + inject, + ViewChild +} from '@angular/core'; +import {ActivatedRoute, Router} from "@angular/router"; +import {PersonService} from "../_services/person.service"; +import {Observable, switchMap, tap} from "rxjs"; +import {Person, PersonRole} from "../_models/metadata/person"; +import {AsyncPipe, DOCUMENT, NgStyle} 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 {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 {Series} from "../_models/series"; +import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {FilterCombination} from "../_models/metadata/v2/filter-combination"; +import {AccountService} from "../_services/account.service"; +import {CardItemComponent} from "../cards/card-item/card-item.component"; +import {CardActionablesComponent} from "../_single-module/card-actionables/card-actionables.component"; +import {Action, ActionFactoryService, ActionItem} from "../_services/action-factory.service"; +import {NgbModal} from "@ng-bootstrap/ng-bootstrap"; +import {EditPersonModalComponent} from "./_modal/edit-person-modal/edit-person-modal.component"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; +import {ChapterCardComponent} from "../cards/chapter-card/chapter-card.component"; +import {ThemeService} from "../_services/theme.service"; + +@Component({ + selector: 'app-person-detail', + standalone: true, + imports: [ + AsyncPipe, + ImageComponent, + SideNavCompanionBarComponent, + NgStyle, + ReadMoreComponent, + TagBadgeComponent, + PersonRolePipe, + CarouselReelComponent, + SeriesCardComponent, + CardItemComponent, + CardActionablesComponent, + TranslocoDirective, + ChapterCardComponent + ], + templateUrl: './person-detail.component.html', + styleUrl: './person-detail.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PersonDetailComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly filterUtilityService = inject(FilterUtilitiesService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly destroyRef = inject(DestroyRef); + protected readonly personService = inject(PersonService); + private readonly actionService = inject(ActionFactoryService); + private readonly modalService = inject(NgbModal); + protected readonly imageService = inject(ImageService); + protected readonly accountService = inject(AccountService); + private readonly themeService = inject(ThemeService); + + protected readonly TagBadgeCursor = TagBadgeCursor; + + @ViewChild('scrollingBlock') scrollingBlock: ElementRef | undefined; + @ViewChild('companionBar') companionBar: ElementRef | undefined; + + personName!: string; + person$: Observable | null = null; + person: Person | null = null; + 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 = {}; + + constructor(@Inject(DOCUMENT) private document: Document) { + this.route.paramMap.subscribe(_ => { + const personName = this.route.snapshot.paramMap.get('name'); + if (personName === null || undefined) { + this.router.navigateByUrl('/home'); + return; + } + + this.personName = personName; + + + this.person$ = this.personService.get(this.personName).pipe(tap(p => { + this.person = p; + + this.themeService.setColorScape(this.person.primaryColor || '', this.person.secondaryColor); + + this.roles$ = this.personService.getRolesForPerson(this.personName).pipe(tap(roles => { + this.roles = roles; + this.filter = this.createFilter(roles); + + for(let role of roles) { + this.chaptersByRole[role] = this.personService.getChaptersByRole(this.person!.id, role).pipe(takeUntilDestroyed(this.destroyRef)); + } + + this.cdRef.markForCheck(); + }), takeUntilDestroyed(this.destroyRef)); + + + this.works$ = this.personService.getSeriesMostKnownFor(this.person.id).pipe( + takeUntilDestroyed(this.destroyRef) + ); + + this.cdRef.markForCheck(); + }), takeUntilDestroyed(this.destroyRef)); + }); + } + + createFilter(roles: PersonRole[]) { + const filter: SeriesFilterV2 = this.filterUtilityService.createSeriesV2Filter(); + filter.combination = FilterCombination.Or; + filter.limitTo = 20; + + // I might want to use roles$ to do all this + allPeople.forEach(f => { + filter.statements.push({comparison: FilterComparison.Contains, value: this.person!.id + '', field: f}); + }); + + return filter; + } + + loadFilterByPerson() { + const loadPage = (person: Person) => { + // Create a filter of all roles with OR + const params: any = {}; + params['page'] = 1; + params['title'] = translate('person-detail.browse-person-title', {name: person.name}); + + const searchFilter = {...this.filter!}; + searchFilter.limitTo = 0; + + return this.filterUtilityService.applyFilterWithParams(['all-series'], searchFilter, params); + }; + + + if (this.person) { + loadPage(this.person).subscribe(); + } else { + this.person$?.pipe(switchMap((p: Person) => { + return loadPage(p); + })).subscribe(); + } + } + + loadFilterByRole(role: PersonRole) { + const personPipe = new PersonRolePipe(); + // Create a filter of all roles with OR + const params: any = {}; + params['page'] = 1; + params['title'] = translate('person-detail.browse-person-by-role-title', {name: this.person!.name, role: personPipe.transform(role)}); + + const searchFilter = this.filterUtilityService.createSeriesV2Filter(); + searchFilter.limitTo = 0; + searchFilter.combination = FilterCombination.Or; + + searchFilter.statements.push({comparison: FilterComparison.Contains, value: this.person!.id + '', field: personRoleForFilterField(role)}); + + this.filterUtilityService.applyFilterWithParams(['all-series'], searchFilter, params).subscribe(); + } + + navigateToSeries(series: Series) { + this.router.navigate(['library', series.libraryId, 'series', series.id]); + } + + handleAction(action: ActionItem, person: Person) { + switch (action.action) { + case(Action.Edit): + const ref = this.modalService.open(EditPersonModalComponent, {scrollable: true, size: 'lg', fullscreen: 'md'}); + ref.componentInstance.person = this.person; + + ref.closed.subscribe(r => { + if (r.success) { + this.person = {...r.person}; + this.cdRef.markForCheck(); + } + }); + break; + default: + break; + } + } + + performAction(action: ActionItem) { + if (typeof action.callback === 'function') { + action.callback(action, this.person); + } + } +} 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 1e91b18c0e..e0037204e9 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 @@ -122,7 +122,7 @@

[allowToggle]="false" (toggle)="switchTabsToDetail()"> - {{item.name}} + {{item.name}}

diff --git a/UI/Web/src/app/shared/_services/download.service.ts b/UI/Web/src/app/shared/_services/download.service.ts index 90747c1d7f..49d57efbc8 100644 --- a/UI/Web/src/app/shared/_services/download.service.ts +++ b/UI/Web/src/app/shared/_services/download.service.ts @@ -26,6 +26,7 @@ import {UtilityService} from "./utility.service"; import {UserCollection} from "../../_models/collection-tag"; import {RecentlyAddedItem} from "../../_models/recently-added-item"; import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter"; +import {BrowsePerson} from "../../_models/person/browse-person"; export const DEBOUNCE_TIME = 100; @@ -360,24 +361,28 @@ export class DownloadService { } } - mapToEntityType(events: DownloadEvent[], entity: Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter) { + mapToEntityType(events: DownloadEvent[], entity: Series | Volume | Chapter | UserCollection | PageBookmark | RecentlyAddedItem | NextExpectedChapter | BrowsePerson) { if(this.utilityService.isSeries(entity)) { return events.find(e => e.entityType === 'series' && e.id == entity.id && e.subTitle === this.downloadSubtitle('series', (entity as Series))) || null; } + if(this.utilityService.isVolume(entity)) { return events.find(e => e.entityType === 'volume' && e.id == entity.id && e.subTitle === this.downloadSubtitle('volume', (entity as Volume))) || null; } + if(this.utilityService.isChapter(entity)) { return events.find(e => e.entityType === 'chapter' && e.id == entity.id && e.subTitle === this.downloadSubtitle('chapter', (entity as Chapter))) || null; } + // Is PageBookmark[] if(entity.hasOwnProperty('length')) { return events.find(e => e.entityType === 'bookmark' && e.subTitle === this.downloadSubtitle('bookmark', [(entity as PageBookmark)])) || null; } + return null; } } diff --git a/UI/Web/src/app/shared/image/image.component.ts b/UI/Web/src/app/shared/image/image.component.ts index 6098f7740f..1e8713e86d 100644 --- a/UI/Web/src/app/shared/image/image.component.ts +++ b/UI/Web/src/app/shared/image/image.component.ts @@ -109,7 +109,8 @@ export class ImageComponent implements OnChanges { } if (this.classes != '') { - this.renderer.addClass(this.imgElem.nativeElement, this.classes); + const classTokens = this.classes.split(' '); + classTokens.forEach(cls => this.renderer.addClass(this.imgElem.nativeElement, cls)); } this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/shared/person-badge/person-badge.component.html b/UI/Web/src/app/shared/person-badge/person-badge.component.html index 687441886c..544d2de985 100644 --- a/UI/Web/src/app/shared/person-badge/person-badge.component.html +++ b/UI/Web/src/app/shared/person-badge/person-badge.component.html @@ -1,15 +1,25 @@ -
+@if (person !== undefined) { +
- - - - - - -
- - {{person.name}} - -
+ @if (HasCoverImage) { + + + } @else { + + } + + +
+ @if (isStaff) { + {{person.name}} + } @else { + {{person.name}} + } +
-
+
+} + diff --git a/UI/Web/src/app/shared/person-badge/person-badge.component.ts b/UI/Web/src/app/shared/person-badge/person-badge.component.ts index 82e1d89b74..8501e635a6 100644 --- a/UI/Web/src/app/shared/person-badge/person-badge.component.ts +++ b/UI/Web/src/app/shared/person-badge/person-badge.component.ts @@ -3,24 +3,38 @@ import { Person } from '../../_models/metadata/person'; import {CommonModule} from "@angular/common"; import {SeriesStaff} from "../../_models/series-detail/external-series-detail"; import {ImageComponent} from "../image/image.component"; +import {ImageService} from "../../_services/image.service"; +import {RouterLink} from "@angular/router"; @Component({ selector: 'app-person-badge', standalone: true, - imports: [CommonModule, ImageComponent], + imports: [ImageComponent, RouterLink], templateUrl: './person-badge.component.html', styleUrls: ['./person-badge.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class PersonBadgeComponent implements OnInit { + protected readonly imageService = inject(ImageService); + private readonly cdRef = inject(ChangeDetectorRef); + @Input({required: true}) person!: Person | SeriesStaff; @Input() isStaff = false; - private readonly cdRef = inject(ChangeDetectorRef); - staff!: SeriesStaff; + get HasCoverImage() { + return this.isStaff || (this.person as Person).coverImage; + } + + get ImageUrl() { + if (this.isStaff && this.staff.imageUrl && !this.staff.imageUrl.endsWith('default.jpg')) { + return (this.person as SeriesStaff).imageUrl || ''; + } + return this.imageService.getPersonImage((this.person as Person).id); + } + ngOnInit() { this.staff = this.person as SeriesStaff; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html index 033503cd7e..7b00703db3 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.html @@ -56,6 +56,10 @@ } + @case (SideNavStreamType.BrowseAuthors) { + + } + @case (SideNavStreamType.SmartFilter) { } diff --git a/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts index 7b69a03719..f42b48d106 100644 --- a/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts +++ b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.ts @@ -20,9 +20,4 @@ export class SidenavStreamListItemComponent { @Output() hide: EventEmitter = new EventEmitter(); protected readonly SideNavStreamType = SideNavStreamType; protected readonly baseUrl = inject(APP_BASE_HREF); - - constructor() { - console.log('baseUrl', this.baseUrl); - } - } diff --git a/UI/Web/src/assets/images/error-person-missing.dark.min.png b/UI/Web/src/assets/images/error-person-missing.dark.min.png new file mode 100644 index 0000000000..1f445e7950 Binary files /dev/null and b/UI/Web/src/assets/images/error-person-missing.dark.min.png differ diff --git a/UI/Web/src/assets/images/error-person-missing.dark.png b/UI/Web/src/assets/images/error-person-missing.dark.png new file mode 100644 index 0000000000..1b1ea697c5 Binary files /dev/null and b/UI/Web/src/assets/images/error-person-missing.dark.png differ diff --git a/UI/Web/src/assets/images/error-person-missing.min.png b/UI/Web/src/assets/images/error-person-missing.min.png new file mode 100644 index 0000000000..f112d61c3b Binary files /dev/null and b/UI/Web/src/assets/images/error-person-missing.min.png differ diff --git a/UI/Web/src/assets/images/error-person-missing.png b/UI/Web/src/assets/images/error-person-missing.png new file mode 100644 index 0000000000..f9ed791a52 Binary files /dev/null and b/UI/Web/src/assets/images/error-person-missing.png differ diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index 1a1ef82ddd..f19a6183d4 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -901,6 +901,7 @@ "collections": "Collections", "reading-lists": "Reading Lists", "bookmarks": "Bookmarks", + "browse-authors": "Browse Authors", "filter-label": "{{common.filter}}", "all-series": "All Series", "clear": "{{common.clear}}", @@ -911,6 +912,19 @@ "customize": "{{settings.customize}}" }, + "browse-authors": { + "title": "Browse Authors & Writers", + "author-count": "{{num}} People", + "cover-image-description": "{{edit-series-modal.cover-image-description}}" + }, + + "person-detail": { + "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}}" + }, + "library-settings-modal": { "close": "{{common.close}}", "edit-title": "Edit {{name}}", @@ -1985,6 +1999,24 @@ "cover-image-description": "{{edit-series-modal.cover-image-description}}" }, + "edit-person-modal": { + "title": "{{personName}} Details", + "general-tab": "{{edit-series-modal.general-tab}}", + "cover-image-tab": "{{edit-series-modal.cover-image-tab}}", + "loading": "{{common.loading}}", + "close": "{{common.close}}", + "name-label": "{{edit-series-modal.name-label}}", + "role-label": "Role", + "mal-id-label": "MAL Id", + "anilist-id-label": "AniList Id", + "hardcover-id-label": "Hardcover Id", + "asin-label": "ASIN", + "description-label": "Description", + "required-field": "{{validations.required-field}}", + "cover-image-description": "{{edit-series-modal.cover-image-description}}", + "save": "{{common.save}}" + }, + "day-breakdown": { "title": "Day Breakdown", "no-data": "No progress, get to reading", @@ -2197,7 +2229,8 @@ "collections": "{{side-nav.collections}}", "reading-lists": "{{side-nav.reading-lists}}", "bookmarks": "{{side-nav.bookmarks}}", - "all-series": "{{side-nav.all-series}}" + "all-series": "{{side-nav.all-series}}", + "browse-authors": "{{side-nav.browse-authors}}" }, "filter-field-pipe": { @@ -2205,7 +2238,7 @@ "characters": "{{metadata-fields.characters-title}}", "collection-tags": "Collection Tags", "colorist": "Colorist", - "cover-artist": "{{person-role-pipe.cover-artist}}", + "cover-artist": "{{person-role-pipe.artist}}", "editor": "Editor", "formats": "Formats", "genres": "{{metadata-fields.genres-title}}", @@ -2555,6 +2588,7 @@ "select-all": "Select All", "deselect-all": "Deselect All", "series-count": "{{num}} Series", + "author-count": "{{num}} Authors", "item-count": "{{num}} Items", "book-num": "Book",