diff --git a/.github/workflows/sonar-scan.yml b/.github/workflows/sonar-scan.yml index 7ae8e9f76..7015ee8d5 100644 --- a/.github/workflows/sonar-scan.yml +++ b/.github/workflows/sonar-scan.yml @@ -35,63 +35,63 @@ jobs: name: csproj path: Kavita.Common/Kavita.Common.csproj - test: - name: Install Sonar & Test - needs: build - runs-on: windows-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - - name: Setup .NET Core - uses: actions/setup-dotnet@v1 - with: - dotnet-version: 6.0.100 - - - name: Install dependencies - run: dotnet restore - - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 1.11 - - - name: Cache SonarCloud packages - uses: actions/cache@v1 - with: - path: ~\sonar\cache - key: ${{ runner.os }}-sonar - restore-keys: ${{ runner.os }}-sonar - - - name: Cache SonarCloud scanner - id: cache-sonar-scanner - uses: actions/cache@v1 - with: - path: .\.sonar\scanner - key: ${{ runner.os }}-sonar-scanner - restore-keys: ${{ runner.os }}-sonar-scanner - - - name: Install SonarCloud scanner - if: steps.cache-sonar-scanner.outputs.cache-hit != 'true' - shell: powershell - run: | - New-Item -Path .\.sonar\scanner -ItemType Directory - dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner - - - name: Sonar Scan - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - shell: powershell - run: | - .\.sonar\scanner\dotnet-sonarscanner begin /k:"Kareadita_Kavita" /o:"kareadita" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" - dotnet build --configuration Release - .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" - - - name: Test - run: dotnet test --no-restore --verbosity normal +# test: +# name: Install Sonar & Test +# needs: build +# runs-on: windows-latest +# steps: +# - name: Checkout Repo +# uses: actions/checkout@v2 +# with: +# fetch-depth: 0 +# +# - name: Setup .NET Core +# uses: actions/setup-dotnet@v1 +# with: +# dotnet-version: 6.0.100 +# +# - name: Install dependencies +# run: dotnet restore +# +# - name: Set up JDK 11 +# uses: actions/setup-java@v1 +# with: +# java-version: 1.11 +# +# - name: Cache SonarCloud packages +# uses: actions/cache@v1 +# with: +# path: ~\sonar\cache +# key: ${{ runner.os }}-sonar +# restore-keys: ${{ runner.os }}-sonar +# +# - name: Cache SonarCloud scanner +# id: cache-sonar-scanner +# uses: actions/cache@v1 +# with: +# path: .\.sonar\scanner +# key: ${{ runner.os }}-sonar-scanner +# restore-keys: ${{ runner.os }}-sonar-scanner +# +# - name: Install SonarCloud scanner +# if: steps.cache-sonar-scanner.outputs.cache-hit != 'true' +# shell: powershell +# run: | +# New-Item -Path .\.sonar\scanner -ItemType Directory +# dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner +# +# - name: Sonar Scan +# env: +# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any +# SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} +# shell: powershell +# run: | +# .\.sonar\scanner\dotnet-sonarscanner begin /k:"Kareadita_Kavita" /o:"kareadita" /d:sonar.login="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" +# dotnet build --configuration Release +# .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.login="${{ secrets.SONAR_TOKEN }}" +# +# - name: Test +# run: dotnet test --no-restore --verbosity normal version: name: Bump version on Develop push diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj index d63d24ddc..cbf7d76f9 100644 --- a/API.Benchmark/API.Benchmark.csproj +++ b/API.Benchmark/API.Benchmark.csproj @@ -1,7 +1,7 @@ - net5.0 + net6.0 Exe diff --git a/API.Benchmark/ParseScannedFilesBenchmarks.cs b/API.Benchmark/ParseScannedFilesBenchmarks.cs index 8681c1261..bf8f65c24 100644 --- a/API.Benchmark/ParseScannedFilesBenchmarks.cs +++ b/API.Benchmark/ParseScannedFilesBenchmarks.cs @@ -1,4 +1,5 @@ using System.IO; +using System.IO.Abstractions; using API.Entities.Enums; using API.Interfaces.Services; using API.Parser; @@ -20,11 +21,13 @@ namespace API.Benchmark private readonly ParseScannedFiles _parseScannedFiles; private readonly ILogger _logger = Substitute.For>(); private readonly ILogger _bookLogger = Substitute.For>(); + private readonly IArchiveService _archiveService = Substitute.For(); public ParseScannedFilesBenchmarks() { IBookService bookService = new BookService(_bookLogger); - _parseScannedFiles = new ParseScannedFiles(bookService, _logger); + _parseScannedFiles = new ParseScannedFiles(bookService, _logger, _archiveService, + new DirectoryService(Substitute.For>(), new FileSystem())); } // [Benchmark] diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 59ecff406..794b6b1ec 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -1,15 +1,16 @@ - net5.0 + net6.0 false - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API.Tests/Extensions/SeriesExtensionsTests.cs b/API.Tests/Extensions/SeriesExtensionsTests.cs index 35054770b..ef6e3f25f 100644 --- a/API.Tests/Extensions/SeriesExtensionsTests.cs +++ b/API.Tests/Extensions/SeriesExtensionsTests.cs @@ -1,4 +1,5 @@ using API.Entities; +using API.Entities.Metadata; using API.Extensions; using API.Parser; using Xunit; diff --git a/API.Tests/Helpers/CacheHelperTests.cs b/API.Tests/Helpers/CacheHelperTests.cs new file mode 100644 index 000000000..6d286f541 --- /dev/null +++ b/API.Tests/Helpers/CacheHelperTests.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions.TestingHelpers; +using API.Entities; +using API.Helpers; +using API.Services; +using Xunit; + +namespace API.Tests.Helpers; + +public class CacheHelperTests +{ + private const string TestCoverImageDirectory = @"c:\"; + private const string TestCoverImageFile = "thumbnail.jpg"; + private readonly string _testCoverPath = Path.Join(TestCoverImageDirectory, TestCoverImageFile); + private const string TestCoverArchive = @"file in folder.zip"; + private readonly ICacheHelper _cacheHelper; + + public CacheHelperTests() + { + var file = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now.Subtract(TimeSpan.FromMinutes(1)) + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Join(TestCoverImageDirectory, TestCoverArchive), file }, + { Path.Join(TestCoverImageDirectory, TestCoverImageFile), file } + }); + + var fileService = new FileService(fileSystem); + _cacheHelper = new CacheHelper(fileService); + } + + [Theory] + [InlineData("", false)] + [InlineData("C:/", false)] + [InlineData(null, false)] + public void CoverImageExists_DoesFileExist(string coverImage, bool exists) + { + Assert.Equal(exists, _cacheHelper.CoverImageExists(coverImage)); + } + + [Fact] + public void CoverImageExists_FileExists() + { + Assert.True(_cacheHelper.CoverImageExists(TestCoverArchive)); + } + + [Fact] + public void ShouldUpdateCoverImage_OnFirstRun() + { + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = DateTime.Now + }; + Assert.True(_cacheHelper.ShouldUpdateCoverImage(null, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), + false, false)); + } + + [Fact] + public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetNotLocked() + { + // Represents first run + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = DateTime.Now + }; + Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), + false, false)); + } + + [Fact] + public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked() + { + // Represents first run + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = DateTime.Now + }; + Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), + false, true)); + } + + [Fact] + public void ShouldUpdateCoverImage_ShouldNotUpdateOnSecondRunWithCoverImageSetLocked_Modified() + { + // Represents first run + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = DateTime.Now + }; + Assert.False(_cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), + false, true)); + } + + [Fact] + public void ShouldUpdateCoverImage_CoverImageSetAndReplaced_Modified() + { + var filesystemFile = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Join(TestCoverImageDirectory, TestCoverArchive), filesystemFile }, + { Path.Join(TestCoverImageDirectory, TestCoverImageFile), filesystemFile } + }); + + var fileService = new FileService(fileSystem); + var cacheHelper = new CacheHelper(fileService); + + var created = DateTime.Now.Subtract(TimeSpan.FromHours(1)); + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = DateTime.Now.Subtract(TimeSpan.FromMinutes(1)) + }; + Assert.True(cacheHelper.ShouldUpdateCoverImage(_testCoverPath, file, created, + false, false)); + } + + [Fact] + public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceCreated() + { + var filesystemFile = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Join(TestCoverImageDirectory, TestCoverArchive), filesystemFile }, + { Path.Join(TestCoverImageDirectory, TestCoverImageFile), filesystemFile } + }); + + var fileService = new FileService(fileSystem); + var cacheHelper = new CacheHelper(fileService); + + var chapter = new Chapter() + { + Created = filesystemFile.LastWriteTime.DateTime, + LastModified = filesystemFile.LastWriteTime.DateTime + }; + + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = filesystemFile.LastWriteTime.DateTime + }; + Assert.True(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file)); + } + + [Fact] + public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceLastModified() + { + var filesystemFile = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Join(TestCoverImageDirectory, TestCoverArchive), filesystemFile }, + { Path.Join(TestCoverImageDirectory, TestCoverImageFile), filesystemFile } + }); + + var fileService = new FileService(fileSystem); + var cacheHelper = new CacheHelper(fileService); + + var chapter = new Chapter() + { + Created = filesystemFile.LastWriteTime.DateTime, + LastModified = filesystemFile.LastWriteTime.DateTime + }; + + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = filesystemFile.LastWriteTime.DateTime + }; + Assert.True(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file)); + } + + [Fact] + public void HasFileNotChangedSinceCreationOrLastScan_NotChangedSinceLastModified_ForceUpdate() + { + var filesystemFile = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Join(TestCoverImageDirectory, TestCoverArchive), filesystemFile }, + { Path.Join(TestCoverImageDirectory, TestCoverImageFile), filesystemFile } + }); + + var fileService = new FileService(fileSystem); + var cacheHelper = new CacheHelper(fileService); + + var chapter = new Chapter() + { + Created = filesystemFile.LastWriteTime.DateTime, + LastModified = filesystemFile.LastWriteTime.DateTime + }; + + var file = new MangaFile() + { + FilePath = TestCoverArchive, + LastModified = filesystemFile.LastWriteTime.DateTime + }; + Assert.False(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, true, file)); + } + + [Fact] + public void HasFileNotChangedSinceCreationOrLastScan_ModifiedSinceLastScan() + { + var filesystemFile = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Join(TestCoverImageDirectory, TestCoverArchive), filesystemFile }, + { Path.Join(TestCoverImageDirectory, TestCoverImageFile), filesystemFile } + }); + + var fileService = new FileService(fileSystem); + var cacheHelper = new CacheHelper(fileService); + + var chapter = new Chapter() + { + Created = filesystemFile.LastWriteTime.DateTime.Subtract(TimeSpan.FromMinutes(10)), + LastModified = filesystemFile.LastWriteTime.DateTime.Subtract(TimeSpan.FromMinutes(10)) + }; + + var file = new MangaFile() + { + FilePath = Path.Join(TestCoverImageDirectory, TestCoverArchive), + LastModified = filesystemFile.LastWriteTime.DateTime + }; + Assert.False(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file)); + } + + [Fact] + public void HasFileNotChangedSinceCreationOrLastScan_ModifiedSinceLastScan_ButLastModifiedSame() + { + var filesystemFile = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { Path.Join(TestCoverImageDirectory, TestCoverArchive), filesystemFile }, + { Path.Join(TestCoverImageDirectory, TestCoverImageFile), filesystemFile } + }); + + var fileService = new FileService(fileSystem); + var cacheHelper = new CacheHelper(fileService); + + var chapter = new Chapter() + { + Created = filesystemFile.LastWriteTime.DateTime.Subtract(TimeSpan.FromMinutes(10)), + LastModified = filesystemFile.LastWriteTime.DateTime + }; + + var file = new MangaFile() + { + FilePath = Path.Join(TestCoverImageDirectory, TestCoverArchive), + LastModified = filesystemFile.LastWriteTime.DateTime + }; + Assert.False(cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, file)); + } +} diff --git a/API.Tests/Helpers/EntityFactory.cs b/API.Tests/Helpers/EntityFactory.cs index 456cd1b52..a312417bd 100644 --- a/API.Tests/Helpers/EntityFactory.cs +++ b/API.Tests/Helpers/EntityFactory.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; namespace API.Tests.Helpers { diff --git a/API.Tests/Helpers/GenreHelperTests.cs b/API.Tests/Helpers/GenreHelperTests.cs new file mode 100644 index 000000000..0d0aebe0c --- /dev/null +++ b/API.Tests/Helpers/GenreHelperTests.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using API.Data; +using API.Entities; +using API.Helpers; +using Xunit; + +namespace API.Tests.Helpers; + +public class GenreHelperTests +{ + [Fact] + public void UpdateGenre_ShouldAddNewGenre() + { + var allGenres = new List + { + DbFactory.Genre("Action", false), + DbFactory.Genre("action", false), + DbFactory.Genre("Sci-fi", false), + }; + var genreAdded = new List(); + + GenreHelper.UpdateGenre(allGenres, new[] {"Action", "Adventure"}, false, genre => + { + genreAdded.Add(genre); + }); + + Assert.Equal(2, genreAdded.Count); + Assert.Equal(4, allGenres.Count); + } + + [Fact] + public void UpdateGenre_ShouldNotAddDuplicateGenre() + { + var allGenres = new List + { + DbFactory.Genre("Action", false), + DbFactory.Genre("action", false), + DbFactory.Genre("Sci-fi", false), + + }; + var genreAdded = new List(); + + GenreHelper.UpdateGenre(allGenres, new[] {"Action", "Scifi"}, false, genre => + { + genreAdded.Add(genre); + }); + + Assert.Equal(3, allGenres.Count); + } + + [Fact] + public void AddGenre_ShouldAddOnlyNonExistingGenre() + { + var existingGenres = new List + { + DbFactory.Genre("Action", false), + DbFactory.Genre("action", false), + DbFactory.Genre("Sci-fi", false), + }; + + + GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Action", false)); + Assert.Equal(3, existingGenres.Count); + + GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("action", false)); + Assert.Equal(3, existingGenres.Count); + + GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Shonen", false)); + Assert.Equal(4, existingGenres.Count); + } + + [Fact] + public void AddGenre_ShouldNotAddSameNameAndExternal() + { + var existingGenres = new List + { + DbFactory.Genre("Action", false), + DbFactory.Genre("action", false), + DbFactory.Genre("Sci-fi", false), + }; + + + GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Action", true)); + Assert.Equal(3, existingGenres.Count); + } + + [Fact] + public void KeepOnlySamePeopleBetweenLists() + { + var existingGenres = new List + { + DbFactory.Genre("Action", false), + DbFactory.Genre("Sci-fi", false), + }; + + var peopleFromChapters = new List + { + DbFactory.Genre("Action", false), + }; + + var genreRemoved = new List(); + GenreHelper.KeepOnlySameGenreBetweenLists(existingGenres, + peopleFromChapters, genre => + { + genreRemoved.Add(genre); + }); + + Assert.Equal(1, genreRemoved.Count); + } +} diff --git a/API.Tests/Helpers/PersonHelperTests.cs b/API.Tests/Helpers/PersonHelperTests.cs new file mode 100644 index 000000000..f5b5551ae --- /dev/null +++ b/API.Tests/Helpers/PersonHelperTests.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using API.Data; +using API.Entities; +using API.Entities.Enums; +using API.Helpers; +using Xunit; + +namespace API.Tests.Helpers; + +public class PersonHelperTests +{ + [Fact] + public void UpdatePeople_ShouldAddNewPeople() + { + var allPeople = new List + { + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist), + DbFactory.Person("Joe Shmo", PersonRole.Writer) + }; + 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 + { + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist), + DbFactory.Person("Joe Shmo", PersonRole.Writer), + DbFactory.Person("Sally Ann", PersonRole.CoverArtist), + + }; + var peopleAdded = new List(); + + PersonHelper.UpdatePeople(allPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.CoverArtist, person => + { + peopleAdded.Add(person); + }); + + Assert.Equal(3, allPeople.Count); + } + + [Fact] + public void RemovePeople_ShouldRemovePeopleOfSameRole() + { + var existingPeople = new List + { + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist), + DbFactory.Person("Joe Shmo", PersonRole.Writer) + }; + var peopleRemoved = new List(); + PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.Writer, person => + { + peopleRemoved.Add(person); + }); + + Assert.NotEqual(existingPeople, peopleRemoved); + Assert.Equal(1, peopleRemoved.Count); + } + + [Fact] + public void RemovePeople_ShouldRemovePeopleFromBothRoles() + { + var existingPeople = new List + { + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist), + DbFactory.Person("Joe Shmo", PersonRole.Writer) + }; + var peopleRemoved = new List(); + PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo", "Sally Ann"}, PersonRole.Writer, person => + { + peopleRemoved.Add(person); + }); + + Assert.NotEqual(existingPeople, peopleRemoved); + Assert.Equal(1, peopleRemoved.Count); + + PersonHelper.RemovePeople(existingPeople, new[] {"Joe Shmo"}, PersonRole.CoverArtist, person => + { + peopleRemoved.Add(person); + }); + + Assert.Equal(0, existingPeople.Count); + Assert.Equal(2, peopleRemoved.Count); + } + + [Fact] + public void KeepOnlySamePeopleBetweenLists() + { + var existingPeople = new List + { + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist), + DbFactory.Person("Joe Shmo", PersonRole.Writer), + DbFactory.Person("Sally", PersonRole.Writer), + }; + + var peopleFromChapters = new List + { + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist), + }; + + var peopleRemoved = new List(); + PersonHelper.KeepOnlySamePeopleBetweenLists(existingPeople, + peopleFromChapters, person => + { + peopleRemoved.Add(person); + }); + + Assert.Equal(2, peopleRemoved.Count); + } + + [Fact] + public void AddPeople_ShouldAddOnlyNonExistingPeople() + { + var existingPeople = new List + { + DbFactory.Person("Joe Shmo", PersonRole.CoverArtist), + DbFactory.Person("Joe Shmo", PersonRole.Writer), + DbFactory.Person("Sally", PersonRole.Writer), + }; + + + PersonHelper.AddPersonIfNotExists(existingPeople, DbFactory.Person("Joe Shmo", PersonRole.CoverArtist)); + Assert.Equal(3, existingPeople.Count); + + PersonHelper.AddPersonIfNotExists(existingPeople, DbFactory.Person("Joe Shmo", PersonRole.Writer)); + Assert.Equal(3, existingPeople.Count); + + PersonHelper.AddPersonIfNotExists(existingPeople, DbFactory.Person("Joe Shmo Two", PersonRole.CoverArtist)); + Assert.Equal(4, existingPeople.Count); + } +} diff --git a/API.Tests/Helpers/SeriesHelperTests.cs b/API.Tests/Helpers/SeriesHelperTests.cs new file mode 100644 index 000000000..df31a9446 --- /dev/null +++ b/API.Tests/Helpers/SeriesHelperTests.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using System.Linq; +using API.Data; +using API.Entities; +using API.Entities.Enums; +using API.Helpers; +using API.Services.Tasks.Scanner; +using Xunit; + +namespace API.Tests.Helpers; + +public class SeriesHelperTests +{ + #region FindSeries + [Fact] + public void FindSeries_ShouldFind_SameFormat() + { + var series = DbFactory.Series("Darker than Black"); + series.OriginalName = "Something Random"; + series.Format = MangaFormat.Archive; + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Archive, + Name = "Darker than Black", + NormalizedName = API.Parser.Parser.Normalize("Darker than Black") + })); + + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Archive, + Name = "Darker than Black".ToLower(), + NormalizedName = API.Parser.Parser.Normalize("Darker than Black") + })); + + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Archive, + Name = "Darker than Black".ToUpper(), + NormalizedName = API.Parser.Parser.Normalize("Darker than Black") + })); + } + + [Fact] + public void FindSeries_ShouldNotFind_WrongFormat() + { + var series = DbFactory.Series("Darker than Black"); + series.OriginalName = "Something Random"; + series.Format = MangaFormat.Archive; + Assert.False(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Darker than Black", + NormalizedName = API.Parser.Parser.Normalize("Darker than Black") + })); + + Assert.False(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Darker than Black".ToLower(), + NormalizedName = API.Parser.Parser.Normalize("Darker than Black") + })); + + Assert.False(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Darker than Black".ToUpper(), + NormalizedName = API.Parser.Parser.Normalize("Darker than Black") + })); + } + + [Fact] + public void FindSeries_ShouldFind_UsingOriginalName() + { + var series = DbFactory.Series("Darker than Black"); + series.OriginalName = "Something Random"; + series.Format = MangaFormat.Image; + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Something Random", + NormalizedName = API.Parser.Parser.Normalize("Something Random") + })); + + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Something Random".ToLower(), + NormalizedName = API.Parser.Parser.Normalize("Something Random") + })); + + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "Something Random".ToUpper(), + NormalizedName = API.Parser.Parser.Normalize("Something Random") + })); + + Assert.True(SeriesHelper.FindSeries(series, new ParsedSeries() + { + Format = MangaFormat.Image, + Name = "SomethingRandom".ToUpper(), + NormalizedName = API.Parser.Parser.Normalize("SomethingRandom") + })); + } + #endregion + + [Fact] + public void RemoveMissingSeries_Should_RemoveSeries() + { + var existingSeries = new List() + { + EntityFactory.CreateSeries("Darker than Black Vol 1"), + EntityFactory.CreateSeries("Darker than Black"), + EntityFactory.CreateSeries("Beastars"), + }; + var missingSeries = new List() + { + EntityFactory.CreateSeries("Darker than Black Vol 1"), + }; + existingSeries = SeriesHelper.RemoveMissingSeries(existingSeries, missingSeries, out var removeCount).ToList(); + + Assert.DoesNotContain(missingSeries[0].Name, existingSeries.Select(s => s.Name)); + Assert.Equal(missingSeries.Count, removeCount); + } +} diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index 8fdf0509d..10aa326ca 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -139,6 +139,14 @@ namespace API.Tests.Parser Assert.Equal(expected, IsImage(filename)); } + [Theory] + [InlineData("Joe Smo", "Joe Smo")] + [InlineData("Smo, Joe", "Joe Smo")] + public void CleanAuthorTest(string author, string expected) + { + Assert.Equal(expected, CleanAuthor(expected)); + } + [Theory] [InlineData("C:/", "C:/Love Hina/Love Hina - Special.cbz", "Love Hina")] [InlineData("C:/", "C:/Love Hina/Specials/Ani-Hina Art Collection.cbz", "Love Hina")] diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs index 7bdc18f1d..6c4d92d9d 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.IO; +using System.IO.Abstractions.TestingHelpers; using System.IO.Compression; using API.Archive; using API.Data.Metadata; @@ -19,7 +20,7 @@ namespace API.Tests.Services private readonly ArchiveService _archiveService; private readonly ILogger _logger = Substitute.For>(); private readonly ILogger _directoryServiceLogger = Substitute.For>(); - private readonly IDirectoryService _directoryService = new DirectoryService(Substitute.For>()); + private readonly IDirectoryService _directoryService = new DirectoryService(Substitute.For>(), new MockFileSystem()); public ArchiveServiceTests(ITestOutputHelper testOutputHelper) { @@ -159,7 +160,7 @@ namespace API.Tests.Services [InlineData("sorting.zip", "sorting.expected.jpg")] public void GetCoverImage_Default_Test(string inputFile, string expectedOutputFile) { - var archiveService = Substitute.For(_logger, new DirectoryService(_directoryServiceLogger)); + var archiveService = Substitute.For(_logger, new DirectoryService(_directoryServiceLogger, new MockFileSystem())); var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"); var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile)); archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.Default); @@ -191,7 +192,7 @@ namespace API.Tests.Services [InlineData("sorting.zip", "sorting.expected.jpg")] public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile) { - var archiveService = Substitute.For(_logger, new DirectoryService(_directoryServiceLogger)); + var archiveService = Substitute.For(_logger, new DirectoryService(_directoryServiceLogger, new MockFileSystem())); var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages"); var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile)); @@ -215,10 +216,23 @@ namespace API.Tests.Services public void ShouldHaveComicInfo() { var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); - var archive = Path.Join(testDirectory, "file in folder.zip"); - var summaryInfo = "By all counts, Ryouta Sakamoto is a loser when he's not holed up in his room, bombing things into oblivion in his favorite online action RPG. But his very own uneventful life is blown to pieces when he's abducted and taken to an uninhabited island, where he soon learns the hard way that he's being pitted against others just like him in a explosives-riddled death match! How could this be happening? Who's putting them up to this? And why!? The name, not to mention the objective, of this very real survival game is eerily familiar to Ryouta, who has mastered its virtual counterpart-BTOOOM! Can Ryouta still come out on top when he's playing for his life!?"; + var archive = Path.Join(testDirectory, "ComicInfo.zip"); + const string summaryInfo = "By all counts, Ryouta Sakamoto is a loser when he's not holed up in his room, bombing things into oblivion in his favorite online action RPG. But his very own uneventful life is blown to pieces when he's abducted and taken to an uninhabited island, where he soon learns the hard way that he's being pitted against others just like him in a explosives-riddled death match! How could this be happening? Who's putting them up to this? And why!? The name, not to mention the objective, of this very real survival game is eerily familiar to Ryouta, who has mastered its virtual counterpart-BTOOOM! Can Ryouta still come out on top when he's playing for his life!?"; - Assert.Equal(summaryInfo, _archiveService.GetComicInfo(archive).Summary); + var comicInfo = _archiveService.GetComicInfo(archive); + Assert.NotNull(comicInfo); + Assert.Equal(summaryInfo, comicInfo.Summary); + } + + [Fact] + public void ShouldHaveComicInfo_WithAuthors() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); + var archive = Path.Join(testDirectory, "ComicInfo_authors.zip"); + + var comicInfo = _archiveService.GetComicInfo(archive); + Assert.NotNull(comicInfo); + Assert.Equal("Junya Inoue", comicInfo.Writer); } [Fact] diff --git a/API.Tests/Services/BookServiceTests.cs b/API.Tests/Services/BookServiceTests.cs index d0cc29040..c46d0a40c 100644 --- a/API.Tests/Services/BookServiceTests.cs +++ b/API.Tests/Services/BookServiceTests.cs @@ -27,5 +27,29 @@ namespace API.Tests.Services Assert.Equal(expectedPages, _bookService.GetNumberOfPages(Path.Join(testDirectory, filePath))); } + [Fact] + public void ShouldHaveComicInfo() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService/EPUB"); + var archive = Path.Join(testDirectory, "The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub"); + const string summaryInfo = "Book Description"; + + var comicInfo = _bookService.GetComicInfo(archive); + Assert.NotNull(comicInfo); + Assert.Equal(summaryInfo, comicInfo.Summary); + Assert.Equal("genre1, genre2", comicInfo.Genre); + } + + [Fact] + public void ShouldHaveComicInfo_WithAuthors() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService/EPUB"); + var archive = Path.Join(testDirectory, "The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub"); + + var comicInfo = _bookService.GetComicInfo(archive); + Assert.NotNull(comicInfo); + Assert.Equal("Roger Starbuck,Junya Inoue", comicInfo.Writer); + } + } } diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index d64df0d82..f51e464e6 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.IO.Abstractions.TestingHelpers; using System.Linq; using API.Services; using Microsoft.Extensions.Logging; @@ -17,7 +18,7 @@ namespace API.Tests.Services public DirectoryServiceTests() { - _directoryService = new DirectoryService(_logger); + _directoryService = new DirectoryService(_logger, new MockFileSystem()); } [Fact] @@ -26,7 +27,7 @@ namespace API.Tests.Services var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Manga"); // ReSharper disable once CollectionNeverQueried.Local var files = new List(); - var fileCount = DirectoryService.TraverseTreeParallelForEach(testDirectory, s => files.Add(s), + var fileCount = _directoryService.TraverseTreeParallelForEach(testDirectory, s => files.Add(s), API.Parser.Parser.ArchiveFileExtensions, _logger); Assert.Equal(28, fileCount); diff --git a/API.Tests/Services/FileSystemTests.cs b/API.Tests/Services/FileSystemTests.cs new file mode 100644 index 000000000..97250ea45 --- /dev/null +++ b/API.Tests/Services/FileSystemTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.IO.Abstractions.TestingHelpers; +using API.Services; +using Xunit; + +namespace API.Tests.Services; + +public class FileSystemTests +{ + [Fact] + public void FileHasNotBeenModifiedSinceCreation() + { + var file = new MockFileData("Testing is meh.") + { + LastWriteTime = DateTimeOffset.Now.Subtract(TimeSpan.FromMinutes(1)) + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { @"c:\myfile.txt", file } + }); + + var fileService = new FileService(fileSystem); + + Assert.False(fileService.HasFileBeenModifiedSince(@"c:\myfile.txt", DateTime.Now)); + } + + [Fact] + public void FileHasBeenModifiedSinceCreation() + { + var file = new MockFileData("Testing is meh.") + { + LastWriteTime = DateTimeOffset.Now + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { @"c:\myfile.txt", file } + }); + + var fileService = new FileService(fileSystem); + + Assert.True(fileService.HasFileBeenModifiedSince(@"c:\myfile.txt", DateTime.Now.Subtract(TimeSpan.FromMinutes(1)))); + } +} diff --git a/API.Tests/Services/MetadataServiceTests.cs b/API.Tests/Services/MetadataServiceTests.cs index 5d61ee249..13cb118cb 100644 --- a/API.Tests/Services/MetadataServiceTests.cs +++ b/API.Tests/Services/MetadataServiceTests.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using System.IO; +using System.IO.Abstractions.TestingHelpers; using API.Entities; +using API.Helpers; using API.Services; using Xunit; @@ -10,6 +13,7 @@ namespace API.Tests.Services { private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives"); private const string TestCoverImageFile = "thumbnail.jpg"; + private const string TestCoverArchive = @"c:\file in folder.zip"; private readonly string _testCoverImageDirectory = Path.Join(Directory.GetCurrentDirectory(), @"../../../Services/Test Data/ArchiveService/CoverImages"); //private readonly MetadataService _metadataService; // private readonly IUnitOfWork _unitOfWork = Substitute.For(); @@ -18,116 +22,23 @@ namespace API.Tests.Services // private readonly IArchiveService _archiveService = Substitute.For(); // private readonly ILogger _logger = Substitute.For>(); // private readonly IHubContext _messageHub = Substitute.For>(); + private readonly ICacheHelper _cacheHelper; + public MetadataServiceTests() { //_metadataService = new MetadataService(_unitOfWork, _logger, _archiveService, _bookService, _imageService, _messageHub); - } - - [Fact] - public void ShouldUpdateCoverImage_OnFirstRun() - { - // Represents first run - Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile() + var file = new MockFileData("") { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = DateTime.Now - }, false, false)); - } - - [Fact] - public void ShouldUpdateCoverImage_OnFirstRunSeries() - { - // Represents first run - Assert.True(MetadataService.ShouldUpdateCoverImage(null,null, false, false)); - } - - [Fact] - public void ShouldUpdateCoverImage_OnFirstRun_FileModified() - { - // Represents first run - Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile() + LastWriteTime = DateTimeOffset.Now.Subtract(TimeSpan.FromMinutes(1)) + }; + var fileSystem = new MockFileSystem(new Dictionary { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime.Subtract(TimeSpan.FromDays(1)) - }, false, false)); - } + { TestCoverArchive, file } + }); - [Fact] - public void ShouldUpdateCoverImage_OnFirstRun_CoverImageLocked() - { - // Represents first run - Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile() - { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime - }, false, true)); - } - - [Fact] - public void ShouldUpdateCoverImage_OnSecondRun_ForceUpdate() - { - // Represents first run - Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile() - { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime - }, true, false)); - } - - [Fact] - public void ShouldUpdateCoverImage_OnSecondRun_NoFileChangeButNoCoverImage() - { - // Represents first run - Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile() - { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime - }, false, false)); - } - - [Fact] - public void ShouldUpdateCoverImage_OnSecondRun_FileChangeButNoCoverImage() - { - // Represents first run - Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile() - { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime + TimeSpan.FromDays(1) - }, false, false)); - } - - [Fact] - public void ShouldNotUpdateCoverImage_OnSecondRun_CoverImageSet() - { - // Represents first run - Assert.False(MetadataService.ShouldUpdateCoverImage(TestCoverImageFile, new MangaFile() - { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime - }, false, false, _testCoverImageDirectory)); - } - - [Fact] - public void ShouldNotUpdateCoverImage_OnSecondRun_HasCoverImage_NoForceUpdate_NoLock() - { - - Assert.False(MetadataService.ShouldUpdateCoverImage(TestCoverImageFile, new MangaFile() - { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = DateTime.Now - }, false, false, _testCoverImageDirectory)); - } - - [Fact] - public void ShouldUpdateCoverImage_OnSecondRun_HasCoverImage_NoForceUpdate_HasLock_CoverImageDoesntExist() - { - - Assert.True(MetadataService.ShouldUpdateCoverImage(@"doesn't_exist.jpg", new MangaFile() - { - FilePath = Path.Join(_testDirectory, "file in folder.zip"), - LastModified = DateTime.Now - }, false, true, _testCoverImageDirectory)); + var fileService = new FileService(fileSystem); + _cacheHelper = new CacheHelper(fileService); } } } diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 0253ccef6..05826d020 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -3,11 +3,14 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Data.Common; using System.IO; +using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; using API.Data; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; +using API.Helpers; using API.Interfaces; using API.Interfaces.Services; using API.Parser; @@ -34,8 +37,9 @@ namespace API.Tests.Services private readonly IArchiveService _archiveService = Substitute.For(); private readonly IBookService _bookService = Substitute.For(); private readonly IImageService _imageService = Substitute.For(); + private readonly IDirectoryService _directoryService = Substitute.For(); private readonly ILogger _metadataLogger = Substitute.For>(); - private readonly ICacheService _cacheService = Substitute.For(); + private readonly ICacheService _cacheService; private readonly IHubContext _messageHub = Substitute.For>(); private readonly DbConnection _connection; @@ -54,9 +58,26 @@ namespace API.Tests.Services IUnitOfWork unitOfWork = new UnitOfWork(_context, Substitute.For(), null); + var file = new MockFileData("") + { + LastWriteTime = DateTimeOffset.Now.Subtract(TimeSpan.FromMinutes(1)) + }; + var fileSystem = new MockFileSystem(new Dictionary + { + { "/data/Darker than Black.zip", file }, + { "/data/Cage of Eden - v10.cbz", file }, + { "/data/Cage of Eden - v1.cbz", file }, + }); - IMetadataService metadataService = Substitute.For(unitOfWork, _metadataLogger, _archiveService, _bookService, _imageService, _messageHub); - _scannerService = new ScannerService(unitOfWork, _logger, _archiveService, metadataService, _bookService, _cacheService, _messageHub); + var fileService = new FileService(fileSystem); + ICacheHelper cacheHelper = new CacheHelper(fileService); + + + IMetadataService metadataService = + Substitute.For(unitOfWork, _metadataLogger, _archiveService, + _bookService, _imageService, _messageHub, cacheHelper); + _scannerService = new ScannerService(unitOfWork, _logger, _archiveService, metadataService, _bookService, + _cacheService, _messageHub, fileService, _directoryService); } private async Task SeedDb() @@ -78,6 +99,13 @@ namespace API.Tests.Services return await _context.SaveChangesAsync() > 0; } + + [Fact] + public void AddOrUpdateFileForChapter() + { + // TODO: This can be tested, it has _filesystem mocked + } + [Fact] public void FindSeriesNotOnDisk_Should_RemoveNothing_Test() { @@ -138,24 +166,24 @@ namespace API.Tests.Services // Assert.Equal(expected, actualName); // } - [Fact] - public void RemoveMissingSeries_Should_RemoveSeries() - { - var existingSeries = new List() - { - EntityFactory.CreateSeries("Darker than Black Vol 1"), - EntityFactory.CreateSeries("Darker than Black"), - EntityFactory.CreateSeries("Beastars"), - }; - var missingSeries = new List() - { - EntityFactory.CreateSeries("Darker than Black Vol 1"), - }; - existingSeries = ScannerService.RemoveMissingSeries(existingSeries, missingSeries, out var removeCount).ToList(); - - Assert.DoesNotContain(missingSeries[0].Name, existingSeries.Select(s => s.Name)); - Assert.Equal(missingSeries.Count, removeCount); - } + // [Fact] + // public void RemoveMissingSeries_Should_RemoveSeries() + // { + // var existingSeries = new List() + // { + // EntityFactory.CreateSeries("Darker than Black Vol 1"), + // EntityFactory.CreateSeries("Darker than Black"), + // EntityFactory.CreateSeries("Beastars"), + // }; + // var missingSeries = new List() + // { + // EntityFactory.CreateSeries("Darker than Black Vol 1"), + // }; + // existingSeries = ScannerService.RemoveMissingSeries(existingSeries, missingSeries, out var removeCount).ToList(); + // + // Assert.DoesNotContain(missingSeries[0].Name, existingSeries.Select(s => s.Name)); + // Assert.Equal(missingSeries.Count, removeCount); + // } private void AddToParsedInfo(IDictionary> collectedSeries, ParserInfo info) { diff --git a/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo_authors.zip b/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo_authors.zip new file mode 100644 index 000000000..e44b8aa5a Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo_authors.zip differ diff --git a/API.Tests/Services/Test Data/BookService/EPUB/The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub b/API.Tests/Services/Test Data/BookService/EPUB/The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub index 7388bc85e..99d4e3bbf 100644 Binary files a/API.Tests/Services/Test Data/BookService/EPUB/The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub and b/API.Tests/Services/Test Data/BookService/EPUB/The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub differ diff --git a/API.Tests/Services/Test Data/BookService/EPUB/content.opf b/API.Tests/Services/Test Data/BookService/EPUB/content.opf new file mode 100644 index 000000000..74e62677a --- /dev/null +++ b/API.Tests/Services/Test Data/BookService/EPUB/content.opf @@ -0,0 +1,81 @@ + + + + + Public domain in the USA. + http://www.gutenberg.org/64999 + Roger Starbuck + The Golden Harpoon / Lost Among the Floes + en + 2021-04-05 + 2021-04-05T23:00:07.039989+00:00 + https://www.gutenberg.org/files/64999/64999-h/64999-h.htm + Book Description + Genre1, Genre2 + Junya Inoue + Inoue, Junya + aut + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/API/API.csproj b/API/API.csproj index d6f059830..c362438e3 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -2,7 +2,7 @@ Default - net5.0 + net6.0 true Linux @@ -41,34 +41,35 @@ - - + + - + - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + + diff --git a/API/Comparators/ChapterSortComparer.cs b/API/Comparators/ChapterSortComparer.cs index 3791e05ff..189919a33 100644 --- a/API/Comparators/ChapterSortComparer.cs +++ b/API/Comparators/ChapterSortComparer.cs @@ -8,7 +8,7 @@ namespace API.Comparators public class ChapterSortComparer : IComparer { /// - /// Normal sort for 2 doubles. 0 always comes before anything else + /// Normal sort for 2 doubles. 0 always comes last /// /// /// diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index 681a962d0..aad1d91e4 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -3,9 +3,9 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data; -using API.DTOs; using API.DTOs.CollectionTags; using API.Entities; +using API.Entities.Metadata; using API.Extensions; using API.Interfaces; using Microsoft.AspNetCore.Authorization; diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index ac28a52e1..d4b7917d0 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -17,7 +17,6 @@ using API.Interfaces.Services; using API.Services; using Kavita.Common; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; namespace API.Controllers { @@ -168,15 +167,8 @@ namespace API.Controllers var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); var isAdmin = await _unitOfWork.UserRepository.IsUserAdmin(user); - IList tags; - if (isAdmin) - { - tags = (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync()).ToList(); - } - else - { - tags = (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync()).ToList(); - } + IList tags = isAdmin ? (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync()).ToList() + : (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync()).ToList(); var feed = CreateFeed("All Collections", $"{apiKey}/collections", apiKey); @@ -653,7 +645,7 @@ namespace API.Controllers DirectoryService.GetHumanReadableBytes(DirectoryService.GetTotalSize(new List() {mangaFile.FilePath})); var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath); - var filename = Uri.EscapeUriString(Path.GetFileName(mangaFile.FilePath) ?? string.Empty); + var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath) ?? string.Empty); return new FeedEntry() { Id = mangaFile.Id.ToString(), diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 5b18cfb98..85eb9139f 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -75,6 +75,7 @@ namespace API.Controllers if (chapter == null) return BadRequest("Could not find Chapter"); var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId); + if (dto == null) return BadRequest("Please perform a scan on this series or library and try again"); var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First(); return Ok(new ChapterInfoDto() @@ -89,6 +90,7 @@ namespace API.Controllers LibraryId = dto.LibraryId, IsSpecial = dto.IsSpecial, Pages = dto.Pages, + ChapterTitle = dto.ChapterTitle }); } diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 19e4a4b49..e4f781f7b 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Comparators; diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index ba0571ec3..15438c70e 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -6,6 +6,7 @@ using API.Data; using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; +using API.DTOs.Metadata; using API.Entities; using API.Extensions; using API.Helpers; @@ -187,7 +188,7 @@ namespace API.Controllers series.Name = updateSeries.Name.Trim(); series.LocalizedName = updateSeries.LocalizedName.Trim(); series.SortName = updateSeries.SortName?.Trim(); - series.Summary = updateSeries.Summary?.Trim(); + series.Metadata.Summary = updateSeries.Summary?.Trim(); var needsRefreshMetadata = false; // This is when you hit Reset @@ -294,6 +295,7 @@ namespace API.Controllers else { series.Metadata.CollectionTags ??= new List(); + // TODO: Move this merging logic into a reusable code as it can be used for any Tag var newTags = new List(); // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different @@ -391,7 +393,5 @@ namespace API.Controllers var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, userId)); } - - } } diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 04ffa3428..35755a48f 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -6,7 +6,6 @@ using API.DTOs.Stats; using API.DTOs.Update; using API.Extensions; using API.Interfaces.Services; -using API.Services.Tasks; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 8c791a395..3a3e6c4c1 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -50,5 +50,17 @@ namespace API.DTOs /// When chapter was created /// public DateTime Created { get; init; } + /// + /// Title of the Chapter/Issue + /// + public string TitleName { get; set; } + public ICollection Writers { get; set; } = new List(); + public ICollection Penciller { get; set; } = new List(); + public ICollection Inker { get; set; } = new List(); + public ICollection Colorist { get; set; } = new List(); + public ICollection Letterer { get; set; } = new List(); + public ICollection CoverArtist { get; set; } = new List(); + public ICollection Editor { get; set; } = new List(); + public ICollection Publisher { get; set; } = new List(); } } diff --git a/API/DTOs/Metadata/ChapterMetadataDto.cs b/API/DTOs/Metadata/ChapterMetadataDto.cs new file mode 100644 index 000000000..77b89fe94 --- /dev/null +++ b/API/DTOs/Metadata/ChapterMetadataDto.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace API.DTOs.Metadata +{ + public class ChapterMetadataDto + { + public int Id { get; set; } + public string Title { get; set; } + public ICollection Writers { get; set; } = new List(); + public ICollection Penciller { get; set; } = new List(); + public ICollection Inker { get; set; } = new List(); + public ICollection Colorist { get; set; } = new List(); + public ICollection Letterer { get; set; } = new List(); + public ICollection CoverArtist { get; set; } = new List(); + public ICollection Editor { get; set; } = new List(); + public ICollection Publisher { get; set; } = new List(); + public int ChapterId { get; set; } + } +} diff --git a/API/DTOs/Metadata/GenreTagDto.cs b/API/DTOs/Metadata/GenreTagDto.cs new file mode 100644 index 000000000..5a4315559 --- /dev/null +++ b/API/DTOs/Metadata/GenreTagDto.cs @@ -0,0 +1,9 @@ +namespace API.DTOs.Metadata +{ + public class GenreTagDto + { + public int Id { get; set; } + public string Title { get; set; } + + } +} diff --git a/API/DTOs/Reader/BookInfoDto.cs b/API/DTOs/Reader/BookInfoDto.cs index 6705c9647..b881c1b10 100644 --- a/API/DTOs/Reader/BookInfoDto.cs +++ b/API/DTOs/Reader/BookInfoDto.cs @@ -14,5 +14,6 @@ namespace API.DTOs.Reader public int LibraryId { get; set; } public int Pages { get; set; } public bool IsSpecial { get; set; } + public string ChapterTitle { get; set; } } } diff --git a/API/DTOs/Reader/IChapterInfoDto.cs b/API/DTOs/Reader/IChapterInfoDto.cs index 67aa6caf6..e448e5e13 100644 --- a/API/DTOs/Reader/IChapterInfoDto.cs +++ b/API/DTOs/Reader/IChapterInfoDto.cs @@ -13,6 +13,7 @@ namespace API.DTOs.Reader public int LibraryId { get; set; } public int Pages { get; set; } public bool IsSpecial { get; set; } + public string ChapterTitle { get; set; } } } diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs index 69dcae2d9..fdbb93705 100644 --- a/API/DTOs/SeriesMetadataDto.cs +++ b/API/DTOs/SeriesMetadataDto.cs @@ -1,16 +1,25 @@ using System.Collections.Generic; using API.DTOs.CollectionTags; -using API.Entities; +using API.DTOs.Metadata; namespace API.DTOs { public class SeriesMetadataDto { public int Id { get; set; } - public ICollection Genres { get; set; } + public string Summary { get; set; } public ICollection Tags { get; set; } - public ICollection Persons { get; set; } - public string Publisher { get; set; } + public ICollection Genres { get; set; } + public ICollection Writers { get; set; } = new List(); + public ICollection Artists { get; set; } = new List(); + public ICollection Publishers { get; set; } = new List(); + public ICollection Characters { get; set; } = new List(); + public ICollection Pencillers { get; set; } = new List(); + public ICollection Inkers { get; set; } = new List(); + public ICollection Colorists { get; set; } = new List(); + public ICollection Letterers { get; set; } = new List(); + public ICollection Editors { get; set; } = new List(); + public int SeriesId { get; set; } } -} \ No newline at end of file +} diff --git a/API/DTOs/Update/UpdateNotificationDto.cs b/API/DTOs/Update/UpdateNotificationDto.cs index 03c56c567..66c979cc4 100644 --- a/API/DTOs/Update/UpdateNotificationDto.cs +++ b/API/DTOs/Update/UpdateNotificationDto.cs @@ -1,6 +1,4 @@ -using System; - -namespace API.DTOs.Update +namespace API.DTOs.Update { /// /// Update Notification denoting a new release available for user to update to diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 8e4dc263e..c264792a6 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using API.Entities; using API.Entities.Interfaces; +using API.Entities.Metadata; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -23,7 +24,6 @@ namespace API.Data public DbSet Library { get; set; } public DbSet Series { get; set; } - public DbSet Chapter { get; set; } public DbSet Volume { get; set; } public DbSet AppUser { get; set; } @@ -37,6 +37,8 @@ namespace API.Data public DbSet AppUserBookmark { get; set; } public DbSet ReadingList { get; set; } public DbSet ReadingListItem { get; set; } + public DbSet Person { get; set; } + public DbSet Genre { get; set; } protected override void OnModelCreating(ModelBuilder builder) diff --git a/API/Data/DbFactory.cs b/API/Data/DbFactory.cs index a57ba4037..4a5412609 100644 --- a/API/Data/DbFactory.cs +++ b/API/Data/DbFactory.cs @@ -1,7 +1,11 @@ using System; using System.Collections.Generic; +using System.IO; +using API.Data.Metadata; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; +using API.Extensions; using API.Parser; using API.Services.Tasks; @@ -21,12 +25,16 @@ namespace API.Data LocalizedName = name, NormalizedName = Parser.Parser.Normalize(name), SortName = name, - Summary = string.Empty, Volumes = new List(), Metadata = SeriesMetadata(Array.Empty()) }; } + public static SeriesMetadata SeriesMetadata(ComicInfo info) + { + return SeriesMetadata(Array.Empty()); + } + public static Volume Volume(string volumeNumber) { return new Volume() @@ -57,7 +65,8 @@ namespace API.Data { return new SeriesMetadata() { - CollectionTags = collectionTags + CollectionTags = collectionTags, + Summary = string.Empty }; } @@ -72,5 +81,37 @@ namespace API.Data Promoted = promoted }; } + + public static Genre Genre(string name, bool external) + { + return new Genre() + { + Title = name.Trim().SentenceCase(), + NormalizedTitle = Parser.Parser.Normalize(name), + ExternalTag = external + }; + } + + public static Person Person(string name, PersonRole role) + { + return new Person() + { + Name = name.Trim(), + NormalizedName = Parser.Parser.Normalize(name), + Role = role + }; + } + + public static MangaFile MangaFile(string filePath, MangaFormat format, int pages) + { + return new MangaFile() + { + FilePath = filePath, + Format = format, + Pages = pages, + LastModified = DateTime.Now //File.GetLastWriteTime(filePath) + }; + } + } } diff --git a/API/Data/Metadata/ComicInfo.cs b/API/Data/Metadata/ComicInfo.cs index 9f846ea42..4fdd845a8 100644 --- a/API/Data/Metadata/ComicInfo.cs +++ b/API/Data/Metadata/ComicInfo.cs @@ -34,11 +34,19 @@ public string AlternativeSeries { get; set; } public string AlternativeNumber { get; set; } + /// + /// This is Epub only: calibre:title_sort + /// Represents the sort order for the title + /// + public string TitleSort { get; set; } + + + /// /// This is the Author. For Books, we map creator tag in OPF to this field. Comma separated if multiple. /// - public string Writer { get; set; } // TODO: Validate if we should make this a list of writers + public string Writer { get; set; } public string Penciller { get; set; } public string Inker { get; set; } public string Colorist { get; set; } diff --git a/API/Data/Migrations/20211127200244_MetadataFoundation.Designer.cs b/API/Data/Migrations/20211127200244_MetadataFoundation.Designer.cs new file mode 100644 index 000000000..32408164b --- /dev/null +++ b/API/Data/Migrations/20211127200244_MetadataFoundation.Designer.cs @@ -0,0 +1,1215 @@ +// +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("20211127200244_MetadataFoundation")] + partial class MetadataFoundation + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .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("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.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + 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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + 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.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ChapterMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId") + .IsUnique(); + + b.ToTable("ChapterMetadata"); + }); + + 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.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("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .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.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + 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.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + 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("ChapterMetadataPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterMetadataPerson"); + }); + + 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("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("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.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .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.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.ChapterMetadata", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithOne("ChapterMetadata") + .HasForeignKey("API.Entities.ChapterMetadata", "ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + 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.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + 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.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + 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("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("ChapterMetadataPerson", b => + { + b.HasOne("API.Entities.ChapterMetadata", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .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.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .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.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.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ChapterMetadata"); + + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211127200244_MetadataFoundation.cs b/API/Data/Migrations/20211127200244_MetadataFoundation.cs new file mode 100644 index 000000000..f2ea2c9c1 --- /dev/null +++ b/API/Data/Migrations/20211127200244_MetadataFoundation.cs @@ -0,0 +1,203 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class MetadataFoundation : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Summary", + table: "Series"); + + migrationBuilder.AddColumn( + name: "Summary", + table: "SeriesMetadata", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "ChapterMetadata", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(type: "TEXT", nullable: true), + Year = table.Column(type: "TEXT", nullable: true), + StoryArc = table.Column(type: "TEXT", nullable: true), + ChapterId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterMetadata", x => x.Id); + table.ForeignKey( + name: "FK_ChapterMetadata_Chapter_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Genre", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: true), + NormalizedName = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Genre", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Person", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: true), + NormalizedName = table.Column(type: "TEXT", nullable: true), + Role = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Person", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "GenreSeriesMetadata", + columns: table => new + { + GenresId = table.Column(type: "INTEGER", nullable: false), + SeriesMetadatasId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GenreSeriesMetadata", x => new { x.GenresId, x.SeriesMetadatasId }); + table.ForeignKey( + name: "FK_GenreSeriesMetadata_Genre_GenresId", + column: x => x.GenresId, + principalTable: "Genre", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_GenreSeriesMetadata_SeriesMetadata_SeriesMetadatasId", + column: x => x.SeriesMetadatasId, + principalTable: "SeriesMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ChapterMetadataPerson", + columns: table => new + { + ChapterMetadatasId = table.Column(type: "INTEGER", nullable: false), + PeopleId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterMetadataPerson", x => new { x.ChapterMetadatasId, x.PeopleId }); + table.ForeignKey( + name: "FK_ChapterMetadataPerson_ChapterMetadata_ChapterMetadatasId", + column: x => x.ChapterMetadatasId, + principalTable: "ChapterMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChapterMetadataPerson_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_ChapterMetadata_ChapterId", + table: "ChapterMetadata", + column: "ChapterId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ChapterMetadataPerson_PeopleId", + table: "ChapterMetadataPerson", + column: "PeopleId"); + + migrationBuilder.CreateIndex( + name: "IX_Genre_NormalizedName", + table: "Genre", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_GenreSeriesMetadata_SeriesMetadatasId", + table: "GenreSeriesMetadata", + column: "SeriesMetadatasId"); + + migrationBuilder.CreateIndex( + name: "IX_PersonSeriesMetadata_SeriesMetadatasId", + table: "PersonSeriesMetadata", + column: "SeriesMetadatasId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChapterMetadataPerson"); + + migrationBuilder.DropTable( + name: "GenreSeriesMetadata"); + + migrationBuilder.DropTable( + name: "PersonSeriesMetadata"); + + migrationBuilder.DropTable( + name: "ChapterMetadata"); + + migrationBuilder.DropTable( + name: "Genre"); + + migrationBuilder.DropTable( + name: "Person"); + + migrationBuilder.DropColumn( + name: "Summary", + table: "SeriesMetadata"); + + migrationBuilder.AddColumn( + name: "Summary", + table: "Series", + type: "TEXT", + nullable: true); + } + } +} diff --git a/API/Data/Migrations/20211129231007_RemoveChapterMetadata.Designer.cs b/API/Data/Migrations/20211129231007_RemoveChapterMetadata.Designer.cs new file mode 100644 index 000000000..27436b91f --- /dev/null +++ b/API/Data/Migrations/20211129231007_RemoveChapterMetadata.Designer.cs @@ -0,0 +1,1232 @@ +// +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("20211129231007_RemoveChapterMetadata")] + partial class RemoveChapterMetadata + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .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("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.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + 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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + 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.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .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.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("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ChapterMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Year") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ChapterMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterMetadataId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterMetadataId"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .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.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + 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.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + 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("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + 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("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("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.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .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.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + 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.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ChapterMetadata", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + 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.Person", b => + { + b.HasOne("API.Entities.Metadata.ChapterMetadata", null) + .WithMany("People") + .HasForeignKey("ChapterMetadataId"); + }); + + 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.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("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("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("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("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("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ChapterMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211129231007_RemoveChapterMetadata.cs b/API/Data/Migrations/20211129231007_RemoveChapterMetadata.cs new file mode 100644 index 000000000..c50578ff9 --- /dev/null +++ b/API/Data/Migrations/20211129231007_RemoveChapterMetadata.cs @@ -0,0 +1,138 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class RemoveChapterMetadata : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChapterMetadataPerson"); + + migrationBuilder.DropIndex( + name: "IX_ChapterMetadata_ChapterId", + table: "ChapterMetadata"); + + migrationBuilder.AddColumn( + name: "ChapterMetadataId", + table: "Person", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "TitleName", + table: "Chapter", + type: "TEXT", + nullable: true); + + 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.CreateIndex( + name: "IX_Person_ChapterMetadataId", + table: "Person", + column: "ChapterMetadataId"); + + migrationBuilder.CreateIndex( + name: "IX_ChapterMetadata_ChapterId", + table: "ChapterMetadata", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_ChapterPerson_PeopleId", + table: "ChapterPerson", + column: "PeopleId"); + + migrationBuilder.AddForeignKey( + name: "FK_Person_ChapterMetadata_ChapterMetadataId", + table: "Person", + column: "ChapterMetadataId", + principalTable: "ChapterMetadata", + principalColumn: "Id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Person_ChapterMetadata_ChapterMetadataId", + table: "Person"); + + migrationBuilder.DropTable( + name: "ChapterPerson"); + + migrationBuilder.DropIndex( + name: "IX_Person_ChapterMetadataId", + table: "Person"); + + migrationBuilder.DropIndex( + name: "IX_ChapterMetadata_ChapterId", + table: "ChapterMetadata"); + + migrationBuilder.DropColumn( + name: "ChapterMetadataId", + table: "Person"); + + migrationBuilder.DropColumn( + name: "TitleName", + table: "Chapter"); + + migrationBuilder.CreateTable( + name: "ChapterMetadataPerson", + columns: table => new + { + ChapterMetadatasId = table.Column(type: "INTEGER", nullable: false), + PeopleId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterMetadataPerson", x => new { x.ChapterMetadatasId, x.PeopleId }); + table.ForeignKey( + name: "FK_ChapterMetadataPerson_ChapterMetadata_ChapterMetadatasId", + column: x => x.ChapterMetadatasId, + principalTable: "ChapterMetadata", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ChapterMetadataPerson_Person_PeopleId", + column: x => x.PeopleId, + principalTable: "Person", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChapterMetadata_ChapterId", + table: "ChapterMetadata", + column: "ChapterId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ChapterMetadataPerson_PeopleId", + table: "ChapterMetadataPerson", + column: "PeopleId"); + } + } +} diff --git a/API/Data/Migrations/20211130134642_GenreProvider.Designer.cs b/API/Data/Migrations/20211130134642_GenreProvider.Designer.cs new file mode 100644 index 000000000..4b90e75ba --- /dev/null +++ b/API/Data/Migrations/20211130134642_GenreProvider.Designer.cs @@ -0,0 +1,1182 @@ +// +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("20211130134642_GenreProvider")] + partial class GenreProvider + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .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("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.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + 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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + 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.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .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.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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .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.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + 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.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + 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("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + 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("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("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.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .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.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + 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.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + 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.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.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("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("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("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("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("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211130134642_GenreProvider.cs b/API/Data/Migrations/20211130134642_GenreProvider.cs new file mode 100644 index 000000000..260210d54 --- /dev/null +++ b/API/Data/Migrations/20211130134642_GenreProvider.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class GenreProvider : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Person_ChapterMetadata_ChapterMetadataId", + table: "Person"); + + migrationBuilder.DropTable( + name: "ChapterMetadata"); + + migrationBuilder.DropIndex( + name: "IX_Person_ChapterMetadataId", + table: "Person"); + + migrationBuilder.DropColumn( + name: "ChapterMetadataId", + table: "Person"); + + migrationBuilder.AddColumn( + name: "ExternalTag", + table: "Genre", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ExternalTag", + table: "Genre"); + + migrationBuilder.AddColumn( + name: "ChapterMetadataId", + table: "Person", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateTable( + name: "ChapterMetadata", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ChapterId = table.Column(type: "INTEGER", nullable: false), + StoryArc = table.Column(type: "TEXT", nullable: true), + Title = table.Column(type: "TEXT", nullable: true), + Year = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ChapterMetadata", x => x.Id); + table.ForeignKey( + name: "FK_ChapterMetadata_Chapter_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Person_ChapterMetadataId", + table: "Person", + column: "ChapterMetadataId"); + + migrationBuilder.CreateIndex( + name: "IX_ChapterMetadata_ChapterId", + table: "ChapterMetadata", + column: "ChapterId"); + + migrationBuilder.AddForeignKey( + name: "FK_Person_ChapterMetadata_ChapterMetadataId", + table: "Person", + column: "ChapterMetadataId", + principalTable: "ChapterMetadata", + principalColumn: "Id"); + } + } +} diff --git a/API/Data/Migrations/20211201230003_GenreTitle.Designer.cs b/API/Data/Migrations/20211201230003_GenreTitle.Designer.cs new file mode 100644 index 000000000..81f69b5a0 --- /dev/null +++ b/API/Data/Migrations/20211201230003_GenreTitle.Designer.cs @@ -0,0 +1,1196 @@ +// +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("20211201230003_GenreTitle")] + partial class GenreTitle + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .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("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.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + 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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + 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.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + 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.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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .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.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + 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.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + 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("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + 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("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("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.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .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.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany("Chapters") + .HasForeignKey("GenreId"); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + 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.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + 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.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.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("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("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("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("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("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Navigation("Chapters"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211201230003_GenreTitle.cs b/API/Data/Migrations/20211201230003_GenreTitle.cs new file mode 100644 index 000000000..ab3e65daf --- /dev/null +++ b/API/Data/Migrations/20211201230003_GenreTitle.cs @@ -0,0 +1,85 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class GenreTitle : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Genre_NormalizedName", + table: "Genre"); + + migrationBuilder.RenameColumn( + name: "NormalizedName", + table: "Genre", + newName: "Title"); + + migrationBuilder.RenameColumn( + name: "Name", + table: "Genre", + newName: "NormalizedTitle"); + + migrationBuilder.AddColumn( + name: "GenreId", + table: "Chapter", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Genre_NormalizedTitle_ExternalTag", + table: "Genre", + columns: new[] { "NormalizedTitle", "ExternalTag" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Chapter_GenreId", + table: "Chapter", + column: "GenreId"); + + migrationBuilder.AddForeignKey( + name: "FK_Chapter_Genre_GenreId", + table: "Chapter", + column: "GenreId", + principalTable: "Genre", + principalColumn: "Id"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Chapter_Genre_GenreId", + table: "Chapter"); + + migrationBuilder.DropIndex( + name: "IX_Genre_NormalizedTitle_ExternalTag", + table: "Genre"); + + migrationBuilder.DropIndex( + name: "IX_Chapter_GenreId", + table: "Chapter"); + + migrationBuilder.DropColumn( + name: "GenreId", + table: "Chapter"); + + migrationBuilder.RenameColumn( + name: "Title", + table: "Genre", + newName: "NormalizedName"); + + migrationBuilder.RenameColumn( + name: "NormalizedTitle", + table: "Genre", + newName: "Name"); + + migrationBuilder.CreateIndex( + name: "IX_Genre_NormalizedName", + table: "Genre", + column: "NormalizedName", + unique: true); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 21a9d930a..fe874ea9d 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -5,6 +5,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +#nullable disable + namespace API.Data.Migrations { [DbContext(typeof(DataContext))] @@ -13,8 +15,7 @@ namespace API.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "5.0.8"); + modelBuilder.HasAnnotation("ProductVersion", "6.0.0"); modelBuilder.Entity("API.Entities.AppRole", b => { @@ -40,7 +41,7 @@ namespace API.Data.Migrations .IsUnique() .HasDatabaseName("RoleNameIndex"); - b.ToTable("AspNetRoles"); + b.ToTable("AspNetRoles", (string)null); }); modelBuilder.Entity("API.Entities.AppUser", b => @@ -118,7 +119,7 @@ namespace API.Data.Migrations .IsUnique() .HasDatabaseName("UserNameIndex"); - b.ToTable("AspNetUsers"); + b.ToTable("AspNetUsers", (string)null); }); modelBuilder.Entity("API.Entities.AppUserBookmark", b => @@ -279,7 +280,7 @@ namespace API.Data.Migrations b.HasIndex("RoleId"); - b.ToTable("AspNetUserRoles"); + b.ToTable("AspNetUserRoles", (string)null); }); modelBuilder.Entity("API.Entities.Chapter", b => @@ -297,6 +298,9 @@ namespace API.Data.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("GenreId") + .HasColumnType("INTEGER"); + b.Property("IsSpecial") .HasColumnType("INTEGER"); @@ -315,11 +319,16 @@ namespace API.Data.Migrations b.Property("Title") .HasColumnType("TEXT"); + b.Property("TitleName") + .HasColumnType("TEXT"); + b.Property("VolumeId") .HasColumnType("INTEGER"); b.HasKey("Id"); + b.HasIndex("GenreId"); + b.HasIndex("VolumeId"); b.ToTable("Chapter"); @@ -382,6 +391,29 @@ namespace API.Data.Migrations b.ToTable("FolderPath"); }); + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + modelBuilder.Entity("API.Entities.Library", b => { b.Property("Id") @@ -439,6 +471,53 @@ namespace API.Data.Migrations b.ToTable("MangaFile"); }); + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .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") @@ -546,9 +625,6 @@ namespace API.Data.Migrations b.Property("SortName") .HasColumnType("TEXT"); - b.Property("Summary") - .HasColumnType("TEXT"); - b.HasKey("Id"); b.HasIndex("LibraryId"); @@ -559,30 +635,6 @@ namespace API.Data.Migrations b.ToTable("Series"); }); - modelBuilder.Entity("API.Entities.SeriesMetadata", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .HasColumnType("INTEGER"); - - b.Property("SeriesId") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("SeriesId") - .IsUnique(); - - b.HasIndex("Id", "SeriesId") - .IsUnique(); - - b.ToTable("SeriesMetadata"); - }); - modelBuilder.Entity("API.Entities.ServerSetting", b => { b.Property("Key") @@ -649,6 +701,21 @@ namespace API.Data.Migrations b.ToTable("AppUserLibrary"); }); + 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("CollectionTagSeriesMetadata", b => { b.Property("CollectionTagsId") @@ -664,6 +731,21 @@ namespace API.Data.Migrations b.ToTable("CollectionTagSeriesMetadata"); }); + 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") @@ -683,7 +765,7 @@ namespace API.Data.Migrations b.HasIndex("RoleId"); - b.ToTable("AspNetRoleClaims"); + b.ToTable("AspNetRoleClaims", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => @@ -705,7 +787,7 @@ namespace API.Data.Migrations b.HasIndex("UserId"); - b.ToTable("AspNetUserClaims"); + b.ToTable("AspNetUserClaims", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => @@ -726,7 +808,7 @@ namespace API.Data.Migrations b.HasIndex("UserId"); - b.ToTable("AspNetUserLogins"); + b.ToTable("AspNetUserLogins", (string)null); }); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => @@ -745,7 +827,22 @@ namespace API.Data.Migrations b.HasKey("UserId", "LoginProvider", "Name"); - b.ToTable("AspNetUserTokens"); + 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("API.Entities.AppUserBookmark", b => @@ -813,6 +910,10 @@ namespace API.Data.Migrations modelBuilder.Entity("API.Entities.Chapter", b => { + b.HasOne("API.Entities.Genre", null) + .WithMany("Chapters") + .HasForeignKey("GenreId"); + b.HasOne("API.Entities.Volume", "Volume") .WithMany("Chapters") .HasForeignKey("VolumeId") @@ -844,6 +945,17 @@ namespace API.Data.Migrations b.Navigation("Chapter"); }); + 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.ReadingList", b => { b.HasOne("API.Entities.AppUser", "AppUser") @@ -901,17 +1013,6 @@ namespace API.Data.Migrations b.Navigation("Library"); }); - modelBuilder.Entity("API.Entities.SeriesMetadata", b => - { - b.HasOne("API.Entities.Series", "Series") - .WithOne("Metadata") - .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Series"); - }); - modelBuilder.Entity("API.Entities.Volume", b => { b.HasOne("API.Entities.Series", "Series") @@ -938,6 +1039,21 @@ namespace API.Data.Migrations .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("CollectionTagSeriesMetadata", b => { b.HasOne("API.Entities.CollectionTag", null) @@ -946,7 +1062,22 @@ namespace API.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("API.Entities.SeriesMetadata", null) + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .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) @@ -989,6 +1120,21 @@ namespace API.Data.Migrations .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("API.Entities.AppRole", b => { b.Navigation("UserRoles"); @@ -1014,6 +1160,11 @@ namespace API.Data.Migrations b.Navigation("Files"); }); + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Navigation("Chapters"); + }); + modelBuilder.Entity("API.Entities.Library", b => { b.Navigation("Folders"); diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 54c808d9c..551648a2e 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.DTOs; @@ -41,7 +42,7 @@ namespace API.Data.Repositories /// public async Task GetChapterInfoDtoAsync(int chapterId) { - return await _context.Chapter + var chapterInfo = await _context.Chapter .Where(c => c.Id == chapterId) .Join(_context.Volume, c => c.VolumeId, v => v.Id, (chapter, volume) => new { @@ -49,8 +50,9 @@ namespace API.Data.Repositories VolumeNumber = volume.Number, VolumeId = volume.Id, chapter.IsSpecial, + chapter.TitleName, volume.SeriesId, - chapter.Pages + chapter.Pages, }) .Join(_context.Series, data => data.SeriesId, series => series.Id, (data, series) => new { @@ -60,11 +62,12 @@ namespace API.Data.Repositories data.IsSpecial, data.SeriesId, data.Pages, + data.TitleName, SeriesFormat = series.Format, SeriesName = series.Name, series.LibraryId }) - .Select(data => new BookInfoDto() + .Select(data => new ChapterInfoDto() { ChapterNumber = data.ChapterNumber, VolumeNumber = data.VolumeNumber + string.Empty, @@ -74,10 +77,13 @@ namespace API.Data.Repositories SeriesFormat = data.SeriesFormat, SeriesName = data.SeriesName, LibraryId = data.LibraryId, - Pages = data.Pages + Pages = data.Pages, + ChapterTitle = data.TitleName }) .AsNoTracking() - .SingleAsync(); + .SingleOrDefaultAsync(); + + return chapterInfo; } public Task GetChapterTotalPagesAsync(int chapterId) diff --git a/API/Data/Repositories/CollectionTagRepository.cs b/API/Data/Repositories/CollectionTagRepository.cs index 182fa86b8..f04b4b364 100644 --- a/API/Data/Repositories/CollectionTagRepository.cs +++ b/API/Data/Repositories/CollectionTagRepository.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using API.DTOs; using API.DTOs.CollectionTags; using API.Entities; using API.Interfaces.Repositories; diff --git a/API/Data/Repositories/FileRepository.cs b/API/Data/Repositories/FileRepository.cs deleted file mode 100644 index 4665dac7e..000000000 --- a/API/Data/Repositories/FileRepository.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using API.Interfaces.Repositories; -using Microsoft.EntityFrameworkCore; - -namespace API.Data.Repositories -{ - public class FileRepository : IFileRepository - { - private readonly DataContext _dbContext; - - public FileRepository(DataContext context) - { - _dbContext = context; - } - - public async Task> GetFileExtensions() - { - var fileExtensions = await _dbContext.MangaFile - .AsNoTracking() - .Select(x => x.FilePath.ToLower()) - .Distinct() - .ToArrayAsync(); - - var uniqueFileTypes = fileExtensions - .Select(Path.GetExtension) - .Where(x => x is not null) - .Distinct(); - - return uniqueFileTypes; - } - } -} diff --git a/API/Data/Repositories/GenreRepository.cs b/API/Data/Repositories/GenreRepository.cs new file mode 100644 index 000000000..5424bb96e --- /dev/null +++ b/API/Data/Repositories/GenreRepository.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Interfaces.Repositories; +using AutoMapper; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories; + +public class GenreRepository : IGenreRepository +{ + private readonly DataContext _context; + private readonly IMapper _mapper; + + public GenreRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Attach(Genre genre) + { + _context.Genre.Attach(genre); + } + + public void Remove(Genre genre) + { + _context.Genre.Remove(genre); + } + + public async Task FindByNameAsync(string genreName) + { + var normalizedName = Parser.Parser.Normalize(genreName); + return await _context.Genre + .FirstOrDefaultAsync(g => g.NormalizedTitle.Equals(normalizedName)); + } + + public async Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false) + { + var genresWithNoConnections = await _context.Genre + .Include(p => p.SeriesMetadatas) + .Include(p => p.Chapters) + .Where(p => p.SeriesMetadatas.Count == 0 && p.Chapters.Count == 0 && p.ExternalTag == removeExternal) + .ToListAsync(); + + _context.Genre.RemoveRange(genresWithNoConnections); + + await _context.SaveChangesAsync(); + } + + public async Task> GetAllGenres() + { + return await _context.Genre.ToListAsync();; + } +} diff --git a/API/Data/Repositories/PersonRepository.cs b/API/Data/Repositories/PersonRepository.cs new file mode 100644 index 000000000..ed2d1a178 --- /dev/null +++ b/API/Data/Repositories/PersonRepository.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Interfaces.Repositories; +using AutoMapper; +using Microsoft.EntityFrameworkCore; + +namespace API.Data.Repositories +{ + public class PersonRepository : IPersonRepository + { + private readonly DataContext _context; + private readonly IMapper _mapper; + + public PersonRepository(DataContext context, IMapper mapper) + { + _context = context; + _mapper = mapper; + } + + public void Attach(Person person) + { + _context.Person.Attach(person); + } + + public void Remove(Person person) + { + _context.Person.Remove(person); + } + + public async Task FindByNameAsync(string name) + { + var normalizedName = Parser.Parser.Normalize(name); + return await _context.Person + .Where(p => normalizedName.Equals(p.NormalizedName)) + .SingleOrDefaultAsync(); + } + + public async Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false) + { + var peopleWithNoConnections = await _context.Person + .Include(p => p.SeriesMetadatas) + .Include(p => p.ChapterMetadatas) + .Where(p => p.SeriesMetadatas.Count == 0 && p.ChapterMetadatas.Count == 0) + .ToListAsync(); + + _context.Person.RemoveRange(peopleWithNoConnections); + + await _context.SaveChangesAsync(); + } + + + public async Task> GetAllPeople() + { + return await _context.Person + .ToListAsync(); + } + } +} diff --git a/API/Data/Repositories/SeriesMetadataRepository.cs b/API/Data/Repositories/SeriesMetadataRepository.cs index 32ab0f4e2..fea430686 100644 --- a/API/Data/Repositories/SeriesMetadataRepository.cs +++ b/API/Data/Repositories/SeriesMetadataRepository.cs @@ -1,4 +1,5 @@ using API.Entities; +using API.Entities.Metadata; using API.Interfaces.Repositories; namespace API.Data.Repositories diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index baa55330f..54a1fc230 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -8,6 +8,7 @@ using API.DTOs.CollectionTags; using API.DTOs.Filtering; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Extensions; using API.Helpers; using API.Interfaces.Repositories; @@ -85,6 +86,12 @@ namespace API.Data.Repositories var query = _context.Series .Where(s => s.LibraryId == libraryId) .Include(s => s.Metadata) + .ThenInclude(m => m.People) + .Include(s => s.Metadata) + .ThenInclude(m => m.Genres) + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(cm => cm.People) .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) .ThenInclude(c => c.Files) @@ -104,9 +111,15 @@ namespace API.Data.Repositories return await _context.Series .Where(s => s.Id == seriesId) .Include(s => s.Metadata) + .ThenInclude(m => m.People) + .Include(s => s.Metadata) + .ThenInclude(m => m.Genres) .Include(s => s.Library) .Include(s => s.Volumes) .ThenInclude(v => v.Chapters) + .ThenInclude(cm => cm.People) + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) .ThenInclude(c => c.Files) .AsSplitQuery() .SingleOrDefaultAsync(); @@ -180,6 +193,10 @@ namespace API.Data.Repositories .Include(s => s.Volumes) .Include(s => s.Metadata) .ThenInclude(m => m.CollectionTags) + .Include(s => s.Metadata) + .ThenInclude(m => m.Genres) + .Include(s => s.Metadata) + .ThenInclude(m => m.People) .Where(s => s.Id == seriesId) .SingleOrDefaultAsync(); } @@ -374,6 +391,7 @@ namespace API.Data.Repositories { var metadataDto = await _context.SeriesMetadata .Where(metadata => metadata.SeriesId == seriesId) + .Include(m => m.Genres) .AsNoTracking() .ProjectTo(_mapper.ConfigurationProvider) .SingleOrDefaultAsync(); @@ -481,17 +499,7 @@ namespace API.Data.Repositories /// private async Task> GetChunkSize(int libraryId = 0) { - // TODO: Think about making this bigger depending on number of files a user has in said library - // and number of cores and amount of memory. We can then make an optimal choice var totalSeries = await GetSeriesCount(libraryId); - // var procCount = Math.Max(Environment.ProcessorCount - 1, 1); - // - // if (totalSeries < procCount * 2 || totalSeries < 50) - // { - // return new Tuple(totalSeries, totalSeries); - // } - // - // return new Tuple(totalSeries, Math.Max(totalSeries / procCount, 50)); return new Tuple(totalSeries, 50); } diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 339da798d..e4d0f84a2 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -157,6 +157,7 @@ namespace API.Data.Repositories var volumes = await _context.Volume .Where(vol => vol.SeriesId == seriesId) .Include(vol => vol.Chapters) + .ThenInclude(c => c.People) // TODO: Measure cost of this .OrderBy(volume => volume.Number) .ProjectTo(_mapper.ConfigurationProvider) .AsNoTracking() diff --git a/API/Data/UnitOfWork.cs b/API/Data/UnitOfWork.cs index a1f797188..2fde0580f 100644 --- a/API/Data/UnitOfWork.cs +++ b/API/Data/UnitOfWork.cs @@ -31,10 +31,11 @@ namespace API.Data public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context); public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper); - public IFileRepository FileRepository => new FileRepository(_context); 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); /// /// Commits changes to the DB. Completes the open transaction. diff --git a/API/Dockerfile b/API/Dockerfile index 4289aaa3d..ce607a02f 100644 --- a/API/Dockerfile +++ b/API/Dockerfile @@ -1,5 +1,5 @@ #This Dockerfile pulls the latest git commit and builds Kavita from source -FROM mcr.microsoft.com/dotnet/sdk:5.0-focal AS builder +FROM mcr.microsoft.com/dotnet/sdk:6.0-focal AS builder ENV DEBIAN_FRONTEND=noninteractive ARG TARGETPLATFORM @@ -37,4 +37,4 @@ EXPOSE 5000 WORKDIR /kavita ENTRYPOINT ["/bin/bash"] -CMD ["/entrypoint.sh"] \ No newline at end of file +CMD ["/entrypoint.sh"] diff --git a/API/Entities/Chapter.cs b/API/Entities/Chapter.cs index ef12de8ce..41bc62cd7 100644 --- a/API/Entities/Chapter.cs +++ b/API/Entities/Chapter.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using API.Entities.Enums; using API.Entities.Interfaces; +using API.Entities.Metadata; using API.Parser; namespace API.Entities @@ -42,10 +43,30 @@ namespace API.Entities /// public string Title { get; set; } + + /// + /// Chapter title + /// + /// This should not be confused with Title which is used for special filenames. + public string TitleName { get; set; } = string.Empty; + // public string Year { get; set; } // Only time I can think this will be more than 1 year is for a volume which will be a spread + + + /// + /// All people attached at a Chapter level. Usually Comics will have different people per issue. + /// + public ICollection People { get; set; } = new List(); + + + + // Relationships public Volume Volume { get; set; } public int VolumeId { get; set; } + //public ChapterMetadata ChapterMetadata { get; set; } + //public int ChapterMetadataId { get; set; } + public void UpdateFrom(ParserInfo info) { Files ??= new List(); diff --git a/API/Entities/CollectionTag.cs b/API/Entities/CollectionTag.cs index ee966cafc..b38960f89 100644 --- a/API/Entities/CollectionTag.cs +++ b/API/Entities/CollectionTag.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using API.Entities.Metadata; using Microsoft.EntityFrameworkCore; namespace API.Entities diff --git a/API/Entities/Enums/PersonRole.cs b/API/Entities/Enums/PersonRole.cs index 47e60721b..cb8cedf95 100644 --- a/API/Entities/Enums/PersonRole.cs +++ b/API/Entities/Enums/PersonRole.cs @@ -5,15 +5,27 @@ /// /// Another role, not covered by other types /// - Other = 0, - /// - /// Author - /// - Author = 1, + Other = 1, /// /// Artist /// - Artist = 2, - + //Artist = 2, + /// + /// Author or Writer + /// + Writer = 3, + Penciller = 4, + Inker = 5, + Colorist = 6, + Letterer = 7, + CoverArtist = 8, + Editor = 9, + Publisher = 10, + /// + /// Represents a character/person within the story + /// + Character = 11 + + } -} \ No newline at end of file +} diff --git a/API/Entities/Genre.cs b/API/Entities/Genre.cs index 9490c03e7..fbd64852e 100644 --- a/API/Entities/Genre.cs +++ b/API/Entities/Genre.cs @@ -1,22 +1,18 @@ -using System.ComponentModel.DataAnnotations; -using API.Entities.Interfaces; +using System.Collections.Generic; +using API.Entities.Metadata; +using Microsoft.EntityFrameworkCore; namespace API.Entities { - public class Genre : IHasConcurrencyToken + [Index(nameof(NormalizedTitle), nameof(ExternalTag), IsUnique = true)] + public class Genre { public int Id { get; set; } - public string Name { get; set; } - // MetadataUpdate add ProviderId + public string Title { get; set; } // TODO: Rename this to Title + public string NormalizedTitle { get; set; } + public bool ExternalTag { get; set; } - /// - [ConcurrencyCheck] - public uint RowVersion { get; private set; } - - /// - public void OnSavingChanges() - { - RowVersion++; - } + public ICollection SeriesMetadatas { get; set; } + public ICollection Chapters { get; set; } } } diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index 2865178c7..fdd8bb23d 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -25,25 +25,18 @@ namespace API.Entities /// public DateTime LastModified { get; set; } + // Relationship Mapping public Chapter Chapter { get; set; } public int ChapterId { get; set; } - // Methods - /// - /// If the File on disk's last modified time is after what is stored in MangaFile - /// - /// - public bool HasFileBeenModified() - { - return File.GetLastWriteTime(FilePath) > LastModified; - } /// /// Updates the Last Modified time of the underlying file /// public void UpdateLastModified() { + // Should this be DateTime.Now ? LastModified = File.GetLastWriteTime(FilePath); } } diff --git a/API/Entities/Metadata/ChapterMetadata.cs b/API/Entities/Metadata/ChapterMetadata.cs new file mode 100644 index 000000000..ef4836c23 --- /dev/null +++ b/API/Entities/Metadata/ChapterMetadata.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace API.Entities.Metadata +{ + /// + /// Has a 1-to-1 relationship with a Chapter. Represents metadata about a chapter. + /// + public class ChapterMetadata + { + public int Id { get; set; } + + /// + /// Chapter title + /// + /// This should not be confused with Chapter.Title which is used for special filenames. + public string Title { get; set; } = string.Empty; + public string Year { get; set; } // Only time I can think this will be more than 1 year is for a volume which will be a spread + public string StoryArc { get; set; } // This might be a list + + /// + /// All people attached at a Chapter level. Usually Comics will have different people per issue. + /// + public ICollection People { get; set; } = new List(); + + + + + + // Relationships + public Chapter Chapter { get; set; } + public int ChapterId { get; set; } + + } +} diff --git a/API/Entities/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs similarity index 68% rename from API/Entities/SeriesMetadata.cs rename to API/Entities/Metadata/SeriesMetadata.cs index f86c5430e..7561653e9 100644 --- a/API/Entities/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -3,15 +3,25 @@ using System.ComponentModel.DataAnnotations; using API.Entities.Interfaces; using Microsoft.EntityFrameworkCore; -namespace API.Entities +namespace API.Entities.Metadata { [Index(nameof(Id), nameof(SeriesId), IsUnique = true)] public class SeriesMetadata : IHasConcurrencyToken { public int Id { get; set; } + public string Summary { get; set; } + + public ICollection CollectionTags { get; set; } + public ICollection Genres { get; set; } = new List(); + /// + /// All people attached at a Series level. + /// + public ICollection People { get; set; } = new List(); + + // Relationship public Series Series { get; set; } public int SeriesId { get; set; } diff --git a/API/Entities/Person.cs b/API/Entities/Person.cs index c9f182215..785a037bd 100644 --- a/API/Entities/Person.cs +++ b/API/Entities/Person.cs @@ -1,23 +1,24 @@ -using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; using API.Entities.Enums; -using API.Entities.Interfaces; +using API.Entities.Metadata; namespace API.Entities { - public class Person : IHasConcurrencyToken + public enum ProviderSource + { + Local = 1, + External = 2 + } + public class Person { public int Id { get; set; } public string Name { get; set; } + public string NormalizedName { get; set; } public PersonRole Role { get; set; } + //public ProviderSource Source { get; set; } - /// - [ConcurrencyCheck] - public uint RowVersion { get; private set; } - - /// - public void OnSavingChanges() - { - RowVersion++; - } + // Relationships + public ICollection SeriesMetadatas { get; set; } + public ICollection ChapterMetadatas { get; set; } } } diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index de02ad427..ed2fd64e2 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using API.Entities.Enums; using API.Entities.Interfaces; +using API.Entities.Metadata; using Microsoft.EntityFrameworkCore; namespace API.Entities @@ -30,10 +31,6 @@ namespace API.Entities /// Original Name on disk. Not exposed to UI. /// public string OriginalName { get; set; } - /// - /// Summary information related to the Series - /// - public string Summary { get; set; } // NOTE: Migrate into SeriesMetdata (with Metadata update) public DateTime Created { get; set; } public DateTime LastModified { get; set; } /// diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index cd5a621fc..9974cad2d 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -1,4 +1,5 @@ -using API.Data; +using System.IO.Abstractions; +using API.Data; using API.Helpers; using API.Interfaces; using API.Interfaces.Services; @@ -38,6 +39,11 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddSqLite(config, env); diff --git a/API/Extensions/DateTimeExtensions.cs b/API/Extensions/DateTimeExtensions.cs new file mode 100644 index 000000000..da205608c --- /dev/null +++ b/API/Extensions/DateTimeExtensions.cs @@ -0,0 +1,18 @@ +using System; + +namespace API.Extensions; + +public static class DateTimeExtensions +{ + /// + /// Truncates a DateTime to a specified resolution. + /// A convenient source for resolution is TimeSpan.TicksPerXXXX constants. + /// + /// The DateTime object to truncate + /// e.g. to round to nearest second, TimeSpan.TicksPerSecond + /// Truncated DateTime + public static DateTime Truncate(this DateTime date, long resolution) + { + return new DateTime(date.Ticks - (date.Ticks % resolution), date.Kind); + } +} diff --git a/API/Extensions/EnumerableExtensions.cs b/API/Extensions/EnumerableExtensions.cs index b8293436e..7bc5a378f 100644 --- a/API/Extensions/EnumerableExtensions.cs +++ b/API/Extensions/EnumerableExtensions.cs @@ -1,21 +1,14 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; namespace API.Extensions { public static class EnumerableExtensions { - public static IEnumerable DistinctBy - (this IEnumerable source, Func keySelector) - { - var seenKeys = new HashSet(); - foreach (var element in source) - { - if (seenKeys.Add(keySelector(element))) - { - yield return element; - } - } - } + + } -} \ No newline at end of file +} diff --git a/API/Extensions/StringExtensions.cs b/API/Extensions/StringExtensions.cs new file mode 100644 index 000000000..b172c0e46 --- /dev/null +++ b/API/Extensions/StringExtensions.cs @@ -0,0 +1,13 @@ +using System.Text.RegularExpressions; + +namespace API.Extensions; + +public static class StringExtensions +{ + private static readonly Regex SentenceCaseRegex = new Regex(@"(^[a-z])|\.\s+(.)", RegexOptions.ExplicitCapture | RegexOptions.Compiled); + + public static string SentenceCase(this string value) + { + return SentenceCaseRegex.Replace(value.ToLower(), s => s.Value.ToUpper()); + } +} diff --git a/API/Extensions/VolumeListExtensions.cs b/API/Extensions/VolumeListExtensions.cs index 4752cce5b..4969e9d0a 100644 --- a/API/Extensions/VolumeListExtensions.cs +++ b/API/Extensions/VolumeListExtensions.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using API.Comparators; using API.Entities; using API.Entities.Enums; @@ -7,11 +8,11 @@ namespace API.Extensions { public static class VolumeListExtensions { - public static Volume FirstWithChapters(this IList volumes, bool inBookSeries) + public static Volume FirstWithChapters(this IEnumerable volumes, bool inBookSeries) { return inBookSeries ? volumes.FirstOrDefault(v => v.Chapters.Any()) - : volumes.FirstOrDefault(v => v.Chapters.Any() && (v.Number == 1)); + : volumes.OrderBy(v => v.Number, new ChapterSortComparer()).FirstOrDefault(v => v.Chapters.Any()); } /// diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 74bd8d57c..c67aca2f6 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -2,10 +2,13 @@ using System.Linq; using API.DTOs; using API.DTOs.CollectionTags; +using API.DTOs.Metadata; using API.DTOs.Reader; using API.DTOs.ReadingLists; using API.DTOs.Settings; using API.Entities; +using API.Entities.Enums; +using API.Entities.Metadata; using API.Helpers.Converters; using AutoMapper; @@ -21,16 +24,98 @@ namespace API.Helpers CreateMap(); - CreateMap(); + CreateMap() + .ForMember(dest => dest.Writers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer))) + .ForMember(dest => dest.CoverArtist, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist))) + .ForMember(dest => dest.Colorist, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist))) + .ForMember(dest => dest.Inker, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker))) + .ForMember(dest => dest.Letterer, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer))) + .ForMember(dest => dest.Penciller, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller))) + .ForMember(dest => dest.Publisher, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher))) + .ForMember(dest => dest.Editor, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); CreateMap(); CreateMap(); - CreateMap(); - CreateMap(); + CreateMap(); + + CreateMap() + .ForMember(dest => dest.Writers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer))) + .ForMember(dest => dest.Artists, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist))) + .ForMember(dest => dest.Characters, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Character))) + .ForMember(dest => dest.Publishers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher))) + .ForMember(dest => dest.Colorists, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist))) + .ForMember(dest => dest.Inkers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker))) + .ForMember(dest => dest.Letterers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer))) + .ForMember(dest => dest.Pencillers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller))) + .ForMember(dest => dest.Editors, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); + + CreateMap() + .ForMember(dest => dest.Writers, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Writer))) + .ForMember(dest => dest.CoverArtist, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.CoverArtist))) + .ForMember(dest => dest.Colorist, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Colorist))) + .ForMember(dest => dest.Inker, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker))) + .ForMember(dest => dest.Letterer, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer))) + .ForMember(dest => dest.Penciller, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Penciller))) + .ForMember(dest => dest.Publisher, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Publisher))) + .ForMember(dest => dest.Editor, + opt => + opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor))); + + + + CreateMap(); CreateMap(); @@ -55,8 +140,10 @@ namespace API.Helpers CreateMap(); + CreateMap, ServerSettingDto>() .ConvertUsing(); + } } } diff --git a/API/Helpers/CacheHelper.cs b/API/Helpers/CacheHelper.cs new file mode 100644 index 000000000..a5febb76f --- /dev/null +++ b/API/Helpers/CacheHelper.cs @@ -0,0 +1,74 @@ +using System; +using System.IO; +using API.Entities; +using API.Entities.Interfaces; +using API.Services; + +namespace API.Helpers; + +public interface ICacheHelper +{ + bool ShouldUpdateCoverImage(string coverPath, MangaFile firstFile, DateTime chapterCreated, + bool forceUpdate = false, + bool isCoverLocked = false); + + bool CoverImageExists(string path); + + bool HasFileNotChangedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile); + +} + +public class CacheHelper : ICacheHelper +{ + private readonly IFileService _fileService; + + public CacheHelper(IFileService fileService) + { + _fileService = fileService; + } + + /// + /// Determines whether an entity should regenerate cover image. + /// + /// If a cover image is locked but the underlying file has been deleted, this will allow regenerating. + /// This should just be the filename, no path information + /// + /// If the user has told us to force the refresh + /// If cover has been locked by user. This will force false + /// + public bool ShouldUpdateCoverImage(string coverPath, MangaFile firstFile, DateTime chapterCreated, bool forceUpdate = false, + bool isCoverLocked = false) + { + if (firstFile == null) return true; + + var fileExists = !string.IsNullOrEmpty(coverPath) && _fileService.Exists(coverPath); + if (isCoverLocked && fileExists) return false; + if (forceUpdate) return true; + return (_fileService.HasFileBeenModifiedSince(coverPath, chapterCreated)) || !fileExists; + } + + /// + /// Has the file been modified since last scan or is user forcing an update + /// + /// + /// + /// + /// + public bool HasFileNotChangedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile) + { + return firstFile != null && + (!forceUpdate && + !(_fileService.HasFileBeenModifiedSince(firstFile.FilePath, chapter.Created) + || _fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified))); + } + + /// + /// Determines if a given coverImage path exists + /// + /// + /// + public bool CoverImageExists(string path) + { + return !string.IsNullOrEmpty(path) && _fileService.Exists(path); + } +} diff --git a/API/Helpers/GenreHelper.cs b/API/Helpers/GenreHelper.cs new file mode 100644 index 000000000..8d897314b --- /dev/null +++ b/API/Helpers/GenreHelper.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.Data; +using API.Entities; + +namespace API.Helpers; + +public static class GenreHelper +{ + /// + /// + /// + /// + /// + /// + /// + public static void UpdateGenre(ICollection allPeople, IEnumerable names, bool isExternal, Action action) + { + foreach (var name in names) + { + if (string.IsNullOrEmpty(name.Trim())) continue; + + var normalizedName = Parser.Parser.Normalize(name); + var genre = allPeople.FirstOrDefault(p => + p.NormalizedTitle.Equals(normalizedName) && p.ExternalTag == isExternal); + if (genre == null) + { + genre = DbFactory.Genre(name, false); + allPeople.Add(genre); + } + + action(genre); + } + } + + public static void KeepOnlySameGenreBetweenLists(ICollection existingGenres, ICollection removeAllExcept, Action action = null) + { + // var normalizedNames = names.Select(s => Parser.Parser.Normalize(s.Trim())) + // .Where(s => !string.IsNullOrEmpty(s)).ToList(); + // var localNamesNotInComicInfos = seriesGenres.Where(g => + // !normalizedNames.Contains(g.NormalizedName) && g.ExternalTag == isExternal); + // + // foreach (var nonExisting in localNamesNotInComicInfos) + // { + // // TODO: Maybe I need to do a cleanup here + // action(nonExisting); + // } + var existing = existingGenres.ToList(); + foreach (var genre in existing) + { + var existingPerson = removeAllExcept.FirstOrDefault(g => g.ExternalTag == genre.ExternalTag && genre.NormalizedTitle.Equals(g.NormalizedTitle)); + if (existingPerson == null) + { + existingGenres.Remove(genre); + action?.Invoke(genre); + } + } + + } + + /// + /// Adds the genre to the list if it's not already in there. This will ignore the ExternalTag. + /// + /// + /// + public static void AddGenreIfNotExists(ICollection metadataGenres, Genre genre) + { + var existingGenre = metadataGenres.FirstOrDefault(p => + p.NormalizedTitle == Parser.Parser.Normalize(genre.Title)); + if (existingGenre == null) + { + metadataGenres.Add(genre); + } + } +} diff --git a/API/Helpers/PersonHelper.cs b/API/Helpers/PersonHelper.cs new file mode 100644 index 000000000..36d544d4d --- /dev/null +++ b/API/Helpers/PersonHelper.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using API.Data; +using API.Entities; +using API.Entities.Enums; + +namespace API.Helpers; + +public static class PersonHelper +{ + /// + /// 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. + /// + /// This is used to add new people to a list without worrying about duplicating rows in the DB + /// + /// + /// + /// + 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 = Parser.Parser.Normalize(name); + var person = allPeopleTypeRole.FirstOrDefault(p => + p.NormalizedName.Equals(normalizedName)); + if (person == null) + { + person = DbFactory.Person(name, role); + allPeople.Add(person); + } + + action(person); + } + } + + /// + /// Remove people on a list for a given role + /// + /// Used to remove before we update/add new people + /// Existing people on Entity + /// People from metadata + /// Role to filter on + /// Callback 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(Parser.Parser.Normalize).ToList(); + 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. + /// + /// + /// + /// Callback for all entities that was removed + public static void KeepOnlySamePeopleBetweenLists(ICollection existingPeople, ICollection removeAllExcept, Action action = null) + { + var existing = existingPeople.ToList(); + foreach (var person in existing) + { + var existingPerson = removeAllExcept.FirstOrDefault(p => p.Role == person.Role && person.NormalizedName.Equals(p.NormalizedName)); + if (existingPerson == null) + { + existingPeople.Remove(person); + action?.Invoke(person); + } + } + } + + /// + /// Adds the person to the list if it's not already in there + /// + /// + /// + public static void AddPersonIfNotExists(ICollection metadataPeople, Person person) + { + var existingPerson = metadataPeople.SingleOrDefault(p => + p.NormalizedName == Parser.Parser.Normalize(person.Name) && p.Role == person.Role); + if (existingPerson == null) + { + metadataPeople.Add(person); + } + } +} diff --git a/API/Helpers/SeriesHelper.cs b/API/Helpers/SeriesHelper.cs new file mode 100644 index 000000000..7aee8bc89 --- /dev/null +++ b/API/Helpers/SeriesHelper.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Linq; +using API.Entities; +using API.Entities.Enums; +using API.Services.Tasks.Scanner; + +namespace API.Helpers; + +public static class SeriesHelper +{ + /// + /// Given a parsedSeries checks if any of the names match against said Series and the format matches + /// + /// + /// + /// + public static bool FindSeries(Series series, ParsedSeries parsedInfoKey) + { + return (series.NormalizedName.Equals(parsedInfoKey.NormalizedName) || Parser.Parser.Normalize(series.OriginalName).Equals(parsedInfoKey.NormalizedName)) + && (series.Format == parsedInfoKey.Format || series.Format == MangaFormat.Unknown); + } + + /// + /// Removes all instances of missingSeries' Series from existingSeries Collection. Existing series is updated by + /// reference and the removed element count is returned. + /// + /// Existing Series in DB + /// Series not found on disk or can't be parsed + /// + /// the updated existingSeries + public static IEnumerable RemoveMissingSeries(IList existingSeries, IEnumerable missingSeries, out int removeCount) + { + var existingCount = existingSeries.Count; + var missingList = missingSeries.ToList(); + + existingSeries = existingSeries.Where( + s => !missingList.Exists( + m => m.NormalizedName.Equals(s.NormalizedName) && m.Format == s.Format)).ToList(); + + removeCount = existingCount - existingSeries.Count; + + return existingSeries; + } +} diff --git a/API/Interfaces/IUnitOfWork.cs b/API/Interfaces/IUnitOfWork.cs index 733008192..8fba1c03a 100644 --- a/API/Interfaces/IUnitOfWork.cs +++ b/API/Interfaces/IUnitOfWork.cs @@ -12,10 +12,11 @@ namespace API.Interfaces ISettingsRepository SettingsRepository { get; } IAppUserProgressRepository AppUserProgressRepository { get; } ICollectionTagRepository CollectionTagRepository { get; } - IFileRepository FileRepository { get; } IChapterRepository ChapterRepository { get; } IReadingListRepository ReadingListRepository { get; } ISeriesMetadataRepository SeriesMetadataRepository { get; } + IPersonRepository PersonRepository { get; } + IGenreRepository GenreRepository { get; } bool Commit(); Task CommitAsync(); bool HasChanges(); diff --git a/API/Interfaces/Repositories/ICollectionTagRepository.cs b/API/Interfaces/Repositories/ICollectionTagRepository.cs index 18c9f490b..6cb422a00 100644 --- a/API/Interfaces/Repositories/ICollectionTagRepository.cs +++ b/API/Interfaces/Repositories/ICollectionTagRepository.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Threading.Tasks; -using API.DTOs; using API.DTOs.CollectionTags; using API.Entities; diff --git a/API/Interfaces/Repositories/IFileRepository.cs b/API/Interfaces/Repositories/IFileRepository.cs deleted file mode 100644 index a852032d7..000000000 --- a/API/Interfaces/Repositories/IFileRepository.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace API.Interfaces.Repositories -{ - public interface IFileRepository - { - Task> GetFileExtensions(); - } -} diff --git a/API/Interfaces/Repositories/IGenreRepository.cs b/API/Interfaces/Repositories/IGenreRepository.cs new file mode 100644 index 000000000..72a3cca55 --- /dev/null +++ b/API/Interfaces/Repositories/IGenreRepository.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Entities; + +namespace API.Interfaces.Repositories +{ + public interface IGenreRepository + { + void Attach(Genre genre); + void Remove(Genre genre); + Task FindByNameAsync(string genreName); + Task> GetAllGenres(); + Task RemoveAllGenreNoLongerAssociated(bool removeExternal = false); + } +} diff --git a/API/Interfaces/Repositories/IPersonRepository.cs b/API/Interfaces/Repositories/IPersonRepository.cs new file mode 100644 index 000000000..dc83bd14f --- /dev/null +++ b/API/Interfaces/Repositories/IPersonRepository.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Entities; + +namespace API.Interfaces.Repositories +{ + public interface IPersonRepository + { + void Attach(Person person); + void Remove(Person person); + Task> GetAllPeople(); + Task RemoveAllPeopleNoLongerAssociated(bool removeExternal = false); + } +} diff --git a/API/Interfaces/Repositories/ISeriesMetadataRepository.cs b/API/Interfaces/Repositories/ISeriesMetadataRepository.cs index 00dd234ee..6d6d09f50 100644 --- a/API/Interfaces/Repositories/ISeriesMetadataRepository.cs +++ b/API/Interfaces/Repositories/ISeriesMetadataRepository.cs @@ -1,4 +1,5 @@ using API.Entities; +using API.Entities.Metadata; namespace API.Interfaces.Repositories { diff --git a/API/Interfaces/Repositories/ISeriesRepository.cs b/API/Interfaces/Repositories/ISeriesRepository.cs index 4c8b2e74e..e4271b247 100644 --- a/API/Interfaces/Repositories/ISeriesRepository.cs +++ b/API/Interfaces/Repositories/ISeriesRepository.cs @@ -5,6 +5,7 @@ using API.DTOs; using API.DTOs.Filtering; using API.Entities; using API.Entities.Enums; +using API.Entities.Metadata; using API.Helpers; namespace API.Interfaces.Repositories diff --git a/API/Interfaces/Services/IDirectoryService.cs b/API/Interfaces/Services/IDirectoryService.cs index a8ae8c05f..9237b9fc3 100644 --- a/API/Interfaces/Services/IDirectoryService.cs +++ b/API/Interfaces/Services/IDirectoryService.cs @@ -1,6 +1,7 @@ -using System.Collections.Generic; -using System.IO; +using System; +using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; namespace API.Interfaces.Services { @@ -16,5 +17,6 @@ namespace API.Interfaces.Services bool CopyFilesToDirectory(IEnumerable filePaths, string directoryPath, string prepend = ""); bool Exists(string directory); void CopyFileToDirectory(string fullFilePath, string targetDirectory); + int TraverseTreeParallelForEach(string root, Action action, string searchPattern, ILogger logger); } } diff --git a/API/Interfaces/Services/IMetadataService.cs b/API/Interfaces/Services/IMetadataService.cs index 6d4d725cf..53f3a2757 100644 --- a/API/Interfaces/Services/IMetadataService.cs +++ b/API/Interfaces/Services/IMetadataService.cs @@ -1,5 +1,4 @@ using System.Threading.Tasks; -using API.Entities; namespace API.Interfaces.Services { @@ -11,10 +10,6 @@ namespace API.Interfaces.Services /// /// Task RefreshMetadata(int libraryId, bool forceUpdate = false); - - public bool UpdateMetadata(Chapter chapter, bool forceUpdate); - public bool UpdateMetadata(Volume volume, bool forceUpdate); - public bool UpdateMetadata(Series series, bool forceUpdate); /// /// Performs a forced refresh of metatdata just for a series and it's nested entities /// diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 02dc6894c..8183505ff 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -48,6 +48,8 @@ namespace API.Parser MatchOptions, RegexTimeout); private static readonly Regex ArchiveFileRegex = new Regex(ArchiveFileExtensions, MatchOptions, RegexTimeout); + private static readonly Regex ComicInfoArchiveRegex = new Regex(@"\.cbz|\.cbr|\.cb7|\.cbt", + MatchOptions, RegexTimeout); private static readonly Regex XmlRegex = new Regex(XmlRegexExtensions, MatchOptions, RegexTimeout); private static readonly Regex BookFileRegex = new Regex(BookFileExtensions, @@ -862,7 +864,6 @@ namespace API.Parser } } - // TODO: Since we have loops like this, think about using a method foreach (var regex in MangaEditionRegex) { var matches = regex.Matches(title); @@ -997,6 +998,10 @@ namespace API.Parser { return ArchiveFileRegex.IsMatch(Path.GetExtension(filePath)); } + public static bool IsComicInfoExtension(string filePath) + { + return ComicInfoArchiveRegex.IsMatch(Path.GetExtension(filePath)); + } public static bool IsBook(string filePath) { return BookFileRegex.IsMatch(Path.GetExtension(filePath)); @@ -1062,5 +1067,17 @@ namespace API.Parser { return Path.GetExtension(filePath).ToLower() == ".pdf"; } + + /// + /// Cleans an author's name + /// + /// If the author is Last, First, this will reverse + /// + /// + public static string CleanAuthor(string author) + { + if (string.IsNullOrEmpty(author)) return string.Empty; + return string.Join(" ", author.Split(",").Reverse().Select(s => s.Trim())); + } } } diff --git a/API/Parser/ParserInfo.cs b/API/Parser/ParserInfo.cs index a2c4a9c51..486f79e29 100644 --- a/API/Parser/ParserInfo.cs +++ b/API/Parser/ParserInfo.cs @@ -1,4 +1,5 @@ -using API.Entities.Enums; +using API.Data.Metadata; +using API.Entities.Enums; namespace API.Parser { @@ -15,7 +16,11 @@ namespace API.Parser /// /// Represents the parsed series from the file or folder /// - public string Series { get; set; } = ""; + public string Series { get; set; } = string.Empty; + /// + /// This can be filled in from ComicInfo.xml/Epub during scanning. Will update the SortName field on + /// + public string SeriesSort { get; set; } = string.Empty; /// /// Represents the parsed volumes from a file. By default, will be 0 which means that nothing could be parsed. /// If Volumes is 0 and Chapters is 0, the file is a special. If Chapters is non-zero, then no volume could be parsed. @@ -55,16 +60,26 @@ namespace API.Parser /// Manga does not use this field /// public string Title { get; set; } = string.Empty; - + /// /// If the ParserInfo has the IsSpecial tag or both volumes and chapters are default aka 0 /// /// public bool IsSpecialInfo() - { + { return (IsSpecial || (Volumes == "0" && Chapters == "0")); } + // (TODO: Make this a ValueType). Has at least 1 year, maybe 2 representing a range + // public string YearRange { get; set; } + // public IList Genres { get; set; } = new List(); + + /// + /// This will contain any EXTRA comicInfo information parsed from the epub or archive. If there is an archive with comicInfo.xml AND it contains + /// series, volume information, that will override what we parsed. + /// + public ComicInfo ComicInfo { get; set; } + /// /// Merges non empty/null properties from info2 into this entity. /// @@ -80,4 +95,4 @@ namespace API.Parser IsSpecial = IsSpecial || info2.IsSpecial; } } -} \ No newline at end of file +} diff --git a/API/Program.cs b/API/Program.cs index f35bf8bd3..33c8420c8 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; using System.IO; -using System.Linq; using System.Security.Cryptography; using System.Threading.Tasks; using API.Data; @@ -41,7 +39,7 @@ namespace API { Console.WriteLine("Generating JWT TokenKey for encrypting user sessions..."); var rBytes = new byte[128]; - using (var crypto = new RNGCryptoServiceProvider()) crypto.GetBytes(rBytes); + RandomNumberGenerator.Create().GetBytes(rBytes); Configuration.JwtToken = Convert.ToBase64String(rBytes).Replace("/", string.Empty); } diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index 2eb59a9fc..9d75adc55 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -322,7 +322,12 @@ namespace API.Services return null; } - public ComicInfo GetComicInfo(string archivePath) + /// + /// This can be null if nothing is found or any errors occur during access + /// + /// + /// + public ComicInfo? GetComicInfo(string archivePath) { if (!IsValidArchive(archivePath)) return null; @@ -336,7 +341,7 @@ namespace API.Services case ArchiveLibrary.Default: { using var archive = ZipFile.OpenRead(archivePath); - var entry = archive.Entries.SingleOrDefault(x => + var entry = archive.Entries.FirstOrDefault(x => !Parser.Parser.HasBlacklistedFolderInPath(x.FullName) && Path.GetFileNameWithoutExtension(x.Name)?.ToLower() == ComicInfoFilename && !Path.GetFileNameWithoutExtension(x.Name) @@ -346,7 +351,18 @@ namespace API.Services { using var stream = entry.Open(); var serializer = new XmlSerializer(typeof(ComicInfo)); - return (ComicInfo) serializer.Deserialize(stream); + var info = (ComicInfo) serializer.Deserialize(stream); + if (info != null) + { + info.Writer = Parser.Parser.CleanAuthor(info.Writer); + info.Colorist = Parser.Parser.CleanAuthor(info.Colorist); + info.Editor = Parser.Parser.CleanAuthor(info.Editor); + info.Inker = Parser.Parser.CleanAuthor(info.Inker); + info.Letterer = Parser.Parser.CleanAuthor(info.Letterer); + info.Penciller = Parser.Parser.CleanAuthor(info.Penciller); + info.Publisher = Parser.Parser.CleanAuthor(info.Publisher); + } + return info; } break; @@ -354,7 +370,7 @@ namespace API.Services case ArchiveLibrary.SharpCompress: { using var archive = ArchiveFactory.Open(archivePath); - return FindComicInfoXml(archive.Entries.Where(entry => !entry.IsDirectory + var info = FindComicInfoXml(archive.Entries.Where(entry => !entry.IsDirectory && !Parser.Parser .HasBlacklistedFolderInPath( Path.GetDirectoryName( @@ -365,6 +381,18 @@ namespace API.Services .Parser .MacOsMetadataFileStartsWith) && Parser.Parser.IsXml(entry.Key))); + if (info != null) + { + info.Writer = Parser.Parser.CleanAuthor(info.Writer); + info.Colorist = Parser.Parser.CleanAuthor(info.Colorist); + info.Editor = Parser.Parser.CleanAuthor(info.Editor); + info.Inker = Parser.Parser.CleanAuthor(info.Inker); + info.Letterer = Parser.Parser.CleanAuthor(info.Letterer); + info.Penciller = Parser.Parser.CleanAuthor(info.Penciller); + info.Publisher = Parser.Parser.CleanAuthor(info.Publisher); + } + + return info; } case ArchiveLibrary.NotSupported: _logger.LogWarning("[GetComicInfo] This archive cannot be read: {ArchivePath}", archivePath); diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 137f21dca..84a70fdef 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -201,11 +201,15 @@ namespace API.Services var info = new ComicInfo() { + // TODO: Summary is in html, we need to turn it into string Summary = epubBook.Schema.Package.Metadata.Description, - Writer = string.Join(",", epubBook.Schema.Package.Metadata.Creators), + Writer = string.Join(",", epubBook.Schema.Package.Metadata.Creators.Select(c => Parser.Parser.CleanAuthor(c.Creator))), Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers), Month = !string.IsNullOrEmpty(publicationDate) ? DateTime.Parse(publicationDate).Month : 0, Year = !string.IsNullOrEmpty(publicationDate) ? DateTime.Parse(publicationDate).Year : 0, + Title = epubBook.Title, + Genre = string.Join(",", epubBook.Schema.Package.Metadata.Subjects.Select(s => s.ToLower().Trim())), + }; // Parse tags not exposed via Library foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems) @@ -215,6 +219,9 @@ namespace API.Services case "calibre:rating": info.UserRating = float.Parse(metadataItem.Content); break; + case "calibre:title_sort": + info.TitleSort = metadataItem.Content; + break; } } @@ -305,8 +312,6 @@ namespace API.Services { using var epubBook = EpubReader.OpenBook(filePath); - // If the epub has the following tags, we can group the books as Volumes - // // // // If all three are present, we can take that over dc:title and format as: @@ -323,6 +328,7 @@ namespace API.Services var series = string.Empty; var specialName = string.Empty; var groupPosition = string.Empty; + var titleSort = string.Empty; foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems) @@ -338,6 +344,7 @@ namespace API.Services break; case "calibre:title_sort": specialName = metadataItem.Content; + titleSort = metadataItem.Content; break; } @@ -363,18 +370,26 @@ namespace API.Services { specialName = epubBook.Title; } - return new ParserInfo() + var info = new ParserInfo() { Chapters = Parser.Parser.DefaultChapter, Edition = string.Empty, Format = MangaFormat.Epub, Filename = Path.GetFileName(filePath), - Title = specialName.Trim(), + Title = specialName?.Trim(), FullFilePath = filePath, IsSpecial = false, Series = series.Trim(), Volumes = seriesIndex }; + + // Don't set titleSort if the book belongs to a group + if (!string.IsNullOrEmpty(titleSort) && string.IsNullOrEmpty(seriesIndex)) + { + info.SeriesSort = titleSort; + } + + return info; } } catch (Exception) @@ -392,7 +407,7 @@ namespace API.Services FullFilePath = filePath, IsSpecial = false, Series = epubBook.Title.Trim(), - Volumes = Parser.Parser.DefaultVolume + Volumes = Parser.Parser.DefaultVolume, }; } catch (Exception ex) @@ -494,6 +509,7 @@ namespace API.Services private static void GetPdfPage(IDocReader docReader, int pageNumber, Stream stream) { + // TODO: BUG: Most of this Bitmap code is only supported on Windows. Refactor. using var pageReader = docReader.GetPageReader(pageNumber); var rawBytes = pageReader.GetImage(new NaiveTransparencyRemover()); var width = pageReader.GetPageWidth(); diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 04240245a..0d13a2ad2 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.IO; +using System.IO.Abstractions; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -13,6 +14,8 @@ namespace API.Services public class DirectoryService : IDirectoryService { private readonly ILogger _logger; + private readonly IFileSystem _fileSystem; + private static readonly Regex ExcludeDirectories = new Regex( @"@eaDir|\.DS_Store", RegexOptions.Compiled | RegexOptions.IgnoreCase); @@ -23,9 +26,10 @@ namespace API.Services public static readonly string BackupDirectory = Path.Join(Directory.GetCurrentDirectory(), "config", "backups"); public static readonly string ConfigDirectory = Path.Join(Directory.GetCurrentDirectory(), "config"); - public DirectoryService(ILogger logger) + public DirectoryService(ILogger logger, IFileSystem fileSystem) { - _logger = logger; + _logger = logger; + _fileSystem = fileSystem; } /// @@ -91,6 +95,11 @@ namespace API.Services return paths; } + /// + /// Does Directory Exist + /// + /// + /// public bool Exists(string directory) { var di = new DirectoryInfo(directory); @@ -365,7 +374,7 @@ namespace API.Services /// Regex pattern to search against /// /// - public static int TraverseTreeParallelForEach(string root, Action action, string searchPattern, ILogger logger) + public int TraverseTreeParallelForEach(string root, Action action, string searchPattern, ILogger logger) { //Count of files traversed and timer for diagnostic output var fileCount = 0; diff --git a/API/Services/FileService.cs b/API/Services/FileService.cs new file mode 100644 index 000000000..a4194b820 --- /dev/null +++ b/API/Services/FileService.cs @@ -0,0 +1,46 @@ +using System; +using System.IO.Abstractions; +using API.Extensions; + +namespace API.Services; + +public interface IFileService +{ + IFileSystem GetFileSystem(); + bool HasFileBeenModifiedSince(string filePath, DateTime time); + bool Exists(string filePath); +} + +public class FileService : IFileService +{ + private readonly IFileSystem _fileSystem; + + public FileService(IFileSystem fileSystem) + { + _fileSystem = fileSystem; + } + + public FileService() : this(fileSystem: new FileSystem()) { } + + public IFileSystem GetFileSystem() + { + return _fileSystem; + } + + /// + /// If the File on disk's last modified time is after passed time + /// + /// This has a resolution to the minute. Will ignore seconds and milliseconds + /// Full qualified path of file + /// + /// + public bool HasFileBeenModifiedSince(string filePath, DateTime time) + { + return !string.IsNullOrEmpty(filePath) && _fileSystem.File.GetLastWriteTime(filePath).Truncate(TimeSpan.TicksPerMinute) > time.Truncate(TimeSpan.TicksPerMinute); + } + + public bool Exists(string filePath) + { + return _fileSystem.File.Exists(filePath); + } +} diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 09161f42a..81c1c753e 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -5,8 +5,10 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; +using API.Data; using API.Data.Metadata; using API.Data.Repositories; +using API.Data.Scanner; using API.Entities; using API.Entities.Enums; using API.Extensions; @@ -17,317 +19,499 @@ using API.SignalR; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; -namespace API.Services +namespace API.Services; + +public class MetadataService : IMetadataService { - public class MetadataService : IMetadataService + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly IArchiveService _archiveService; + private readonly IBookService _bookService; + private readonly IImageService _imageService; + private readonly IHubContext _messageHub; + private readonly ICacheHelper _cacheHelper; + private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); + public MetadataService(IUnitOfWork unitOfWork, ILogger logger, + IArchiveService archiveService, IBookService bookService, IImageService imageService, + IHubContext messageHub, ICacheHelper cacheHelper) { - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - private readonly IArchiveService _archiveService; - private readonly IBookService _bookService; - private readonly IImageService _imageService; - private readonly IHubContext _messageHub; - private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); + _unitOfWork = unitOfWork; + _logger = logger; + _archiveService = archiveService; + _bookService = bookService; + _imageService = imageService; + _messageHub = messageHub; + _cacheHelper = cacheHelper; + } - public MetadataService(IUnitOfWork unitOfWork, ILogger logger, - IArchiveService archiveService, IBookService bookService, IImageService imageService, IHubContext messageHub) + /// + /// Gets the cover image for the file + /// + /// Has side effect of marking the file as updated + /// + /// + /// + /// + private string GetCoverImage(MangaFile file, int volumeId, int chapterId) + { + //file.UpdateLastModified(); + switch (file.Format) { - _unitOfWork = unitOfWork; - _logger = logger; - _archiveService = archiveService; - _bookService = bookService; - _imageService = imageService; - _messageHub = messageHub; + case MangaFormat.Pdf: + case MangaFormat.Epub: + return _bookService.GetCoverImage(file.FilePath, ImageService.GetChapterFormat(chapterId, volumeId)); + case MangaFormat.Image: + var coverImage = _imageService.GetCoverFile(file); + return _imageService.GetCoverImage(coverImage, ImageService.GetChapterFormat(chapterId, volumeId)); + case MangaFormat.Archive: + return _archiveService.GetCoverImage(file.FilePath, ImageService.GetChapterFormat(chapterId, volumeId)); + case MangaFormat.Unknown: + default: + return string.Empty; } - /// - /// Determines whether an entity should regenerate cover image. - /// - /// If a cover image is locked but the underlying file has been deleted, this will allow regenerating. - /// - /// - /// - /// - /// Directory where cover images are. Defaults to - /// - public static bool ShouldUpdateCoverImage(string coverImage, MangaFile firstFile, bool forceUpdate = false, - bool isCoverLocked = false, string coverImageDirectory = null) - { - if (string.IsNullOrEmpty(coverImageDirectory)) - { - coverImageDirectory = DirectoryService.CoverImageDirectory; - } + } - var fileExists = File.Exists(Path.Join(coverImageDirectory, coverImage)); - if (isCoverLocked && fileExists) return false; - if (forceUpdate) return true; - return (firstFile != null && firstFile.HasFileBeenModified()) || !HasCoverImage(coverImage, fileExists); - } - - - private static bool HasCoverImage(string coverImage) - { - return HasCoverImage(coverImage, File.Exists(coverImage)); - } - - private static bool HasCoverImage(string coverImage, bool fileExists) - { - return !string.IsNullOrEmpty(coverImage) && fileExists; - } - - private string GetCoverImage(MangaFile file, int volumeId, int chapterId) - { - file.UpdateLastModified(); - switch (file.Format) - { - case MangaFormat.Pdf: - case MangaFormat.Epub: - return _bookService.GetCoverImage(file.FilePath, ImageService.GetChapterFormat(chapterId, volumeId)); - case MangaFormat.Image: - var coverImage = _imageService.GetCoverFile(file); - return _imageService.GetCoverImage(coverImage, ImageService.GetChapterFormat(chapterId, volumeId)); - case MangaFormat.Archive: - return _archiveService.GetCoverImage(file.FilePath, ImageService.GetChapterFormat(chapterId, volumeId)); - default: - return string.Empty; - } - - } - - /// - /// Updates the metadata for a Chapter - /// - /// - /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - public bool UpdateMetadata(Chapter chapter, bool forceUpdate) - { - var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); - - if (ShouldUpdateCoverImage(chapter.CoverImage, firstFile, forceUpdate, chapter.CoverImageLocked)) - { - _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile?.FilePath); - chapter.CoverImage = GetCoverImage(firstFile, chapter.VolumeId, chapter.Id); - return true; - } + /// + /// Updates the metadata for a Chapter + /// + /// + /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image + private bool UpdateChapterCoverImage(Chapter chapter, bool forceUpdate) + { + var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + if (!_cacheHelper.ShouldUpdateCoverImage(Path.Join(DirectoryService.CoverImageDirectory, chapter.CoverImage), firstFile, chapter.Created, forceUpdate, chapter.CoverImageLocked)) return false; + + _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile?.FilePath); + chapter.CoverImage = GetCoverImage(firstFile, chapter.VolumeId, chapter.Id); + + return true; + } + + private void UpdateChapterMetadata(Chapter chapter, ICollection allPeople, bool forceUpdate) + { + var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault(); + if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return; + + UpdateChapterFromComicInfo(chapter, allPeople, firstFile); + firstFile.UpdateLastModified(); + } + + private void UpdateChapterFromComicInfo(Chapter chapter, ICollection allPeople, MangaFile firstFile) + { + var comicInfo = GetComicInfo(firstFile); // TODO: Think about letting the higher level loop have access for series to avoid duplicate IO operations + if (comicInfo == null) return; + + if (!string.IsNullOrEmpty(comicInfo.Title)) + { + chapter.TitleName = comicInfo.Title.Trim(); } - /// - /// Updates the metadata for a Volume - /// - /// - /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - public bool UpdateMetadata(Volume volume, bool forceUpdate) + if (!string.IsNullOrEmpty(comicInfo.Colorist)) { - // We need to check if Volume coverImage matches first chapters if forceUpdate is false - if (volume == null || !ShouldUpdateCoverImage(volume.CoverImage, null, forceUpdate)) return false; - - volume.Chapters ??= new List(); - var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).FirstOrDefault(); - if (firstChapter == null) return false; - - volume.CoverImage = firstChapter.CoverImage; - return true; + var people = comicInfo.Colorist.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Colorist); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Colorist, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); } - /// - /// Updates metadata for Series - /// - /// - /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - public bool UpdateMetadata(Series series, bool forceUpdate) + if (!string.IsNullOrEmpty(comicInfo.Writer)) { - var madeUpdate = false; - if (series == null) return false; + var people = comicInfo.Writer.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Writer); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Writer, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } - // NOTE: This will fail if we replace the cover of the first volume on a first scan. Because the series will already have a cover image - if (ShouldUpdateCoverImage(series.CoverImage, null, forceUpdate, series.CoverImageLocked)) + if (!string.IsNullOrEmpty(comicInfo.Editor)) + { + var people = comicInfo.Editor.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Editor); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Editor, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + + if (!string.IsNullOrEmpty(comicInfo.Inker)) + { + var people = comicInfo.Inker.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Inker); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Inker, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + + if (!string.IsNullOrEmpty(comicInfo.Letterer)) + { + var people = comicInfo.Letterer.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Letterer); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Letterer, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + + if (!string.IsNullOrEmpty(comicInfo.Penciller)) + { + var people = comicInfo.Penciller.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Penciller); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Penciller, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + + if (!string.IsNullOrEmpty(comicInfo.CoverArtist)) + { + var people = comicInfo.CoverArtist.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.CoverArtist); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.CoverArtist, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + + if (!string.IsNullOrEmpty(comicInfo.Publisher)) + { + var people = comicInfo.Publisher.Split(","); + PersonHelper.RemovePeople(chapter.People, people, PersonRole.Publisher); + PersonHelper.UpdatePeople(allPeople, people, PersonRole.Publisher, + person => PersonHelper.AddPersonIfNotExists(chapter.People, person)); + } + } + + + /// + /// Updates the cover image for a Volume + /// + /// + /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image + private bool UpdateVolumeCoverImage(Volume volume, bool forceUpdate) + { + // We need to check if Volume coverImage matches first chapters if forceUpdate is false + if (volume == null || !_cacheHelper.ShouldUpdateCoverImage(Path.Join(DirectoryService.CoverImageDirectory, volume.CoverImage), null, volume.Created, forceUpdate)) return false; + + volume.Chapters ??= new List(); + var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).FirstOrDefault(); + if (firstChapter == null) return false; + + volume.CoverImage = firstChapter.CoverImage; + return true; + } + + /// + /// Updates metadata for Series + /// + /// + /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image + private void UpdateSeriesCoverImage(Series series, bool forceUpdate) + { + if (series == null) return; + + // NOTE: This will fail if we replace the cover of the first volume on a first scan. Because the series will already have a cover image + if (!_cacheHelper.ShouldUpdateCoverImage(Path.Join(DirectoryService.CoverImageDirectory, series.CoverImage), null, series.Created, forceUpdate, series.CoverImageLocked)) + return; + + series.Volumes ??= new List(); + var firstCover = series.Volumes.GetCoverImage(series.Format); + string coverImage = null; + if (firstCover == null && series.Volumes.Any()) + { + // If firstCover is null and one volume, the whole series is Chapters under Vol 0. + if (series.Volumes.Count == 1) { - series.Volumes ??= new List(); - var firstCover = series.Volumes.GetCoverImage(series.Format); - string coverImage = null; - if (firstCover == null && series.Volumes.Any()) - { - // If firstCover is null and one volume, the whole series is Chapters under Vol 0. - if (series.Volumes.Count == 1) - { - coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting) - .FirstOrDefault(c => !c.IsSpecial)?.CoverImage; - madeUpdate = true; - } - - if (!HasCoverImage(coverImage)) - { - coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting) - .FirstOrDefault()?.CoverImage; - madeUpdate = true; - } - } - series.CoverImage = firstCover?.CoverImage ?? coverImage; + coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting) + .FirstOrDefault(c => !c.IsSpecial)?.CoverImage; } - return UpdateSeriesSummary(series, forceUpdate) || madeUpdate ; - } - - private bool UpdateSeriesSummary(Series series, bool forceUpdate) - { - // NOTE: This can be problematic when the file changes and a summary already exists, but it is likely - // better to let the user kick off a refresh metadata on an individual Series than having overhead of - // checking File last write time. - if (!string.IsNullOrEmpty(series.Summary) && !forceUpdate) return false; - - var isBook = series.Library.Type == LibraryType.Book; - var firstVolume = series.Volumes.FirstWithChapters(isBook); - var firstChapter = firstVolume?.Chapters.GetFirstChapterWithFiles(); - - var firstFile = firstChapter?.Files.FirstOrDefault(); - if (firstFile == null || (!forceUpdate && !firstFile.HasFileBeenModified())) return false; - if (Parser.Parser.IsPdf(firstFile.FilePath)) return false; - - var comicInfo = GetComicInfo(series.Format, firstFile); - if (string.IsNullOrEmpty(comicInfo?.Summary)) return false; - - series.Summary = comicInfo.Summary; - return true; - } - - private ComicInfo GetComicInfo(MangaFormat format, MangaFile firstFile) - { - if (format is MangaFormat.Archive or MangaFormat.Epub) + if (!_cacheHelper.CoverImageExists(coverImage)) { - return Parser.Parser.IsEpub(firstFile.FilePath) ? _bookService.GetComicInfo(firstFile.FilePath) : _archiveService.GetComicInfo(firstFile.FilePath); + coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparerForInChapterSorting) + .FirstOrDefault()?.CoverImage; } + } + series.CoverImage = firstCover?.CoverImage ?? coverImage; + } - return null; + private void UpdateSeriesMetadata(Series series, ICollection allPeople, ICollection allGenres, bool forceUpdate) + { + var isBook = series.Library.Type == LibraryType.Book; + var firstVolume = series.Volumes.OrderBy(c => c.Number, new ChapterSortComparer()).FirstWithChapters(isBook); + var firstChapter = firstVolume?.Chapters.GetFirstChapterWithFiles(); + + var firstFile = firstChapter?.Files.FirstOrDefault(); + if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(firstChapter, forceUpdate, firstFile)) return; + if (Parser.Parser.IsPdf(firstFile.FilePath)) return; + + var comicInfo = GetComicInfo(firstFile); + if (comicInfo == null) return; + + + // Summary Info + if (!string.IsNullOrEmpty(comicInfo.Summary)) + { + series.Metadata.Summary = comicInfo.Summary; // NOTE: I can move this to the bottom as I have a comicInfo selection, save me an extra read } - - /// - /// Refreshes Metadata for a whole library - /// - /// This can be heavy on memory first run - /// - /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image - public async Task RefreshMetadata(int libraryId, bool forceUpdate = false) + foreach (var chapter in series.Volumes.SelectMany(volume => volume.Chapters)) { - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); - _logger.LogInformation("[MetadataService] Beginning metadata refresh of {LibraryName}", library.Name); + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Writer).Select(p => p.Name), PersonRole.Writer, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); - var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); - var stopwatch = Stopwatch.StartNew(); - var totalTime = 0L; - _logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, - MessageFactory.RefreshMetadataProgressEvent(library.Id, 0F)); + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.CoverArtist).Select(p => p.Name), PersonRole.CoverArtist, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); - var i = 0; - for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++, i++) - { - if (chunkInfo.TotalChunks == 0) continue; - totalTime += stopwatch.ElapsedMilliseconds; - stopwatch.Restart(); - _logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", - chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Publisher).Select(p => p.Name), PersonRole.Publisher, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); - var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, - new UserParams() - { - PageNumber = chunk, - PageSize = chunkInfo.ChunkSize - }); - _logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count); + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Character).Select(p => p.Name), PersonRole.Character, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); - Parallel.ForEach(nonLibrarySeries, series => - { - try - { - _logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName); - var volumeUpdated = false; - foreach (var volume in series.Volumes) - { - var chapterUpdated = false; - foreach (var chapter in volume.Chapters) - { - chapterUpdated = UpdateMetadata(chapter, forceUpdate); - } + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Colorist).Select(p => p.Name), PersonRole.Colorist, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); - volumeUpdated = UpdateMetadata(volume, chapterUpdated || forceUpdate); - } + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Editor).Select(p => p.Name), PersonRole.Editor, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); - UpdateMetadata(series, volumeUpdated || forceUpdate); - } - catch (Exception) - { - /* Swallow exception */ - } - }); + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Inker).Select(p => p.Name), PersonRole.Inker, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); - if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) - { - _logger.LogInformation( - "[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", - chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Letterer).Select(p => p.Name), PersonRole.Letterer, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); - foreach (var series in nonLibrarySeries) - { - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(library.Id, series.Id)); - } - } - else - { - _logger.LogInformation( - "[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", - chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); - } - var progress = Math.Max(0F, Math.Min(1F, i * 1F / chunkInfo.TotalChunks)); - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, - MessageFactory.RefreshMetadataProgressEvent(library.Id, progress)); - } - - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, - MessageFactory.RefreshMetadataProgressEvent(library.Id, 1F)); - - _logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime); + PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Penciller).Select(p => p.Name), PersonRole.Penciller, + person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); } + var comicInfos = series.Volumes + .SelectMany(volume => volume.Chapters) + .SelectMany(c => c.Files) + .Select(GetComicInfo) + .Where(ci => ci != null) + .ToList(); - /// - /// Refreshes Metadata for a Series. Will always force updates. - /// - /// - /// - public async Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false) + var genres = comicInfos.SelectMany(i => i.Genre.Split(",")).Distinct().ToList(); + var people = series.Volumes.SelectMany(volume => volume.Chapters).SelectMany(c => c.People).ToList(); + + + PersonHelper.KeepOnlySamePeopleBetweenLists(series.Metadata.People, + people, person => series.Metadata.People.Remove(person)); + + GenreHelper.UpdateGenre(allGenres, genres, false, genre => GenreHelper.AddGenreIfNotExists(series.Metadata.Genres, genre)); + GenreHelper.KeepOnlySameGenreBetweenLists(series.Metadata.Genres, genres.Select(g => DbFactory.Genre(g, false)).ToList(), + genre => series.Metadata.Genres.Remove(genre)); + + } + + private ComicInfo GetComicInfo(MangaFile firstFile) + { + if (firstFile?.Format is MangaFormat.Archive or MangaFormat.Epub) + { + return Parser.Parser.IsEpub(firstFile.FilePath) ? _bookService.GetComicInfo(firstFile.FilePath) : _archiveService.GetComicInfo(firstFile.FilePath); + } + + return null; + } + + /// + /// + /// + /// This cannot have any Async code within. It is used within Parallel.ForEach + /// + /// + private void ProcessSeriesMetadataUpdate(Series series, IDictionary> chapterIds, ICollection allPeople, ICollection allGenres, bool forceUpdate) + { + _logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName); + try { - var sw = Stopwatch.StartNew(); - var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); - if (series == null) - { - _logger.LogError("[MetadataService] Series {SeriesId} was not found on Library {LibraryId}", seriesId, libraryId); - return; - } - _logger.LogInformation("[MetadataService] Beginning metadata refresh of {SeriesName}", series.Name); var volumeUpdated = false; foreach (var volume in series.Volumes) { var chapterUpdated = false; foreach (var chapter in volume.Chapters) { - chapterUpdated = UpdateMetadata(chapter, forceUpdate); + chapterUpdated = UpdateChapterCoverImage(chapter, forceUpdate); + UpdateChapterMetadata(chapter, allPeople, forceUpdate || chapterUpdated); } - volumeUpdated = UpdateMetadata(volume, chapterUpdated || forceUpdate); + volumeUpdated = UpdateVolumeCoverImage(volume, chapterUpdated || forceUpdate); } - UpdateMetadata(series, volumeUpdated || forceUpdate); - - - if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) - { - await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(series.LibraryId, series.Id)); - } - - _logger.LogInformation("[MetadataService] Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds); + UpdateSeriesCoverImage(series, volumeUpdated || forceUpdate); + UpdateSeriesMetadata(series, allPeople, allGenres, forceUpdate); + } + catch (Exception ex) + { + _logger.LogError(ex, "[MetadataService] There was an exception during updating metadata for {SeriesName} ", series.Name); } } + + + /// + /// Refreshes Metadata for a whole library + /// + /// This can be heavy on memory first run + /// + /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image + public async Task RefreshMetadata(int libraryId, bool forceUpdate = false) + { + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); + _logger.LogInformation("[MetadataService] Beginning metadata refresh of {LibraryName}", library.Name); + + var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); + var stopwatch = Stopwatch.StartNew(); + var totalTime = 0L; + _logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(library.Id, 0F)); + + for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) + { + if (chunkInfo.TotalChunks == 0) continue; + totalTime += stopwatch.ElapsedMilliseconds; + stopwatch.Restart(); + + _logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", + chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); + + var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, + new UserParams() + { + PageNumber = chunk, + PageSize = chunkInfo.ChunkSize + }); + _logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count); + + var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(nonLibrarySeries.Select(s => s.Id).ToArray()); + var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); + var allGenres = await _unitOfWork.GenreRepository.GetAllGenres(); + + + var seriesIndex = 0; + foreach (var series in nonLibrarySeries) + { + try + { + ProcessSeriesMetadataUpdate(series, chapterIds, allPeople, allGenres, forceUpdate); + } + catch (Exception ex) + { + _logger.LogError(ex, "[MetadataService] There was an exception during metadata refresh for {SeriesName}", series.Name); + } + var index = chunk * seriesIndex; + var progress = Math.Max(0F, Math.Min(1F, index * 1F / chunkInfo.TotalSize)); + + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(library.Id, progress)); + seriesIndex++; + } + + await _unitOfWork.CommitAsync(); + foreach (var series in nonLibrarySeries) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(library.Id, series.Id)); + } + _logger.LogInformation( + "[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", + chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); + } + + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(library.Id, 1F)); + + // TODO: Remove any leftover People from DB + await _unitOfWork.PersonRepository.RemoveAllPeopleNoLongerAssociated(); + await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); + + + _logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime); + } + + // TODO: I can probably refactor RefreshMetadata and RefreshMetadataForSeries to be the same by utilizing chunk size of 1, so most of the code can be the same. + private async Task PerformScan(Library library, bool forceUpdate, Action action) + { + var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); + var stopwatch = Stopwatch.StartNew(); + var totalTime = 0L; + _logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(library.Id, 0F)); + + for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) + { + if (chunkInfo.TotalChunks == 0) continue; + totalTime += stopwatch.ElapsedMilliseconds; + stopwatch.Restart(); + + action(chunk, chunkInfo); + + // _logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", + // chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); + // var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, + // new UserParams() + // { + // PageNumber = chunk, + // PageSize = chunkInfo.ChunkSize + // }); + // _logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count); + // + // var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(nonLibrarySeries.Select(s => s.Id).ToArray()); + // var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); + // var allGenres = await _unitOfWork.GenreRepository.GetAllGenres(); + // + // + // var seriesIndex = 0; + // foreach (var series in nonLibrarySeries) + // { + // try + // { + // ProcessSeriesMetadataUpdate(series, chapterIds, allPeople, allGenres, forceUpdate); + // } + // catch (Exception ex) + // { + // _logger.LogError(ex, "[MetadataService] There was an exception during metadata refresh for {SeriesName}", series.Name); + // } + // var index = chunk * seriesIndex; + // var progress = Math.Max(0F, Math.Min(1F, index * 1F / chunkInfo.TotalSize)); + // + // await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + // MessageFactory.RefreshMetadataProgressEvent(library.Id, progress)); + // seriesIndex++; + // } + + await _unitOfWork.CommitAsync(); + } + } + + + + /// + /// Refreshes Metadata for a Series. Will always force updates. + /// + /// + /// + public async Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = true) + { + var sw = Stopwatch.StartNew(); + var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); + if (series == null) + { + _logger.LogError("[MetadataService] Series {SeriesId} was not found on Library {LibraryId}", seriesId, libraryId); + return; + } + + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(libraryId, 0F)); + + var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(new [] { seriesId }); + var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); + var allGenres = await _unitOfWork.GenreRepository.GetAllGenres(); + + ProcessSeriesMetadataUpdate(series, chapterIds, allPeople, allGenres, forceUpdate); + + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, + MessageFactory.RefreshMetadataProgressEvent(libraryId, 1F)); + + + if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(series.LibraryId, series.Id)); + } + + _logger.LogInformation("[MetadataService] Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds); + } } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 1af8d47dc..f042e83de 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -1,5 +1,4 @@ using System; -using System.IO; using System.Threading; using System.Threading.Tasks; using API.Entities.Enums; diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index f88caab89..32e108da2 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; +using API.Data.Metadata; using API.Entities; using API.Entities.Enums; using API.Interfaces.Services; @@ -25,6 +26,8 @@ namespace API.Services.Tasks.Scanner private readonly ConcurrentDictionary> _scannedSeries; private readonly IBookService _bookService; private readonly ILogger _logger; + private readonly IArchiveService _archiveService; + private readonly IDirectoryService _directoryService; /// /// An instance of a pipeline for processing files and returning a Map of Series -> ParserInfos. @@ -32,10 +35,13 @@ namespace API.Services.Tasks.Scanner /// /// /// - public ParseScannedFiles(IBookService bookService, ILogger logger) + public ParseScannedFiles(IBookService bookService, ILogger logger, IArchiveService archiveService, + IDirectoryService directoryService) { _bookService = bookService; _logger = logger; + _archiveService = archiveService; + _directoryService = directoryService; _scannedSeries = new ConcurrentDictionary>(); } @@ -53,6 +59,20 @@ namespace API.Services.Tasks.Scanner return existingKey != null ? parsedSeries[existingKey] : new List(); } + private ComicInfo GetComicInfo(string path) + { + if (Parser.Parser.IsEpub(path)) + { + return _bookService.GetComicInfo(path); + } + + if (Parser.Parser.IsComicInfoExtension(path)) + { + return _archiveService.GetComicInfo(path); + } + return null; + } + /// /// Processes files found during a library scan. /// Populates a collection of for DB updates later. @@ -90,9 +110,32 @@ namespace API.Services.Tasks.Scanner info.Merge(info2); } + // TODO: Think about doing this before the Fallback code to speed up + info.ComicInfo = GetComicInfo(path); + if (info.ComicInfo != null) + { + var sw = Stopwatch.StartNew(); + + if (!string.IsNullOrEmpty(info.ComicInfo.Volume)) + { + info.Volumes = info.ComicInfo.Volume; + } + if (!string.IsNullOrEmpty(info.ComicInfo.Series)) + { + info.Series = info.ComicInfo.Series; + } + if (!string.IsNullOrEmpty(info.ComicInfo.Number)) + { + info.Chapters = info.ComicInfo.Number; + } + + _logger.LogDebug("ComicInfo read added {Time} ms to processing", sw.ElapsedMilliseconds); + } + TrackSeries(info); } + /// /// Attempts to either add a new instance of a show mapping to the _scannedSeries bag or adds to an existing. /// This will check if the name matches an existing series name (multiple fields) @@ -161,12 +204,12 @@ namespace API.Services.Tasks.Scanner { var sw = Stopwatch.StartNew(); totalFiles = 0; - var searchPattern = GetLibrarySearchPattern(); + var searchPattern = Parser.Parser.SupportedExtensions; foreach (var folderPath in folders) { try { - totalFiles += DirectoryService.TraverseTreeParallelForEach(folderPath, (f) => + totalFiles += _directoryService.TraverseTreeParallelForEach(folderPath, (f) => { try { @@ -191,11 +234,6 @@ namespace API.Services.Tasks.Scanner return SeriesWithInfos(); } - private static string GetLibrarySearchPattern() - { - return Parser.Parser.SupportedExtensions; - } - /// /// Returns any series where there were parsed infos /// diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index f1d5a2b96..daebf27cc 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -21,697 +21,659 @@ using Hangfire; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; -namespace API.Services.Tasks +namespace API.Services.Tasks; + +public class ScannerService : IScannerService { - public class ScannerService : IScannerService + private readonly IUnitOfWork _unitOfWork; + private readonly ILogger _logger; + private readonly IArchiveService _archiveService; + private readonly IMetadataService _metadataService; + private readonly IBookService _bookService; + private readonly ICacheService _cacheService; + private readonly IHubContext _messageHub; + private readonly IFileService _fileService; + private readonly IDirectoryService _directoryService; + private readonly NaturalSortComparer _naturalSort = new (); + + public ScannerService(IUnitOfWork unitOfWork, ILogger logger, IArchiveService archiveService, + IMetadataService metadataService, IBookService bookService, ICacheService cacheService, IHubContext messageHub, + IFileService fileService, IDirectoryService directoryService) { - private readonly IUnitOfWork _unitOfWork; - private readonly ILogger _logger; - private readonly IArchiveService _archiveService; - private readonly IMetadataService _metadataService; - private readonly IBookService _bookService; - private readonly ICacheService _cacheService; - private readonly IHubContext _messageHub; - private readonly NaturalSortComparer _naturalSort = new (); + _unitOfWork = unitOfWork; + _logger = logger; + _archiveService = archiveService; + _metadataService = metadataService; + _bookService = bookService; + _cacheService = cacheService; + _messageHub = messageHub; + _fileService = fileService; + _directoryService = directoryService; + } - public ScannerService(IUnitOfWork unitOfWork, ILogger logger, IArchiveService archiveService, - IMetadataService metadataService, IBookService bookService, ICacheService cacheService, IHubContext messageHub) - { - _unitOfWork = unitOfWork; - _logger = logger; - _archiveService = archiveService; - _metadataService = metadataService; - _bookService = bookService; - _cacheService = cacheService; - _messageHub = messageHub; - } + [DisableConcurrentExecution(timeoutInSeconds: 360)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] + public async Task ScanSeries(int libraryId, int seriesId, CancellationToken token) + { + var sw = new Stopwatch(); + var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); + var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); + var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId}); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders); + var folderPaths = library.Folders.Select(f => f.Path).ToList(); - [DisableConcurrentExecution(timeoutInSeconds: 360)] - [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - public async Task ScanSeries(int libraryId, int seriesId, CancellationToken token) - { - var sw = new Stopwatch(); - var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); - var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); - var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId}); - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders); - var folderPaths = library.Folders.Select(f => f.Path).ToList(); + // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are + if (folderPaths.Any(f => !DirectoryService.IsDriveMounted(f))) + { + _logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); + return; + } - // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are - if (folderPaths.Any(f => !DirectoryService.IsDriveMounted(f))) - { - _logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); - return; - } + var dirs = DirectoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList()); - var dirs = DirectoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList()); + _logger.LogInformation("Beginning file scan on {SeriesName}", series.Name); + var scanner = new ParseScannedFiles(_bookService, _logger, _archiveService, _directoryService); + var parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles, out var scanElapsedTime); - _logger.LogInformation("Beginning file scan on {SeriesName}", series.Name); - var scanner = new ParseScannedFiles(_bookService, _logger); - var parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles, out var scanElapsedTime); + // 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); - // 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); + // If nothing was found, first validate any of the files still exist. If they don't then we have a deletion and can skip the rest of the logic flow + if (parsedSeries.Count == 0) + { + var anyFilesExist = + (await _unitOfWork.SeriesRepository.GetFilesForSeries(series.Id)).Any(m => File.Exists(m.FilePath)); - // If nothing was found, first validate any of the files still exist. If they don't then we have a deletion and can skip the rest of the logic flow - if (parsedSeries.Count == 0) - { - var anyFilesExist = - (await _unitOfWork.SeriesRepository.GetFilesForSeries(series.Id)).Any(m => File.Exists(m.FilePath)); + if (!anyFilesExist) + { + try + { + _unitOfWork.SeriesRepository.Remove(series); + await CommitAndSend(totalFiles, parsedSeries, sw, scanElapsedTime, series); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "There was an error during ScanSeries to delete the series"); + await _unitOfWork.RollbackAsync(); + } - if (!anyFilesExist) - { - try - { - _unitOfWork.SeriesRepository.Remove(series); - await CommitAndSend(totalFiles, parsedSeries, sw, scanElapsedTime, series); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "There was an error during ScanSeries to delete the series"); - await _unitOfWork.RollbackAsync(); - } + } + else + { + // We need to do an additional check for an edge case: If the scan ran and the files do not match the existing Series name, then it is very likely, + // the files have crap naming and if we don't correct, the series will get deleted due to the parser not being able to fallback onto folder parsing as the root + // is the series folder. + var existingFolder = dirs.Keys.FirstOrDefault(key => key.Contains(series.OriginalName)); + if (dirs.Keys.Count == 1 && !string.IsNullOrEmpty(existingFolder)) + { + dirs = new Dictionary(); + var path = Directory.GetParent(existingFolder)?.FullName; + if (!folderPaths.Contains(path) || !folderPaths.Any(p => p.Contains(path ?? string.Empty))) + { + _logger.LogInformation("[ScanService] Aborted: {SeriesName} has bad naming convention and sits at root of library. Cannot scan series without deletion occuring. Correct file names to have Series Name within it or perform Scan Library", series.OriginalName); + return; + } + if (!string.IsNullOrEmpty(path)) + { + dirs[path] = string.Empty; + } + } - } - else - { - // We need to do an additional check for an edge case: If the scan ran and the files do not match the existing Series name, then it is very likely, - // the files have crap naming and if we don't correct, the series will get deleted due to the parser not being able to fallback onto folder parsing as the root - // is the series folder. - var existingFolder = dirs.Keys.FirstOrDefault(key => key.Contains(series.OriginalName)); - if (dirs.Keys.Count == 1 && !string.IsNullOrEmpty(existingFolder)) - { - dirs = new Dictionary(); - var path = Directory.GetParent(existingFolder)?.FullName; - if (!folderPaths.Contains(path) || !folderPaths.Any(p => p.Contains(path ?? string.Empty))) - { - _logger.LogInformation("[ScanService] Aborted: {SeriesName} has bad naming convention and sits at root of library. Cannot scan series without deletion occuring. Correct file names to have Series Name within it or perform Scan Library", series.OriginalName); - return; - } - if (!string.IsNullOrEmpty(path)) - { - dirs[path] = string.Empty; - } - } + _logger.LogInformation("{SeriesName} has bad naming convention, forcing rescan at a higher directory", series.OriginalName); + scanner = new ParseScannedFiles(_bookService, _logger, _archiveService, _directoryService); + parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles2, out var scanElapsedTime2); + totalFiles += totalFiles2; + scanElapsedTime += scanElapsedTime2; + RemoveParsedInfosNotForSeries(parsedSeries, series); + } + } - _logger.LogInformation("{SeriesName} has bad naming convention, forcing rescan at a higher directory", series.OriginalName); - scanner = new ParseScannedFiles(_bookService, _logger); - parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles2, out var scanElapsedTime2); - totalFiles += totalFiles2; - scanElapsedTime += scanElapsedTime2; - RemoveParsedInfosNotForSeries(parsedSeries, series); - } - } + // 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; - // 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; + try + { + UpdateSeries(series, parsedSeries); + await CommitAndSend(totalFiles, parsedSeries, sw, scanElapsedTime, series); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "There was an error during ScanSeries to update the series"); + await _unitOfWork.RollbackAsync(); + } + // Tell UI that this series is done + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(seriesId, series.Name), token); + await CleanupDbEntities(); + BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); + BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, series.Id, false)); + } - try - { - UpdateSeries(series, parsedSeries); - await CommitAndSend(totalFiles, parsedSeries, sw, scanElapsedTime, series); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "There was an error during ScanSeries to update the series"); - await _unitOfWork.RollbackAsync(); - } - // Tell UI that this series is done - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(seriesId, series.Name), token); - await CleanupDbEntities(); - BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); - BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, series.Id, false)); - } + private static void RemoveParsedInfosNotForSeries(Dictionary> parsedSeries, Series series) + { + var keys = parsedSeries.Keys; + foreach (var key in keys.Where(key => + !series.NameInParserInfo(parsedSeries[key].FirstOrDefault()) || series.Format != key.Format)) + { + parsedSeries.Remove(key); + } + } - private static void RemoveParsedInfosNotForSeries(Dictionary> parsedSeries, Series series) - { - var keys = parsedSeries.Keys; - foreach (var key in keys.Where(key => - !series.NameInParserInfo(parsedSeries[key].FirstOrDefault()) || series.Format != key.Format)) - { - parsedSeries.Remove(key); - } - } - - private async Task CommitAndSend(int totalFiles, - Dictionary> parsedSeries, Stopwatch sw, long scanElapsedTime, Series series) - { - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - _logger.LogInformation( - "Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {SeriesName}", - totalFiles, parsedSeries.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, series.Name); - } - } + private async Task CommitAndSend(int totalFiles, + Dictionary> parsedSeries, Stopwatch sw, long scanElapsedTime, Series series) + { + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + _logger.LogInformation( + "Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {SeriesName}", + totalFiles, parsedSeries.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, series.Name); + } + } - [DisableConcurrentExecution(timeoutInSeconds: 360)] - [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - public async Task ScanLibraries() - { - _logger.LogInformation("Starting Scan of All Libraries"); - var libraries = await _unitOfWork.LibraryRepository.GetLibrariesAsync(); - foreach (var lib in libraries) - { - await ScanLibrary(lib.Id); - } - _logger.LogInformation("Scan of All Libraries Finished"); - } + [DisableConcurrentExecution(timeoutInSeconds: 360)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] + public async Task ScanLibraries() + { + _logger.LogInformation("Starting Scan of All Libraries"); + var libraries = await _unitOfWork.LibraryRepository.GetLibrariesAsync(); + foreach (var lib in libraries) + { + await ScanLibrary(lib.Id); + } + _logger.LogInformation("Scan of All Libraries Finished"); + } - /// - /// Scans a library for file changes. - /// Will kick off a scheduled background task to refresh metadata, - /// ie) all entities will be rechecked for new cover images and comicInfo.xml changes - /// - /// - [DisableConcurrentExecution(360)] - [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - public async Task ScanLibrary(int libraryId) - { - Library library; - try - { - library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders); - } - catch (Exception ex) - { - // This usually only fails if user is not authenticated. - _logger.LogError(ex, "[ScannerService] There was an issue fetching Library {LibraryId}", libraryId); - return; - } + /// + /// Scans a library for file changes. + /// Will kick off a scheduled background task to refresh metadata, + /// ie) all entities will be rechecked for new cover images and comicInfo.xml changes + /// + /// + [DisableConcurrentExecution(360)] + [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] + public async Task ScanLibrary(int libraryId) + { + Library library; + try + { + library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders); + } + catch (Exception ex) + { + // This usually only fails if user is not authenticated. + _logger.LogError(ex, "[ScannerService] There was an issue fetching Library {LibraryId}", libraryId); + return; + } - // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are - if (library.Folders.Any(f => !DirectoryService.IsDriveMounted(f.Path))) - { - _logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); - return; - } + // Check if any of the folder roots are not available (ie disconnected from network, etc) and fail if any of them are + if (library.Folders.Any(f => !DirectoryService.IsDriveMounted(f.Path))) + { + _logger.LogError("Some of the root folders for library are not accessible. Please check that drives are connected and rescan. Scan will be aborted"); + return; + } + + _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(libraryId, 0)); + + var scanner = new ParseScannedFiles(_bookService, _logger, _archiveService, _directoryService); + var series = scanner.ScanLibrariesForSeries(library.Type, library.Folders.Select(fp => fp.Path), out var totalFiles, out var scanElapsedTime); + + foreach (var folderPath in library.Folders) + { + folderPath.LastScanned = DateTime.Now; + } + var sw = Stopwatch.StartNew(); + + await UpdateLibrary(library, series); + + library.LastScanned = DateTime.Now; + _unitOfWork.LibraryRepository.Update(library); + if (await _unitOfWork.CommitAsync()) + { + _logger.LogInformation( + "[ScannerService] Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}", + totalFiles, series.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, library.Name); + } + else + { + _logger.LogCritical( + "[ScannerService] There was a critical error that resulted in a failed scan. Please check logs and rescan"); + } + + await CleanupDbEntities(); + + BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false)); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); + } + + /// + /// Remove any user progress rows that no longer exist since scan library ran and deleted series/volumes/chapters + /// + private async Task CleanupAbandonedChapters() + { + var cleanedUp = await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); + _logger.LogInformation("Removed {Count} abandoned progress rows", cleanedUp); + } - _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(libraryId, 0)); + /// + /// Cleans up any abandoned rows due to removals from Scan loop + /// + private async Task CleanupDbEntities() + { + await CleanupAbandonedChapters(); + var cleanedUp = await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); + _logger.LogInformation("Removed {Count} abandoned collection tags", cleanedUp); + } - var scanner = new ParseScannedFiles(_bookService, _logger); - var series = scanner.ScanLibrariesForSeries(library.Type, library.Folders.Select(fp => fp.Path), out var totalFiles, out var scanElapsedTime); + private async Task UpdateLibrary(Library library, Dictionary> parsedSeries) + { + if (parsedSeries == null) return; - foreach (var folderPath in library.Folders) - { - folderPath.LastScanned = DateTime.Now; - } - var sw = Stopwatch.StartNew(); + // Library contains no Series, so we need to fetch series in groups of ChunkSize + var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); + var stopwatch = Stopwatch.StartNew(); + var totalTime = 0L; - await UpdateLibrary(library, series); + // Update existing series + _logger.LogInformation("[ScannerService] Updating existing series for {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", + library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); + for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) + { + if (chunkInfo.TotalChunks == 0) continue; + totalTime += stopwatch.ElapsedMilliseconds; + stopwatch.Restart(); + _logger.LogInformation("[ScannerService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", + chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); + var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, new UserParams() + { + PageNumber = chunk, + PageSize = chunkInfo.ChunkSize + }); - library.LastScanned = DateTime.Now; - _unitOfWork.LibraryRepository.Update(library); - if (await _unitOfWork.CommitAsync()) - { - _logger.LogInformation( - "[ScannerService] Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}", - totalFiles, series.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, library.Name); - } - else - { - _logger.LogCritical( - "[ScannerService] There was a critical error that resulted in a failed scan. Please check logs and rescan"); - } + // First, remove any series that are not in parsedSeries list + var missingSeries = FindSeriesNotOnDisk(nonLibrarySeries, parsedSeries).ToList(); - await CleanupDbEntities(); + foreach (var missing in missingSeries) + { + _unitOfWork.SeriesRepository.Remove(missing); + } - BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false)); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(libraryId, 1F)); - } + var cleanedSeries = SeriesHelper.RemoveMissingSeries(nonLibrarySeries, missingSeries, out var removeCount); + if (removeCount > 0) + { + _logger.LogInformation("[ScannerService] Removed {RemoveMissingSeries} series that are no longer on disk:", removeCount); + foreach (var s in missingSeries) + { + _logger.LogDebug("[ScannerService] Removed {SeriesName} ({Format})", s.Name, s.Format); + } + } - /// - /// Remove any user progress rows that no longer exist since scan library ran and deleted series/volumes/chapters - /// - private async Task CleanupAbandonedChapters() - { - var cleanedUp = await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters(); - _logger.LogInformation("Removed {Count} abandoned progress rows", cleanedUp); - } + // Now, we only have to deal with series that exist on disk. Let's recalculate the volumes for each series + var librarySeries = cleanedSeries.ToList(); + Parallel.ForEach(librarySeries, (series) => + { + UpdateSeries(series, parsedSeries); + }); + + try + { + await _unitOfWork.CommitAsync(); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "[ScannerService] There was an issue writing to the DB. Chunk {ChunkNumber} did not save to DB. If debug mode, series to check will be printed", chunk); + foreach (var series in nonLibrarySeries) + { + _logger.LogDebug("[ScannerService] There may be a constraint issue with {SeriesName}", series.OriginalName); + } + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryError, + MessageFactory.ScanLibraryError(library.Id)); + continue; + } + _logger.LogInformation( + "[ScannerService] Processed {SeriesStart} - {SeriesEnd} series in {ElapsedScanTime} milliseconds for {LibraryName}", + chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, totalTime, library.Name); + + // Emit any series removed + foreach (var missing in missingSeries) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved, MessageFactory.SeriesRemovedEvent(missing.Id, missing.Name, library.Id)); + } + + var progress = Math.Max(0, Math.Min(1, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize)); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); + } - /// - /// Cleans up any abandoned rows due to removals from Scan loop - /// - private async Task CleanupDbEntities() - { - await CleanupAbandonedChapters(); - var cleanedUp = await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries(); - _logger.LogInformation("Removed {Count} abandoned collection tags", cleanedUp); - } + // Add new series that have parsedInfos + _logger.LogDebug("[ScannerService] Adding new series"); + var newSeries = new List(); + var allSeries = (await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id)).ToList(); + _logger.LogDebug("[ScannerService] Fetched {AllSeriesCount} series for comparing new series with. There should be {DeltaToParsedSeries} new series", + allSeries.Count, parsedSeries.Count - allSeries.Count); + foreach (var (key, infos) in parsedSeries) + { + // Key is normalized already + Series existingSeries; + try + { + existingSeries = allSeries.SingleOrDefault(s => SeriesHelper.FindSeries(s, key)); + } + catch (Exception e) + { + // NOTE: If I ever want to put Duplicates table, this is where it can go + _logger.LogCritical(e, "[ScannerService] There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it. This will be skipped", key.NormalizedName); + var duplicateSeries = allSeries.Where(s => SeriesHelper.FindSeries(s, key)); + foreach (var series in duplicateSeries) + { + _logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName); + } - private async Task UpdateLibrary(Library library, Dictionary> parsedSeries) - { - if (parsedSeries == null) return; + continue; + } - // Library contains no Series, so we need to fetch series in groups of ChunkSize - var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); - var stopwatch = Stopwatch.StartNew(); - var totalTime = 0L; + if (existingSeries != null) continue; - // Update existing series - _logger.LogInformation("[ScannerService] Updating existing series for {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", - library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); - for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) - { - if (chunkInfo.TotalChunks == 0) continue; - totalTime += stopwatch.ElapsedMilliseconds; - stopwatch.Restart(); - _logger.LogInformation("[ScannerService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}", - chunk, chunkInfo.TotalChunks, chunkInfo.ChunkSize, chunk * chunkInfo.ChunkSize, (chunk + 1) * chunkInfo.ChunkSize); - var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, new UserParams() - { - PageNumber = chunk, - PageSize = chunkInfo.ChunkSize - }); + var s = DbFactory.Series(infos[0].Series); + if (!string.IsNullOrEmpty(infos[0].SeriesSort)) + { + s.SortName = infos[0].SeriesSort; + } + s.Format = key.Format; + s.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series. + newSeries.Add(s); + } - // First, remove any series that are not in parsedSeries list - var missingSeries = FindSeriesNotOnDisk(nonLibrarySeries, parsedSeries).ToList(); + var i = 0; + foreach(var series in newSeries) + { + _logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName); + UpdateSeries(series, parsedSeries); + _unitOfWork.SeriesRepository.Attach(series); + try + { + await _unitOfWork.CommitAsync(); + _logger.LogInformation( + "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", + newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); - foreach (var missing in missingSeries) - { - _unitOfWork.SeriesRepository.Remove(missing); - } + // Inform UI of new series added + await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, library.Id)); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "[ScannerService] There was a critical exception adding new series entry for {SeriesName} with a duplicate index key: {IndexKey} ", + series.Name, $"{series.Name}_{series.NormalizedName}_{series.LocalizedName}_{series.LibraryId}_{series.Format}"); + } - var cleanedSeries = RemoveMissingSeries(nonLibrarySeries, missingSeries, out var removeCount); - if (removeCount > 0) - { - _logger.LogInformation("[ScannerService] Removed {RemoveMissingSeries} series that are no longer on disk:", removeCount); - foreach (var s in missingSeries) - { - _logger.LogDebug("[ScannerService] Removed {SeriesName} ({Format})", s.Name, s.Format); - } - } + var progress = Math.Max(0F, Math.Min(1F, i * 1F / newSeries.Count)); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); + i++; + } - // Now, we only have to deal with series that exist on disk. Let's recalculate the volumes for each series - var librarySeries = cleanedSeries.ToList(); - Parallel.ForEach(librarySeries, (series) => - { - UpdateSeries(series, parsedSeries); - }); + _logger.LogInformation( + "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", + newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); + } - try - { - await _unitOfWork.CommitAsync(); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "[ScannerService] There was an issue writing to the DB. Chunk {ChunkNumber} did not save to DB. If debug mode, series to check will be printed", chunk); - foreach (var series in nonLibrarySeries) - { - _logger.LogDebug("[ScannerService] There may be a constraint issue with {SeriesName}", series.OriginalName); - } - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryError, - MessageFactory.ScanLibraryError(library.Id)); - continue; - } - _logger.LogInformation( - "[ScannerService] Processed {SeriesStart} - {SeriesEnd} series in {ElapsedScanTime} milliseconds for {LibraryName}", - chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, totalTime, library.Name); + // private static bool FindSeries(Series series, ParsedSeries parsedInfoKey) + // { + // return (series.NormalizedName.Equals(parsedInfoKey.NormalizedName) || Parser.Parser.Normalize(series.OriginalName).Equals(parsedInfoKey.NormalizedName)) + // && (series.Format == parsedInfoKey.Format || series.Format == MangaFormat.Unknown); + // } - // Emit any series removed - foreach (var missing in missingSeries) - { - await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved, MessageFactory.SeriesRemovedEvent(missing.Id, missing.Name, library.Id)); - } + private void UpdateSeries(Series series, Dictionary> parsedSeries) + { + try + { + _logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName); - var progress = Math.Max(0, Math.Min(1, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize)); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); - } + var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series); + UpdateVolumes(series, parsedInfos); + series.Pages = series.Volumes.Sum(v => v.Pages); + series.NormalizedName = Parser.Parser.Normalize(series.Name); + series.Metadata ??= DbFactory.SeriesMetadata(new List()); + if (series.Format == MangaFormat.Unknown) + { + series.Format = parsedInfos[0].Format; + } + series.OriginalName ??= parsedInfos[0].Series; + series.SortName ??= parsedInfos[0].SeriesSort; + } + catch (Exception ex) + { + _logger.LogError(ex, "[ScannerService] There was an exception updating volumes for {SeriesName}", series.Name); + } + } - // Add new series that have parsedInfos - _logger.LogDebug("[ScannerService] Adding new series"); - var newSeries = new List(); - var allSeries = (await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id)).ToList(); - _logger.LogDebug("[ScannerService] Fetched {AllSeriesCount} series for comparing new series with. There should be {DeltaToParsedSeries} new series", - allSeries.Count, parsedSeries.Count - allSeries.Count); - foreach (var (key, infos) in parsedSeries) - { - // Key is normalized already - Series existingSeries; - try - { - existingSeries = allSeries.SingleOrDefault(s => FindSeries(s, key)); - } - catch (Exception e) - { - // NOTE: If I ever want to put Duplicates table, this is where it can go - _logger.LogCritical(e, "[ScannerService] There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it. This will be skipped", key.NormalizedName); - var duplicateSeries = allSeries.Where(s => FindSeries(s, key)); - foreach (var series in duplicateSeries) - { - _logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName); - } + public static IEnumerable FindSeriesNotOnDisk(IEnumerable existingSeries, Dictionary> parsedSeries) + { + var foundSeries = parsedSeries.Select(s => s.Key.Name).ToList(); + return existingSeries.Where(es => !es.NameInList(foundSeries) && !SeriesHasMatchingParserInfoFormat(es, parsedSeries)); + } - continue; - } + /// + /// Checks each parser info to see if there is a name match and if so, checks if the format matches the Series object. + /// This accounts for if the Series has an Unknown type and if so, considers it matching. + /// + /// + /// + /// + private static bool SeriesHasMatchingParserInfoFormat(Series series, + Dictionary> parsedSeries) + { + var format = MangaFormat.Unknown; + foreach (var pSeries in parsedSeries.Keys) + { + var name = pSeries.Name; + var normalizedName = Parser.Parser.Normalize(name); - if (existingSeries != null) continue; + if (normalizedName == series.NormalizedName || + normalizedName == Parser.Parser.Normalize(series.Name) || + name == series.Name || name == series.LocalizedName || + name == series.OriginalName || + normalizedName == Parser.Parser.Normalize(series.OriginalName)) + { + format = pSeries.Format; + break; + } + } - var s = DbFactory.Series(infos[0].Series); - s.Format = key.Format; - s.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series. - newSeries.Add(s); - } + if (series.Format == MangaFormat.Unknown && format != MangaFormat.Unknown) + { + return true; + } - var i = 0; - foreach(var series in newSeries) - { - _logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName); - UpdateSeries(series, parsedSeries); - _unitOfWork.SeriesRepository.Attach(series); - try - { - await _unitOfWork.CommitAsync(); - _logger.LogInformation( - "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", - newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); + return format == series.Format; + } - // Inform UI of new series added - await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, library.Id)); - } - catch (Exception ex) - { - _logger.LogCritical(ex, "[ScannerService] There was a critical exception adding new series entry for {SeriesName} with a duplicate index key: {IndexKey} ", - series.Name, $"{series.Name}_{series.NormalizedName}_{series.LocalizedName}_{series.LibraryId}_{series.Format}"); - } - - var progress = Math.Max(0F, Math.Min(1F, i * 1F / newSeries.Count)); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, - MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); - i++; - } - - _logger.LogInformation( - "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", - newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); - } - - private static bool FindSeries(Series series, ParsedSeries parsedInfoKey) - { - return (series.NormalizedName.Equals(parsedInfoKey.NormalizedName) || Parser.Parser.Normalize(series.OriginalName).Equals(parsedInfoKey.NormalizedName)) - && (series.Format == parsedInfoKey.Format || series.Format == MangaFormat.Unknown); - } - - private void UpdateSeries(Series series, Dictionary> parsedSeries) - { - try - { - _logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName); - - var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series); - UpdateVolumes(series, parsedInfos); - series.Pages = series.Volumes.Sum(v => v.Pages); - - series.NormalizedName = Parser.Parser.Normalize(series.Name); - series.Metadata ??= DbFactory.SeriesMetadata(new List()); - if (series.Format == MangaFormat.Unknown) - { - series.Format = parsedInfos[0].Format; - } - series.OriginalName ??= parsedInfos[0].Series; - } - catch (Exception ex) - { - _logger.LogError(ex, "[ScannerService] There was an exception updating volumes for {SeriesName}", series.Name); - } - } - - public static IEnumerable FindSeriesNotOnDisk(IEnumerable existingSeries, Dictionary> parsedSeries) - { - var foundSeries = parsedSeries.Select(s => s.Key.Name).ToList(); - return existingSeries.Where(es => !es.NameInList(foundSeries) && !SeriesHasMatchingParserInfoFormat(es, parsedSeries)); - } - - /// - /// Checks each parser info to see if there is a name match and if so, checks if the format matches the Series object. - /// This accounts for if the Series has an Unknown type and if so, considers it matching. - /// - /// - /// - /// - private static bool SeriesHasMatchingParserInfoFormat(Series series, - Dictionary> parsedSeries) - { - var format = MangaFormat.Unknown; - foreach (var pSeries in parsedSeries.Keys) - { - var name = pSeries.Name; - var normalizedName = Parser.Parser.Normalize(name); - - if (normalizedName == series.NormalizedName || - normalizedName == Parser.Parser.Normalize(series.Name) || - name == series.Name || name == series.LocalizedName || - name == series.OriginalName || - normalizedName == Parser.Parser.Normalize(series.OriginalName)) - { - format = pSeries.Format; - break; - } - } - - if (series.Format == MangaFormat.Unknown && format != MangaFormat.Unknown) - { - return true; - } - - return format == series.Format; - } - - /// - /// Removes all instances of missingSeries' Series from existingSeries Collection. Existing series is updated by - /// reference and the removed element count is returned. - /// - /// Existing Series in DB - /// Series not found on disk or can't be parsed - /// - /// the updated existingSeries - public static IEnumerable RemoveMissingSeries(IList existingSeries, IEnumerable missingSeries, out int removeCount) - { - var existingCount = existingSeries.Count; - var missingList = missingSeries.ToList(); - - existingSeries = existingSeries.Where( - s => !missingList.Exists( - m => m.NormalizedName.Equals(s.NormalizedName) && m.Format == s.Format)).ToList(); - - removeCount = existingCount - existingSeries.Count; - - return existingSeries; - } - - private void UpdateVolumes(Series series, IList parsedInfos) - { - var startingVolumeCount = series.Volumes.Count; - // Add new volumes and update chapters per volume - var distinctVolumes = parsedInfos.DistinctVolumes(); - _logger.LogDebug("[ScannerService] Updating {DistinctVolumes} volumes on {SeriesName}", distinctVolumes.Count, series.Name); - foreach (var volumeNumber in distinctVolumes) - { - var volume = series.Volumes.SingleOrDefault(s => s.Name == volumeNumber); - if (volume == null) - { + private void UpdateVolumes(Series series, IList parsedInfos) + { + var startingVolumeCount = series.Volumes.Count; + // Add new volumes and update chapters per volume + var distinctVolumes = parsedInfos.DistinctVolumes(); + _logger.LogDebug("[ScannerService] Updating {DistinctVolumes} volumes on {SeriesName}", distinctVolumes.Count, series.Name); + foreach (var volumeNumber in distinctVolumes) + { + var volume = series.Volumes.SingleOrDefault(s => s.Name == volumeNumber); + if (volume == null) + { volume = DbFactory.Volume(volumeNumber); series.Volumes.Add(volume); _unitOfWork.VolumeRepository.Add(volume); - } + } - _logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); - var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); - UpdateChapters(volume, infos); - volume.Pages = volume.Chapters.Sum(c => c.Pages); - } + _logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); + var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); + UpdateChapters(volume, infos); + volume.Pages = volume.Chapters.Sum(c => c.Pages); + } - // Remove existing volumes that aren't in parsedInfos - var nonDeletedVolumes = series.Volumes.Where(v => parsedInfos.Select(p => p.Volumes).Contains(v.Name)).ToList(); - if (series.Volumes.Count != nonDeletedVolumes.Count) - { - _logger.LogDebug("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name", + // Remove existing volumes that aren't in parsedInfos + var nonDeletedVolumes = series.Volumes.Where(v => parsedInfos.Select(p => p.Volumes).Contains(v.Name)).ToList(); + if (series.Volumes.Count != nonDeletedVolumes.Count) + { + _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 ?? ""; - if (!string.IsNullOrEmpty(file) && File.Exists(file)) - { - _logger.LogError( - "[ScannerService] Volume cleanup code was trying to remove a volume with a file still existing on disk. File: {File}", - file); - } + var deletedVolumes = series.Volumes.Except(nonDeletedVolumes); + foreach (var volume in deletedVolumes) + { + var file = volume.Chapters.FirstOrDefault()?.Files?.FirstOrDefault()?.FilePath ?? ""; + if (!string.IsNullOrEmpty(file) && File.Exists(file)) + { + _logger.LogError( + "[ScannerService] Volume cleanup code was trying to remove a volume with a file still existing on disk. File: {File}", + file); + } - _logger.LogDebug("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file); - } + _logger.LogDebug("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file); + } - series.Volumes = nonDeletedVolumes; - } + series.Volumes = nonDeletedVolumes; + } - _logger.LogDebug("[ScannerService] Updated {SeriesName} volumes from {StartingVolumeCount} to {VolumeCount}", - series.Name, startingVolumeCount, series.Volumes.Count); - } + _logger.LogDebug("[ScannerService] Updated {SeriesName} volumes from {StartingVolumeCount} to {VolumeCount}", + series.Name, startingVolumeCount, series.Volumes.Count); + } - /// - /// - /// - /// - /// - private void UpdateChapters(Volume volume, IList parsedInfos) - { - // Add new chapters - foreach (var info in parsedInfos) - { - // Specials go into their own chapters with Range being their filename and IsSpecial = True. Non-Specials with Vol and Chap as 0 - // also are treated like specials for UI grouping. - Chapter chapter; - try - { + private void UpdateChapters(Volume volume, IList parsedInfos) + { + // Add new chapters + foreach (var info in parsedInfos) + { + // Specials go into their own chapters with Range being their filename and IsSpecial = True. Non-Specials with Vol and Chap as 0 + // also are treated like specials for UI grouping. + Chapter chapter; + try + { chapter = volume.Chapters.GetChapterByRange(info); - } - catch (Exception ex) - { + } + catch (Exception ex) + { _logger.LogError(ex, "{FileName} mapped as '{Series} - Vol {Volume} Ch {Chapter}' is a duplicate, skipping", info.FullFilePath, info.Series, info.Volumes, info.Chapters); continue; - } + } - if (chapter == null) - { + if (chapter == null) + { _logger.LogDebug( - "[ScannerService] Adding new chapter, {Series} - Vol {Volume} Ch {Chapter}", info.Series, info.Volumes, info.Chapters); + "[ScannerService] Adding new chapter, {Series} - Vol {Volume} Ch {Chapter}", info.Series, info.Volumes, info.Chapters); volume.Chapters.Add(DbFactory.Chapter(info)); - } - else - { + } + else + { chapter.UpdateFrom(info); - } + } - } + } - // Add files - foreach (var info in parsedInfos) - { - var specialTreatment = info.IsSpecialInfo(); - Chapter chapter; - try - { + // Add files + foreach (var info in parsedInfos) + { + var specialTreatment = info.IsSpecialInfo(); + Chapter chapter; + try + { chapter = volume.Chapters.GetChapterByRange(info); - } - catch (Exception ex) - { + } + catch (Exception ex) + { _logger.LogError(ex, "There was an exception parsing chapter. Skipping {SeriesName} Vol {VolumeNumber} Chapter {ChapterNumber} - Special treatment: {NeedsSpecialTreatment}", info.Series, volume.Name, info.Chapters, specialTreatment); continue; - } - if (chapter == null) continue; - AddOrUpdateFileForChapter(chapter, info); - chapter.Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + string.Empty; - chapter.Range = specialTreatment ? info.Filename : info.Chapters; - } + } + if (chapter == null) continue; + AddOrUpdateFileForChapter(chapter, info); + chapter.Number = Parser.Parser.MinimumNumberFromRange(info.Chapters) + string.Empty; + chapter.Range = specialTreatment ? info.Filename : info.Chapters; + } - // Remove chapters that aren't in parsedInfos or have no files linked - var existingChapters = volume.Chapters.ToList(); - foreach (var existingChapter in existingChapters) - { - if (existingChapter.Files.Count == 0 || !parsedInfos.HasInfo(existingChapter)) - { + // Remove chapters that aren't in parsedInfos or have no files linked + var existingChapters = volume.Chapters.ToList(); + foreach (var existingChapter in existingChapters) + { + if (existingChapter.Files.Count == 0 || !parsedInfos.HasInfo(existingChapter)) + { _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", existingChapter.Range, volume.Name, parsedInfos[0].Series); volume.Chapters.Remove(existingChapter); - } - else - { + } + else + { // Ensure we remove any files that no longer exist AND order existingChapter.Files = existingChapter.Files - .Where(f => parsedInfos.Any(p => p.FullFilePath == f.FilePath)) - .OrderBy(f => f.FilePath, _naturalSort).ToList(); + .Where(f => parsedInfos.Any(p => p.FullFilePath == f.FilePath)) + .OrderBy(f => f.FilePath, _naturalSort).ToList(); existingChapter.Pages = existingChapter.Files.Sum(f => f.Pages); - } - } - } + } + } + } - private MangaFile CreateMangaFile(ParserInfo info) - { - MangaFile mangaFile = null; - switch (info.Format) - { - case MangaFormat.Archive: - { - mangaFile = new MangaFile() - { - FilePath = info.FullFilePath, - Format = info.Format, - Pages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath) - }; - break; - } - case MangaFormat.Pdf: - case MangaFormat.Epub: - { - mangaFile = new MangaFile() - { - FilePath = info.FullFilePath, - Format = info.Format, - Pages = _bookService.GetNumberOfPages(info.FullFilePath) - }; - break; - } - case MangaFormat.Image: - { - mangaFile = new MangaFile() - { - FilePath = info.FullFilePath, - Format = info.Format, - Pages = 1 - }; - break; - } - default: - _logger.LogWarning("[Scanner] Ignoring {Filename}. File type is not supported", info.Filename); + private MangaFile CreateMangaFile(ParserInfo info) + { + var pages = 0; + switch (info.Format) + { + case MangaFormat.Archive: + { + pages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath); break; - } + } + case MangaFormat.Pdf: + case MangaFormat.Epub: + { + pages = _bookService.GetNumberOfPages(info.FullFilePath); + break; + } + case MangaFormat.Image: + { + pages = 1; + break; + } + } - mangaFile?.UpdateLastModified(); - return mangaFile; - } + return DbFactory.MangaFile(info.FullFilePath, info.Format, pages); + } - private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info) - { - chapter.Files ??= new List(); - var existingFile = chapter.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); - if (existingFile != null) - { - existingFile.Format = info.Format; - if (!existingFile.HasFileBeenModified() && existingFile.Pages != 0) return; - switch (existingFile.Format) - { - case MangaFormat.Epub: - case MangaFormat.Pdf: - existingFile.Pages = _bookService.GetNumberOfPages(info.FullFilePath); - break; - case MangaFormat.Image: - existingFile.Pages = 1; - break; - case MangaFormat.Unknown: - existingFile.Pages = 0; - break; - case MangaFormat.Archive: - existingFile.Pages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath); - break; - } - existingFile.LastModified = File.GetLastWriteTime(info.FullFilePath); - } - else - { - var file = CreateMangaFile(info); - if (file == null) return; + private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info) + { + chapter.Files ??= new List(); + var existingFile = chapter.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath); + if (existingFile != null) + { + existingFile.Format = info.Format; + if (!_fileService.HasFileBeenModifiedSince(existingFile.FilePath, existingFile.LastModified) && existingFile.Pages != 0) return; + switch (existingFile.Format) + { + case MangaFormat.Epub: + case MangaFormat.Pdf: + existingFile.Pages = _bookService.GetNumberOfPages(info.FullFilePath); + break; + case MangaFormat.Image: + existingFile.Pages = 1; + break; + case MangaFormat.Unknown: + existingFile.Pages = 0; + break; + case MangaFormat.Archive: + existingFile.Pages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath); + break; + } + //existingFile.LastModified = File.GetLastWriteTime(info.FullFilePath); // This is messing up our logic on when last modified + } + else + { + var file = CreateMangaFile(info); + if (file == null) return; - chapter.Files.Add(file); - } - } + chapter.Files.Add(file); + } } } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 982d82f91..25262430a 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -50,7 +50,7 @@ namespace API.SignalR { return new SignalRMessage() { - Name = SignalREvents.ScanLibrary, + Name = SignalREvents.ScanLibraryProgress, Body = new { LibraryId = libraryId, diff --git a/API/SignalR/SignalREvents.cs b/API/SignalR/SignalREvents.cs index 4a82191ed..15590f426 100644 --- a/API/SignalR/SignalREvents.cs +++ b/API/SignalR/SignalREvents.cs @@ -12,12 +12,29 @@ /// Event sent out during Refresh Metadata for progress tracking /// public const string RefreshMetadataProgress = "RefreshMetadataProgress"; - public const string ScanLibrary = "ScanLibrary"; + /// + /// Series is added to server + /// public const string SeriesAdded = "SeriesAdded"; + /// + /// Series is removed from server + /// public const string SeriesRemoved = "SeriesRemoved"; + /// + /// Progress event for Scan library + /// public const string ScanLibraryProgress = "ScanLibraryProgress"; + /// + /// When a user is connects/disconnects from server + /// public const string OnlineUsers = "OnlineUsers"; + /// + /// When a series is added to a collection + /// public const string SeriesAddedToCollection = "SeriesAddedToCollection"; + /// + /// When an error occurs during a scan library task + /// public const string ScanLibraryError = "ScanLibraryError"; /// /// Event sent out during backing up the database diff --git a/API/SignalR/SignalRMessage.cs b/API/SignalR/SignalRMessage.cs index 89b992b4b..dfb181105 100644 --- a/API/SignalR/SignalRMessage.cs +++ b/API/SignalR/SignalRMessage.cs @@ -1,5 +1,8 @@ namespace API.SignalR { + /// + /// Payload for SignalR messages to Frontend + /// public class SignalRMessage { public object Body { get; set; } diff --git a/API/Startup.cs b/API/Startup.cs index 6668927b4..20218d866 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -5,8 +5,6 @@ using System.Linq; using System.Net; using System.Net.Sockets; using API.Extensions; -using API.Interfaces; -using API.Interfaces.Repositories; using API.Middleware; using API.Services; using API.Services.HostedServices; diff --git a/API/config/pre-metadata/kavita.db b/API/config/pre-metadata/kavita.db new file mode 100644 index 000000000..691a65180 Binary files /dev/null and b/API/config/pre-metadata/kavita.db differ diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index 6e6899f3f..7593ae84a 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Linq; using System.Text.Json; using Kavita.Common.EnvironmentInfo; using Microsoft.Extensions.Hosting; @@ -193,12 +194,9 @@ namespace Kavita.Common foreach (var property in tokenElement.EnumerateObject()) { if (!property.Name.Equals("LogLevel")) continue; - foreach (var logProperty in property.Value.EnumerateObject()) + foreach (var logProperty in property.Value.EnumerateObject().Where(logProperty => logProperty.Name.Equals("Default"))) { - if (logProperty.Name.Equals("Default")) - { - return logProperty.Value.GetString(); - } + return logProperty.Value.GetString(); } } } diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index c482e26e3..d0d661c12 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -1,7 +1,7 @@ - net5.0 + net6.0 kavitareader.com Kavita 0.4.9.3 @@ -9,13 +9,13 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - \ No newline at end of file + diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 5f8160aba..801b4ac6c 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -5326,9 +5326,9 @@ "dev": true }, "decimal.js": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.2.1.tgz", - "integrity": "sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz", + "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==", "dev": true }, "decode-uri-component": { @@ -5352,9 +5352,9 @@ } }, "deep-is": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", - "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, "deepmerge": { @@ -5956,18 +5956,24 @@ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "escodegen": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", - "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", + "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", "dev": true, "requires": { "esprima": "^4.0.1", - "estraverse": "^4.2.0", + "estraverse": "^5.2.0", "esutils": "^2.0.2", "optionator": "^0.8.1", "source-map": "~0.6.1" }, "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7856,9 +7862,9 @@ } }, "is-potential-custom-element-name": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz", - "integrity": "sha1-DFLlS8yjkbssSUsh6GJtczbG45c=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, "is-regex": { @@ -9593,66 +9599,91 @@ "dev": true }, "jsdom": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.4.0.tgz", - "integrity": "sha512-lYMm3wYdgPhrl7pDcRmvzPhhrGVBeVhPIqeHjzeiHN3DFmD1RBpbExbi8vU7BJdH8VAZYovR8DMt0PNNDM7k8w==", + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", + "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", "dev": true, "requires": { - "abab": "^2.0.3", - "acorn": "^7.1.1", + "abab": "^2.0.5", + "acorn": "^8.2.4", "acorn-globals": "^6.0.0", "cssom": "^0.4.4", - "cssstyle": "^2.2.0", + "cssstyle": "^2.3.0", "data-urls": "^2.0.0", - "decimal.js": "^10.2.0", + "decimal.js": "^10.2.1", "domexception": "^2.0.1", - "escodegen": "^1.14.1", + "escodegen": "^2.0.0", + "form-data": "^3.0.0", "html-encoding-sniffer": "^2.0.1", - "is-potential-custom-element-name": "^1.0.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.0", - "parse5": "5.1.1", - "request": "^2.88.2", - "request-promise-native": "^1.0.8", - "saxes": "^5.0.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", "symbol-tree": "^3.2.4", - "tough-cookie": "^3.0.1", + "tough-cookie": "^4.0.0", "w3c-hr-time": "^1.0.2", "w3c-xmlserializer": "^2.0.0", "webidl-conversions": "^6.1.0", "whatwg-encoding": "^1.0.5", "whatwg-mimetype": "^2.3.0", - "whatwg-url": "^8.0.0", - "ws": "^7.2.3", + "whatwg-url": "^8.5.0", + "ws": "^7.4.6", "xml-name-validator": "^3.0.0" }, "dependencies": { "acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.6.0.tgz", + "integrity": "sha512-U1riIR+lBSNi3IbxtaHOIKdH8sLFv3NYfNv8sg7ZsNhcfl4HF2++BfqqrNAxoCLQW1iiylOj76ecnaUxz+z9yw==", "dev": true }, - "parse5": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", - "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", - "dev": true - }, - "tough-cookie": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-3.0.1.tgz", - "integrity": "sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==", + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "dev": true, "requires": { - "ip-regex": "^2.1.0", - "psl": "^1.1.28", - "punycode": "^2.1.1" + "debug": "4" + } + }, + "form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "tough-cookie": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz", + "integrity": "sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==", + "dev": true, + "requires": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.1.2" } }, "ws": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz", - "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==", + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.6.tgz", + "integrity": "sha512-6GLgCqo2cy2A2rjCNFlxQS6ZljG/coZfZXclldI8FB/1G3CCI36Zd8xy2HrFVACi8tfk5XrgLQEk+P0Tnz9UcA==", "dev": true } } @@ -9675,9 +9706,9 @@ "dev": true }, "json-schema": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", "dev": true }, "json-schema-traverse": { @@ -9728,14 +9759,14 @@ "dev": true }, "jsprim": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", - "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", "dev": true, "requires": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", - "json-schema": "0.2.3", + "json-schema": "0.4.0", "verror": "1.10.0" } }, @@ -9955,12 +9986,6 @@ "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", "dev": true }, - "lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", - "dev": true - }, "lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -12359,8 +12384,7 @@ "dependencies": { "ansi-regex": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "resolved": "", "dev": true }, "strip-ansi": { @@ -12565,8 +12589,7 @@ "dependencies": { "ansi-regex": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "resolved": "", "dev": true }, "ansi-styles": { @@ -13093,26 +13116,6 @@ } } }, - "request-promise-core": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz", - "integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==", - "dev": true, - "requires": { - "lodash": "^4.17.19" - } - }, - "request-promise-native": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.9.tgz", - "integrity": "sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==", - "dev": true, - "requires": { - "request-promise-core": "1.1.4", - "stealthy-require": "^1.1.1", - "tough-cookie": "^2.3.3" - } - }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -14442,12 +14445,6 @@ "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", "dev": true }, - "stealthy-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", - "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", - "dev": true - }, "stream-browserify": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.2.tgz", @@ -15009,9 +15006,9 @@ } }, "tr46": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.0.2.tgz", - "integrity": "sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", "dev": true, "requires": { "punycode": "^2.1.1" @@ -16651,13 +16648,13 @@ "dev": true }, "whatwg-url": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.4.0.tgz", - "integrity": "sha512-vwTUFf6V4zhcPkWp/4CQPr1TW9Ml6SF4lVyaIMBdJw5i6qUUJ1QWM4Z6YYVkfka0OUIzVo/0aNtGVGk256IKWw==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", "dev": true, "requires": { - "lodash.sortby": "^4.7.0", - "tr46": "^2.0.2", + "lodash": "^4.7.0", + "tr46": "^2.1.0", "webidl-conversions": "^6.1.0" } }, @@ -16819,9 +16816,9 @@ } }, "ws": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", - "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.2.tgz", + "integrity": "sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw==", "requires": { "async-limiter": "~1.0.0" } diff --git a/UI/Web/src/app/_models/chapter-metadata.ts b/UI/Web/src/app/_models/chapter-metadata.ts new file mode 100644 index 000000000..56f400210 --- /dev/null +++ b/UI/Web/src/app/_models/chapter-metadata.ts @@ -0,0 +1,16 @@ +import { Person } from "./person"; + +export interface ChapterMetadata { + id: number; + chapterId: number; + title: string; + year: string; + writers: Array; + penciller: Array; + inker: Array; + colorist: Array; + letterer: Array; + coverArtist: Array; + editor: Array; + publishers: Array; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/chapter.ts b/UI/Web/src/app/_models/chapter.ts index 4a9399489..98345dfa0 100644 --- a/UI/Web/src/app/_models/chapter.ts +++ b/UI/Web/src/app/_models/chapter.ts @@ -1,4 +1,5 @@ import { MangaFile } from './manga-file'; +import { Person } from './person'; export interface Chapter { id: number; @@ -16,4 +17,15 @@ export interface Chapter { isSpecial: boolean; title: string; created: string; + + titleName: string; + year: string; + writers: Array; + penciller: Array; + inker: Array; + colorist: Array; + letterer: Array; + coverArtist: Array; + editor: Array; + publisher: Array; } diff --git a/UI/Web/src/app/_models/genre.ts b/UI/Web/src/app/_models/genre.ts new file mode 100644 index 000000000..f1160230d --- /dev/null +++ b/UI/Web/src/app/_models/genre.ts @@ -0,0 +1,4 @@ +export interface Genre { + id: number, + title: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/person.ts b/UI/Web/src/app/_models/person.ts index a8a60436d..c0ecc7c4e 100644 --- a/UI/Web/src/app/_models/person.ts +++ b/UI/Web/src/app/_models/person.ts @@ -1,7 +1,15 @@ export enum PersonRole { - Other = 0, - Author = 1, - Artist = 2 + Other = 1, + Artist = 2, + Writer = 3, + Penciller = 4, + Inker = 5, + Colorist = 6, + Letterer = 7, + CoverArtist = 8, + Editor = 9, + Publisher = 10, + Character = 11 } export interface Person { diff --git a/UI/Web/src/app/_models/series-metadata.ts b/UI/Web/src/app/_models/series-metadata.ts index 4cba7848f..5768d5fc5 100644 --- a/UI/Web/src/app/_models/series-metadata.ts +++ b/UI/Web/src/app/_models/series-metadata.ts @@ -1,10 +1,21 @@ import { CollectionTag } from "./collection-tag"; +import { Genre } from "./genre"; import { Person } from "./person"; export interface SeriesMetadata { publisher: string; - genres: Array; + summary: string; + genres: Array; tags: Array; - persons: Array; + writers: Array; + artists: Array; + publishers: Array; + characters: Array; + pencillers: Array; + inkers: Array; + colorists: Array; + letterers: Array; + editors: Array; + seriesId: number; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/series.ts b/UI/Web/src/app/_models/series.ts index be233122b..f8141eaa9 100644 --- a/UI/Web/src/app/_models/series.ts +++ b/UI/Web/src/app/_models/series.ts @@ -7,7 +7,7 @@ export interface Series { originalName: string; // This is not shown to user localizedName: string; sortName: string; - summary: string; + //summary: string; coverImageLocked: boolean; volumes: Volume[]; pages: number; // Total pages in series diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index e8f98d497..8b4235797 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -58,7 +58,7 @@ export class ActionService implements OnDestroy { return; } this.libraryService.scan(library?.id).pipe(take(1)).subscribe((res: any) => { - this.toastr.success('Scan started for ' + library.name); + this.toastr.success('Scan queued for ' + library.name); if (callback) { callback(library); } @@ -77,11 +77,14 @@ export class ActionService implements OnDestroy { } if (!await this.confirmService.confirm('Refresh metadata will force all cover images and metadata to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) { + if (callback) { + callback(library); + } return; } this.libraryService.refreshMetadata(library?.id).pipe(take(1)).subscribe((res: any) => { - this.toastr.success('Scan started for ' + library.name); + this.toastr.success('Scan queued for ' + library.name); if (callback) { callback(library); } @@ -125,7 +128,7 @@ export class ActionService implements OnDestroy { */ scanSeries(series: Series, callback?: SeriesActionCallback) { this.seriesService.scan(series.libraryId, series.id).pipe(take(1)).subscribe((res: any) => { - this.toastr.success('Scan started for ' + series.name); + this.toastr.success('Scan queued for ' + series.name); if (callback) { callback(series); } diff --git a/UI/Web/src/app/_services/metadata.service.ts b/UI/Web/src/app/_services/metadata.service.ts new file mode 100644 index 000000000..98f9c5288 --- /dev/null +++ b/UI/Web/src/app/_services/metadata.service.ts @@ -0,0 +1,18 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { environment } from 'src/environments/environment'; +import { ChapterMetadata } from '../_models/chapter-metadata'; + +@Injectable({ + providedIn: 'root' +}) +export class MetadataService { + + baseUrl = environment.apiUrl; + + constructor(private httpClient: HttpClient) { } + + getChapterMetadata(chapterId: number) { + return this.httpClient.get(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId); + } +} diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index a9cb82151..7984fd121 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -22,7 +22,6 @@ import { AutocompleteLibModule } from 'angular-ng-autocomplete'; import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component'; import { CarouselModule } from './carousel/carousel.module'; -import { PersonBadgeComponent } from './person-badge/person-badge.component'; import { TypeaheadModule } from './typeahead/typeahead.module'; import { RecentlyAddedComponent } from './recently-added/recently-added.component'; import { OnDeckComponent } from './on-deck/on-deck.component'; @@ -33,6 +32,8 @@ import { ReadingListModule } from './reading-list/reading-list.module'; import { SAVER, getSaver } from './shared/_providers/saver.provider'; import { ConfigData } from './_models/config-data'; import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component'; +import { PersonRolePipe } from './person-role.pipe'; +import { SeriesMetadataDetailComponent } from './series-metadata-detail/series-metadata-detail.component'; @NgModule({ @@ -45,11 +46,12 @@ import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle. SeriesDetailComponent, NotConnectedComponent, // Move into ExtrasModule ReviewSeriesModalComponent, - PersonBadgeComponent, RecentlyAddedComponent, OnDeckComponent, DashboardComponent, NavEventsToggleComponent, + PersonRolePipe, + SeriesMetadataDetailComponent, ], imports: [ HttpClientModule, diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html index 62a40f9e0..8bdaf5731 100644 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html +++ b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html @@ -9,7 +9,12 @@ -